Skip to main content

adhammer_graph/
lib.rs

1//! Control-path graph (the BloodHound-style layer, in-process on petgraph).
2//!
3//! Nodes are security principals; a directed edge `A -> B` means "A holds a primitive
4//! that lets it control B". We seed Tier-0 (Domain/Enterprise Admins, DC computers,
5//! the domain head) and do a reverse traversal to find every principal that can reach it.
6//! Each edge carries a weight; the cheapest path is the attacker's likely route.
7
8use adhammer_core::sid::Sid;
9use adhammer_core::snapshot::Snapshot;
10use adhammer_core::AdObject;
11use petgraph::graph::{DiGraph, NodeIndex};
12use petgraph::visit::EdgeRef;
13use std::collections::HashMap;
14
15pub use ad_acl::{ControlPrimitive, SchemaMap};
16
17/// Edge label. ACL-derived edges are [`ad_acl::ControlPrimitive`]s; the rest come from object
18/// attributes rather than from a security descriptor.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum EdgeKind {
21    /// Group membership.
22    MemberOf,
23    /// Derived from an ACE on the target's security descriptor.
24    Acl(ControlPrimitive),
25    /// `msDS-AllowedToDelegateTo` names a service on the target — constrained delegation
26    /// with protocol transition impersonates any user to it.
27    AllowedToDelegate,
28    /// The source runs with `TRUSTED_FOR_DELEGATION`: coerce the target to authenticate to
29    /// it and its TGT lands in the source's cache.
30    UnconstrainedDelegation,
31    /// The source carries the target's SID in `sIDHistory` — it already *is* the target as
32    /// far as access checks are concerned.
33    SidHistory,
34    /// A privileged principal has a live logon session on the source host; controlling the host
35    /// yields that principal's credentials/TGT. Populated by `enum sessions` (S2).
36    HasSession,
37    /// The target can be coerced into authenticating to an attacker listener (MS-EFSR/RPRN/
38    /// DFSNM/FSRVP), feeding an NTLM relay. Populated by coercion posture (S2).
39    Coercible,
40    /// The source is a local administrator on the target host — full control and secret
41    /// extraction. Populated by session/local-admin collection (S5).
42    AdminTo,
43}
44
45impl EdgeKind {
46    /// Lower = cheaper for the attacker = more dangerous.
47    pub fn weight(self) -> u32 {
48        match self {
49            EdgeKind::MemberOf | EdgeKind::SidHistory | EdgeKind::AdminTo => 0,
50            EdgeKind::Acl(p) => p.cost(),
51            EdgeKind::AllowedToDelegate
52            | EdgeKind::UnconstrainedDelegation
53            | EdgeKind::Coercible => 2,
54            EdgeKind::HasSession => 1,
55        }
56    }
57
58    pub fn name(self) -> &'static str {
59        match self {
60            EdgeKind::MemberOf => "MemberOf",
61            EdgeKind::Acl(p) => p.name(),
62            EdgeKind::AllowedToDelegate => "AllowedToDelegate",
63            EdgeKind::UnconstrainedDelegation => "UnconstrainedDelegation",
64            EdgeKind::SidHistory => "SidHistory",
65            EdgeKind::HasSession => "HasSession",
66            EdgeKind::Coercible => "Coercible",
67            EdgeKind::AdminTo => "AdminTo",
68        }
69    }
70
71    /// What traversing this edge gets the attacker.
72    pub fn impact(self) -> &'static str {
73        match self {
74            EdgeKind::MemberOf => "inherits every privilege of the group",
75            EdgeKind::Acl(p) => p.impact(),
76            EdgeKind::AllowedToDelegate => {
77                "S4U2Self+S4U2Proxy impersonates any user to the allowed service"
78            }
79            EdgeKind::UnconstrainedDelegation => {
80                "coerce the target to authenticate here and its TGT is captured, then replayed"
81            }
82            EdgeKind::SidHistory => "access checks already grant the target's rights",
83            EdgeKind::HasSession => {
84                "a privileged logon session is live on this host — its TGT/credentials can be dumped"
85            }
86            EdgeKind::Coercible => {
87                "can be coerced to authenticate to an attacker listener, feeding an NTLM relay"
88            }
89            EdgeKind::AdminTo => "local administrator on the target — full control and secret extraction",
90        }
91    }
92
93    /// How a defender removes this edge.
94    pub fn mitigation(self) -> &'static str {
95        match self {
96            EdgeKind::MemberOf => "remove the principal from the group",
97            EdgeKind::Acl(p) => p.mitigation(),
98            EdgeKind::AllowedToDelegate => {
99                "clear msDS-AllowedToDelegateTo; mark Tier-0 accounts sensitive and non-delegatable"
100            }
101            EdgeKind::UnconstrainedDelegation => {
102                "clear TRUSTED_FOR_DELEGATION, put Tier-0 in Protected Users, block the coercion RPCs"
103            }
104            EdgeKind::SidHistory => "clean sIDHistory after the migration and enable SID filtering",
105            EdgeKind::HasSession => {
106                "keep privileged logons off member hosts; enable Credential Guard; Protected Users"
107            }
108            EdgeKind::Coercible => {
109                "patch MS-EFSR/RPRN/DFSNM/FSRVP coercion and require SMB + LDAP signing"
110            }
111            EdgeKind::AdminTo => "remove the local-admin grant; deploy LAPS; enforce Tier-0 isolation",
112        }
113    }
114
115    /// The `adhammer` command that walks this edge, as a template over `{from}` and `{to}`.
116    ///
117    /// `None` means the tool can *find* this edge but cannot yet *walk* it — which is exactly
118    /// the roadmap: an edge without an executor is an attack still owed.
119    pub fn executor(self) -> Option<&'static str> {
120        use ControlPrimitive as P;
121        Some(match self {
122            EdgeKind::MemberOf | EdgeKind::SidHistory => return None,
123            EdgeKind::AllowedToDelegate => "attack constrained --target {to}",
124            EdgeKind::UnconstrainedDelegation => return None, // planned: attack unconstrained
125            EdgeKind::HasSession => "attack secretsdump --host {from}",
126            EdgeKind::Coercible => "attack coerce --host {to} --listener <ATTACKER>",
127            EdgeKind::AdminTo => "attack secretsdump --host {to}",
128            EdgeKind::Acl(p) => match p {
129                P::DcsyncGetChangesAll | P::AllExtendedRights => "attack dcsync --user krbtgt",
130                P::AddMember | P::AddSelfToGroup => {
131                    "attack abuse --add-member --group {to} --member {from}"
132                }
133                P::ForceChangePassword => "attack abuse --set-password --target {to}",
134                P::WriteRbcd | P::GenericAll | P::GenericWrite | P::WriteDacl | P::Owns => {
135                    "attack abuse --write-rbcd --target {to} && attack rbcd --target {to}"
136                }
137                P::WriteSpn => "attack abuse --add-spn --target {to} && attack roast",
138                P::ReadGmsaPassword => "attack gmsa --account {to}",
139                P::ReadLapsPassword => "attack laps --computer {to}",
140                P::Enroll => "attack esc1 --template {to}",
141                // Found by `enum`, not yet executable: shadow credentials outside the relay
142                // path, BadSuccessor, template rewrite, the rest.
143                _ => return None,
144            },
145        })
146    }
147
148    /// [`EdgeKind::executor`] with the endpoints filled in.
149    pub fn command(self, from: &str, to: &str) -> Option<String> {
150        self.executor()
151            .map(|t| format!("adhammer {}", t.replace("{from}", from).replace("{to}", to)))
152    }
153}
154
155impl From<ControlPrimitive> for EdgeKind {
156    fn from(p: ControlPrimitive) -> Self {
157        EdgeKind::Acl(p)
158    }
159}
160
161#[cfg(test)]
162mod edge_tests {
163    use super::*;
164
165    /// The S2/S5 edge kinds each carry a name, impact, mitigation, and (for the walkable ones)
166    /// an `adhammer` command — so `scan` can plan and report them like every other hop.
167    #[test]
168    fn new_edges_are_fully_described() {
169        for e in [EdgeKind::HasSession, EdgeKind::Coercible, EdgeKind::AdminTo] {
170            assert!(!e.name().is_empty());
171            assert!(!e.impact().is_empty());
172            assert!(!e.mitigation().is_empty());
173        }
174        assert_eq!(EdgeKind::AdminTo.weight(), 0); // already admin — free hop
175        assert_eq!(EdgeKind::HasSession.weight(), 1);
176        assert!(EdgeKind::Coercible
177            .command("bob", "dc01")
178            .unwrap()
179            .starts_with("adhammer attack coerce"));
180        assert!(EdgeKind::AdminTo
181            .command("bob", "srv01")
182            .unwrap()
183            .contains("secretsdump --host srv01"));
184        assert!(EdgeKind::HasSession
185            .command("dc01", "admin")
186            .unwrap()
187            .contains("secretsdump --host dc01"));
188    }
189}
190
191#[derive(Clone, Debug)]
192pub struct Node {
193    pub sid: Sid,
194    pub label: String,
195    pub tier0: bool,
196}
197
198pub struct ControlGraph {
199    g: DiGraph<Node, EdgeKind>,
200    by_sid: HashMap<String, NodeIndex>,
201}
202
203impl ControlGraph {
204    pub fn build(snap: &Snapshot) -> Self {
205        Self::build_with(snap, &SchemaMap::new())
206    }
207
208    /// [`ControlGraph::build`] with a forest schema map, so ACEs on LAPS / gMSA / dMSA
209    /// attributes — whose GUIDs differ per forest — become edges too.
210    pub fn build_with(snap: &Snapshot, schema: &SchemaMap) -> Self {
211        let mut cg = ControlGraph {
212            g: DiGraph::new(),
213            by_sid: HashMap::new(),
214        };
215
216        // 1. one node per principal that has a SID.
217        for o in &snap.objects {
218            if let Some(sid) = o.bin1("objectSid").and_then(Sid::from_bytes) {
219                let tier0 = is_tier0(snap, &sid);
220                cg.ensure(sid, label_of(o), tier0);
221            }
222        }
223
224        // 2. edges.
225        for o in &snap.objects {
226            let Some(dst) = o.bin1("objectSid").and_then(Sid::from_bytes) else {
227                continue;
228            };
229            cg.add_membership_edges(snap, o, &dst);
230            cg.add_acl_edges(o, &dst, schema);
231            cg.add_rbcd_edge(o, &dst);
232            cg.add_delegation_edges(snap, o, &dst);
233            cg.add_sid_history_edges(snap, o, &dst);
234        }
235        cg
236    }
237
238    fn ensure(&mut self, sid: Sid, label: String, tier0: bool) -> NodeIndex {
239        if let Some(&ix) = self.by_sid.get(&sid.to_string()) {
240            if tier0 {
241                self.g[ix].tier0 = true;
242            }
243            return ix;
244        }
245        let key = sid.to_string();
246        let ix = self.g.add_node(Node { sid, label, tier0 });
247        self.by_sid.insert(key, ix);
248        ix
249    }
250
251    fn node_for(&mut self, sid: Sid) -> NodeIndex {
252        self.ensure(sid.clone(), sid.to_string(), false)
253    }
254
255    fn add_membership_edges(&mut self, snap: &Snapshot, o: &AdObject, self_sid: &Sid) {
256        // memberOf: this principal -> group. Edge means "member can act as group".
257        let me = self.node_for(self_sid.clone());
258        for group_dn in o.all("memberOf") {
259            if let Some(g) = snap
260                .by_dn(group_dn)
261                .and_then(|g| g.bin1("objectSid"))
262                .and_then(Sid::from_bytes)
263            {
264                let gx = self.node_for(g);
265                self.g.add_edge(me, gx, EdgeKind::MemberOf);
266            }
267        }
268
269        // Primary group membership is *not* in memberOf — it is a bare RID on the account.
270        // Hiding privilege there is a known trick, so the edge has to be drawn from it too.
271        if let Some(rid) = o.one("primaryGroupID").and_then(|s| s.parse::<u32>().ok()) {
272            if let Some(dsid) = &snap.domain.domain_sid {
273                let mut group = dsid.clone();
274                group.sub_authorities.push(rid);
275                if group != *self_sid {
276                    let gx = self.node_for(group);
277                    self.g.add_edge(me, gx, EdgeKind::MemberOf);
278                }
279            }
280        }
281    }
282
283    fn add_rbcd_edge(&mut self, o: &AdObject, self_sid: &Sid) {
284        // msDS-AllowedToActOnBehalfOfOtherIdentity is itself an SD; anyone in it can act on `self`.
285        if let Some(raw) = o.bin1("msDS-AllowedToActOnBehalfOfOtherIdentity") {
286            if let Ok(sd) = windows_sddl::parse(raw) {
287                let target = self.node_for(self_sid.clone());
288                for ace in sd
289                    .dacl
290                    .iter()
291                    .flat_map(|d| &d.aces)
292                    .filter(|a| a.is_allow())
293                {
294                    let src = self.node_for(ace.trustee.clone());
295                    self.g
296                        .add_edge(src, target, ControlPrimitive::WriteRbcd.into());
297                }
298            }
299        }
300    }
301
302    /// Delegation, both flavours. Neither lives in a security descriptor, so neither shows up
303    /// in the ACL pass — which is why an ACL-only graph misses the shortest routes to a DC.
304    fn add_delegation_edges(&mut self, snap: &Snapshot, o: &AdObject, self_sid: &Sid) {
305        use adhammer_core::object::uac;
306
307        // Constrained: this account may impersonate anyone to the named services.
308        let me = self.node_for(self_sid.clone());
309        for spn in o.all("msDS-AllowedToDelegateTo") {
310            if let Some(target) = spn_host_sid(snap, spn) {
311                let tx = self.node_for(target);
312                self.g.add_edge(me, tx, EdgeKind::AllowedToDelegate);
313            }
314        }
315
316        // Unconstrained: anything coerced into authenticating here leaves its TGT behind.
317        // The DCs are the payoff, so that is the edge worth drawing.
318        if o.uac() & uac::TRUSTED_FOR_DELEGATION != 0 {
319            let dcs: Vec<Sid> = snap
320                .objects
321                .iter()
322                .filter(|c| is_domain_controller(c))
323                .filter_map(|c| c.bin1("objectSid").and_then(Sid::from_bytes))
324                .filter(|s| s != self_sid)
325                .collect();
326            for dc in dcs {
327                let dx = self.node_for(dc);
328                self.g.add_edge(me, dx, EdgeKind::UnconstrainedDelegation);
329            }
330        }
331    }
332
333    /// `sIDHistory` — the holder already carries the other principal's access.
334    fn add_sid_history_edges(&mut self, snap: &Snapshot, o: &AdObject, self_sid: &Sid) {
335        let raws: Vec<Sid> = o
336            .all("sIDHistory")
337            .iter()
338            .filter_map(|s| Sid::parse(s))
339            .filter(|s| snap.by_sid(s).is_some())
340            .collect();
341        if raws.is_empty() {
342            return;
343        }
344        let me = self.node_for(self_sid.clone());
345        for sid in raws {
346            let tx = self.node_for(sid);
347            self.g.add_edge(me, tx, EdgeKind::SidHistory);
348        }
349    }
350
351    fn add_acl_edges(&mut self, o: &AdObject, self_sid: &Sid, schema: &SchemaMap) {
352        let Some(raw) = o.bin1("nTSecurityDescriptor") else {
353            return;
354        };
355        let Ok(sd) = windows_sddl::parse(raw) else {
356            return;
357        };
358        let target = self.node_for(self_sid.clone());
359
360        // `ad_acl` owns the ACE→primitive semantics (deny ACEs yield nothing); we only
361        // decide which trustees are worth a node.
362        for grant in ad_acl::grants_with(&sd, schema) {
363            if grant.trustee_is_well_known() {
364                continue;
365            }
366            let src = self.node_for(grant.trustee.clone());
367            self.g.add_edge(src, target, grant.primitive.into());
368        }
369    }
370
371    /// Every principal that can reach any Tier-0 node, with the cheapest route and the exact
372    /// steps it walks.
373    ///
374    /// Dijkstra runs *backwards* from each Tier-0 node over incoming edges, so the recorded
375    /// predecessor is the next hop forwards — reversing it yields the attacker's route.
376    pub fn paths_to_tier0(&self) -> Vec<AttackPath> {
377        use petgraph::Direction::Incoming;
378        use std::cmp::Reverse;
379        use std::collections::BinaryHeap;
380
381        let mut out = Vec::new();
382        for tix in self.g.node_indices().filter(|&i| self.g[i].tier0) {
383            // next[x] = (node after x on the way to tix, edge x -> that node)
384            let mut next: HashMap<NodeIndex, (NodeIndex, EdgeKind)> = HashMap::new();
385            let mut dist: HashMap<NodeIndex, u32> = HashMap::new();
386            let mut heap = BinaryHeap::new();
387
388            dist.insert(tix, 0);
389            heap.push(Reverse((0u32, tix.index())));
390
391            while let Some(Reverse((cost, raw))) = heap.pop() {
392                let node = NodeIndex::new(raw);
393                if cost > *dist.get(&node).unwrap_or(&u32::MAX) {
394                    continue;
395                }
396                for e in self.g.edges_directed(node, Incoming) {
397                    let src = e.source();
398                    let kind = *e.weight();
399                    let nc = cost.saturating_add(kind.weight());
400                    if nc < *dist.get(&src).unwrap_or(&u32::MAX) {
401                        dist.insert(src, nc);
402                        next.insert(src, (node, kind));
403                        heap.push(Reverse((nc, src.index())));
404                    }
405                }
406            }
407
408            for (src, cost) in dist {
409                if src == tix || self.g[src].tier0 {
410                    continue;
411                }
412                out.push(AttackPath {
413                    principal: self.g[src].label.clone(),
414                    principal_sid: self.g[src].sid.to_string(),
415                    target: self.g[tix].label.clone(),
416                    cost,
417                    steps: self.walk(src, tix, &next),
418                });
419            }
420        }
421        out.sort_by(|a, b| a.cost.cmp(&b.cost).then(a.principal.cmp(&b.principal)));
422        out
423    }
424
425    /// Follow the `next` map from `src` to `dst`, turning each hop into a [`Step`].
426    fn walk(
427        &self,
428        src: NodeIndex,
429        dst: NodeIndex,
430        next: &HashMap<NodeIndex, (NodeIndex, EdgeKind)>,
431    ) -> Vec<Step> {
432        let mut steps = Vec::new();
433        let mut cur = src;
434        // The map is acyclic by construction; the node bound is a belt-and-braces stop.
435        while cur != dst && steps.len() < self.g.node_count() {
436            let Some(&(to, edge)) = next.get(&cur) else {
437                break;
438            };
439            let from_label = self.g[cur].label.clone();
440            let to_label = self.g[to].label.clone();
441            steps.push(Step {
442                command: edge.command(&from_label, &to_label),
443                from: from_label,
444                from_sid: self.g[cur].sid.to_string(),
445                edge: edge.name(),
446                to: to_label,
447                to_sid: self.g[to].sid.to_string(),
448                impact: edge.impact(),
449                mitigation: edge.mitigation(),
450            });
451            cur = to;
452        }
453        steps
454    }
455
456    pub fn stats(&self) -> (usize, usize) {
457        (self.g.node_count(), self.g.edge_count())
458    }
459
460    /// Direct `kind` edges from a non-Tier-0 principal into a Tier-0 node.
461    pub fn direct_edges_to_tier0(&self, kind: EdgeKind) -> Vec<(String, String)> {
462        let mut out = Vec::new();
463        for e in self.g.edge_indices() {
464            if self.g[e] != kind {
465                continue;
466            }
467            let (src, dst) = self.g.edge_endpoints(e).unwrap();
468            if self.g[dst].tier0 && !self.g[src].tier0 {
469                out.push((self.g[src].label.clone(), self.g[dst].label.clone()));
470            }
471        }
472        out.sort();
473        out.dedup();
474        out
475    }
476}
477
478/// One hop of an attack path, with everything a report needs about it: what it is, what it
479/// buys the attacker, the command that walks it, and how a defender removes it.
480#[derive(Clone, Debug, serde::Serialize)]
481pub struct Step {
482    pub from: String,
483    pub from_sid: String,
484    /// Edge label, e.g. `AddKeyCredential`.
485    pub edge: &'static str,
486    pub to: String,
487    pub to_sid: String,
488    pub impact: &'static str,
489    pub mitigation: &'static str,
490    /// The `adhammer` invocation for this hop, or `None` when the tool can find the edge but
491    /// cannot yet walk it.
492    pub command: Option<String>,
493}
494
495#[derive(Clone, Debug, serde::Serialize)]
496pub struct AttackPath {
497    pub principal: String,
498    pub principal_sid: String,
499    pub target: String,
500    pub cost: u32,
501    /// The route from `principal` to `target`, hop by hop.
502    pub steps: Vec<Step>,
503}
504
505impl AttackPath {
506    /// `user → [AddKeyCredential] → svc → [MemberOf] → Domain Admins`
507    pub fn render(&self) -> String {
508        if self.steps.is_empty() {
509            return format!("{} → {}", self.principal, self.target);
510        }
511        let mut s = self.principal.clone();
512        for st in &self.steps {
513            s.push_str(&format!(" → [{}] → {}", st.edge, st.to));
514        }
515        s
516    }
517
518    /// `true` if every hop has an executor — the whole route can be walked by the tool.
519    pub fn fully_executable(&self) -> bool {
520        !self.steps.is_empty() && self.steps.iter().all(|s| s.command.is_some())
521    }
522}
523
524/// `HOST/dc01.testlab.local:1234/whatever` → the SID of the account that owns that SPN.
525fn spn_host_sid(snap: &Snapshot, spn: &str) -> Option<Sid> {
526    // The host part is the second field, minus any port or instance suffix.
527    let host = spn
528        .split('/')
529        .nth(1)?
530        .split(':')
531        .next()?
532        .to_ascii_lowercase();
533    let short = host.split('.').next().unwrap_or(&host);
534
535    snap.objects
536        .iter()
537        .find(|o| {
538            o.all("servicePrincipalName")
539                .iter()
540                .any(|s| s.eq_ignore_ascii_case(spn))
541        })
542        .or_else(|| snap.by_sam(&format!("{short}$")))
543        .and_then(|o| o.bin1("objectSid"))
544        .and_then(Sid::from_bytes)
545}
546
547/// A domain controller: a computer whose `userAccountControl` carries the SERVER_TRUST_ACCOUNT
548/// bit, which is what makes it a DC rather than a member server.
549fn is_domain_controller(o: &AdObject) -> bool {
550    const SERVER_TRUST_ACCOUNT: u32 = 0x0000_2000;
551    o.uac() & SERVER_TRUST_ACCOUNT != 0
552}
553
554fn is_tier0(snap: &Snapshot, sid: &Sid) -> bool {
555    use adhammer_core::sid::rid;
556
557    // A domain controller's *computer account* is Tier-0 by definition — it holds the
558    // replication rights. Its RID is an ordinary one, so the RID table below never catches it.
559    if snap.by_sid(sid).is_some_and(is_domain_controller) {
560        return true;
561    }
562
563    let Some(rid) = sid.rid() else { return false };
564    // domain-relative privileged RIDs
565    if matches!(
566        rid,
567        rid::DOMAIN_ADMINS
568            | rid::ENTERPRISE_ADMINS
569            | rid::SCHEMA_ADMINS
570            | rid::ADMINISTRATOR
571            | rid::KRBTGT
572            | rid::DOMAIN_CONTROLLERS
573    ) {
574        // ensure it belongs to this domain (or is builtin admins)
575        if let Some(dsid) = &snap.domain.domain_sid {
576            let prefix = &sid.sub_authorities[..sid.sub_authorities.len().saturating_sub(1)];
577            if prefix == &dsid.sub_authorities[..] {
578                return true;
579            }
580        }
581    }
582    // BUILTIN\Administrators  S-1-5-32-544
583    sid.identifier_authority == 5
584        && sid.sub_authorities.first() == Some(&32)
585        && sid.rid() == Some(rid::ADMINISTRATORS_BUILTIN)
586}
587
588fn label_of(o: &AdObject) -> String {
589    o.one("sAMAccountName")
590        .map(String::from)
591        .unwrap_or_else(|| o.dn.clone())
592}