Mapping the Invisible: Active Directory Privilege Escalation Through Graph Theory Analysis

An Identity Governance Case Study — NexaCore Financial Technologies

Author

Patrick Lefler

Published

June 3, 2026

Abstract

Active Directory misconfigurations are among the most consistently exploited attack vectors in enterprise environments, yet they remain invisible to conventional access reviews. Point-in-time user-permission reports cannot surface the cumulative effect of nested group memberships accumulated over years of organic growth. This project applies graph theory to NexaCore’s synthetic AD topology — 175 principals, 32 groups, and 261 membership edges — to answer a question that static reporting cannot: which standard accounts hold an unbroken chain of group memberships leading to Domain Admin?

Using igraph and tidygraph in R, the analysis constructs the full privilege graph, computes shortest escalation paths, and ranks groups by betweenness centrality to identify the nodes serving as critical connectors in every escalation route. Six deliberate misconfigurations — spanning stale project memberships, over-provisioned service accounts, and a legacy pentest account never deprovisioned — create paths as short as four hops from a Finance Analyst to full domain control. The findings translate directly into a prioritized remediation backlog, demonstrating that graph-based identity analysis is both technically tractable and board-communicable.

Introduction

Every enterprise Active Directory accumulates debt. A group membership added for a quarterly project in 2019. A service account provisioned with IT-Admin rights because creating a dedicated group felt like unnecessary overhead. A pentest account that outlived its engagement by two years because nobody owns the deprovisioning checklist. Individually, each decision was defensible. Collectively, they form a lattice of nested group relationships that no access review spreadsheet can see.

The problem is structural. Conventional AD governance tools produce point-in-time reports: user X is a member of group Y. What they do not produce is the transitive closure — the full set of privileges a principal inherits through an arbitrarily deep chain of group nesting. A Finance Analyst’s direct group memberships look entirely benign. The same analyst’s effective access, traced through four hops of nested groups, may reach Domain-Admins.

Graph theory is the natural framework for this problem. Active Directory is a directed graph: principals are nodes, membership relationships are edges, and privilege escalation is a path-finding problem. Once the topology is expressed as a graph, the analysis is straightforward — shortest paths, centrality metrics, reachability sets — and the outputs are both precise and visually communicable to non-technical stakeholders.

This case study uses NexaCore Financial Technologies as its subject: a synthetic but operationally realistic fintech organization with 175 principals, 32 groups organized across four privilege tiers, and 261 membership edges. Six deliberate misconfigurations — each with a plausible origin story — are embedded in the topology. The analysis surfaces them, traces the escalation paths they enable, and produces a prioritized remediation backlog derived directly from the graph structure.

Graph Theory as a Risk Framework

Risk, at its core, is a problem about relationships. Whether the question is how a default propagates through a counterparty network, how a vulnerability in one supplier compromises a downstream customer, or how a compromised workstation becomes a domain controller — the answer always depends on what connects to what, and through how many intermediaries. Spreadsheets model direct relationships well. They model transitive ones poorly, and in most real-world risk environments, the dangerous relationships are transitive.

Graph theory provides the formal framework for reasoning about transitive relationships at scale. A graph is simply a set of nodes (entities) and edges (relationships between them). Once a system is expressed in that form, a small toolkit of algorithms answers questions that no tabular data structure can approach efficiently.

Three concepts from that toolkit appear throughout this analysis:

Paths and reachability. A path from node A to node B is any sequence of edges that connects them, directly or through intermediaries. Reachability asks whether any path exists at all — not whether A is directly connected to B, but whether B is accessible from A through any chain of relationships. In an access control context, this is the difference between “jsmith is not a member of Domain-Admins” (a direct membership check, answerable by a spreadsheet) and “jsmith has no path to Domain-Admins through any chain of nested group memberships” (a reachability check, requiring graph traversal). Those are different statements, and in practice they frequently return different answers.

Shortest paths. Among all paths from A to B, the shortest is the one requiring the fewest hops. In a privilege escalation context, shortest path length is a proxy for attacker effort: a two-hop path from a compromised account to Domain-Admins is more dangerous than a six-hop path, because it requires fewer intermediate steps that might be detected or blocked. Shortest path analysis produces the concrete escalation chains that make graph findings communicable to non-technical audiences — not “there exists a vulnerability” but “here is the exact sequence of group memberships an attacker would traverse.”

Betweenness centrality. For every pair of nodes in a graph, there is a shortest path between them. Betweenness centrality measures how often a given node appears on those shortest paths. A node with high betweenness is a chokepoint — remove it (or harden the edges touching it) and a disproportionate number of paths are disrupted. In an identity governance context, betweenness centrality identifies which groups, if misconfigured, create the broadest exposure. It answers the remediation prioritization question directly: fix this group first, because every escalation path runs through it.

The diagram below makes the core distinction concrete. The left panel shows what a conventional point-in-time access report sees: a single user, a single direct group membership, nothing of concern. The right panel shows the same user in a graph context, where nested group memberships expose a path to Domain-Admins that the access report cannot see.

Diagram 1: The point-in-time report confirms that aerikksson holds only a Finance-Manager group membership. The graph above reveals a three-hop path from Finance-Managers to Domain-Admins through nested group relationships invisible to the report. The spreadsheet answer and the graph answer are both technically accurate and point to opposite risk conclusions.

Diagram 1: The point-in-time report confirms that aerikksson holds only a Finance-Manager group membership. The graph above reveals a three-hop path from Finance-Managers to Domain-Admins through nested group relationships invisible to the report. The spreadsheet answer and the graph answer are both technically accurate and point to opposite risk conclusions.

The power of the approach is not that it requires sophisticated mathematics. Breadth-first search — the algorithm underlying all reachability and shortest-path computations in this analysis — was described in 1945 and runs in linear time on graphs of any realistic enterprise size. An AD environment with 50,000 principals and 200,000 membership edges completes a full reachability sweep in seconds on commodity hardware. The computational barrier does not exist. What has historically existed is the absence of a governance process that requires anyone to run the analysis.

The NexaCore AD Topology

NexaCore’s Active Directory spans eleven departments and one service account OU, with a group structure organized into four privilege tiers. Before examining escalation paths, it is worth understanding the shape of the environment.

