pub mod catalog;
mod schema;
pub use schema::{names, SchemaMap};
use windows_sddl::sid::{Guid, Sid};
use windows_sddl::{AccessMask, Ace, SecurityDescriptor};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ControlPrimitive {
Owns,
WriteDacl,
WriteOwner,
GenericAll,
GenericWrite,
AllExtendedRights,
ForceChangePassword,
AddMember,
AddSelfToGroup,
AddKeyCredential,
WriteRbcd,
WriteSpn,
WriteAltSecurityIdentities,
WriteAllowedToDelegateTo,
WriteGpLink,
ReadGmsaPassword,
ReadLapsPassword,
DcsyncGetChanges,
DcsyncGetChangesAll,
DcsyncGetChangesFiltered,
ReanimateTombstones,
Enroll,
CreateDmsa,
CreateChild(Option<Guid>),
}
impl ControlPrimitive {
pub fn name(self) -> &'static str {
use ControlPrimitive::*;
match self {
Owns => "Owns",
WriteDacl => "WriteDacl",
WriteOwner => "WriteOwner",
GenericAll => "GenericAll",
GenericWrite => "GenericWrite",
AllExtendedRights => "AllExtendedRights",
ForceChangePassword => "ForceChangePassword",
AddMember => "AddMember",
AddSelfToGroup => "AddSelfToGroup",
AddKeyCredential => "AddKeyCredential",
WriteRbcd => "WriteRbcd",
WriteSpn => "WriteSpn",
WriteAltSecurityIdentities => "WriteAltSecurityIdentities",
WriteAllowedToDelegateTo => "WriteAllowedToDelegateTo",
WriteGpLink => "WriteGpLink",
ReadGmsaPassword => "ReadGmsaPassword",
ReadLapsPassword => "ReadLapsPassword",
DcsyncGetChanges => "DcsyncGetChanges",
DcsyncGetChangesAll => "DcsyncGetChangesAll",
DcsyncGetChangesFiltered => "DcsyncGetChangesFiltered",
ReanimateTombstones => "ReanimateTombstones",
Enroll => "Enroll",
CreateDmsa => "CreateDmsa",
CreateChild(_) => "CreateChild",
}
}
pub fn cost(self) -> u32 {
use ControlPrimitive::*;
match self {
AllExtendedRights | DcsyncGetChangesAll => 0,
DcsyncGetChanges | DcsyncGetChangesFiltered => 1,
Owns | WriteDacl | WriteOwner | GenericAll => 1,
ForceChangePassword | AddMember | AddSelfToGroup | AddKeyCredential => 1,
ReadGmsaPassword | ReadLapsPassword => 1,
GenericWrite | WriteAltSecurityIdentities => 2,
WriteRbcd | WriteAllowedToDelegateTo => 2,
CreateDmsa => 2,
Enroll | CreateChild(_) | ReanimateTombstones => 3,
WriteSpn | WriteGpLink => 3,
}
}
pub fn impact(self) -> &'static str {
use ControlPrimitive::*;
match self {
Owns => "owner can rewrite the DACL and grant itself full control",
WriteDacl => "can grant itself full control over the object",
WriteOwner => "can take ownership, then rewrite the DACL",
GenericAll => "full control over the object",
GenericWrite => {
"can write every attribute, including the delegation and credential ones"
}
AllExtendedRights => "holds every extended right on the object, DCSync included",
ForceChangePassword => "can reset the password without knowing the current one",
AddMember => "can add any principal to the group, inheriting its privilege",
AddSelfToGroup => "can add itself to the group, inheriting its privilege",
AddKeyCredential => "Shadow Credentials: PKINIT as the target, then its NT hash",
WriteRbcd => "RBCD: S4U2Self+S4U2Proxy to impersonate any user to the target",
WriteSpn => "targeted Kerberoast: set an SPN, request a TGS, crack it offline",
WriteAltSecurityIdentities => "binds an attacker certificate to the account for PKINIT",
WriteAllowedToDelegateTo => {
"constrained delegation with protocol transition to any service"
}
WriteGpLink => "attaches a hostile GPO to every computer under the container",
ReadGmsaPassword => "reads the managed-password blob and derives the account's keys",
ReadLapsPassword => "local administrator on that machine, no cracking needed",
DcsyncGetChanges => {
"half of DCSync; combined with Get-Changes-All it replicates secrets"
}
DcsyncGetChangesAll => "replicates every secret in the domain, krbtgt included",
DcsyncGetChangesFiltered => "replicates the RODC-filtered attribute set",
ReanimateTombstones => "resurrects deleted objects, reviving stale privilege",
Enroll => "requests a certificate from the template; abusable if the template is weak",
CreateDmsa => "BadSuccessor: a delegated MSA that inherits a privileged account's keys",
CreateChild(_) => "creates child objects under the container",
}
}
pub fn mitigation(self) -> &'static str {
use ControlPrimitive::*;
match self {
Owns | WriteOwner => "reset the owner to Domain Admins / the object's OU owner and audit ownership changes",
WriteDacl => "remove the WRITE_DAC ACE; DACL writes on Tier-0 objects belong to Domain Admins only",
GenericAll | GenericWrite => "replace full control with the narrowest right the delegation actually needs",
AllExtendedRights => "remove the unscoped CONTROL_ACCESS ACE; grant individual extended rights instead",
ForceChangePassword => "restrict password resets to the helpdesk OU; never on Tier-0 accounts",
AddMember | AddSelfToGroup => "manage membership through a PAM/AGDLP group, not a write ACE on the group",
AddKeyCredential => "remove write access to msDS-KeyCredentialLink and audit 5136 on that attribute",
WriteRbcd => "clear msDS-AllowedToActOnBehalfOfOtherIdentity and deny writes to it",
WriteSpn => "deny servicePrincipalName writes; put service accounts in Protected Users or use gMSA",
WriteAltSecurityIdentities => "deny writes to altSecurityIdentities and enforce strong certificate mapping (KB5014754)",
WriteAllowedToDelegateTo => "remove the delegation; mark Tier-0 accounts sensitive and non-delegatable",
WriteGpLink => "restrict gPLink writes on the OU; review linked GPOs",
ReadGmsaPassword => "narrow msDS-GroupMSAMembership to the hosts that actually run the service",
ReadLapsPassword => "scope the LAPS read ACL to the machine's admins; enable Windows LAPS encryption",
DcsyncGetChanges | DcsyncGetChangesAll | DcsyncGetChangesFiltered =>
"remove replication rights from the domain head for anyone but DCs and AAD Connect",
ReanimateTombstones => "remove the right; audit object restores",
Enroll => "restrict template enrollment and fix the template flags (manager approval, no SAN)",
CreateDmsa => "deny CreateChild for msDS-DelegatedManagedServiceAccount on OUs low-privilege users control",
CreateChild(_) => "scope CreateChild to the classes the delegation needs",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Source {
Owner,
Dacl,
}
#[derive(Clone, Debug)]
pub struct Grant {
pub trustee: Sid,
pub primitive: ControlPrimitive,
pub inherited: bool,
pub source: Source,
}
impl Grant {
pub fn trustee_is_well_known(&self) -> bool {
self.trustee.is_well_known()
}
}
const INHERITED_ACE: u8 = 0x10;
pub fn classify(ace: &Ace) -> Vec<ControlPrimitive> {
classify_with(ace, &SchemaMap::new())
}
pub fn classify_with(ace: &Ace, schema: &SchemaMap) -> Vec<ControlPrimitive> {
use ControlPrimitive as P;
if !ace.is_allow() {
return Vec::new();
}
let m = ace.mask;
let g = ace.object_type;
let mut v = Vec::new();
if m.contains(AccessMask::GENERIC_ALL) {
v.push(P::GenericAll);
}
if m.contains(AccessMask::WRITE_DAC) {
v.push(P::WriteDacl);
}
if m.contains(AccessMask::WRITE_OWNER) {
v.push(P::WriteOwner);
}
if m.contains(AccessMask::GENERIC_WRITE) {
v.push(P::GenericWrite);
}
if m.contains(AccessMask::CONTROL_ACCESS) {
match &g {
None => v.push(P::AllExtendedRights),
Some(g) if catalog::FORCE_CHANGE_PASSWORD.matches(g) => v.push(P::ForceChangePassword),
Some(g) if catalog::REPL_GET_CHANGES_ALL.matches(g) => v.push(P::DcsyncGetChangesAll),
Some(g) if catalog::REPL_GET_CHANGES.matches(g) => v.push(P::DcsyncGetChanges),
Some(g) if catalog::REPL_GET_CHANGES_FILTERED.matches(g) => {
v.push(P::DcsyncGetChangesFiltered)
}
Some(g) if catalog::REANIMATE_TOMBSTONES.matches(g) => v.push(P::ReanimateTombstones),
Some(g) if catalog::is_enrollment_right(g) => v.push(P::Enroll),
Some(_) => {}
}
}
if m.contains(AccessMask::WRITE_PROP) {
match &g {
None => v.push(P::GenericWrite),
Some(g) if catalog::MEMBER.matches(g) => v.push(P::AddMember),
Some(g) if catalog::KEY_CREDENTIAL_LINK.matches(g) => v.push(P::AddKeyCredential),
Some(g) if catalog::RBCD.matches(g) => v.push(P::WriteRbcd),
Some(g) if catalog::SPN.matches(g) => v.push(P::WriteSpn),
Some(g) if catalog::ALT_SECURITY_IDENTITIES.matches(g) => {
v.push(P::WriteAltSecurityIdentities)
}
Some(g) if catalog::ALLOWED_TO_DELEGATE_TO.matches(g) => {
v.push(P::WriteAllowedToDelegateTo)
}
Some(g) if catalog::GP_LINK.matches(g) => v.push(P::WriteGpLink),
Some(_) => {}
}
}
if m.contains(AccessMask::READ_PROP) {
if let Some(g) = &g {
if schema.is_managed_password_attr(g) {
v.push(P::ReadGmsaPassword);
} else if schema.is_laps_attr(g) {
v.push(P::ReadLapsPassword);
}
}
}
if m.contains(AccessMask::SELF) {
match &g {
Some(g) if catalog::SELF_MEMBERSHIP.matches(g) => v.push(P::AddSelfToGroup),
Some(g) if catalog::VALIDATED_SPN.matches(g) => v.push(P::WriteSpn),
_ => {}
}
}
if m.contains(AccessMask::CREATE_CHILD) {
match &g {
Some(g) if schema.is_dmsa_class(g) => v.push(P::CreateDmsa),
other => v.push(P::CreateChild(*other)),
}
}
v.dedup();
v
}
pub fn grants(sd: &SecurityDescriptor) -> Vec<Grant> {
grants_with(sd, &SchemaMap::new())
}
pub fn grants_with(sd: &SecurityDescriptor, schema: &SchemaMap) -> Vec<Grant> {
let mut out = Vec::new();
if let Some(owner) = &sd.owner {
out.push(Grant {
trustee: owner.clone(),
primitive: ControlPrimitive::Owns,
inherited: false,
source: Source::Owner,
});
}
for ace in sd.dacl.iter().flat_map(|d| &d.aces) {
let inherited = ace.flags & INHERITED_ACE != 0;
for primitive in classify_with(ace, schema) {
out.push(Grant {
trustee: ace.trustee.clone(),
primitive,
inherited,
source: Source::Dacl,
});
}
}
out
}
pub fn is_dcsync(primitives: &[ControlPrimitive]) -> bool {
use ControlPrimitive::*;
let has = |p: ControlPrimitive| primitives.contains(&p);
if has(GenericAll) || has(AllExtendedRights) {
return true;
}
has(DcsyncGetChangesAll) && (has(DcsyncGetChanges) || has(DcsyncGetChangesFiltered))
}