Skip to content
academia.sh

Lesson 20 / 21

Privilege Escalation

Measuring horizontal and vertical escalation as path counting: finding 37 distinct paths from a limited user to a domain administrator across 9 nodes and 20 edges, 32 paths passing through at least one horizontal edge, a single control closing at most 24 paths, three controls together closing all 37, and one control closing no path at all.

Published Updated

In this lesson

  • Models privilege escalation as a set of nodes and edges and calculates the path count
  • Separates horizontal and vertical edges' share of the paths
  • Measures the number of paths each control closes and finds the smallest set that closes all paths
Contents

The previous lesson counted a setting being locally droppable on 67 machines. Dropping it requires being a local administrator on that machine, and the real question is how an ordinary account obtains that privilege. This transition is called privilege escalation, and on an enterprise network it is not a single event but a set of paths.

Two types are distinguished. Horizontal escalation is moving to another subject’s data or account at the same level; vertical escalation is rising to a higher level. This lesson treats both as path counting: how many distinct paths exist, which control closes how many of them, and how many controls are needed to close them all. In the IT and Systems Fundamentals course, escalation was measured as the number of tickets opened once it is assumed to have happened; what is measured here is how many distinct paths escalation is possible through. This lesson does not write out a step sequence, a command, or a tool; attempting such a thing without written authorization and a defined scope is both illegal and unprofessional.

Edge, Path, and Control

EI20: privilege escalation is not a single event but a set of edges; each edge arises from a configuration item, and when that item is removed, the edge disappears too. EI21: horizontal and vertical escalation are counted separately, because they are closed by separate controls. EI22: a control closes an edge, not a path; how many paths close depends on how many paths that edge appears in.

EI20 determines this lesson’s manner of measurement. Escalation is not a skill but a property of the inventory: behind every edge in the graph stands a written membership, a granted permission, or a forgotten account. This is why what is measured is not the attacker’s skill but the organization’s own configuration items. The number does not change unless the edge list changes; the number is recalculated every time the list changes, and this calculation is an inventory task, not an audit task — when the inventory is not kept, the path count is unknown, and in most organizations it is assumed to be zero.

Nine Nodes, Twenty Edges

The graph below is a model: the account levels in a fictional regional measurement network company and the configuration items that make moving between them possible. There is no real organization, no real account, and no working step sequence.

// escalation/graph.mjs — the graph of privilege escalation paths (model): nodes are account
// levels, edges are the configuration items that make moving from one level to another possible.
// There is no real organization, real account, or working step sequence. No randomness.

// EI20: privilege escalation is not a single event but a set of edges; each edge arises from a
// configuration item, and when that item is removed, the edge disappears too. EI21: horizontal
// escalation is moving to another subject at the same level, vertical escalation is rising to a higher
// level; they are counted separately because they are closed by separate controls. EI22: a control
// closes an edge, not a path; how many paths close depends on how many paths that edge appears in.
// Columns: source, target, type, the item that opens the edge, the control that closes the edge.
export const EDGE = `
  limited-user       other-user         horizontal  shared-local-password         per-machine-password
  limited-user       other-user         horizontal  shared-directory-permission   file-permission-narrowing
  limited-user       local-admin        vertical    local-admin-membership        group-membership-enforcement
  limited-user       local-admin        vertical    unsigned-script               unsigned-script-block
  limited-user       backup-operator    vertical    legacy-group-membership       group-membership-review
  other-user         local-admin        vertical    secondary-account-membership  group-membership-enforcement
  other-user         help-desk          horizontal  help-desk-membership          group-membership-review
  local-admin        service-account    horizontal  machine-running-service       service-account-restriction
  local-admin        help-desk          horizontal  privileged-session-trace      session-isolation
  local-admin        server-admin       vertical    common-local-password         per-machine-password
  local-admin        backup-operator    horizontal  backup-agent-account          backup-access-separation
  service-account    server-admin       vertical    over-privileged-service       least-privilege
  service-account    policy-admin       vertical    service-policy-permission     policy-write-separation
  help-desk          other-user         horizontal  password-reset                reset-approval
  help-desk          domain-admin       vertical    delegated-administration      delegation-restriction
  help-desk          backup-operator    horizontal  group-management-permission   delegation-restriction
  backup-operator    domain-admin       vertical    backup-restore                backup-access-separation
  server-admin       policy-admin       vertical    policy-editing                policy-write-separation
  server-admin       domain-admin       vertical    admin-trace-on-server         session-isolation
  policy-admin       domain-admin       vertical    policy-enforcement            policy-signing`
  .trim().split("\n").map((r) => {
    const p = r.trim().split(/\s{2,}/);
    return { source: p[0], target: p[1], type: p[2], item: p[3], control: p[4] };
  });