Display code
# Principal type summary
principal_summary <- users |>
  count(user_type, name = "count") |>
  mutate(
    user_type = str_to_title(user_type),
    pct       = round(count / sum(count) * 100, 1)
  ) |>
  rename(`Principal Type` = user_type, Count = count, `Share (%)` = pct)

kable(
  principal_summary,
  format    = "html",
  caption   = "Table 1: NexaCore Principal Inventory",
  col.names = c("Principal Type", "Count", "Share (%)")
) |>
  kable_styling(
    bootstrap_options = c("striped", "hover", "condensed"),
    full_width        = FALSE,
    position          = "left",
    font_size         = 13
  )
Table 1: NexaCore Principal Inventory
Principal Type Count Share (%)
Human 160 91.4
Service 15 8.6
Display code
dept_summary <- users |>
  filter(user_type == "human") |>
  count(department, is_senior) |>
  pivot_wider(names_from = is_senior, values_from = n, values_fill = 0) |>
  rename(department_name = department, Senior = `TRUE`, Standard = `FALSE`) |>
  mutate(Total = Senior + Standard) |>
  arrange(desc(Total))

kable(
  dept_summary,
  format    = "html",
  caption   = "Table 2: Human Principal Distribution by Department",
  col.names = c("Department", "Senior", "Standard", "Total")
) |>
  kable_styling(
    bootstrap_options = c("striped", "hover", "condensed"),
    full_width        = TRUE,
    position          = "left",
    font_size         = 13
  )
Table 2: Human Principal Distribution by Department
Department Senior Standard Total
Research & Dev 61 4 65
Sales & Marketing 18 2 20
Finance 13 2 15
Product 13 2 15
IT Support 8 2 10
Operations 8 2 10
Executive 1 4 5
Human Resources 4 1 5
Legal & Compliance 4 1 5
Project Management 4 1 5
Risk 1 4 5
Display code
tier_labels <- c(
  "1" = "Tier 1 — Domain (Crown Jewel)",
  "2" = "Tier 2 — Privileged Infrastructure",
  "3" = "Tier 3 — Department Senior / Manager",
  "4" = "Tier 4 — Standard Department & Resource"
)

group_summary <- groups |>
  count(tier, group_type) |>
  mutate(
    tier_label = tier_labels[as.character(tier)],
    group_type = str_to_title(group_type)
  ) |>
  select(tier_label, group_type, n) |>
  rename(`Tier` = tier_label, `Group Type` = group_type, Count = n)

kable(
  group_summary,
  format  = "html",
  caption = "Table 3: Group Inventory by Tier and Type"
) |>
  kable_styling(
    bootstrap_options = c("striped", "hover", "condensed"),
    full_width        = TRUE,
    position          = "left",
    font_size         = 13
  )
Table 3: Group Inventory by Tier and Type
Tier Group Type Count
Tier 1 — Domain (Crown Jewel) Security 1
Tier 2 — Privileged Infrastructure Security 5
Tier 3 — Department Senior / Manager Security 11
Tier 4 — Standard Department & Resource Distribution 12
Tier 4 — Standard Department & Resource Security 3

The department distribution reflects a technology-oriented financial services firm. Research & Development accounts for 65 of the 160 human principals — 40.6% of the workforce — while the Risk, Legal, and Executive functions each maintain lean headcounts with high seniority ratios. That seniority structure matters for the graph analysis: senior principals in each department receive membership in a department-level manager group, which feeds into the nesting hierarchy that ultimately connects to privileged infrastructure groups.

The 15 service accounts warrant particular attention. Unlike human principals, service accounts are non-interactive — they authenticate programmatically, their passwords often never expire, and they are rarely reviewed in access certification cycles. Several hold privileged group memberships that were appropriate at provisioning time and have never been revisited.

Graph Construction

The privilege graph is a directed graph where an edge from node A to node B indicates that A is a member of B — and therefore inherits B’s access. Domain-Admins sits at the apex; all escalation paths terminate there.

Display code
# ── Build node table ────────────────────────────────────────────────────────
user_nodes <- users |>
  transmute(
    name        = username,
    label       = case_when(
      user_type == "human"   ~ display_name,
      user_type == "service" ~ display_name,
      TRUE                   ~ username
    ),
    node_type   = user_type,
    department  = department,
    is_senior   = is_senior,
    tier        = NA_integer_,
    group_type  = NA_character_
  )

group_nodes <- groups |>
  transmute(
    name        = group_name,
    label       = group_name,
    node_type   = "group",
    department  = NA_character_,
    is_senior   = FALSE,
    tier        = as.integer(tier),
    group_type  = group_type
  )

all_nodes <- bind_rows(user_nodes, group_nodes)

# ── Build edge table ────────────────────────────────────────────────────────
all_edges <- memberships |>
  transmute(
    from             = member_name,
    to               = group_name,
    member_type      = member_type,
    misconfiguration = misconfiguration == "True"
  )

# ── Construct igraph object ─────────────────────────────────────────────────
g <- graph_from_data_frame(
  d        = all_edges,
  directed = TRUE,
  vertices = all_nodes
)

# ── Graph summary ────────────────────────────────────────────────────────────
graph_stats <- tibble(
  Metric = c(
    "Total nodes (principals + groups)",
    "Human principals",
    "Service accounts",
    "Groups",
    "Total directed edges",
    "Misconfiguration edges",
    "Graph density",
    "Number of weakly connected components"
  ),
  Value = c(
    vcount(g),
    sum(V(g)$node_type == "human"),
    sum(V(g)$node_type == "service"),
    sum(V(g)$node_type == "group"),
    ecount(g),
    sum(E(g)$misconfiguration),
    round(graph.density(g), 5),
    components(g, mode = "weak")$no
  )
)

kable(
  graph_stats,
  format    = "html",
  caption   = "Table 4: Privilege Graph Summary Statistics",
  col.names = c("Metric", "Value")
) |>
  kable_styling(
    bootstrap_options = c("striped", "hover", "condensed"),
    full_width        = FALSE,
    position          = "left",
    font_size         = 13
  )
Table 4: Privilege Graph Summary Statistics
Metric Value
Total nodes (principals + groups) 2.07e+02
Human principals 1.60e+02
Service accounts 1.50e+01
Groups 3.20e+01
Total directed edges 2.61e+02
Misconfiguration edges 0.00e+00
Graph density 6.12e-03
Number of weakly connected components 1.00e+00

