Lab 01 — Active Directory Attack-Path Solver (BloodHound-style)
Lens: Identity attack paths over the directory graph. WARMUP: Chapter 11 (the BloodHound graph model), with the edge techniques drawn from Chapters 5–10. Engagement tie-in: Operation Cedar Lattice, Phase 05. With a foothold user on a Meridian Freight workstation, you model Meridian's directory as a graph and compute the cheapest, quietest chain of identity abuses from that owned user to Domain Admins — then hand the defender the edges to cut.
The problem
A modern Active Directory forest is not a list of permissions — it is a graph of who-can-do-what-
to-whom. SharpHound collects it; BloodHound walks it. The reason AD intrusions so often end in
total domain compromise is that the graph is dense with abusable relationships nobody audited:
a help-desk group with ForceChangePassword over a service account, a workstation where a Domain
Admin left a session, an object with GenericAll granted "temporarily" three years ago. Each of
these is an edge. Chain enough edges and an unprivileged foothold reaches Domain Admins.
The attacker's question is a shortest-path question — but the cost is not hops, it is attacker
effort and detectability. Reusing a live admin session (HasSession → dump) is cheap but loud;
walking MemberOf group edges is free and silent; cracking a delegated ticket is expensive. The
defender's question is the dual: which edges lie on every path — the choke points — so I cut
the fewest things and break the most paths? This lab answers both over a synthetic directory.
What you build (lab.py)
The edge taxonomy (EDGE_CATALOG), the per-edge-type cost table (DEFAULT_COST), the high-value
set (DEFAULT_HIGH_VALUE), and the Edge dataclass are given. You implement the graph
algorithms:
shortest_path(edges, start, target)→(node_path, edge_path, total_cost)— Dijkstra overEdge.effective_cost();(None, (), inf)if unreachable;((start,), (), 0.0)ifstart == target.path_cost(edges, start, target)→ the total attacker effort of the cheapest path.reachable_high_value(edges, start, high_value=None)→ every reachable high-value target as(target, cheapest_cost), ranked by cost (case-insensitive name match).choke_edges(edges, start, target)→ the(src, dst, kind)edges whose removal disconnects the target — the highest-leverage remediations.path_techniques(edge_path)→ per-step{src, dst, kind, cost, technique, technique_name, detection}so every hop names its ATT&CK technique and the Windows/Kerberos event that detects it.
The edge taxonomy (a slice of the BloodHound model)
| Edge | Abuse | ATT&CK | Detection (the pairing) |
|---|---|---|---|
MemberOf | inherit a group's rights | T1078 | none alone; the granted right is what fires |
AdminTo | local admin → creds of anyone on the host | T1078.002 | 4624 type 3/10 then 4672 on the host |
HasSession | harvest a logged-on user's creds | T1003 | 4624 session; LSASS access (Sysmon 10) |
CanRDP / CanPSRemote | remote interactive / WinRM | T1021.001/.006 | 4624 type 10 / 4778-4779; 5985-5986, PS 4104 |
GenericAll / GenericWrite | full / attribute control of an object | T1098 | 4662 / 5136 on the target object |
WriteDacl / WriteOwner | rewrite the object's ACL | T1222.001 / T1098 | 4670 / 5136 nTSecurityDescriptor write |
ForceChangePassword | reset the victim's password | T1098 | 4724 password reset by another account |
AddMember | add self to a privileged group | T1098 | 4728/4732/4756 member added |
AddKeyCredentialLink | shadow credentials (PKINIT) | T1556 | 5136 msDS-KeyCredentialLink; 4768 PKINIT |
AllowedToDelegate / AllowedToAct | constrained / RBCD delegation abuse | T1558 | 4769 S4U pattern; 5136 RBCD write |
ADCSESC1 | enroll a cert that authenticates as anyone | T1649 | CA 4886/4887 with attacker SAN; 4768 PKINIT |
DCSync | replicate secrets from the DC | T1003.006 | 4662 with DS-Replication-Get-Changes GUIDs |
The cost per edge orders least-resistance paths; lower is quieter. MemberOf is ~free, a
session harvest is moderate, DCSync/cert theft is expensive.
Attack cases the tests cover
- Known path found. A foothold user reaches Domain Admins; the returned node path and edge path are consistent (each edge connects consecutive nodes).
- Weighting picks the quieter path. Given a loud session-harvest route and a quiet
GenericAllroute to the same target, Dijkstra returns the quiet one (the path of least resistance, not fewest hops); a cheaper long path beats an expensive direct edge; an explicit per-edge cost overrides the type default. - Unreachable target returns
(None, (), inf); an isolated component is never reached. - Reachable high-value targets are ranked by cost — Domain Admins (cheap) ranks above KRBTGT (needs an extra DCSync hop).
- Choke edge identified. The single
MemberOfedge into Domain Admins lies on every path and is flagged; theGenericAlledge — which has an alternate route around it — is not a choke. - Technique + detection labeled for every step; an uncatalogued edge kind degrades to a safe
UNKNOWNlabel instead of crashing the solver. - Determinism — identical inputs give identical paths, rankings, and choke sets.
Run
pip install -r requirements.txt
LAB_MODULE=solution pytest -q # reference passes (12 tests)
pytest -q # your implementation after the TODOs
Hardening / detection (the break-the-chain pairing)
choke_edges is the remediation engine: it names the few edges whose removal severs the most
paths to the crown jewels — the help-desk ForceChangePassword, the stale GenericAll, the Domain
Admin's HasSession on a tier-2 box. Cutting a choke edge (remove the dangerous ACE; enforce
tiered admin so DAs never log on to workstations; convert to gMSA so service-account creds
cannot be reset and reused) is worth more than patching ten leaf nodes. path_techniques ties
every surviving edge to the exact telemetry — 4662/5136 for DACL abuse, 4724 for a forced reset,
4769 for delegation, 4886/4887 for ADCS — so the SOC can build the detection for the paths you
cannot cut. The lesson: in AD, remediation is graph surgery, and detection is per-edge.
Extensions (your own isolated, owned range)
- Derive edge cost from real BloodHound signals (session freshness, OS patch level, whether the abuse needs cracking) instead of static type costs.
- Compute a true minimum-cost edge cut (cheapest set of edges to disconnect the high-value set), not just single-edge choke points.
- Add inbound high-value reasoning: rank every owned principal by how many high-value targets it can reach (the "where would an attacker start?" view defenders use to find toxic foothold accounts).
- Ingest a real SharpHound JSON export from an owned, purpose-built GOAD-style forest (see
HITCHHIKERS-GUIDE.md) and solve over it — never a production domain. - Emit a Cypher query equivalent for each found path so a blue team can reproduce it in their own BloodHound instance.
Interview / resume
"Built a BloodHound-style AD attack-path solver: model the directory as a typed, weighted, directed graph; Dijkstra finds the quietest (least-resistance) chain of identity abuses from an owned principal to Domain Admins; choke-edge analysis names the minimum set of dangerous ACLs/sessions a defender must cut; every edge is labeled with its ATT&CK technique and the Windows/Kerberos event that detects it."
Limitations: a single static cost per edge type (real BloodHound weights by exploitability and session freshness); choke edges found by single-edge removal, not a formal min-cut; the edge→technique catalog is a representative slice of the BloodHound taxonomy, not exhaustive; costs are illustrative ordering signals, not measured probabilities; reasons over synthetic directory metadata only — no live AD, no collection, no weaponization.