export const START = "limited-user";
export const TARGET = "domain-admin";

// Path counting: acyclic, each node is visited at most once per path.
export const paths = (closed = []) => {
  const open = EDGE.filter((k) => !closed.includes(k.control));
  const result = [];
  const walk = (node, visited, path) => {
    if (node === TARGET) { result.push(path); return; }
    for (const k of open.filter((x) => x.source === node && !visited.includes(x.target)))
      walk(k.target, [...visited, k.target], [...path, k]);
  };
  walk(START, [START], []);
  return result;
};
// escalation/tally.mjs — how many distinct escalation paths exist, how many of the paths pass through
// a horizontal edge, which control closes how many of them, and how many controls are needed to close them all.
import { EDGE, START, TARGET, paths } from "./graph.mjs";

const print = (g, ...s) => console.log(s.map((v, i) =>
  (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join(""));
const controls = [...new Set(EDGE.map((k) => k.control))];
const node = [...new Set(EDGE.flatMap((k) => [k.source, k.target]))];
const all = paths();

console.log(`${node.length} nodes, ${EDGE.length} edges (` +
  `${EDGE.filter((k) => k.type === "vertical").length} vertical, ` +
  `${EDGE.filter((k) => k.type === "horizontal").length} horizontal), ` +
  `${controls.length} controls`);
console.log(`there are ${all.length} distinct paths from ${START} to ${TARGET}`);

const A = [-10, 8, 15, 16, 11];
console.log("\n1. distribution by path length");
print(A, "length", "paths", "vertical only", "has horizontal", "avg edges");
for (const u of [2, 3, 4, 5]) {
  const s = all.filter((y) => y.length === u);
  if (s.length === 0) continue;
  print(A, u, s.length, s.filter((y) => y.every((k) => k.type === "vertical")).length,
    s.filter((y) => y.some((k) => k.type === "horizontal")).length, u);
}
const hasHorizontal = all.filter((y) => y.some((k) => k.type === "horizontal")).length;
console.log(`paths passing through at least one horizontal edge ${hasHorizontal}/${all.length}, ` +
  `paths going entirely through vertical edges ${all.length - hasHorizontal}`);

const B = [-30, 8, 10, 11, 11];
console.log("\n2. per control: how many edges, how many paths close");
print(B, "control", "edges", "closed", "remaining", "closed %");
const ranked = controls.map((c) => ({ c, remaining: paths([c]).length }))
  .sort((a, b) => a.remaining - b.remaining);
for (const { c, remaining } of ranked) {
  print(B, c, EDGE.filter((k) => k.control === c).length, all.length - remaining, remaining,
    `${(100 * (all.length - remaining) / all.length).toFixed(0)}%`);
}

const C = [-30, 9, 11, 11];
console.log("\n3. adding controls from most effective (cumulative)");
print(C, "added", "controls", "remaining", "closed");
const chosen = [];
print(C, "none", 0, all.length, 0);
let remain = all.length;
while (remain > 0) {
  let best = null, least = remain;
  for (const c of controls.filter((x) => !chosen.includes(x))) {
    const k = paths([...chosen, c]).length;
    if (k < least) { least = k; best = c; }
  }
  if (best === null) break;
  chosen.push(best); remain = least;
  print(C, best, chosen.length, remain, all.length - remain);
}

const D = [-22, 10, 10, 16, 16];
console.log("\n4. per node: how many paths pass through it");
print(D, "node", "in paths", "percent", "incoming edges", "outgoing edges");
const passes = (d) => all.filter((y) =>
  y.some((k) => k.source === d || k.target === d)).length;
for (const d of node.sort((a, b) => passes(b) - passes(a))) {
  print(D, d, passes(d), `${(100 * passes(d) / all.length).toFixed(0)}%`,
    EDGE.filter((k) => k.target === d).length,
    EDGE.filter((k) => k.source === d).length);
}
9 nodes, 20 edges (12 vertical, 8 horizontal), 13 controls
there are 37 distinct paths from limited-user to domain-admin

1. distribution by path length
length       paths  vertical only  has horizontal  avg edges
2                1              1               0          2
3                8              2               6          3
4               16              2              14          4
5               10              0              10          5
paths passing through at least one horizontal edge 32/37, paths going entirely through vertical edges 5

2. per control: how many edges, how many paths close
control                          edges    closed  remaining   closed %
group-membership-enforcement         2        24         13        65%
per-machine-password                 2        16         21        43%
session-isolation                    2        16         21        43%
service-account-restriction          1        12         25        32%
policy-write-separation              2        12         25        32%
delegation-restriction               2        12         25        32%
policy-signing                       1        12         25        32%
backup-access-separation             2        11         26        30%
file-permission-narrowing            1        10         27        27%
unsigned-script-block                1         8         29        22%
least-privilege                      1         8         29        22%
group-membership-review              2         5         32        14%
reset-approval                       1         0         37         0%

3. adding controls from most effective (cumulative)
added                          controls  remaining     closed
none                                  0         37          0
group-membership-enforcement          1         13         24
unsigned-script-block                 2          5         32
group-membership-review               3          0         37

4. per node: how many paths pass through it
node                    in paths   percent  incoming edges  outgoing edges
limited-user                  37      100%               0               5
domain-admin                  37      100%               4               0
local-admin                   32       86%               3               4
other-user                    20       54%               3               2
server-admin                  16       43%               2               2
help-desk                     12       32%               2               3
service-account               12       32%               1               2
policy-admin                  12       32%               2               1
backup-operator               11       30%               3               1

The numbers are of the measurement class; their inputs are EI20–EI22 and the edge list.

Twenty Edges, Thirty-Seven Paths

The header line gives this lesson’s first result: in a twenty-edge graph there are 37 distinct paths from a limited user to a domain administrator. Twenty items were written, thirty-seven outcomes were born. Paths multiplying according to the combination pattern of edges, not their count, is this measurement’s core property; adding a new edge does not increase the path count by one, it multiplies it.

The first table gives the distribution. The shortest path is two edges and has a single instance. The three-edge path has eight instances, the four-edge path sixteen, the five-edge path ten. The majority of paths cluster in the middle; the shortest and longest ends are sparse. This shows that the only meaningful answer to the question “in how many steps can it be taken over” for an inventory is a distribution — there is no single number.

The share of horizontal edges is decisive. 32 of the thirty-seven paths pass through at least one horizontal edge; only five paths proceed with vertical edges from start to finish. There are eight horizontal edges, that is, 40% of the edges; but they appear in 86% of the paths. Horizontal transitions are not escalation itself — they do not change level — but they provide access to vertical edges, and that is why they appear in most of the paths. An assessment that measures only vertical escalation misses thirty-two of the thirty-seven paths.

How Many Paths Does One Control Close

The second table counts EI22. Group membership enforcement closes two edges and drops 24 paths — 65% of the total. Per-machine unique password and session isolation each close two edges as well, but drop 16 paths apiece. The same number of edges, a different number of paths: an edge’s value lies not in its own count but in how many paths it appears in.

The table’s last row is the extreme example of this. Reset approval closes one edge and drops zero paths. The edge it closes — the move from help desk to another user — sits on no path at all, because everywhere reachable after that edge is already reachable by shorter paths. The control is not unnecessary; it may work against something else. But its value by the path-closing measure in this graph is zero, and a security budget should not be allocated without seeing this row.

The fourth table gives the same information from the node side. The local-admin node appears in 86% of the paths; it has three incoming and four outgoing edges. The next most loaded node is other-user, appearing in 54% of the paths. A node’s load behaves close to the product of its incoming and outgoing edge counts: for local-admin, three times four gives the table’s highest row.

Where the Graph Itself Stands

Which item each of the twenty edges arises from is written in the list, and most of these items are kept in one place: the central directory. Group memberships, delegated administrative privileges, and the scope of service accounts are records of the repository measured in the first lesson. This lesson’s trust root is the assumption that those records reflect the truth; when the repository’s integrity breaks, the graph gains new edges that no one has seen, and the count becomes stale.

Two edges show this directly. The edge arising from local administrator membership is the counterpart of one of the twelve settings enforced by central policy in the previous lesson; on the 30 machines that setting does not reach, the edge stays open. Likewise, on the 67 machines open to local override, the edge closed by the unsigned-script block is reborn per machine. The missing coverage in the propagation table is written directly into this graph’s edge count.

This ties the two lessons’ numbers together. The third table says three controls close thirty-seven paths; the sixth lesson says those controls reach 90.8% of machines and never reach 9.2% of them. The number of closed paths and the share of machines the closing reaches are two separate measures, and real coverage is their product. The path count that drops to zero in the graph is not zero across the entire inventory; every unreachable machine keeps open, on itself, the edges assumed to be closed, and those edges show as closed in the central report.

Three Controls Close Thirty-Seven Paths

The third table draws out the cumulative result by repeatedly selecting the most effective control. Group membership enforcement reduces 37 paths to 13; unsigned-script block reduces 13 to 5; group membership review reduces 5 to zero. Three controls close all thirty-seven paths.

The shape of the result is surprising, and the reason lies in the graph’s structure. The three controls close three of the five edges leaving the limited-user node; the remaining two edges go to the other-user node, and that node’s own two outgoing edges are closed by the same three controls as well. Every path leaving the start either gets cut or arrives at a dead end; no matter how rich the rest of the graph is, it becomes unreachable. This is why the one-by-one ranking in the second table and the cumulative selection in the third table highlight different controls: policy signing alone closes 12 paths but is not selected, because the paths it closes are already closed by others.

This is path counting’s most practical result: a defense budget is allocated by cumulative effect, not one-by-one effect. It may not be necessary to apply all thirteen of the thirteen controls; but what says which three will suffice is neither experience nor intuition — it is counting performed on the edge list.

The model’s limit is written here too. The three controls close the paths in this edge list; an edge not in the list — an unrecorded group membership, a forgotten service account — does not enter the count and does not close. Path counting is only as accurate as the inventory’s accuracy, and a gap in the inventory shows up as a number that is too small.

Summary

  • Privilege escalation is a set of edges: in a graph with 9 nodes and 20 edges, there are 37 distinct paths from a limited user to a domain administrator.
  • Path lengths range from 2 to 5 edges; the majority are 4-edge paths, and there is no single “how many steps” number.
  • Horizontal edges are 40% of the edges but appear in 32 of the paths; an assessment that measures only vertical escalation misses 32 of the 37 paths.
  • Two controls that close the same number of edges close different numbers of paths; one control closes 1 edge and drops 0 paths.
  • Three controls close all 37 paths: they cut 3 of the 5 edges leaving the start node and turn the node the remaining 2 lead to into a dead end; cumulative effect produces a different ranking from one-by-one effect.

Next Step

This lesson counted escalation through configuration items: a membership, a permission, an account. There is one more item that stays quiet behind the edge list. Moving from one account to another often requires not knowing that account’s password but capturing the value used to verify it; in machines’ memory, such a value sits for every signed-in user. The next lesson measures this on the defense axis: which item stays in memory for how long, why pass-the-hash works without knowing the password, and which control closes how many paths. That lesson also closes out the course.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close