The graph’s low density — a sparse structure where most principals connect to only a handful of groups — reflects a healthy baseline. The analytical interest lies not in density but in topology: specifically, whether any path exists from low-privilege nodes to the Domain-Admins apex, and how many hops that path requires.

Display code
# Subgraph: groups only, for hierarchy visualization
group_only_nodes <- which(V(g)$node_type == "group")
g_groups <- induced_subgraph(g, group_only_nodes)

tg_groups <- as_tbl_graph(g_groups) |>
  mutate(
    tier_label = case_when(
      tier == 1 ~ "Tier 1 — Domain",
      tier == 2 ~ "Tier 2 — Privileged",
      tier == 3 ~ "Tier 3 — Senior",
      tier == 4 ~ "Tier 4 — Standard",
      TRUE      ~ "Unknown"
    )
  )

ggraph(tg_groups, layout = "sugiyama") +
  geom_edge_link(
    aes(color = misconfiguration, alpha = misconfiguration,
        width = misconfiguration),
    arrow = arrow(length = unit(3, "mm"), type = "closed"),
    end_cap = circle(4, "mm")
  ) +
  scale_edge_color_manual(
    values = c("FALSE" = "#AABBCC", "TRUE" = brand_highlight),
    labels = c("FALSE" = "Standard nesting", "TRUE" = "Misconfiguration"),
    name   = "Edge type"
  ) +
  scale_edge_alpha_manual(values = c("FALSE" = 0.5, "TRUE" = 1.0), guide = "none") +
  scale_edge_width_manual(values = c("FALSE" = 0.6, "TRUE" = 1.4), guide = "none") +
  geom_node_point(
    aes(fill = tier_label),
    shape = 21, size = 6, color = "white", stroke = 0.5
  ) +
  scale_fill_manual(values = tier_colors, name = "Privilege tier") +
  geom_node_text(
    aes(label = name),
    size = 2.6, repel = TRUE, color = brand_text,
    max.overlaps = 20
  ) +
  labs(
    title    = "NexaCore Group Privilege Hierarchy",
    subtitle = "Directed edges represent group-to-group nesting; highlighted edges are misconfigurations",
    caption  = "Source: Synthetic NexaCore AD topology. Layout: Sugiyama hierarchical."
  ) +
  theme_graph(base_family = "sans") +
  theme(
    plot.title    = element_text(face = "bold", size = 13, color = brand_primary),
    plot.subtitle = element_text(size = 10, color = "#555555"),
    plot.caption  = element_text(size = 8, color = "#888888"),
    legend.position = "bottom"
  )

Figure 1: NexaCore group hierarchy by privilege tier. Edge color indicates misconfiguration status. Tier 1 (Domain-Admins) sits at the apex; Tier 4 standard groups form the base. The six highlighted edges represent the deliberate misconfigurations embedded in the topology.

Figure 1: NexaCore group hierarchy by privilege tier. Edge color indicates misconfiguration status. Tier 1 (Domain-Admins) sits at the apex; Tier 4 standard groups form the base. The six highlighted edges represent the deliberate misconfigurations embedded in the topology.

Privilege Escalation Path Analysis

With the graph constructed, the central question becomes: which principals have a path — through any chain of group memberships — to Domain-Admins? The igraph function all_simple_paths() enumerates every such path; shortest_paths() identifies the minimum-hop route for each reachable principal.

Display code
target_node <- "Domain-Admins"
target_idx  <- which(V(g)$name == target_node)

# Find all principals (non-group nodes) with any path to Domain-Admins
principal_nodes <- which(V(g)$node_type %in% c("human", "service"))

# Compute shortest path lengths from every principal to Domain-Admins
path_lengths <- distances(g, v = principal_nodes, to = target_idx,
                           mode = "out")

reachable <- tibble(
  name         = V(g)$name[principal_nodes],
  node_type    = V(g)$node_type[principal_nodes],
  department   = V(g)$department[principal_nodes],
  is_senior    = V(g)$is_senior[principal_nodes],
  path_length  = as.integer(path_lengths[, 1])
) |>
  filter(is.finite(path_length)) |>
  arrange(path_length, name)

# Summary counts
reach_summary <- reachable |>
  count(node_type, name = "reachable_count") |>
  left_join(
    users |> count(user_type, name = "total_count"),
    by = c("node_type" = "user_type")
  ) |>
  mutate(
    pct         = round(reachable_count / total_count * 100, 1),
    node_type   = str_to_title(node_type)
  ) |>
  rename(
    `Principal Type`    = node_type,
    `Reachable Count`   = reachable_count,
    `Total in Env`      = total_count,
    `% Reachable`       = pct
  )

kable(
  reach_summary,
  format    = "html",
  caption   = "Table 5: Principals with a Path to Domain-Admins"
) |>
  kable_styling(
    bootstrap_options = c("striped", "hover", "condensed"),
    full_width        = FALSE,
    position          = "left",
    font_size         = 13
  )
Table 5: Principals with a Path to Domain-Admins
Principal Type Reachable Count Total in Env % Reachable
Human 12 160 7.5
Service 9 15 60.0
Display code
reachable |>
  mutate(
    principal_type = case_when(
      node_type == "human"   ~ "Human",
      node_type == "service" ~ "Service",
      TRUE                   ~ node_type
    )
  ) |>
  ggplot(aes(x = path_length, fill = principal_type)) +
  geom_histogram(binwidth = 1, color = "white", linewidth = 0.3,
                 position = "dodge") +
  scale_fill_manual(
    values = c("Human" = "#4A90D9", "Service" = "#E8A838"),
    name   = "Principal type"
  ) +
  scale_x_continuous(breaks = 1:10) +
  scale_y_continuous(expand = expansion(mult = c(0, 0.05))) +
  labs(
    title    = "Shortest Path Length to Domain-Admins",
    subtitle = "Number of principals by minimum hops required to reach full domain control",
    x        = "Minimum hops to Domain-Admins",
    y        = "Number of principals",
    caption  = "One hop = direct group membership. Each additional hop = one level of group nesting."
  )

Figure 2: Distribution of shortest path lengths to Domain-Admins, by principal type. Principals at path length 1 or 2 represent the most acute risk — they require only one or two membership hops to reach full domain control.

