Private/AD/Checks/Invoke-ADAclDelegationChecks.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 function Invoke-ADAclDelegationChecks { [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$AuditData ) $checkDefs = Get-AuditCategoryDefinitions -Category 'ADAclDelegationChecks' $findings = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($check in $checkDefs.checks) { $funcName = "Test-Recon$($check.id -replace '-', '')" if (Get-Command $funcName -ErrorAction SilentlyContinue) { try { $finding = & $funcName -AuditData $AuditData -CheckDefinition $check if ($finding) { $findings.Add($finding) } } catch { $findings.Add((New-AuditFinding -CheckDefinition $check -Status 'ERROR' ` -CurrentValue "Check failed: $_")) } } else { $findings.Add((New-AuditFinding -CheckDefinition $check -Status 'SKIP' ` -CurrentValue 'Check not yet implemented')) } } return @($findings) } # ── Helper: Test if a SID is a well-known safe admin principal ──────────────── function Test-SafeAdminSid { [CmdletBinding()] param( [string]$Sid, [string]$IdentityReference ) # Well-known safe SIDs $safeSids = @( 'S-1-5-18' # SYSTEM 'S-1-5-10' # SELF 'S-1-5-32-544' # BUILTIN\Administrators ) if ($Sid -in $safeSids) { return $true } # Domain Admins (RID -512), Enterprise Admins (RID -519) if ($Sid -match '-512$|-519$') { return $true } # Name-based fallback matching $safeNames = @( 'Domain Admins', 'Enterprise Admins', 'SYSTEM', 'BUILTIN\Administrators', 'Administrators' ) foreach ($name in $safeNames) { if ($IdentityReference -eq $name -or $IdentityReference -like "*\$name") { return $true } } return $false } # ── Helper: Get all dangerous ACEs from critical objects, filtered ──────────── function Get-FilteredDangerousACEs { [CmdletBinding()] param( [hashtable]$ACLData, [string]$RightsFilter = $null ) if (-not $ACLData -or -not $ACLData.DangerousACEs) { return @() } $aces = @($ACLData.DangerousACEs) if ($RightsFilter) { $aces = @($aces | Where-Object { $_.ActiveDirectoryRights -match $RightsFilter }) } # Filter out safe admin SIDs $filtered = @($aces | Where-Object { -not (Test-SafeAdminSid -Sid $_.IdentitySID -IdentityReference $_.IdentityReference) }) return $filtered } # ── Well-known broad group SIDs ────────────────────────────────────────────── function Test-BroadGroupSid { [CmdletBinding()] param( [string]$Sid, [string]$IdentityReference ) # Well-known broad group SIDs $broadSids = @( 'S-1-1-0' # Everyone 'S-1-5-11' # Authenticated Users ) if ($Sid -in $broadSids) { return $true } # Domain Users (RID -513), Domain Computers (RID -515) if ($Sid -match '-513$|-515$') { return $true } # Name-based fallback $broadNames = @('Everyone', 'Authenticated Users', 'Domain Users', 'Domain Computers') foreach ($name in $broadNames) { if ($IdentityReference -eq $name -or $IdentityReference -like "*\$name") { return $true } } return $false } # ── ADACL-001: Critical Object ACL Audit ───────────────────────────────────── function Test-ReconADACL001 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) if (-not $AuditData.ACLs) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'ACL data not available' } $aclData = $AuditData.ACLs $dangerousACEs = @($aclData.DangerousACEs) if ($dangerousACEs.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No non-default dangerous ACEs found on critical AD objects' ` -Details @{ CriticalObjectsAudited = @($aclData.CriticalObjectACLs.Keys) DangerousACECount = 0 } } # Group by object name for reporting $byObject = @{} foreach ($ace in $dangerousACEs) { $objName = $ace.ObjectName ?? 'Unknown' if (-not $byObject.ContainsKey($objName)) { $byObject[$objName] = [System.Collections.Generic.List[hashtable]]::new() } $byObject[$objName].Add($ace) } $summaryParts = @() foreach ($objName in $byObject.Keys) { $count = $byObject[$objName].Count $principals = @($byObject[$objName] | ForEach-Object { $_.IdentityReference } | Sort-Object -Unique) $summaryParts += "$objName`: $count ACE(s) from $($principals -join ', ')" } $currentValue = "$($dangerousACEs.Count) non-default dangerous ACE(s) found on critical objects: $($summaryParts -join '; ')" return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue $currentValue ` -Details @{ TotalDangerousACEs = $dangerousACEs.Count CriticalObjectsAudited = @($aclData.CriticalObjectACLs.Keys) ByObject = $byObject DangerousACEs = $dangerousACEs } } # ── ADACL-002: GenericAll Permissions on Critical Objects ───────────────────── function Test-ReconADACL002 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) if (-not $AuditData.ACLs) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'ACL data not available' } $genericAllACEs = Get-FilteredDangerousACEs -ACLData $AuditData.ACLs -RightsFilter 'GenericAll' if ($genericAllACEs.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No non-default principals with GenericAll on critical objects' ` -Details @{ GenericAllACECount = 0 } } $principals = @($genericAllACEs | ForEach-Object { $_.IdentityReference } | Sort-Object -Unique) $objects = @($genericAllACEs | ForEach-Object { $_.ObjectName } | Sort-Object -Unique) $currentValue = "$($genericAllACEs.Count) GenericAll ACE(s) from non-default principal(s): $($principals -join ', ') on $($objects -join ', ')" return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue $currentValue ` -Details @{ GenericAllACECount = $genericAllACEs.Count Principals = $principals AffectedObjects = $objects ACEs = $genericAllACEs } } # ── ADACL-003: GenericWrite Permissions on Critical Objects ─────────────────── function Test-ReconADACL003 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) if (-not $AuditData.ACLs) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'ACL data not available' } $genericWriteACEs = Get-FilteredDangerousACEs -ACLData $AuditData.ACLs -RightsFilter 'GenericWrite' if ($genericWriteACEs.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No non-default principals with GenericWrite on critical objects' ` -Details @{ GenericWriteACECount = 0 } } $principals = @($genericWriteACEs | ForEach-Object { $_.IdentityReference } | Sort-Object -Unique) $objects = @($genericWriteACEs | ForEach-Object { $_.ObjectName } | Sort-Object -Unique) $currentValue = "$($genericWriteACEs.Count) GenericWrite ACE(s) from non-default principal(s): $($principals -join ', ') on $($objects -join ', ')" return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue $currentValue ` -Details @{ GenericWriteACECount = $genericWriteACEs.Count Principals = $principals AffectedObjects = $objects ACEs = $genericWriteACEs } } # ── ADACL-004: WriteDACL Permissions on Critical Objects ────────────────────── function Test-ReconADACL004 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) if (-not $AuditData.ACLs) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'ACL data not available' } $writeDaclACEs = Get-FilteredDangerousACEs -ACLData $AuditData.ACLs -RightsFilter 'WriteDacl' if ($writeDaclACEs.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No non-default principals with WriteDACL on critical objects' ` -Details @{ WriteDaclACECount = 0 } } $principals = @($writeDaclACEs | ForEach-Object { $_.IdentityReference } | Sort-Object -Unique) $objects = @($writeDaclACEs | ForEach-Object { $_.ObjectName } | Sort-Object -Unique) $currentValue = "$($writeDaclACEs.Count) WriteDACL ACE(s) from non-default principal(s): $($principals -join ', ') on $($objects -join ', ')" return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue $currentValue ` -Details @{ WriteDaclACECount = $writeDaclACEs.Count Principals = $principals AffectedObjects = $objects ACEs = $writeDaclACEs } } # ── ADACL-005: WriteOwner Permissions on Critical Objects ───────────────────── function Test-ReconADACL005 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) if (-not $AuditData.ACLs) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'ACL data not available' } $writeOwnerACEs = Get-FilteredDangerousACEs -ACLData $AuditData.ACLs -RightsFilter 'WriteOwner' if ($writeOwnerACEs.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No non-default principals with WriteOwner on critical objects' ` -Details @{ WriteOwnerACECount = 0 } } $principals = @($writeOwnerACEs | ForEach-Object { $_.IdentityReference } | Sort-Object -Unique) $objects = @($writeOwnerACEs | ForEach-Object { $_.ObjectName } | Sort-Object -Unique) $currentValue = "$($writeOwnerACEs.Count) WriteOwner ACE(s) from non-default principal(s): $($principals -join ', ') on $($objects -join ', ')" return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue $currentValue ` -Details @{ WriteOwnerACECount = $writeOwnerACEs.Count Principals = $principals AffectedObjects = $objects ACEs = $writeOwnerACEs } } # ── ADACL-006: ForceChangePassword Rights ──────────────────────────────────── function Test-ReconADACL006 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) if (-not $AuditData.ACLs) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'ACL data not available' } $aclData = $AuditData.ACLs $dangerousACEs = @($aclData.DangerousACEs) # Filter for User-Force-Change-Password extended right # GUID: 00299570-246d-11d0-a768-00aa006e0529 $forceChangePwdACEs = @($dangerousACEs | Where-Object { ($_.ObjectType -and $_.ObjectType -match 'User-Force-Change-Password') -or ($_.ObjectTypeGUID -and $_.ObjectTypeGUID -eq '00299570-246d-11d0-a768-00aa006e0529') }) # Further filter out safe admin SIDs $forceChangePwdACEs = @($forceChangePwdACEs | Where-Object { -not (Test-SafeAdminSid -Sid $_.IdentitySID -IdentityReference $_.IdentityReference) }) if ($forceChangePwdACEs.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No non-admin principals with ForceChangePassword rights on critical objects' ` -Details @{ ForceChangePasswordACECount = 0 } } $principals = @($forceChangePwdACEs | ForEach-Object { $_.IdentityReference } | Sort-Object -Unique) $objects = @($forceChangePwdACEs | ForEach-Object { $_.ObjectName } | Sort-Object -Unique) $currentValue = "$($forceChangePwdACEs.Count) ForceChangePassword ACE(s) from non-admin principal(s): $($principals -join ', ') on $($objects -join ', ')" return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue $currentValue ` -Details @{ ForceChangePasswordACECount = $forceChangePwdACEs.Count Principals = $principals AffectedObjects = $objects ACEs = $forceChangePwdACEs } } # ── ADACL-007: Excessive Delegation to Broad Groups ────────────────────────── function Test-ReconADACL007 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) if (-not $AuditData.ACLs) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'ACL data not available' } $aclData = $AuditData.ACLs $dangerousACEs = @($aclData.DangerousACEs) # Filter for ACEs where the identity is a broad group $broadGroupACEs = @($dangerousACEs | Where-Object { Test-BroadGroupSid -Sid $_.IdentitySID -IdentityReference $_.IdentityReference }) if ($broadGroupACEs.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No dangerous rights delegated to broad groups (Everyone, Authenticated Users, Domain Users) on critical objects' ` -Details @{ BroadGroupACECount = 0 } } # Group by identity for reporting $byGroup = @{} foreach ($ace in $broadGroupACEs) { $identity = $ace.IdentityReference if (-not $byGroup.ContainsKey($identity)) { $byGroup[$identity] = [System.Collections.Generic.List[hashtable]]::new() } $byGroup[$identity].Add($ace) } $summaryParts = @() foreach ($group in $byGroup.Keys) { $rights = @($byGroup[$group] | ForEach-Object { $_.ActiveDirectoryRights } | Sort-Object -Unique) $objects = @($byGroup[$group] | ForEach-Object { $_.ObjectName } | Sort-Object -Unique) $summaryParts += "$group has $($rights -join ', ') on $($objects -join ', ')" } $currentValue = "$($broadGroupACEs.Count) dangerous ACE(s) delegated to broad groups: $($summaryParts -join '; ')" return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue $currentValue ` -Details @{ BroadGroupACECount = $broadGroupACEs.Count ByGroup = $byGroup ACEs = $broadGroupACEs } } # ── ADACL-008: OU Delegation Analysis ──────────────────────────────────────── function Test-ReconADACL008 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) if (-not $AuditData.ACLs) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'ACL data not available' } $aclData = $AuditData.ACLs $ouDelegations = @($aclData.OUDelegation) if ($ouDelegations.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No non-default OU delegations detected' ` -Details @{ OUDelegationCount = 0 } } # Group by OU for analysis $byOU = @{} foreach ($delegation in $ouDelegations) { $ouDN = $delegation.OUDN ?? 'Unknown' if (-not $byOU.ContainsKey($ouDN)) { $byOU[$ouDN] = [System.Collections.Generic.List[hashtable]]::new() } $byOU[$ouDN].Add($delegation) } # Count unique principals and OUs with delegations $uniquePrincipals = @($ouDelegations | ForEach-Object { $_.IdentityReference } | Sort-Object -Unique) $ouCount = $byOU.Keys.Count # Check for broad delegations (GenericAll on OUs) $broadDelegations = @($ouDelegations | Where-Object { $_.ActiveDirectoryRights -match 'GenericAll' }) $status = if ($broadDelegations.Count -gt 0) { 'WARN' } elseif ($ouDelegations.Count -gt 20) { 'WARN' } else { 'PASS' } $currentValue = "$($ouDelegations.Count) OU delegation(s) across $ouCount OU(s) for $($uniquePrincipals.Count) principal(s)" if ($broadDelegations.Count -gt 0) { $currentValue += ". $($broadDelegations.Count) delegation(s) grant GenericAll (excessive)" } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue $currentValue ` -Details @{ TotalDelegations = $ouDelegations.Count OUsWithDelegations = $ouCount UniquePrincipals = $uniquePrincipals BroadDelegations = $broadDelegations.Count ByOU = $byOU Delegations = $ouDelegations } } # ── ADACL-009: Machine Account Quota ───────────────────────────────────────── function Test-ReconADACL009 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) if (-not $AuditData.ACLs) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'ACL data not available' } $aclData = $AuditData.ACLs $maq = [int]$aclData.MachineAccountQuota if ($maq -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'ms-DS-MachineAccountQuota is 0. Authenticated users cannot create machine accounts' ` -Details @{ MachineAccountQuota = 0 } } $currentValue = "ms-DS-MachineAccountQuota is $maq (default: 10, recommended: 0). Any authenticated user can create up to $maq machine account(s), enabling RBCD and relay attacks" return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue $currentValue ` -Details @{ MachineAccountQuota = $maq } } # ── ADACL-010: Extended Rights Audit (DCSync) ──────────────────────────────── function Test-ReconADACL010 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) if (-not $AuditData.ACLs) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'ACL data not available' } $aclData = $AuditData.ACLs $dangerousACEs = @($aclData.DangerousACEs) # DCSync GUIDs $dcSyncGuids = @( '1131f6aa-9c07-11d1-f79f-00c04fc2dcd2' # DS-Replication-Get-Changes '1131f6ad-9c07-11d1-f79f-00c04fc2dcd2' # DS-Replication-Get-Changes-All ) # Filter for ACEs with DCSync extended rights $dcSyncACEs = @($dangerousACEs | Where-Object { ($_.ObjectTypeGUID -and $_.ObjectTypeGUID -in $dcSyncGuids) -or ($_.ObjectType -and $_.ObjectType -match 'DS-Replication-Get-Changes') }) # Filter out safe admin SIDs $dcSyncACEs = @($dcSyncACEs | Where-Object { -not (Test-SafeAdminSid -Sid $_.IdentitySID -IdentityReference $_.IdentityReference) }) if ($dcSyncACEs.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No non-default accounts with DCSync replication rights (DS-Replication-Get-Changes, DS-Replication-Get-Changes-All)' ` -Details @{ DCSyncACECount = 0 } } # Group by identity to show unique principals $dcSyncPrincipals = @{} foreach ($ace in $dcSyncACEs) { $identity = $ace.IdentityReference ?? $ace.IdentitySID if (-not $identity) { continue } if (-not $dcSyncPrincipals.ContainsKey($identity)) { $dcSyncPrincipals[$identity] = [System.Collections.Generic.List[string]]::new() } $rightName = $ace.ObjectType ?? $ace.ObjectTypeGUID ?? 'ExtendedRight' if (-not $dcSyncPrincipals[$identity].Contains($rightName)) { $dcSyncPrincipals[$identity].Add($rightName) } } $principalSummary = @() foreach ($principal in $dcSyncPrincipals.Keys) { $rights = $dcSyncPrincipals[$principal] -join ' + ' $principalSummary += "$principal ($rights)" } $currentValue = "$($dcSyncPrincipals.Count) non-default principal(s) with DCSync rights: $($principalSummary -join '; ')" return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue $currentValue ` -Details @{ DCSyncACECount = $dcSyncACEs.Count DCSyncPrincipals = $dcSyncPrincipals ACEs = $dcSyncACEs } } # ── ADACL-011: Ownership of Critical Objects ───────────────────────────────── function Test-ReconADACL011 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) if (-not $AuditData.ACLs) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'ACL data not available' } $aclData = $AuditData.ACLs $criticalObjects = $aclData.CriticalObjectACLs if (-not $criticalObjects -or $criticalObjects.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Critical object ACL data not available for ownership analysis' } # Check domain root owner specifically $domainRootOwner = $aclData.DomainRootOwner $ownershipIssues = [System.Collections.Generic.List[hashtable]]::new() if ($domainRootOwner -and $domainRootOwner -ne 'Unknown') { # Try to determine if the owner is a safe admin # Domain root owner is typically "Domain Admins" or "Enterprise Admins" $ownerIsSafe = $false $safeOwnerPatterns = @( 'Domain Admins', 'Enterprise Admins', 'Administrators', 'BUILTIN\Administrators', 'SYSTEM' ) foreach ($pattern in $safeOwnerPatterns) { if ($domainRootOwner -eq $pattern -or $domainRootOwner -like "*\$pattern") { $ownerIsSafe = $true break } } # Also check SID pattern for DA/EA if (-not $ownerIsSafe -and $domainRootOwner -match '-512$|-519$|S-1-5-18|S-1-5-32-544') { $ownerIsSafe = $true } if (-not $ownerIsSafe) { $ownershipIssues.Add(@{ ObjectName = 'Domain Root' Owner = $domainRootOwner Risk = 'Domain root owned by non-admin principal. Owner can modify DACL implicitly.' }) } } # Check each critical object for owner information in the ACE data # Note: The collector stores owner info on the domain root; for other objects # we examine the ACL data structure for ownership anomalies via WriteOwner ACEs # as a proxy for ownership risk if ($ownershipIssues.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "Domain root owned by $domainRootOwner. Critical object ownership appears correct" ` -Details @{ DomainRootOwner = $domainRootOwner ObjectsChecked = @($criticalObjects.Keys) OwnershipIssues = @() } } $issueDescriptions = @($ownershipIssues | ForEach-Object { "$($_.ObjectName) owned by $($_.Owner)" }) $currentValue = "$($ownershipIssues.Count) critical object(s) with non-admin ownership: $($issueDescriptions -join '; ')" return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue $currentValue ` -Details @{ DomainRootOwner = $domainRootOwner ObjectsChecked = @($criticalObjects.Keys) OwnershipIssues = @($ownershipIssues) } } # ── ADACL-012: Non-Default Domain Root Permissions ─────────────────────────── function Test-ReconADACL012 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) if (-not $AuditData.ACLs) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'ACL data not available' } $aclData = $AuditData.ACLs $criticalObjects = $aclData.CriticalObjectACLs if (-not $criticalObjects -or -not $criticalObjects.ContainsKey('Domain Root')) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Domain root ACL data not available' } $domainRootData = $criticalObjects['Domain Root'] $allACEs = @($domainRootData.ACEs) if ($allACEs.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No ACE data available for domain root' } # Filter for non-default Allow ACEs from unexpected principals that grant write/modify rights $dangerousRightsPattern = 'GenericAll|GenericWrite|WriteDacl|WriteOwner|WriteProperty|ExtendedRight' $nonDefaultACEs = @($allACEs | Where-Object { $_.AccessControlType -eq 'Allow' -and $_.ActiveDirectoryRights -match $dangerousRightsPattern -and -not (Test-SafeAdminSid -Sid $_.IdentitySID -IdentityReference $_.IdentityReference) }) if ($nonDefaultACEs.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "Domain root has $($allACEs.Count) ACE(s); no non-default write/modify permissions from unexpected principals" ` -Details @{ TotalACECount = $allACEs.Count NonDefaultACECount = 0 } } $principals = @($nonDefaultACEs | ForEach-Object { $_.IdentityReference } | Sort-Object -Unique) $rightsList = @($nonDefaultACEs | ForEach-Object { $_.ActiveDirectoryRights } | Sort-Object -Unique) $currentValue = "$($nonDefaultACEs.Count) non-default ACE(s) on domain root from unexpected principal(s): $($principals -join ', ') with rights: $($rightsList -join ', ')" return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue $currentValue ` -Details @{ TotalACECount = $allACEs.Count NonDefaultACECount = $nonDefaultACEs.Count Principals = $principals Rights = $rightsList NonDefaultACEs = $nonDefaultACEs } } # ── ADACL-013: GPO Link Permissions ────────────────────────────────────────── function Test-ReconADACL013 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) if (-not $AuditData.ACLs) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'ACL data not available' } $aclData = $AuditData.ACLs $criticalObjects = $aclData.CriticalObjectACLs # Check for write permissions on critical OUs that could allow GPO linking # GPO linking requires write access to gPLink attribute on the OU/domain object $gpoLinkIssues = [System.Collections.Generic.List[hashtable]]::new() $linkTargets = @('Domain Root', 'Domain Controllers OU') foreach ($targetName in $linkTargets) { if (-not $criticalObjects.ContainsKey($targetName)) { continue } $targetData = $criticalObjects[$targetName] $aces = @($targetData.ACEs) foreach ($ace in $aces) { if ($ace.AccessControlType -ne 'Allow') { continue } if (Test-SafeAdminSid -Sid $ace.IdentitySID -IdentityReference $ace.IdentityReference) { continue } # WriteProperty on gPLink or GenericWrite/GenericAll (which includes gPLink modification) $rights = $ace.ActiveDirectoryRights $canModifyGPOLink = $false if ($rights -match 'GenericAll|GenericWrite') { $canModifyGPOLink = $true } elseif ($rights -match 'WriteProperty') { # WriteProperty with no specific ObjectType means all properties including gPLink if (-not $ace.ObjectTypeGUID) { $canModifyGPOLink = $true } # gPLink attribute GUID: f30e3bbe-9ff0-11d1-b603-0000f80367c1 if ($ace.ObjectTypeGUID -eq 'f30e3bbe-9ff0-11d1-b603-0000f80367c1') { $canModifyGPOLink = $true } } if ($canModifyGPOLink) { $gpoLinkIssues.Add(@{ TargetObject = $targetName IdentityReference = $ace.IdentityReference IdentitySID = $ace.IdentitySID Rights = $rights }) } } } if ($gpoLinkIssues.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No non-admin principals can modify GPO links on critical OUs' ` -Details @{ GPOLinkIssueCount = 0; CheckedTargets = $linkTargets } } $principals = @($gpoLinkIssues | ForEach-Object { $_.IdentityReference } | Sort-Object -Unique) $targets = @($gpoLinkIssues | ForEach-Object { $_.TargetObject } | Sort-Object -Unique) $currentValue = "$($gpoLinkIssues.Count) non-admin principal(s) can modify GPO links: $($principals -join ', ') on $($targets -join ', ')" return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue $currentValue ` -Details @{ GPOLinkIssueCount = $gpoLinkIssues.Count Principals = $principals AffectedTargets = $targets Issues = @($gpoLinkIssues) } } # ── ADACL-014: GPO Edit Permissions ────────────────────────────────────────── function Test-ReconADACL014 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) if (-not $AuditData.ACLs) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'ACL data not available' } $aclData = $AuditData.ACLs $gpoPermissions = $aclData.GPOPermissions if (-not $gpoPermissions -or $gpoPermissions.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'GPO permission data not available' } # Find GPOs where non-admin principals can edit $gpoEditIssues = [System.Collections.Generic.List[hashtable]]::new() foreach ($gpoName in $gpoPermissions.Keys) { $gpoPerm = $gpoPermissions[$gpoName] $editors = @($gpoPerm.CanEdit) foreach ($editor in $editors) { # Skip safe admin principals by name $isSafe = $false $safeEditorPatterns = @( 'Domain Admins', 'Enterprise Admins', 'SYSTEM', 'BUILTIN\Administrators', 'Administrators', 'Group Policy Creator Owners' ) foreach ($pattern in $safeEditorPatterns) { if ($editor -eq $pattern -or $editor -like "*\$pattern") { $isSafe = $true break } } # Check SID patterns if (-not $isSafe -and $editor -match '-512$|-519$|S-1-5-18|S-1-5-32-544') { $isSafe = $true } if (-not $isSafe) { $gpoEditIssues.Add(@{ GPOName = $gpoName GPODN = $gpoPerm.DN Editor = $editor }) } } } if ($gpoEditIssues.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "Analyzed $($gpoPermissions.Count) GPO(s); no non-admin principals with edit permissions" ` -Details @{ GPOsAnalyzed = $gpoPermissions.Count GPOEditIssueCount = 0 } } # Group by GPO for reporting $byGPO = @{} foreach ($issue in $gpoEditIssues) { $gpoName = $issue.GPOName if (-not $byGPO.ContainsKey($gpoName)) { $byGPO[$gpoName] = [System.Collections.Generic.List[string]]::new() } if (-not $byGPO[$gpoName].Contains($issue.Editor)) { $byGPO[$gpoName].Add($issue.Editor) } } $summaryParts = @() foreach ($gpoName in $byGPO.Keys) { $summaryParts += "$gpoName editable by $($byGPO[$gpoName] -join ', ')" } $currentValue = "$($byGPO.Count) GPO(s) with non-admin edit permissions: $($summaryParts -join '; ')" return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue $currentValue ` -Details @{ GPOsAnalyzed = $gpoPermissions.Count GPOEditIssueCount = $gpoEditIssues.Count AffectedGPOs = $byGPO Issues = @($gpoEditIssues) } } # ── ADACL-015: Shadow Admins Detection ─────────────────────────────────────── function Test-ReconADACL015 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) if (-not $AuditData.ACLs) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'ACL data not available' } $aclData = $AuditData.ACLs $dangerousACEs = @($aclData.DangerousACEs) if ($dangerousACEs.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No shadow admin paths identified; no non-default dangerous ACEs on critical objects' ` -Details @{ ShadowAdminCount = 0 } } # Shadow admins are principals that have dangerous rights (which could escalate # to Domain Admin) but are NOT members of standard admin groups. # We compile all unique non-admin principals with dangerous rights. $shadowAdmins = @{} foreach ($ace in $dangerousACEs) { $identity = $ace.IdentityReference ?? $ace.IdentitySID if (-not $identity) { continue } # Skip known-safe admin SIDs if (Test-SafeAdminSid -Sid $ace.IdentitySID -IdentityReference $ace.IdentityReference) { continue } if (-not $shadowAdmins.ContainsKey($identity)) { $shadowAdmins[$identity] = @{ Identity = $identity SID = $ace.IdentitySID Rights = [System.Collections.Generic.List[string]]::new() Objects = [System.Collections.Generic.List[string]]::new() } } $rightDesc = $ace.ActiveDirectoryRights if ($ace.ObjectType) { $rightDesc += " ($($ace.ObjectType))" } if (-not $shadowAdmins[$identity].Rights.Contains($rightDesc)) { $shadowAdmins[$identity].Rights.Add($rightDesc) } $objName = $ace.ObjectName ?? 'Unknown' if (-not $shadowAdmins[$identity].Objects.Contains($objName)) { $shadowAdmins[$identity].Objects.Add($objName) } } if ($shadowAdmins.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No shadow admin principals identified after filtering default admin accounts' ` -Details @{ ShadowAdminCount = 0 } } $summaryParts = @() foreach ($identity in $shadowAdmins.Keys) { $info = $shadowAdmins[$identity] $summaryParts += "${identity}: $($info.Rights -join ', ') on $($info.Objects -join ', ')" } $currentValue = "$($shadowAdmins.Count) shadow admin principal(s) with dangerous rights that could escalate to Domain Admin: $($summaryParts -join '; ')" return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue $currentValue ` -Details @{ ShadowAdminCount = $shadowAdmins.Count ShadowAdmins = $shadowAdmins } } # ── ADACL-016: Attack Path Enumeration ─────────────────────────────────────── function Test-ReconADACL016 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) if (-not $AuditData.ACLs) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'ACL data not available' } $aclData = $AuditData.ACLs $dangerousACEs = @($aclData.DangerousACEs) if ($dangerousACEs.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No ACL-based attack paths identified; no non-default dangerous ACEs on critical objects' ` -Details @{ AttackPathCount = 0 Note = 'For comprehensive attack path analysis, use BloodHound or similar graph-based tools' } } # Simplified attack path analysis: identify principals that have dangerous rights # and categorize by the type of escalation they enable. $attackPaths = [System.Collections.Generic.List[hashtable]]::new() foreach ($ace in $dangerousACEs) { $identity = $ace.IdentityReference ?? $ace.IdentitySID if (-not $identity) { continue } # Skip known-safe admin SIDs if (Test-SafeAdminSid -Sid $ace.IdentitySID -IdentityReference $ace.IdentityReference) { continue } $rights = $ace.ActiveDirectoryRights $objectName = $ace.ObjectName ?? 'Unknown' $pathType = 'Unknown' # Classify the attack path type if ($rights -match 'GenericAll') { $pathType = 'FullControl' } elseif ($rights -match 'WriteDacl') { $pathType = 'DACLModification' } elseif ($rights -match 'WriteOwner') { $pathType = 'OwnershipTakeover' } elseif ($rights -match 'GenericWrite') { $pathType = 'PropertyWrite' } elseif ($ace.ObjectType -match 'DS-Replication-Get-Changes') { $pathType = 'DCSync' } elseif ($ace.ObjectType -match 'User-Force-Change-Password') { $pathType = 'PasswordReset' } elseif ($rights -match 'ExtendedRight') { $pathType = 'ExtendedRight' } # Higher risk if targeting domain root or AdminSDHolder $isHighRisk = $objectName -match 'Domain Root|AdminSDHolder|Domain Controllers' # Even higher risk if the source is a broad group $isBroadGroup = Test-BroadGroupSid -Sid $ace.IdentitySID -IdentityReference $ace.IdentityReference $attackPaths.Add(@{ Source = $identity SourceSID = $ace.IdentitySID Target = $objectName TargetDN = $ace.ObjectDN PathType = $pathType Rights = $rights IsHighRisk = $isHighRisk IsBroadGroup = $isBroadGroup }) } if ($attackPaths.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No ACL-based attack paths from non-admin principals to critical objects' ` -Details @{ AttackPathCount = 0 Note = 'For comprehensive attack path analysis, use BloodHound or similar graph-based tools' } } $highRiskPaths = @($attackPaths | Where-Object { $_.IsHighRisk }) $broadGroupPaths = @($attackPaths | Where-Object { $_.IsBroadGroup }) # Determine status based on risk $status = if ($broadGroupPaths.Count -gt 0) { 'FAIL' } elseif ($highRiskPaths.Count -gt 0) { 'FAIL' } else { 'WARN' } $uniqueSources = @($attackPaths | ForEach-Object { $_.Source } | Sort-Object -Unique) $uniquePathTypes = @($attackPaths | ForEach-Object { $_.PathType } | Sort-Object -Unique) $currentValue = "$($attackPaths.Count) ACL-based attack path(s) from $($uniqueSources.Count) principal(s) to critical objects via $($uniquePathTypes -join ', ')" if ($highRiskPaths.Count -gt 0) { $currentValue += ". $($highRiskPaths.Count) target high-value objects (Domain Root, AdminSDHolder, Domain Controllers OU)" } if ($broadGroupPaths.Count -gt 0) { $currentValue += ". $($broadGroupPaths.Count) originate from broad groups (Everyone, Authenticated Users, Domain Users)" } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue $currentValue ` -Details @{ AttackPathCount = $attackPaths.Count HighRiskPathCount = $highRiskPaths.Count BroadGroupPaths = $broadGroupPaths.Count UniqueSources = $uniqueSources PathTypes = $uniquePathTypes AttackPaths = @($attackPaths) Note = 'For comprehensive multi-hop attack path analysis, use BloodHound or similar graph-based tools' } } |