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 => "attack constrained --target {to}",
124 EdgeKind::UnconstrainedDelegation => return None, 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 _ => return None,
144 },
145 })
146 }
147
148 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 #[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); 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 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 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 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 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 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 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 fn add_delegation_edges(&mut self, snap: &Snapshot, o: &AdObject, self_sid: &Sid) {
305 use adhammer_core::object::uac;
306
307 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 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 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 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 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 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 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 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 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#[derive(Clone, Debug, serde::Serialize)]
481pub struct Step {
482 pub from: String,
483 pub from_sid: String,
484 pub edge: &'static str,
486 pub to: String,
487 pub to_sid: String,
488 pub impact: &'static str,
489 pub mitigation: &'static str,
490 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 pub steps: Vec<Step>,
503}
504
505impl AttackPath {
506 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 pub fn fully_executable(&self) -> bool {
520 !self.steps.is_empty() && self.steps.iter().all(|s| s.command.is_some())
521 }
522}
523
524fn spn_host_sid(snap: &Snapshot, spn: &str) -> Option<Sid> {
526 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
547fn 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 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 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 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 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}