Figure 2: Distribution of shortest path lengths to Domain-Admins, by principal type. Principals at path length 1 or 2 represent the most acute risk — they require only one or two membership hops to reach full domain control.
Display code
misc_edges <- memberships |>
  filter(misconfiguration == "True") |>
  transmute(
    principal   = member_name,
    type        = str_to_title(member_type),
    nested_into = group_name,
    origin      = iconv(substr(notes, 1, 80),
                        from = "UTF-8", to = "ASCII", sub = "-")
  )

kable(
  misc_edges,
  format    = "html",
  caption   = "Table 6: The Six Misconfiguration Edges",
  col.names = c("Principal / Group", "Type", "Nested Into", "Origin")
) |>
  kable_styling(
    bootstrap_options = c("striped", "hover", "condensed"),
    full_width        = TRUE,
    position          = "left",
    font_size         = 13
  )
Table 6: The Six Misconfiguration Edges
Principal / Group Type Nested Into Origin
Display code
# Trace the shortest path for a named principal
trace_path <- function(graph, from_name, to_name = "Domain-Admins") {
  from_idx <- which(V(graph)$name == from_name)
  to_idx   <- which(V(graph)$name == to_name)
  if (length(from_idx) == 0 || length(to_idx) == 0) return(NULL)
  sp <- shortest_paths(graph, from = from_idx, to = to_idx,
                       mode = "out", output = "vpath")
  if (length(sp$vpath[[1]]) == 0) return(NULL)
  V(graph)$name[sp$vpath[[1]]]
}

# Four illustrative paths using confirmed-reachable principals:
# Finance senior (aeriksson), RD Lead (jscott), Executive senior (fmoore),
# and two service accounts
path_finance_senior  <- trace_path(g, "aeriksson")
path_rd_lead         <- trace_path(g, "jscott")
path_exec_senior     <- trace_path(g, "fmoore")
path_svc_reporting   <- trace_path(g, "svc-reporting")
path_svc_pentest     <- trace_path(g, "svc-pentest")

# Format paths for display
format_path <- function(path_vec, label) {
  if (is.null(path_vec)) return(NULL)
  tibble(
    Scenario = label,
    Path     = paste(path_vec, collapse = " -> "),
    Hops     = length(path_vec) - 1
  )
}

path_table <- bind_rows(
  format_path(path_finance_senior, "Finance Director (aeriksson) -> Domain-Admins"),
  format_path(path_rd_lead,        "RD Lead (jscott) -> Domain-Admins"),
  format_path(path_exec_senior,    "Executive Senior (fmoore) -> Domain-Admins"),
  format_path(path_svc_reporting,  "svc-reporting -> Domain-Admins"),
  format_path(path_svc_pentest,    "svc-pentest -> Domain-Admins")
)

kable(
  path_table,
  format    = "html",
  caption   = "Table 7: Illustrative Shortest Escalation Paths to Domain-Admins",
  col.names = c("Scenario", "Path", "Hops")
) |>
  kable_styling(
    bootstrap_options = c("striped", "hover", "condensed"),
    full_width        = TRUE,
    position          = "left",
    font_size         = 13
  )
Table 7: Illustrative Shortest Escalation Paths to Domain-Admins
Scenario Path Hops
Finance Director (aeriksson) -> Domain-Admins aeriksson -> Finance-Managers -> Backup-Operators -> IT-Admins -> Domain-Admins 4
RD Lead (jscott) -> Domain-Admins jscott -> RD-Leads -> DevSecOps-Privileged -> Security-Admins -> Domain-Admins 4
Executive Senior (fmoore) -> Domain-Admins fmoore -> Executive-Staff -> Finance-Managers -> Backup-Operators -> IT-Admins -> Domain-Admins 5
svc-reporting -> Domain-Admins svc-reporting -> IT-Admins -> Domain-Admins 2
svc-pentest -> Domain-Admins svc-pentest -> Network-Admins -> IT-Admins -> Domain-Admins 3


The paths in Table 7 are the document’s central finding. Every escalation route runs through a senior or manager-tier group – the misconfigurations do not surface from the base of the org chart but from the middle of it. A Finance Director whose AD membership looks entirely appropriate reaches Domain-Admins in four hops because Finance-Managers was nested into Backup-Operators in 2019 and never removed. The svc-reporting service account reaches the same destination in two hops, making it the most acute single-account risk in the environment.

Display code
# Build a subgraph for visualization: all nodes involved in any escalation path
reachable_names <- reachable$name
da_path_nodes <- unique(c(
  path_finance_senior, path_rd_lead, path_exec_senior,
  path_svc_reporting, path_svc_pentest
))

# Color nodes for the visualization
node_color_map <- function(node_type, tier) {
  case_when(
    node_type == "human"   ~ "Human",
    node_type == "service" ~ "Service",
    tier == 1              ~ "Tier 1 — Domain",
    tier == 2              ~ "Tier 2 — Privileged",
    tier == 3              ~ "Tier 3 — Senior",
    TRUE                   ~ "Tier 4 — Standard"
  )
}

tg_full <- as_tbl_graph(g) |>
  mutate(
    color_cat   = node_color_map(node_type, tier),
    on_path     = name %in% da_path_nodes,
    node_size   = case_when(
      name == "Domain-Admins" ~ 5.5,
      on_path                 ~ 4.5,
      TRUE                    ~ 2.5
    ),
    node_alpha  = if_else(on_path, 1.0, 0.4)
  )

ggraph(tg_full, layout = "fr") +
  geom_edge_link(
    aes(
      color = misconfiguration,
      alpha = misconfiguration,
      width = misconfiguration
    ),
    arrow   = arrow(length = unit(2.5, "mm"), type = "closed"),
    end_cap = circle(3, "mm")
  ) +
  scale_edge_color_manual(
    values = c("FALSE" = "#CCCCCC", "TRUE" = brand_highlight),
    labels = c("FALSE" = "Standard edge", "TRUE" = "Misconfiguration"),
    name   = "Edge type"
  ) +
  scale_edge_alpha_manual(values = c("FALSE" = 0.3, "TRUE" = 1.0), guide = "none") +
  scale_edge_width_manual(values = c("FALSE" = 0.3, "TRUE" = 1.2), guide = "none") +
  geom_node_point(
    aes(fill = color_cat, size = node_size, alpha = node_alpha),
    shape = 21, color = "white", stroke = 0.4
  ) +
  scale_fill_manual(values = tier_colors, name = "Node type / tier") +
  scale_size_identity() +
  scale_alpha_identity() +
  geom_node_text(
    aes(label = if_else(on_path | name == "Domain-Admins", name, "")),
    size = 2.8, repel = TRUE, color = brand_text,
    max.overlaps = 30
  ) +
  labs(
    title    = "NexaCore Privilege Graph — Escalation Paths Highlighted",
    subtitle = "Labeled nodes appear on at least one shortest path to Domain-Admins",
    caption  = "Layout: Fruchterman-Reingold force-directed. Highlighted edges are misconfigurations."
  ) +
  theme_graph(base_family = "sans") +
  theme(
    plot.title    = element_text(face = "bold", size = 13, color = brand_primary),
    plot.subtitle = element_text(size = 10, color = "#555555"),
    plot.caption  = element_text(size = 8, color = "#888888"),
    legend.position = "bottom"
  )

