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).
#
# 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 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 ?? '')

        # 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 }

        $alreadyPrivileged = ($sid -and $privSids.Contains($sid)) -or $privNames.Contains(($principal -split '\\')[-1])

        $paths.Add([PSCustomObject]@{
            PSTypeName         = 'PSGuerrilla.AttackPath'
            Source             = $principal
            SourceSID          = $sid
            SourceIsPrivileged = [bool]$alreadyPrivileged
            Edge               = $right
            Inherited          = [bool]$ace.IsInherited
            TargetObject       = $objName
            ReachesTier0       = $map.Target
            Technique          = $map.Impact
            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 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
                if (-not $gName -or ($wellKnownTier0 -contains $gName)) { continue }
                $key = "nest|$gName|$t0Group"
                if (-not $seen.Add($key)) { continue }
                $paths.Add([PSCustomObject]@{
                    PSTypeName         = 'PSGuerrilla.AttackPath'
                    Source             = $gName
                    SourceSID          = [string]($m.SID ?? '')
                    SourceIsPrivileged = $false   # the pivot group itself IS the escalation surface
                    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"
                })
            }
        }
    }

    # Highest-impact, genuinely-non-privileged paths first.
    $sevRank = @{ Critical = 0; High = 1; Medium = 2; Low = 3 }
    $sorted = @($paths | Sort-Object `
        @{ Expression = { if ($_.SourceIsPrivileged) { 1 } else { 0 } } }, `
        @{ Expression = { $sevRank[$_.Severity] ?? 4 } }, `
        Source)
    return [PSCustomObject]@{ DataAvailable = $true; Paths = $sorted }
}