Skip to main content

ad_acl/
lib.rs

1//! Active Directory ACL semantics.
2//!
3//! [`windows-sddl`](https://docs.rs/windows-sddl) turns a `nTSecurityDescriptor` blob into
4//! ACEs. This crate answers the next question: *what can the trustee actually do with it?*
5//! An ACE carrying `WRITE_PROP` plus object GUID `5b47d60f-…` is not "write property" — it
6//! is [`ControlPrimitive::AddKeyCredential`], i.e. Shadow Credentials, i.e. account takeover
7//! without touching the password.
8//!
9//! ```no_run
10//! use ad_acl::{grants, ControlPrimitive};
11//! # let raw_nt_security_descriptor: Vec<u8> = vec![];
12//!
13//! let sd = windows_sddl::parse(&raw_nt_security_descriptor).unwrap();
14//! for g in grants(&sd) {
15//!     if g.primitive == ControlPrimitive::DcsyncGetChangesAll {
16//!         println!("{} can DCSync — {}", g.trustee, g.primitive.mitigation());
17//!     }
18//! }
19//! ```
20//!
21//! Forest-specific attributes (LAPS, gMSA, dMSA) have per-forest `schemaIDGUID`s and are
22//! resolved at runtime — see [`SchemaMap`] and [`grants_with`].
23//!
24//! Only *allow* ACEs are interpreted; deny ACEs are skipped rather than subtracted, so the
25//! output is an over-approximation of effective access. That matches how attack-path tools
26//! reason (a deny ACE that is ordered after an allow does not remove the primitive), but it
27//! is not an effective-permissions engine.
28
29pub mod catalog;
30mod schema;
31
32pub use schema::{names, SchemaMap};
33
34use windows_sddl::sid::{Guid, Sid};
35use windows_sddl::{AccessMask, Ace, SecurityDescriptor};
36
37/// A concrete thing a trustee can do to an object, derived from one ACE.
38#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
39#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
40pub enum ControlPrimitive {
41    /// Owner of the object — can rewrite its DACL at will.
42    Owns,
43    /// `WRITE_DAC` — can grant itself anything else.
44    WriteDacl,
45    /// `WRITE_OWNER` — can take ownership, then rewrite the DACL.
46    WriteOwner,
47    /// `GENERIC_ALL` / full control.
48    GenericAll,
49    /// `GENERIC_WRITE`, or unscoped `WRITE_PROP` — can write every attribute.
50    GenericWrite,
51    /// Unscoped `CONTROL_ACCESS` — holds every extended right, DCSync included.
52    AllExtendedRights,
53    /// `User-Force-Change-Password`.
54    ForceChangePassword,
55    /// Write `member` — add any principal to the group.
56    AddMember,
57    /// Validated write on `member` — add *itself* to the group.
58    AddSelfToGroup,
59    /// Write `msDS-KeyCredentialLink` — Shadow Credentials.
60    AddKeyCredential,
61    /// Write `msDS-AllowedToActOnBehalfOfOtherIdentity` — resource-based constrained delegation.
62    WriteRbcd,
63    /// Write `servicePrincipalName` — make the account Kerberoastable (targeted roasting).
64    WriteSpn,
65    /// Write `altSecurityIdentities` — bind an attacker certificate to the account.
66    WriteAltSecurityIdentities,
67    /// Write `msDS-AllowedToDelegateTo` — constrained delegation with protocol transition.
68    WriteAllowedToDelegateTo,
69    /// Write `gPLink` — attach a hostile GPO to the container.
70    WriteGpLink,
71    /// Read the gMSA managed-password blob — derive the account's keys.
72    ReadGmsaPassword,
73    /// Read a LAPS password attribute — local administrator on that machine.
74    ReadLapsPassword,
75    /// `DS-Replication-Get-Changes`.
76    DcsyncGetChanges,
77    /// `DS-Replication-Get-Changes-All` — the half that carries secrets.
78    DcsyncGetChangesAll,
79    /// `DS-Replication-Get-Changes-In-Filtered-Set`.
80    DcsyncGetChangesFiltered,
81    /// `Reanimate-Tombstones` — resurrect deleted objects.
82    ReanimateTombstones,
83    /// `Certificate-Enrollment` / `Certificate-AutoEnrollment` on a template.
84    Enroll,
85    /// Create a delegated MSA under this container (BadSuccessor).
86    CreateDmsa,
87    /// `CREATE_CHILD`, optionally scoped to one object class GUID.
88    CreateChild(Option<Guid>),
89}
90
91impl ControlPrimitive {
92    /// Stable identifier, usable as a graph edge label.
93    pub fn name(self) -> &'static str {
94        use ControlPrimitive::*;
95        match self {
96            Owns => "Owns",
97            WriteDacl => "WriteDacl",
98            WriteOwner => "WriteOwner",
99            GenericAll => "GenericAll",
100            GenericWrite => "GenericWrite",
101            AllExtendedRights => "AllExtendedRights",
102            ForceChangePassword => "ForceChangePassword",
103            AddMember => "AddMember",
104            AddSelfToGroup => "AddSelfToGroup",
105            AddKeyCredential => "AddKeyCredential",
106            WriteRbcd => "WriteRbcd",
107            WriteSpn => "WriteSpn",
108            WriteAltSecurityIdentities => "WriteAltSecurityIdentities",
109            WriteAllowedToDelegateTo => "WriteAllowedToDelegateTo",
110            WriteGpLink => "WriteGpLink",
111            ReadGmsaPassword => "ReadGmsaPassword",
112            ReadLapsPassword => "ReadLapsPassword",
113            DcsyncGetChanges => "DcsyncGetChanges",
114            DcsyncGetChangesAll => "DcsyncGetChangesAll",
115            DcsyncGetChangesFiltered => "DcsyncGetChangesFiltered",
116            ReanimateTombstones => "ReanimateTombstones",
117            Enroll => "Enroll",
118            CreateDmsa => "CreateDmsa",
119            CreateChild(_) => "CreateChild",
120        }
121    }
122
123    /// Attacker cost of traversing this primitive. Lower = cheaper = more dangerous.
124    ///
125    /// `0` — already equivalent to control (no action needed).
126    /// `1` — one write/read and the target is owned.
127    /// `2` — needs a second step (a coerced auth, a TGT request, a roast).
128    /// `3` — noisy or slow (offline cracking, waiting for a GPO refresh).
129    pub fn cost(self) -> u32 {
130        use ControlPrimitive::*;
131        match self {
132            AllExtendedRights | DcsyncGetChangesAll => 0,
133            DcsyncGetChanges | DcsyncGetChangesFiltered => 1,
134            Owns | WriteDacl | WriteOwner | GenericAll => 1,
135            ForceChangePassword | AddMember | AddSelfToGroup | AddKeyCredential => 1,
136            ReadGmsaPassword | ReadLapsPassword => 1,
137            GenericWrite | WriteAltSecurityIdentities => 2,
138            WriteRbcd | WriteAllowedToDelegateTo => 2,
139            CreateDmsa => 2,
140            Enroll | CreateChild(_) | ReanimateTombstones => 3,
141            WriteSpn | WriteGpLink => 3,
142        }
143    }
144
145    /// What the attacker gets out of it — the `impact` line of a report.
146    pub fn impact(self) -> &'static str {
147        use ControlPrimitive::*;
148        match self {
149            Owns => "owner can rewrite the DACL and grant itself full control",
150            WriteDacl => "can grant itself full control over the object",
151            WriteOwner => "can take ownership, then rewrite the DACL",
152            GenericAll => "full control over the object",
153            GenericWrite => {
154                "can write every attribute, including the delegation and credential ones"
155            }
156            AllExtendedRights => "holds every extended right on the object, DCSync included",
157            ForceChangePassword => "can reset the password without knowing the current one",
158            AddMember => "can add any principal to the group, inheriting its privilege",
159            AddSelfToGroup => "can add itself to the group, inheriting its privilege",
160            AddKeyCredential => "Shadow Credentials: PKINIT as the target, then its NT hash",
161            WriteRbcd => "RBCD: S4U2Self+S4U2Proxy to impersonate any user to the target",
162            WriteSpn => "targeted Kerberoast: set an SPN, request a TGS, crack it offline",
163            WriteAltSecurityIdentities => "binds an attacker certificate to the account for PKINIT",
164            WriteAllowedToDelegateTo => {
165                "constrained delegation with protocol transition to any service"
166            }
167            WriteGpLink => "attaches a hostile GPO to every computer under the container",
168            ReadGmsaPassword => "reads the managed-password blob and derives the account's keys",
169            ReadLapsPassword => "local administrator on that machine, no cracking needed",
170            DcsyncGetChanges => {
171                "half of DCSync; combined with Get-Changes-All it replicates secrets"
172            }
173            DcsyncGetChangesAll => "replicates every secret in the domain, krbtgt included",
174            DcsyncGetChangesFiltered => "replicates the RODC-filtered attribute set",
175            ReanimateTombstones => "resurrects deleted objects, reviving stale privilege",
176            Enroll => "requests a certificate from the template; abusable if the template is weak",
177            CreateDmsa => "BadSuccessor: a delegated MSA that inherits a privileged account's keys",
178            CreateChild(_) => "creates child objects under the container",
179        }
180    }
181
182    /// The defensive counterpart — the `defence` line of a report.
183    pub fn mitigation(self) -> &'static str {
184        use ControlPrimitive::*;
185        match self {
186            Owns | WriteOwner => "reset the owner to Domain Admins / the object's OU owner and audit ownership changes",
187            WriteDacl => "remove the WRITE_DAC ACE; DACL writes on Tier-0 objects belong to Domain Admins only",
188            GenericAll | GenericWrite => "replace full control with the narrowest right the delegation actually needs",
189            AllExtendedRights => "remove the unscoped CONTROL_ACCESS ACE; grant individual extended rights instead",
190            ForceChangePassword => "restrict password resets to the helpdesk OU; never on Tier-0 accounts",
191            AddMember | AddSelfToGroup => "manage membership through a PAM/AGDLP group, not a write ACE on the group",
192            AddKeyCredential => "remove write access to msDS-KeyCredentialLink and audit 5136 on that attribute",
193            WriteRbcd => "clear msDS-AllowedToActOnBehalfOfOtherIdentity and deny writes to it",
194            WriteSpn => "deny servicePrincipalName writes; put service accounts in Protected Users or use gMSA",
195            WriteAltSecurityIdentities => "deny writes to altSecurityIdentities and enforce strong certificate mapping (KB5014754)",
196            WriteAllowedToDelegateTo => "remove the delegation; mark Tier-0 accounts sensitive and non-delegatable",
197            WriteGpLink => "restrict gPLink writes on the OU; review linked GPOs",
198            ReadGmsaPassword => "narrow msDS-GroupMSAMembership to the hosts that actually run the service",
199            ReadLapsPassword => "scope the LAPS read ACL to the machine's admins; enable Windows LAPS encryption",
200            DcsyncGetChanges | DcsyncGetChangesAll | DcsyncGetChangesFiltered =>
201                "remove replication rights from the domain head for anyone but DCs and AAD Connect",
202            ReanimateTombstones => "remove the right; audit object restores",
203            Enroll => "restrict template enrollment and fix the template flags (manager approval, no SAN)",
204            CreateDmsa => "deny CreateChild for msDS-DelegatedManagedServiceAccount on OUs low-privilege users control",
205            CreateChild(_) => "scope CreateChild to the classes the delegation needs",
206        }
207    }
208}
209
210/// Where a grant came from.
211#[derive(Clone, Copy, Debug, PartialEq, Eq)]
212pub enum Source {
213    /// The security descriptor's owner field.
214    Owner,
215    /// An allow ACE in the DACL.
216    Dacl,
217}
218
219/// One trustee holding one primitive over the object the descriptor belongs to.
220#[derive(Clone, Debug)]
221pub struct Grant {
222    pub trustee: Sid,
223    pub primitive: ControlPrimitive,
224    /// The ACE carried `INHERITED_ACE` (0x10) — it came from a parent container.
225    pub inherited: bool,
226    pub source: Source,
227}
228
229impl Grant {
230    /// Well-known trustees (Everyone, SYSTEM, BUILTIN\\Administrators, …) are usually noise
231    /// in an attack graph; callers normally drop them.
232    pub fn trustee_is_well_known(&self) -> bool {
233        self.trustee.is_well_known()
234    }
235}
236
237const INHERITED_ACE: u8 = 0x10;
238
239/// Every primitive one allow-ACE grants. Deny ACEs yield nothing.
240pub fn classify(ace: &Ace) -> Vec<ControlPrimitive> {
241    classify_with(ace, &SchemaMap::new())
242}
243
244/// [`classify`], additionally resolving forest-specific attributes through `schema`.
245pub fn classify_with(ace: &Ace, schema: &SchemaMap) -> Vec<ControlPrimitive> {
246    use ControlPrimitive as P;
247
248    if !ace.is_allow() {
249        return Vec::new();
250    }
251    let m = ace.mask;
252    let g = ace.object_type;
253    let mut v = Vec::new();
254
255    if m.contains(AccessMask::GENERIC_ALL) {
256        v.push(P::GenericAll);
257    }
258    if m.contains(AccessMask::WRITE_DAC) {
259        v.push(P::WriteDacl);
260    }
261    if m.contains(AccessMask::WRITE_OWNER) {
262        v.push(P::WriteOwner);
263    }
264    if m.contains(AccessMask::GENERIC_WRITE) {
265        v.push(P::GenericWrite);
266    }
267
268    // Extended rights. No GUID = every extended right on the object.
269    if m.contains(AccessMask::CONTROL_ACCESS) {
270        match &g {
271            None => v.push(P::AllExtendedRights),
272            Some(g) if catalog::FORCE_CHANGE_PASSWORD.matches(g) => v.push(P::ForceChangePassword),
273            Some(g) if catalog::REPL_GET_CHANGES_ALL.matches(g) => v.push(P::DcsyncGetChangesAll),
274            Some(g) if catalog::REPL_GET_CHANGES.matches(g) => v.push(P::DcsyncGetChanges),
275            Some(g) if catalog::REPL_GET_CHANGES_FILTERED.matches(g) => {
276                v.push(P::DcsyncGetChangesFiltered)
277            }
278            Some(g) if catalog::REANIMATE_TOMBSTONES.matches(g) => v.push(P::ReanimateTombstones),
279            Some(g) if catalog::is_enrollment_right(g) => v.push(P::Enroll),
280            Some(_) => {}
281        }
282    }
283
284    // Attribute writes. No GUID = every attribute.
285    if m.contains(AccessMask::WRITE_PROP) {
286        match &g {
287            None => v.push(P::GenericWrite),
288            Some(g) if catalog::MEMBER.matches(g) => v.push(P::AddMember),
289            Some(g) if catalog::KEY_CREDENTIAL_LINK.matches(g) => v.push(P::AddKeyCredential),
290            Some(g) if catalog::RBCD.matches(g) => v.push(P::WriteRbcd),
291            Some(g) if catalog::SPN.matches(g) => v.push(P::WriteSpn),
292            Some(g) if catalog::ALT_SECURITY_IDENTITIES.matches(g) => {
293                v.push(P::WriteAltSecurityIdentities)
294            }
295            Some(g) if catalog::ALLOWED_TO_DELEGATE_TO.matches(g) => {
296                v.push(P::WriteAllowedToDelegateTo)
297            }
298            Some(g) if catalog::GP_LINK.matches(g) => v.push(P::WriteGpLink),
299            Some(_) => {}
300        }
301    }
302
303    // Attribute reads only matter for the two secret-bearing attributes, both forest-specific.
304    if m.contains(AccessMask::READ_PROP) {
305        if let Some(g) = &g {
306            if schema.is_managed_password_attr(g) {
307                v.push(P::ReadGmsaPassword);
308            } else if schema.is_laps_attr(g) {
309                v.push(P::ReadLapsPassword);
310            }
311        }
312    }
313
314    // Validated writes.
315    if m.contains(AccessMask::SELF) {
316        match &g {
317            Some(g) if catalog::SELF_MEMBERSHIP.matches(g) => v.push(P::AddSelfToGroup),
318            Some(g) if catalog::VALIDATED_SPN.matches(g) => v.push(P::WriteSpn),
319            _ => {}
320        }
321    }
322
323    // Child creation. Scoped to the dMSA class this is BadSuccessor.
324    if m.contains(AccessMask::CREATE_CHILD) {
325        match &g {
326            Some(g) if schema.is_dmsa_class(g) => v.push(P::CreateDmsa),
327            other => v.push(P::CreateChild(*other)),
328        }
329    }
330
331    v.dedup();
332    v
333}
334
335/// Every grant a descriptor hands out: the owner, plus one entry per allow-ACE primitive.
336pub fn grants(sd: &SecurityDescriptor) -> Vec<Grant> {
337    grants_with(sd, &SchemaMap::new())
338}
339
340/// [`grants`], resolving forest-specific attributes through `schema`.
341pub fn grants_with(sd: &SecurityDescriptor, schema: &SchemaMap) -> Vec<Grant> {
342    let mut out = Vec::new();
343
344    if let Some(owner) = &sd.owner {
345        out.push(Grant {
346            trustee: owner.clone(),
347            primitive: ControlPrimitive::Owns,
348            inherited: false,
349            source: Source::Owner,
350        });
351    }
352
353    for ace in sd.dacl.iter().flat_map(|d| &d.aces) {
354        let inherited = ace.flags & INHERITED_ACE != 0;
355        for primitive in classify_with(ace, schema) {
356            out.push(Grant {
357                trustee: ace.trustee.clone(),
358                primitive,
359                inherited,
360                source: Source::Dacl,
361            });
362        }
363    }
364    out
365}
366
367/// True if the set of primitives held over the domain head amounts to DCSync.
368///
369/// `Get-Changes` alone is not enough; it needs `Get-Changes-All` (or a blanket right).
370pub fn is_dcsync(primitives: &[ControlPrimitive]) -> bool {
371    use ControlPrimitive::*;
372    let has = |p: ControlPrimitive| primitives.contains(&p);
373    if has(GenericAll) || has(AllExtendedRights) {
374        return true;
375    }
376    has(DcsyncGetChangesAll) && (has(DcsyncGetChanges) || has(DcsyncGetChangesFiltered))
377}