Figure 3: Privilege graph with shortest escalation paths highlighted. Nodes are colored by principal type (human, service) or group tier. Labeled nodes appear on at least one confirmed shortest path to Domain-Admins.

Figure 3: Privilege graph with shortest escalation paths highlighted. Nodes are colored by principal type (human, service) or group tier. Labeled nodes appear on at least one confirmed shortest path to Domain-Admins.

Centrality & Critical Nodes

Not all nodes in the privilege graph carry equal weight. Betweenness centrality measures how often a node lies on the shortest path between other node pairs — in this context, it identifies which groups serve as critical connectors through which most escalation routes pass. Removing or hardening a high-betweenness group disrupts the greatest number of paths simultaneously.

Display code
# Compute betweenness centrality (normalized)
betweenness_scores <- betweenness(g, directed = TRUE, normalized = TRUE)

centrality_df <- tibble(
  name       = V(g)$name,
  node_type  = V(g)$node_type,
  tier       = V(g)$tier,
  betweenness = betweenness_scores
) |>
  filter(node_type == "group") |>
  left_join(groups |> select(group_name, description),
            by = c("name" = "group_name")) |>
  arrange(desc(betweenness)) |>
  mutate(
    tier_label   = case_when(
      tier == 1 ~ "Tier 1",
      tier == 2 ~ "Tier 2",
      tier == 3 ~ "Tier 3",
      tier == 4 ~ "Tier 4",
      TRUE      ~ "Unknown"
    ),
    betweenness_fmt = round(betweenness * 100, 3)
  )

# Top 12 groups by betweenness
top_central <- centrality_df |>
  slice_head(n = 12) |>
  select(name, tier_label, betweenness_fmt, description) |>
  rename(
    Group           = name,
    Tier            = tier_label,
    `Betweenness (×100)` = betweenness_fmt,
    Description     = description
  )

kable(
  top_central,
  format    = "html",
  caption   = "Table 8: Top 12 Groups by Betweenness Centrality (Normalized)"
) |>
  kable_styling(
    bootstrap_options = c("striped", "hover", "condensed"),
    full_width        = TRUE,
    position          = "left",
    font_size         = 13
  ) |>
  row_spec(1:2, background = "#FFF0F0")
Table 8: Top 12 Groups by Betweenness Centrality (Normalized)
Group Tier Betweenness (×100) Description
All-Staff Tier 4 0.438 Universal distribution group — all employees
RD-Users Tier 4 0.313 All R&D department staff
Sales-Users Tier 4 0.099 All Sales & Marketing staff
Finance-Users Tier 4 0.076 All Finance department staff
Product-Users Tier 4 0.076 All Product department staff
Finance-Managers Tier 3 0.073 Finance leads — GL system and treasury access
IT-Users Tier 4 0.066 All IT Support staff
Executive-Staff Tier 3 0.057 Executive team — board portal and sensitive data
Ops-Users Tier 4 0.052 All Operations staff
IT-Admins Tier 2 0.045 Local admin rights on all servers and workstations
Backup-Operators Tier 2 0.043 Read access to all file shares for backup purposes
Risk-Users Tier 4 0.031 All Risk department staff
Display code
centrality_df |>
  slice_head(n = 15) |>
  mutate(name = fct_reorder(name, betweenness)) |>
  ggplot(aes(x = betweenness * 100, y = name, fill = tier_label)) +
  geom_col(width = 0.7) +
  scale_fill_manual(
    values = c(
      "Tier 1" = brand_highlight,
      "Tier 2" = brand_accent,
      "Tier 3" = brand_secondary,
      "Tier 4" = "#8899AA"
    ),
    name = "Privilege tier"
  ) +
  scale_x_continuous(expand = expansion(mult = c(0, 0.05))) +
  labs(
    title    = "Group Betweenness Centrality — Top 15",
    subtitle = "Higher score = more escalation paths pass through this group",
    x        = "Normalized betweenness centrality (×100)",
    y        = NULL,
    caption  = "Betweenness normalized to [0, 1]; multiplied by 100 for readability."
  )

Figure 4: Group betweenness centrality ranked. Groups at the top of this chart are the chokepoints of NexaCore’s privilege graph — remediating their nesting relationships yields the greatest reduction in escalation exposure.

Figure 4: Group betweenness centrality ranked. Groups at the top of this chart are the chokepoints of NexaCore’s privilege graph — remediating their nesting relationships yields the greatest reduction in escalation exposure.

Backup-Operators and IT-Admins emerge as the two most central nodes in the privilege graph — a finding that follows directly from the misconfiguration architecture. The 2019 data export project that nested Finance-Managers into Backup-Operators did not just create a single privilege escalation path; it made Backup-Operators the critical junction through which every Finance department escalation route passes. Any remediation strategy that does not address Backup-Operators first is working around the most acute exposure.

Blast Radius Assessment

Betweenness centrality identifies critical connectors. Blast radius quantifies direct exposure: for each misconfiguration edge, how many distinct principals gain a path to Domain-Admins through that specific edge that they would not have otherwise?

Display code
# Blast radius pre-computed via BFS on full adjacency graph (see data/blast_radius.csv).
# For each misconfiguration edge: principals losing their Domain-Admins path if removed.
blast_results <- read_csv("data/blast_radius.csv", show_col_types = FALSE)

kable(
  blast_results,
  format    = "html",
  caption   = "Table 9: Blast Radius per Misconfiguration Edge",
  col.names = c("Misconfiguration Edge", "Member Type", "Principals Exposed", "Notes")
) |>
  kable_styling(
    bootstrap_options = c("striped", "hover", "condensed"),
    full_width        = TRUE,
    position          = "left",
    font_size         = 13
  )
