1pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
39#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
40pub enum ControlPrimitive {
41 Owns,
43 WriteDacl,
45 WriteOwner,
47 GenericAll,
49 GenericWrite,
51 AllExtendedRights,
53 ForceChangePassword,
55 AddMember,
57 AddSelfToGroup,
59 AddKeyCredential,
61 WriteRbcd,
63 WriteSpn,
65 WriteAltSecurityIdentities,
67 WriteAllowedToDelegateTo,
69 WriteGpLink,
71 ReadGmsaPassword,
73 ReadLapsPassword,
75 DcsyncGetChanges,
77 DcsyncGetChangesAll,
79 DcsyncGetChangesFiltered,
81 ReanimateTombstones,
83 Enroll,
85 CreateDmsa,
87 CreateChild(Option<Guid>),
89}
90
91impl ControlPrimitive {
92 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 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 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
212pub enum Source {
213 Owner,
215 Dacl,
217}
218
219#[derive(Clone, Debug)]
221pub struct Grant {
222 pub trustee: Sid,
223 pub primitive: ControlPrimitive,
224 pub inherited: bool,
226 pub source: Source,
227}
228
229impl Grant {
230 pub fn trustee_is_well_known(&self) -> bool {
233 self.trustee.is_well_known()
234 }
235}
236
237const INHERITED_ACE: u8 = 0x10;
238
239pub fn classify(ace: &Ace) -> Vec<ControlPrimitive> {
241 classify_with(ace, &SchemaMap::new())
242}
243
244pub 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 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 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 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 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 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
335pub fn grants(sd: &SecurityDescriptor) -> Vec<Grant> {
337 grants_with(sd, &SchemaMap::new())
338}
339
340pub 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
367pub 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}