Private/AD/Checks/Invoke-ADTradecraftChecks.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-ADTradecraftChecks { [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$AuditData ) $checkDefs = Get-AuditCategoryDefinitions -Category 'ADTradecraftChecks' $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) } # ── ADTRADE-001: GPP cpassword Leftovers ─────────────────────────────────── function Test-ReconADTRADE001 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $na = Get-NotAssessedFinding -CheckDefinition $CheckDefinition -ErrorMap $AuditData.Errors ` -SourceKey 'TradecraftSignals' -Subject 'tradecraft signals' if ($na) { return $na } $tc = $AuditData.Tradecraft if (-not $tc) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Tradecraft data not collected.' } if (-not $tc.SysvolReadable) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'SYSVOL not readable from this host (auth/network issue).' } $hits = @($tc.CpasswordHits) if ($hits.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No GPP cpassword fields found in SYSVOL Policies XML files (MS14-025 cleanup verified)' ` -Details @{ FilesScanned = 'SYSVOL Policies recursive *.xml' } } $summary = @($hits | Select-Object -First 5 | ForEach-Object { "$($_.ExposedUser) in $($_.FilePath)" }) -join '; ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "Found $($hits.Count) GPP cpassword leftover(s) in SYSVOL. Examples: $summary. Rotate every exposed credential — the cpassword AES key is public, anyone with SYSVOL read access can decrypt." ` -Details @{ HitCount = $hits.Count; Hits = $hits } } # ── ADTRADE-002: DCShadow Indicator ──────────────────────────────────────── function Test-ReconADTRADE002 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $na = Get-NotAssessedFinding -CheckDefinition $CheckDefinition -ErrorMap $AuditData.Errors ` -SourceKey @('TradecraftSignals','DomainControllers') -Subject 'tradecraft signals' if ($na) { return $na } $tc = $AuditData.Tradecraft $dcs = $AuditData.DomainControllers if (-not $tc) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Tradecraft data not collected.' } $configServers = @($tc.ConfigPartitionServers) if ($configServers.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' ` -CurrentValue 'No server objects found under CN=Sites,CN=Configuration. This is unusual for a real domain — verify the collector had read access to the configuration partition.' ` -Details @{ ConfigServerCount = 0 } } # Build a set of known DC hostnames (lowercased) from the DomainControllers collection. # Get-ADDomainControllers returns a flat array of DC hashtables — keys are Name + FQDN. $knownDcHosts = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) if ($dcs) { foreach ($d in @($dcs)) { if ($d.FQDN) { [void]$knownDcHosts.Add($d.FQDN) } if ($d.Name) { [void]$knownDcHosts.Add($d.Name) } } } if ($knownDcHosts.Count -eq 0) { # If we have no DC inventory we can't tell orphans from real DCs — SKIP rather # than flag every server in the config partition as a rogue. return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'DomainControllers data not available to cross-reference. Re-run with DomainForest category enabled to populate the DC inventory.' } $orphans = [System.Collections.Generic.List[hashtable]]::new() foreach ($s in $configServers) { $sHost = $s.DNSHostName ?? $s.CN ?? '' if (-not $sHost) { continue } # The configuration partition may include short-name servers; also try just the CN. $isKnown = $knownDcHosts.Contains($sHost) -or $knownDcHosts.Contains($s.CN) if (-not $isKnown) { $orphans.Add(@{ Server = $sHost DN = $s.DistinguishedName Created = $s.WhenCreated }) } } if ($orphans.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "All $($configServers.Count) server objects in CN=Sites,CN=Configuration match known domain controllers — no DCShadow indicator." ` -Details @{ ConfigServerCount = $configServers.Count; DcCount = $knownDcHosts.Count } } $summary = @($orphans | ForEach-Object { "$($_.Server) (created $($_.Created))" }) -join '; ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($orphans.Count) server object(s) under CN=Sites,CN=Configuration that do NOT match a known DC: $summary. Investigate immediately — DCShadow registers fake DCs here, but legitimate causes also exist (replicated objects from a removed DC, legacy site setup)." ` -Details @{ OrphanCount = $orphans.Count; Orphans = @($orphans) } } # ── ADTRADE-003: Stale BitLocker Recovery Keys ───────────────────────────── function Test-ReconADTRADE003 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $na = Get-NotAssessedFinding -CheckDefinition $CheckDefinition -ErrorMap $AuditData.Errors ` -SourceKey 'TradecraftSignals' -Subject 'tradecraft signals' if ($na) { return $na } $tc = $AuditData.Tradecraft if (-not $tc) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Tradecraft data not collected.' } $keys = @($tc.BitLockerKeys) if ($keys.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No BitLocker recovery information found in AD. Either BitLocker is not deployed, or the recovery keys are stored elsewhere (Intune, MBAM, etc.).' ` -Details @{ KeyCount = 0 } } $thresholdDays = 365 $cutoff = [datetime]::UtcNow.AddDays(-$thresholdDays) $staleKeys = [System.Collections.Generic.List[hashtable]]::new() foreach ($k in $keys) { $created = $k.WhenCreated if ($created -is [datetime] -and $created -lt $cutoff) { $staleKeys.Add(@{ DN = $k.DistinguishedName ParentComputer = $k.ParentComputer AgeDays = [int]([datetime]::UtcNow - $created).TotalDays }) } } if ($staleKeys.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "$($keys.Count) BitLocker recovery key(s) found, all within $thresholdDays days" ` -Details @{ KeyCount = $keys.Count } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' ` -CurrentValue "$($staleKeys.Count) BitLocker recovery key(s) older than $thresholdDays days (of $($keys.Count) total). Review their parent computer accounts — confirm the drives have been destroyed or wiped, then prune the AD computer objects to cascade-delete the orphan keys." ` -Details @{ StaleCount = $staleKeys.Count; TotalCount = $keys.Count; ThresholdDays = $thresholdDays; Sample = @($staleKeys | Select-Object -First 10) } } # ── ADTRADE-004: RODC Password Replication Policy Hygiene ────────────────── function Test-ReconADTRADE004 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $na = Get-NotAssessedFinding -CheckDefinition $CheckDefinition -ErrorMap $AuditData.Errors ` -SourceKey 'TradecraftSignals' -Subject 'tradecraft signals' if ($na) { return $na } $tc = $AuditData.Tradecraft if (-not $tc) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Tradecraft data not collected.' } $rodcs = @($tc.Rodcs) if ($rodcs.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No RODCs in this domain — PRP hygiene N/A.' ` -Details @{ RodcCount = 0 } } # The PRP itself isn't a simple LDAP attribute — it's expressed via msDS-RevealOnDemandGroup # and msDS-NeverRevealGroup on each RODC computer object, plus the canonical Deny / Allow # groups. Surfacing the inventory + the manual verification command is the right call here # rather than half-checking with incomplete logic. $summary = @($rodcs | ForEach-Object { $_.DNSHostName }) -join ', ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' ` -CurrentValue "Domain has $($rodcs.Count) RODC(s): $summary. Verify each RODC's Password Replication Policy out-of-band: Get-ADDomainControllerPasswordReplicationPolicy -Identity <rodc> -Denied. Domain Admins, Enterprise Admins, Schema Admins, Account Operators, and krbtgt must be in the Denied list (typically via the 'Denied RODC Password Replication Group' builtin)." ` -Details @{ RodcCount = $rodcs.Count; Rodcs = @($rodcs) } } # ── ADTRADE-005: Seamless SSO (AZUREADSSOACC$) Key Rotation ──────────────── function Test-ReconADTRADE005 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $na = Get-NotAssessedFinding -CheckDefinition $CheckDefinition -ErrorMap $AuditData.Errors ` -SourceKey 'TradecraftSignals' -Subject 'tradecraft signals' if ($na) { return $na } $tc = $AuditData.Tradecraft if (-not $tc) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Tradecraft data not collected.' } $sso = $tc.SeamlessSsoAccount if (-not $sso) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'AZUREADSSOACC$ account not present — Entra Seamless SSO is not configured in this domain, so there is no key to rotate.' } $pwdLastSet = $sso.PwdLastSet if ($pwdLastSet -isnot [datetime]) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'AZUREADSSOACC$ exists but pwdLastSet could not be read (attribute not collected or never set).' ` -Details @{ DistinguishedName = $sso.DistinguishedName } } $ageDays = [int]([datetime]::UtcNow - $pwdLastSet).TotalDays $threshold = 90 if ($ageDays -le $threshold) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "AZUREADSSOACC`$ Kerberos key was rotated $ageDays day(s) ago (within the $threshold-day target)." ` -Details @{ PwdAgeDays = $ageDays; ThresholdDays = $threshold; PwdLastSet = $pwdLastSet } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "AZUREADSSOACC`$ Kerberos key has not been rotated in $ageDays day(s) (target: every $threshold days). A stale key lets an attacker who has captured it forge Silver Tickets for any hybrid user indefinitely. Roll it twice with Update-AzureADSSOForest." ` -Details @{ PwdAgeDays = $ageDays; ThresholdDays = $threshold; PwdLastSet = $pwdLastSet; DistinguishedName = $sso.DistinguishedName } } # ── ADTRADE-006: Shadow Credentials on Privileged Principals ─────────────── function Test-ReconADTRADE006 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $na = Get-NotAssessedFinding -CheckDefinition $CheckDefinition -ErrorMap $AuditData.Errors ` -SourceKey 'TradecraftSignals' -Subject 'tradecraft signals' if ($na) { return $na } $tc = $AuditData.Tradecraft if (-not $tc) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Tradecraft data not collected.' } if (-not $tc.ShadowCredCollected) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'msDS-KeyCredentialLink enumeration did not complete (read access to the attribute requires DC/privileged rights). Absence of data is not evidence of cleanliness — re-run with sufficient privilege.' } $hits = @($tc.ShadowCredentials) if ($hits.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No msDS-KeyCredentialLink (shadow credential) values found on privileged/Tier-0 principals (admins, domain controllers, adminCount=1 objects).' ` -Details @{ ScannedScope = 'adminCount=1 + domain controllers' } } # A member computer carrying its own msDS-KeyCredentialLink is the normal signature of a # Windows Hello for Business / Entra hybrid device-registration key — NOT the shadow-credential # primitive. Failing on those screams on every hybrid-joined estate. Score key credentials on # user/admin principals or domain controllers as high-signal (FAIL); treat member-computer # device keys as review-only (WARN) — never silently PASS, but never a false FAIL either. $highSignal = @($hits | Where-Object { -not $_.IsComputer -or $_.IsDomainController }) $deviceKeys = @($hits | Where-Object { $_.IsComputer -and -not $_.IsDomainController }) if ($highSignal.Count -gt 0) { $summary = @($highSignal | Select-Object -First 8 | ForEach-Object { "$($_.SamAccountName) [$($_.ObjectClass)$(if ($_.IsDomainController) { '/DC' })] ($($_.KeyCredentialCount) key(s))" }) -join '; ' $tail = if ($deviceKeys.Count) { " ($($deviceKeys.Count) member-computer device key(s) excluded as likely-legitimate.)" } else { '' } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($highSignal.Count) privileged principal(s) (user/admin/domain controller) carry msDS-KeyCredentialLink values: $summary. A key credential on an admin account or a domain controller is the shadow-credential backdoor (Whisker/pyWhisker, T1556) allowing PKINIT logon as that account. Verify every key against a legitimate enrollment and remove unrecognised entries.$tail" ` -Details @{ HitCount = $highSignal.Count; Principals = @($highSignal); DeviceKeyComputers = $deviceKeys.Count } } # Only member-computer device keys remain — overwhelmingly legitimate WHfB / Entra-hybrid registrations. $summary = @($deviceKeys | Select-Object -First 8 | ForEach-Object { "$($_.SamAccountName) ($($_.KeyCredentialCount) key(s))" }) -join '; ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' ` -CurrentValue "$($deviceKeys.Count) member-computer account(s) carry msDS-KeyCredentialLink values: $summary. These are typically legitimate Windows Hello for Business / Entra hybrid device-registration keys, not shadow credentials. Confirm each key's owner matches the computer object — a key whose owner differs from the object is the actual shadow-credential primitive. Not failing on expected device keys." ` -Details @{ DeviceKeyComputers = $deviceKeys.Count; Principals = @($deviceKeys) } } # ── ADTRADE-007: BadSuccessor dMSA Escalation Surface ────────────────────── function Test-ReconADTRADE007 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $na = Get-NotAssessedFinding -CheckDefinition $CheckDefinition -ErrorMap $AuditData.Errors ` -SourceKey 'TradecraftSignals' -Subject 'tradecraft signals' if ($na) { return $na } $tc = $AuditData.Tradecraft if (-not $tc) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Tradecraft data not collected.' } if ($tc.DmsaClassPresent -eq $false) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Schema does not contain the msDS-DelegatedManagedServiceAccount class — this forest predates Windows Server 2025, so the BadSuccessor dMSA migration abuse is not applicable.' } if ($null -eq $tc.DmsaClassPresent) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Could not determine whether the dMSA class exists (schema partition unreadable). Absence of data is not a PASS — re-run with read access to the schema NC.' } if (-not $tc.DmsaAclCollected) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'dMSA class exists but the OU ACL sweep did not complete (ntSecurityDescriptor read failed). Re-run with rights to read OU DACLs.' } $ous = @($tc.BadSuccessorOus) if ($ous.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No OU grants a non-Tier-0 principal the ability to create or write a delegated Managed Service Account (dMSA). BadSuccessor escalation surface not present.' ` -Details @{ DmsaClassPresent = $true } } $summary = @($ous | Select-Object -First 6 | ForEach-Object { $aces = @($_.RiskyAces | ForEach-Object { "$($_.Principal) [$($_.Scope)]" }) -join ', ' "$($_.Name): $aces" }) -join '; ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($ous.Count) OU(s) let a non-Tier-0 principal create/write a dMSA (BadSuccessor). Examples: $summary. Such a principal can create a dMSA, mark it as superseding a privileged account, and inherit that account's Kerberos keys. Remove CreateChild/GenericAll on these OUs from non-admin principals." ` -Details @{ OuCount = $ous.Count; OUs = @($ous) } } # ── ADTRADE-008: Enterprise Key Admins / Key Admins Membership ───────────── function Test-ReconADTRADE008 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $na = Get-NotAssessedFinding -CheckDefinition $CheckDefinition -ErrorMap $AuditData.Errors ` -SourceKey 'TradecraftSignals' -Subject 'tradecraft signals' if ($na) { return $na } $tc = $AuditData.Tradecraft if (-not $tc) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Tradecraft data not collected.' } if (-not $tc.KeyAdminGroupsFound) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Neither the Key Admins (RID 526) nor Enterprise Key Admins (RID 527) group could be resolved in this domain. Cannot assess membership.' } $ek = @($tc.EnterpriseKeyAdmins | Where-Object { $_.ObjectClass -ne 'group' }) $ka = @($tc.KeyAdmins | Where-Object { $_.ObjectClass -ne 'group' }) $total = $ek.Count + $ka.Count if ($total -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'Key Admins and Enterprise Key Admins groups are empty (recommended). These groups hold domain-wide msDS-KeyCredentialLink write rights.' ` -Details @{ KeyAdminsCount = 0; EnterpriseKeyAdminsCount = 0 } } $names = @(@($ek | ForEach-Object { "$($_.SamAccountName) (Enterprise Key Admins)" }) + @($ka | ForEach-Object { "$($_.SamAccountName) (Key Admins)" })) -join ', ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$total member(s) in Key Admins / Enterprise Key Admins: $names. Members can write msDS-KeyCredentialLink on objects domain-wide, a one-step shadow-credential primitive over any account. These groups should be empty unless WHfB key provisioning explicitly requires them." ` -Details @{ EnterpriseKeyAdminsCount = $ek.Count KeyAdminsCount = $ka.Count EnterpriseKeyAdmins = @($ek | ForEach-Object { @{ SamAccountName = $_.SamAccountName; ObjectClass = $_.ObjectClass } }) KeyAdmins = @($ka | ForEach-Object { @{ SamAccountName = $_.SamAccountName; ObjectClass = $_.ObjectClass } }) } } # ── ADTRADE-009: Cert Publishers Membership ──────────────────────────────── function Test-ReconADTRADE009 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $na = Get-NotAssessedFinding -CheckDefinition $CheckDefinition -ErrorMap $AuditData.Errors ` -SourceKey 'TradecraftSignals' -Subject 'tradecraft signals' if ($na) { return $na } $tc = $AuditData.Tradecraft if (-not $tc) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Tradecraft data not collected.' } if (-not $tc.CertPublishersFound) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Cert Publishers group (RID 517) could not be resolved in this domain. Cannot assess membership.' } $members = @($tc.CertPublishers | Where-Object { $_.ObjectClass -ne 'group' }) # Default membership is the Enterprise CA computer account(s). Member computers are expected; # user/service-account members are the concern. $nonComputer = @($members | Where-Object { $_.ObjectClass -ne 'computer' }) if ($members.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'Cert Publishers group is empty (no AD CS Enterprise CA, or membership is clean).' ` -Details @{ MemberCount = 0 } } if ($nonComputer.Count -eq 0) { $names = @($members | ForEach-Object { $_.SamAccountName }) -join ', ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "Cert Publishers contains only computer account(s) (expected: Enterprise CA hosts): $names." ` -Details @{ MemberCount = $members.Count; ComputerOnly = $true } } $names = @($nonComputer | ForEach-Object { "$($_.SamAccountName) [$($_.ObjectClass)]" }) -join ', ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($nonComputer.Count) non-computer member(s) in Cert Publishers: $names. Members can publish certificates into the NTAuth store, enabling certificate-based authentication abuse (ESC). Only Enterprise CA computer accounts belong here." ` -Details @{ MemberCount = $members.Count NonComputerCount = $nonComputer.Count NonComputerMembers = @($nonComputer | ForEach-Object { @{ SamAccountName = $_.SamAccountName; ObjectClass = $_.ObjectClass } }) } } # ── ADTRADE-010: gMSA Posture & Password Exposure ────────────────────────── function Test-ReconADTRADE010 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $na = Get-NotAssessedFinding -CheckDefinition $CheckDefinition -ErrorMap $AuditData.Errors ` -SourceKey 'TradecraftSignals' -Subject 'tradecraft signals' if ($na) { return $na } $tc = $AuditData.Tradecraft if (-not $tc) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Tradecraft data not collected.' } if (-not $tc.GmsaCollected) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'gMSA enumeration did not complete (LDAP query failed). Cannot assess managed-account posture.' } $gmsas = @($tc.GmsaAccounts) if ($gmsas.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' ` -CurrentValue 'No group Managed Service Accounts (gMSA) found. If service accounts run under static-password user accounts they are exposed to Kerberoasting and manual password rotation — migrate service identities to gMSAs (auto-rotated 240-bit passwords).' ` -Details @{ GmsaCount = 0 } } $exposed = @($gmsas | Where-Object { $_.BroadlyRetrievable -or $_.NonTier0Retrievable }) if ($exposed.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "$($gmsas.Count) gMSA(s) in use; none expose their managed password to a broad or non-Tier-0 principal via PrincipalsAllowedToRetrieveManagedPassword." ` -Details @{ GmsaCount = $gmsas.Count } } $summary = @($exposed | Select-Object -First 6 | ForEach-Object { $why = if ($_.BroadlyRetrievable) { 'broad principal (Everyone/Authenticated Users/Domain Users)' } else { 'non-privileged principal' } "$($_.SamAccountName) -> $why" }) -join '; ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($exposed.Count) of $($gmsas.Count) gMSA(s) expose their managed password to a broad/non-privileged principal: $summary. Any such principal can recover the cleartext gMSA password (e.g. GMSAPasswordReader) and impersonate the service. Restrict msDS-GroupMSAMembership to the specific hosts that must run the service." ` -Details @{ GmsaCount = $gmsas.Count; ExposedCount = $exposed.Count; Exposed = @($exposed) } } |