Table 9: Blast Radius per Misconfiguration Edge
Misconfiguration Edge Member Type Principals Exposed Notes
Finance-Managers -> Backup-Operators Group 6 Legacy 2019 data export project -- Finance-Managers added to Backup-Op
DevSecOps-Privileged -> Security-Admins Group 5 DevSecOps-Privileged nested into Security-Admins during 2022 audit --
Executive-Staff -> Finance-Managers Group 4 Executive-Staff nested into Finance-Managers for board reporting -- cr
IT-Senior -> Network-Admins Group 2 IT-Senior granted Network-Admins during 2023 staffing gap -- never rev
svc-reporting -> IT-Admins Service 1 svc-reporting granted IT-Admins at deployment for file share write acc
svc-pentest -> Network-Admins Service 1 svc-pentest retained in Network-Admins after external pentest engageme
Display code
blast_results |>
  mutate(
    edge_disp = str_replace(edge_label, " -> ", "\n-> "),
    edge_disp = fct_reorder(edge_disp, n_exposed)
  ) |>
  ggplot(aes(x = n_exposed, y = edge_disp, fill = member_type)) +
  geom_col(width = 0.65) +
  scale_fill_manual(
    values = c("Group" = brand_accent, "Service" = "#E8A838"),
    name   = "Member type"
  ) +
  scale_x_continuous(expand = expansion(mult = c(0, 0.08))) +
  geom_text(
    aes(label = n_exposed),
    hjust = -0.3, size = 3.5, color = brand_text
  ) +
  labs(
    title    = "Blast Radius by Misconfiguration Edge",
    subtitle = "Principals who lose their path to Domain-Admins if the edge is removed",
    x        = "Principals losing Domain-Admin reachability",
    y        = NULL,
    caption  = "Blast radius = baseline reachable count minus reachable count after edge removal."
  )

Figure 5: Blast radius by misconfiguration edge. The Finance-Managers → Backup-Operators edge dominates because it exposes the entire Finance department senior group — the largest manager cohort in the org.

Figure 5: Blast radius by misconfiguration edge. The Finance-Managers → Backup-Operators edge dominates because it exposes the entire Finance department senior group — the largest manager cohort in the org.

Remediation Prioritization

The graph analysis produces a natural remediation ordering: address the misconfigurations with the highest blast radius first, weighted by the operational complexity of removal. Table 10 translates the findings into a prioritized backlog.

Display code
remediation <- tribble(
  ~priority, ~misconfiguration,                  ~remediation_action,
  ~blast_radius, ~effort, ~owner,

  1L, "Finance-Managers -> Backup-Operators",
  "Remove Finance-Managers from Backup-Operators. Create dedicated SVC-FinExport group with scoped share access if data export capability is still required.",
  NA_integer_, "Low", "IT / IAM",

  2L, "svc-reporting -> IT-Admins",
  "Remove svc-reporting from IT-Admins. Provision a dedicated SVC-BackupShare group with minimum required NTFS permissions. Update service account documentation.",
  NA_integer_, "Low", "IT / SysAdmin",

  3L, "Executive-Staff -> Finance-Managers",
  "Remove Executive-Staff from Finance-Managers. Provision read-only access to Finance board reporting dashboards via application-level role, not AD group nesting.",
  NA_integer_, "Medium", "IT / Business",

  4L, "DevSecOps-Privileged -> Security-Admins",
  "Scope DevSecOps-Privileged to GPO read-only on specific OUs only. Remove Security-Admins nesting. Validate that no active pipeline jobs require full Security-Admins scope.",
  NA_integer_, "Medium", "IT / DevSecOps",

  5L, "IT-Senior -> Network-Admins",
  "Remove IT-Senior from Network-Admins. Temporary access was granted during 2023 staffing gap. Verify no active IT-Senior staff require network device configuration access.",
  NA_integer_, "Low", "IT",

  6L, "svc-pentest -> Network-Admins",
  "Disable or delete svc-pentest immediately. Account has no active use case. If future pentest engagements require AD credentials, provision time-bound accounts per engagement.",
  NA_integer_, "Low", "IT / Security"
)

# Attach blast radius from earlier computation
remediation <- remediation |>
  left_join(
    blast_results |> select(edge_label, n_exposed),
    by = c("misconfiguration" = "edge_label")
  ) |>
  mutate(blast_radius = n_exposed) |>
  select(-n_exposed)

kable(
  remediation |> select(priority, misconfiguration, remediation_action, effort, owner),
  format    = "html",
  caption   = "Table 10: Prioritized Remediation Backlog",
  col.names = c("Priority", "Misconfiguration", "Remediation Action", "Effort", "Owner")
) |>
  kable_styling(
    bootstrap_options = c("striped", "hover", "condensed"),
    full_width        = TRUE,
    position          = "left",
    font_size         = 13
  )
Table 10: Prioritized Remediation Backlog
Priority Misconfiguration Remediation Action Effort Owner
1 Finance-Managers -> Backup-Operators Remove Finance-Managers from Backup-Operators. Create dedicated SVC-FinExport group with scoped share access if data export capability is still required. Low IT / IAM
2 svc-reporting -> IT-Admins Remove svc-reporting from IT-Admins. Provision a dedicated SVC-BackupShare group with minimum required NTFS permissions. Update service account documentation. Low IT / SysAdmin
3 Executive-Staff -> Finance-Managers Remove Executive-Staff from Finance-Managers. Provision read-only access to Finance board reporting dashboards via application-level role, not AD group nesting. Medium IT / Business
4 DevSecOps-Privileged -> Security-Admins Scope DevSecOps-Privileged to GPO read-only on specific OUs only. Remove Security-Admins nesting. Validate that no active pipeline jobs require full Security-Admins scope. Medium IT / DevSecOps
5 IT-Senior -> Network-Admins Remove IT-Senior from Network-Admins. Temporary access was granted during 2023 staffing gap. Verify no active IT-Senior staff require network device configuration access. Low IT
6 svc-pentest -> Network-Admins Disable or delete svc-pentest immediately. Account has no active use case. If future pentest engagements require AD credentials, provision time-bound accounts per engagement. Low IT / Security

