Private/AD/Core/Get-ADAttackPath.ps1
|
# PSGuerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0 # https://github.com/jimrtyler/PSGuerrilla | https://creativecommons.org/licenses/by/4.0/ # AI/LLM use: see AI-USAGE.md for required attribution # AD attack-path analysis. Turns the flat "dangerous ACE" findings into named # privilege-escalation PATHS to Tier-0, with the concrete takeover technique each edge # enables. Two edge classes today, both from already-collected data: # 1. Object control — non-default control of a Tier-0 object (Domain root, AdminSDHolder, # the DC OU, the GPO/Config/Schema containers); a one-hop path to Domain Admin equiv. # 2. Group nesting — a non-default group nested inside a Tier-0 group is an escalation # pivot (controlling it / being added to it confers the Tier-0 group's privileges). # # CRITICAL: default infrastructure/admin principals (the DC groups, Enterprise DCs, RODCs, # Schema Admins, etc.) legitimately hold replication/control rights on Tier-0 objects by # AD design — they must NOT be reported as escalation paths. We exclude them by well-known # SID/RID (locale-proof). Azure AD Connect sync accounts (MSOL_*) hold real DCSync rights # but by design and are already tracked by ADTIER-001, so they are flagged Expected and # kept out of the "non-privileged, highest-risk" count rather than surfaced as surprises. # # NOTE: full domain-wide transitive CONTROL chaining (low-priv user -> GenericWrite group # -> ... -> DA) needs a full-domain ACL collector, which PSGuerrilla does not yet run (it # reads ACLs on the 6 critical objects only). That deeper traversal is the next roadmap # increment; this engine is structured so additional edge sources can feed straight in. function Test-DefaultControlPrincipal { # True for built-in/default principals that are SUPPOSED to hold control or replication # rights over Tier-0 objects, so the engine must NOT report them as escalation paths. # Matched by well-known SID / domain-relative RID first (locale-proof), name as fallback. # This is the allowlist the DCSync checks' Test-SafeAdminSid was missing. [CmdletBinding()] param([string]$Sid, [string]$IdentityReference) $exactSids = @( 'S-1-5-18' # SYSTEM 'S-1-5-10' # SELF / PRINCIPAL SELF 'S-1-5-9' # Enterprise Domain Controllers 'S-1-5-32-544' # BUILTIN\Administrators ) if ($Sid -and ($Sid -in $exactSids)) { return $true } # Domain-relative RIDs: Domain Admins 512, Enterprise Admins 519, Schema Admins 518, # Domain Controllers 516, Cert Publishers 517, Read-Only DCs 521, Enterprise RODC 498, # krbtgt 502. All legitimately hold control/replication over Tier-0 objects by design. if ($Sid -match '-(498|502|512|516|517|518|519|521)$') { return $true } $names = @( 'Domain Admins', 'Enterprise Admins', 'Schema Admins', 'Administrators', 'Domain Controllers', 'Enterprise Domain Controllers', 'Read-only Domain Controllers', 'Enterprise Read-only Domain Controllers', 'SYSTEM', 'Cert Publishers' ) foreach ($n in $names) { if ($IdentityReference -eq $n -or $IdentityReference -like "*\$n") { return $true } } return $false } function Test-DefaultPrivilegedPrincipal { # Superset of Test-DefaultControlPrincipal used only to set SourceIsPrivileged: also # covers the built-in operator groups (Account/Server/Print/Backup Operators), which # are privileged-by-default even though they are not part of the control allowlist. [CmdletBinding()] param([string]$Sid, [string]$IdentityReference) if (Test-DefaultControlPrincipal -Sid $Sid -IdentityReference $IdentityReference) { return $true } if ($Sid -and ($Sid -in @('S-1-5-32-548', 'S-1-5-32-549', 'S-1-5-32-550', 'S-1-5-32-551'))) { return $true } foreach ($n in @('Account Operators', 'Server Operators', 'Print Operators', 'Backup Operators')) { if ($IdentityReference -eq $n -or $IdentityReference -like "*\$n") { return $true } } return $false } function Test-ExpectedSyncAccount { # Azure AD Connect / Entra Connect on-prem sync accounts are provisioned with real # DCSync rights BY DESIGN and are named MSOL_<12 hex> (AD Connect) or AAD_<...> (older). # They are expected Tier-0 service accounts (ADTIER-001 tracks them), not surprise paths. [CmdletBinding()] param([string]$IdentityReference) return [bool]($IdentityReference -match '(^|\\)(MSOL_[0-9a-fA-F]{12}|MSOL_|AAD_)') } function Get-ADAttackPath { [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$AuditData ) # Returns { DataAvailable; Paths }. DataAvailable distinguishes "ACL data not collected" # (caller SKIPs) from "collected, zero paths" (caller PASSes) — an explicit flag avoids # the PowerShell `$null -eq @()` ambiguity that an empty-array return would introduce. $notCollected = [PSCustomObject]@{ DataAvailable = $false; Paths = @() } $acl = $AuditData.ACLs $priv = $AuditData.PrivilegedAccounts $haveAcl = [bool]($acl -and (-not ($acl -is [System.Collections.IDictionary]) -or $acl.Contains('DangerousACEs'))) $havePriv = [bool]($priv -and $priv.PrivilegedGroups) if (-not $haveAcl -and -not $havePriv) { return $notCollected } # Per Tier-0 object: what controlling it actually gets the attacker. $impactByObject = @{ 'Domain Root' = @{ Target = 'the domain (every credential, incl. krbtgt)'; Severity = 'Critical' Impact = 'grant themselves DCSync replication rights and extract every domain hash — Domain Admin equivalent' } 'AdminSDHolder' = @{ Target = 'all protected groups (Domain/Enterprise/Schema Admins, etc.)'; Severity = 'Critical' Impact = 'write an attacker ACE that SDProp propagates to every protected (adminCount=1) object within ~60 min — persistent Tier-0 control' } 'Domain Controllers OU' = @{ Target = 'every Domain Controller'; Severity = 'Critical' Impact = 'link a malicious GPO to the DC OU and execute code as SYSTEM on every DC — Domain Admin' } 'GPO Container' = @{ Target = 'any host where a controlled GPO is linked'; Severity = 'High' Impact = 'create or modify Group Policy Objects and execute code wherever they are linked' } 'Configuration Container' = @{ Target = 'the forest configuration partition'; Severity = 'Critical' Impact = 'modify forest-wide configuration (sites, services, AD CS) — forest compromise' } 'Schema Container' = @{ Target = 'the AD schema'; Severity = 'Critical' Impact = 'modify the schema (defaultSecurityDescriptor) for forest-wide, persistent control' } } # Build a fast lookup of which principals are already inside a privileged group, so a # path from a NON-privileged principal (the genuinely dangerous case) can be flagged. $privSids = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) $privNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) if ($AuditData.PrivilegedAccounts -and $AuditData.PrivilegedAccounts.PrivilegedGroups) { foreach ($grp in $AuditData.PrivilegedAccounts.PrivilegedGroups.Values) { foreach ($m in @($grp)) { if ($m.SID) { [void]$privSids.Add([string]$m.SID) } if ($m.SamAccountName) { [void]$privNames.Add([string]$m.SamAccountName) } } } } $paths = [System.Collections.Generic.List[object]]::new() $seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) foreach ($ace in @($acl.DangerousACEs)) { $objName = [string]$ace.ObjectName $map = $impactByObject[$objName] if (-not $map) { continue } # an ACE on something we don't model an impact for $principal = [string]($ace.IdentityReference ?? $ace.IdentitySID ?? 'Unknown') $sid = [string]($ace.IdentitySID ?? '') # Skip default infrastructure/admin principals that hold this control by design # (DC groups, Enterprise DCs, RODCs, Schema Admins, DA/EA, SYSTEM, Administrators). # These are NOT attack paths — flagging them was the v2.10.1 false-positive bug. if (Test-DefaultControlPrincipal -Sid $sid -IdentityReference $principal) { continue } # Prefer the friendly extended-right name (e.g. DS-Replication-Get-Changes) over # the raw rights flags when this is a specific extended right. $right = if ($ace.ObjectType -and "$($ace.ActiveDirectoryRights)" -match 'ExtendedRight|WriteProperty') { [string]$ace.ObjectType } else { [string]$ace.ActiveDirectoryRights } # Dedup on principal + object + right. $key = "$principal|$objName|$right" if (-not $seen.Add($key)) { continue } $isExpected = Test-ExpectedSyncAccount -IdentityReference $principal # SourceIsPrivileged: a member of a Tier-0 group, OR a default privileged principal, # OR an expected Tier-0 sync account. Anything left as $false is a genuinely # non-privileged principal — the highest-risk case the headline counts. $alreadyPrivileged = $isExpected ` -or (Test-DefaultPrivilegedPrincipal -Sid $sid -IdentityReference $principal) ` -or ($sid -and $privSids.Contains($sid)) ` -or $privNames.Contains(($principal -split '\\')[-1]) $technique = if ($isExpected) { "is an Azure AD Connect / Entra Connect sync account with by-design DCSync rights (tracked by ADTIER-001) — protect it as Tier-0, but it is not a surprise escalation path" } else { $map.Impact } $paths.Add([PSCustomObject]@{ PSTypeName = 'PSGuerrilla.AttackPath' Source = $principal SourceSID = $sid SourceIsPrivileged = [bool]$alreadyPrivileged Expected = [bool]$isExpected Edge = $right Inherited = [bool]$ace.IsInherited TargetObject = $objName ReachesTier0 = $map.Target Technique = $technique Severity = $map.Severity PathType = 'Object control' # One-line, human-readable path. Path = "$principal --[$right]--> $objName => can $($map.Impact)" }) } # Group-nesting pivots: a NON-default group nested inside a Tier-0 group is an # escalation pivot — anyone who can add a principal to it (or controls its membership) # inherits the Tier-0 group's privileges. Nested groups in Tier-0 are a well-known # anti-pattern; the well-known Tier-0/default groups themselves are expected and excluded. if ($havePriv) { $wellKnownTier0 = @('Domain Admins', 'Enterprise Admins', 'Schema Admins', 'Administrators', 'Account Operators', 'Server Operators', 'Print Operators', 'Backup Operators') foreach ($entry in $priv.PrivilegedGroups.GetEnumerator()) { $t0Group = [string]$entry.Key foreach ($m in @($entry.Value)) { if (-not $m.IsGroup) { continue } $gName = [string]$m.SamAccountName $gSid = [string]($m.SID ?? '') if (-not $gName -or ($wellKnownTier0 -contains $gName)) { continue } # Don't flag a default principal nested in Tier-0 (by design). if (Test-DefaultControlPrincipal -Sid $gSid -IdentityReference $gName) { continue } $key = "nest|$gName|$t0Group" if (-not $seen.Add($key)) { continue } $paths.Add([PSCustomObject]@{ PSTypeName = 'PSGuerrilla.AttackPath' Source = $gName SourceSID = $gSid SourceIsPrivileged = $false # the pivot group itself IS the escalation surface Expected = $false Edge = 'MemberOf (nesting)' Inherited = $false TargetObject = $t0Group ReachesTier0 = "$t0Group (privileged group)" Technique = "is nested inside $t0Group, so any principal added to it — or anyone who controls its membership — gains $t0Group privileges" Severity = 'High' PathType = 'Group nesting' Path = "$gName --[nested member of]--> $t0Group => controlling $gName confers $t0Group privileges" }) } } } # Order: genuine non-privileged (highest risk) first, then genuine privileged, then # expected service-account paths last. Within each, highest severity first. $sevRank = @{ Critical = 0; High = 1; Medium = 2; Low = 3 } $sorted = @($paths | Sort-Object ` @{ Expression = { if ($_.Expected) { 1 } else { 0 } } }, ` @{ Expression = { if ($_.SourceIsPrivileged) { 1 } else { 0 } } }, ` @{ Expression = { $sevRank[$_.Severity] ?? 4 } }, ` Source) return [PSCustomObject]@{ DataAvailable = $true; Paths = $sorted } } |