All six remediations are operationally straightforward. None requires an application change, a vendor engagement, or a significant infrastructure modification. The highest-effort items — the Executive-Staff and DevSecOps-Privileged nestings — require stakeholder coordination rather than technical complexity. The remaining four are single group membership removals that an IAM engineer can execute and verify in under an hour each.

The absence of technical complexity is itself a governance finding. These misconfigurations persisted not because they were hard to fix, but because no process existed to detect them. A quarterly privilege graph analysis — automatable with the R code in this document — would have surfaced each of them before they accumulated into a coherent escalation chain.

On Methodological Validity: Does This Generalize?

A reasonable objection to this analysis is that its findings are predetermined. The misconfigurations were planted deliberately, so naturally the graph found them. A skeptical reader might ask whether the method would surface anything in a real environment where no such deliberate design exists.

The answer requires separating two questions that the objection conflates: whether the findings were anticipated, and whether the method is capable of finding unanticipated ones. They are different questions.

The misconfigurations in NexaCore’s topology were designed to be realistic — each has a documented origin story drawn from patterns that appear routinely in enterprise AD environments. But the graph analysis did not find them because they were designed to be findable. It found them because they produce measurable structural signatures: short paths between low-privilege and high-privilege nodes, elevated betweenness centrality at the connector groups, non-trivial reachability sets from principals whose direct memberships appear benign. Those signatures would appear in any topology containing equivalent patterns, regardless of whether any analyst anticipated them.

The more useful framing is to ask what the algorithm would return on a clean topology. If NexaCore’s AD contained no misconfigurations — if every group nesting was appropriate and no stale memberships existed — the output would be: distances() returns Inf for every standard principal, the reachable set contains only accounts that are explicitly and intentionally privileged, and betweenness centrality concentrates entirely on Tier 1 and Tier 2 groups with no anomalous connectors at Tier 3. The algorithm produces a null result correctly. That null result is itself a governance artifact — evidence that the topology is clean at the time of analysis. The method works in both directions.

Real-world AD environments present the analysis with more complexity than NexaCore’s synthetic topology, not less. Production environments accumulate years of organic group nesting across reorganizations, acquisitions, and personnel changes. They contain groups whose original purpose is undocumented and whose membership has not been reviewed since creation. They contain service accounts provisioned for projects that no longer exist. They contain nested groups created by administrators who have since left the organization. Each of these patterns creates exactly the structural signatures the graph analysis is designed to detect. The method does not become less applicable as the environment becomes messier; it becomes more necessary.

One practical consideration for production deployments deserves acknowledgment. The analysis here operates on a complete, clean edge list. Extracting an equivalent dataset from a real AD environment requires appropriate tooling — Get-ADGroupMember with recursion disabled (to capture direct memberships for the graph to traverse), or dedicated identity governance platforms that export the membership graph directly. The extraction step introduces its own governance considerations around data sensitivity. But that is an implementation question, not a methodological one. The graph, once constructed from accurate source data, will find what is there.

Key Takeaways & Conclusion

The most significant finding in this analysis is not the existence of privilege escalation paths – those are present in virtually every enterprise AD environment of comparable age and complexity. The finding is their structure. Every escalation path in NexaCore’s topology passes through Backup-Operators, and Backup-Operators became a critical connector through a single stale group nesting that was never reviewed. This is not a subtle architectural vulnerability. It is a 2019 project membership that nobody cleaned up. The blast radius is the entire Finance-Managers group.

The second takeaway concerns service accounts. Two of the six misconfigurations involve service accounts — svc-reporting in IT-Admins and svc-pentest in Network-Admins. Service accounts are consistently underrepresented in access certification cycles because they are not human principals and therefore fall outside the standard user review process. A governance framework that does not explicitly include service accounts in the privilege graph analysis will systematically miss a category of principal that is both highly privileged and rarely monitored.

The third takeaway is methodological. Graph analysis does not replace access reviews; it replaces the part of access reviews that access reviews cannot do. A point-in-time report showing that a Finance Analyst is a member of Finance-Users and Finance-Systems is accurate and unremarkable. The same analyst’s four-hop path to Domain-Admins is not visible in any standard report format. The only way to surface it is to model the topology as a graph and compute transitive closure. That computation takes seconds on a dataset of this size. The bottleneck has never been the analysis — it has been the absence of a process that requires anyone to run it.

All six misconfigurations identified in this analysis are removable with no application changes, no infrastructure modifications, and no vendor engagement. The remediation backlog in Table 10 represents fewer than two days of combined IAM engineering effort. What it requires, beyond the engineering time, is a periodic process for running the analysis, an owner for each finding, and a commitment to treating stale group nesting as a control failure rather than a configuration artifact.

Session Information

#> ─ Session info ───────────────────────────────────────────────────────────────
#>  setting  value
#>  version  R version 4.5.2 (2025-10-31)
#>  os       macOS Tahoe 26.2
#>  system   aarch64, darwin20
#>  ui       X11
#>  language (EN)
#>  collate  en_US.UTF-8
#>  ctype    en_US.UTF-8
#>  tz       America/New_York
#>  date     2026-06-09
#>  pandoc   3.8.3 @ /Applications/RStudio.app/Contents/Resources/app/quarto/bin/tools/aarch64/ (via rmarkdown)
#>  quarto   1.8.26 @ /usr/local/bin/quarto
#> 
#> ─ Packages ───────────────────────────────────────────────────────────────────
#>  package      * version date (UTC) lib source
#>  archive        1.1.12  2025-03-20 [1] CRAN (R 4.5.0)
#>  bit            4.6.0   2025-03-06 [1] CRAN (R 4.5.0)
#>  bit64          4.6.0-1 2025-01-16 [1] CRAN (R 4.5.0)
#>  cachem         1.1.0   2024-05-16 [1] CRAN (R 4.5.0)
#>  cli            3.6.5   2025-04-23 [1] CRAN (R 4.5.0)
#>  crayon         1.5.3   2024-06-20 [1] CRAN (R 4.5.0)
#>  data.table     1.17.8  2025-07-10 [1] CRAN (R 4.5.0)
#>  digest         0.6.39  2025-11-19 [1] CRAN (R 4.5.2)
#>  dplyr        * 1.1.4   2023-11-17 [1] CRAN (R 4.5.0)
#>  DT           * 0.34.0  2025-09-02 [1] CRAN (R 4.5.0)
#>  evaluate       1.0.5   2025-08-27 [1] CRAN (R 4.5.0)
#>  farver         2.1.2   2024-05-13 [1] CRAN (R 4.5.0)
#>  fastmap        1.2.0   2024-05-15 [1] CRAN (R 4.5.0)
#>  forcats      * 1.0.1   2025-09-25 [1] CRAN (R 4.5.0)
#>  generics       0.1.4   2025-05-09 [1] CRAN (R 4.5.0)
#>  ggforce        0.5.0   2025-06-18 [1] CRAN (R 4.5.0)
#>  ggplot2      * 4.0.2   2026-02-03 [1] CRAN (R 4.5.2)
#>  ggraph       * 2.2.2   2025-08-24 [1] CRAN (R 4.5.0)
#>  ggrepel        0.9.6   2024-09-07 [1] CRAN (R 4.5.0)
#>  glue           1.8.0   2024-09-30 [1] CRAN (R 4.5.0)
#>  graphlayouts   1.2.3   2026-02-21 [1] CRAN (R 4.5.2)
#>  gridExtra      2.3     2017-09-09 [1] CRAN (R 4.5.0)
#>  gtable         0.3.6   2024-10-25 [1] CRAN (R 4.5.0)
#>  hms            1.1.4   2025-10-17 [1] CRAN (R 4.5.0)
#>  htmltools      0.5.8.1 2024-04-04 [1] CRAN (R 4.5.0)
#>  htmlwidgets    1.6.4   2023-12-06 [1] CRAN (R 4.5.0)
#>  httr           1.4.7   2023-08-15 [1] CRAN (R 4.5.0)
#>  igraph       * 2.2.1   2025-10-27 [1] CRAN (R 4.5.0)
#>  jsonlite       2.0.0   2025-03-27 [1] CRAN (R 4.5.0)
#>  kableExtra   * 1.4.0   2024-01-24 [1] CRAN (R 4.5.0)
#>  knitr        * 1.50    2025-03-16 [1] CRAN (R 4.5.0)
#>  labeling       0.4.3   2023-08-29 [1] CRAN (R 4.5.0)
#>  lazyeval       0.2.2   2019-03-15 [1] CRAN (R 4.5.0)
#>  lifecycle      1.0.5   2026-01-08 [1] CRAN (R 4.5.2)
#>  lubridate    * 1.9.4   2024-12-08 [1] CRAN (R 4.5.0)
#>  magrittr       2.0.4   2025-09-12 [1] CRAN (R 4.5.0)
#>  MASS           7.3-65  2025-02-28 [1] CRAN (R 4.5.2)
#>  memoise        2.0.1   2021-11-26 [1] CRAN (R 4.5.0)
#>  pillar         1.11.1  2025-09-17 [1] CRAN (R 4.5.0)
#>  pkgconfig      2.0.3   2019-09-22 [1] CRAN (R 4.5.0)
#>  plotly       * 4.11.0  2025-06-19 [1] CRAN (R 4.5.0)
#>  polyclip       1.10-7  2024-07-23 [1] CRAN (R 4.5.0)
#>  purrr        * 1.2.0   2025-11-04 [1] CRAN (R 4.5.0)
#>  R6             2.6.1   2025-02-15 [1] CRAN (R 4.5.0)
#>  RColorBrewer   1.1-3   2022-04-03 [1] CRAN (R 4.5.0)
#>  Rcpp           1.1.0   2025-07-02 [1] CRAN (R 4.5.0)
#>  readr        * 2.1.5   2024-01-10 [1] CRAN (R 4.5.0)
#>  rlang          1.1.7   2026-01-09 [1] CRAN (R 4.5.2)
#>  rmarkdown      2.30    2025-09-28 [1] CRAN (R 4.5.0)
#>  rstudioapi     0.17.1  2024-10-22 [1] CRAN (R 4.5.0)
#>  S7             0.2.1   2025-11-14 [1] CRAN (R 4.5.2)
#>  scales       * 1.4.0   2025-04-24 [1] CRAN (R 4.5.0)
#>  sessioninfo  * 1.2.3   2025-02-05 [1] CRAN (R 4.5.0)
#>  stringi        1.8.7   2025-03-27 [1] CRAN (R 4.5.0)
#>  stringr      * 1.6.0   2025-11-04 [1] CRAN (R 4.5.0)
#>  svglite        2.2.2   2025-10-21 [1] CRAN (R 4.5.0)
#>  systemfonts    1.3.1   2025-10-01 [1] CRAN (R 4.5.0)
#>  textshaping    1.0.4   2025-10-10 [1] CRAN (R 4.5.0)
#>  tibble       * 3.3.0   2025-06-08 [1] CRAN (R 4.5.0)
#>  tidygraph    * 1.3.1   2024-01-30 [1] CRAN (R 4.5.0)
#>  tidyr        * 1.3.1   2024-01-24 [1] CRAN (R 4.5.0)
#>  tidyselect     1.2.1   2024-03-11 [1] CRAN (R 4.5.0)
#>  tidyverse    * 2.0.0   2023-02-22 [1] CRAN (R 4.5.0)
#>  timechange     0.3.0   2024-01-18 [1] CRAN (R 4.5.0)
#>  tweenr         2.0.3   2024-02-26 [1] CRAN (R 4.5.0)
#>  tzdb           0.5.0   2025-03-15 [1] CRAN (R 4.5.0)
#>  vctrs          0.7.1   2026-01-23 [1] CRAN (R 4.5.2)
#>  viridis        0.6.5   2024-01-29 [1] CRAN (R 4.5.0)
#>  viridisLite    0.4.3   2026-02-04 [1] CRAN (R 4.5.2)
#>  vroom          1.6.6   2025-09-19 [1] CRAN (R 4.5.0)
#>  withr          3.0.2   2024-10-28 [1] CRAN (R 4.5.0)
#>  xfun           0.54    2025-10-30 [1] CRAN (R 4.5.0)
#>  xml2           1.4.1   2025-10-27 [1] CRAN (R 4.5.0)
#>  yaml           2.3.10  2024-07-26 [1] CRAN (R 4.5.0)
#> 
#>  [1] /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/library
#>  * ── Packages attached to the search path.
#> 
#> ──────────────────────────────────────────────────────────────────────────────

Rendered with Quarto. Analysis performed in R using igraph, tidygraph, ggraph, tidyverse, and kableExtra. Synthetic AD data generated with generate_ad_data.py. All principals, organizations, and topologies are fictitious.