Private/AD/Core/Get-ADUserRightsAssignment.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 # # Get-ADUserRightsAssignment # ------------------------------------------------------------------------------- # Parses the Domain Controllers OU GPO security templates (GptTmpl.inf) from # SYSVOL and extracts the interactive- and remote-interactive-logon User Rights # Assignments. Feeds: # ADPRIV-026 (local logon on DCs) <- SeInteractiveLogonRight # ADPRIV-027 (RDP on DCs) <- SeRemoteInteractiveLogonRight # # Honesty contract (project rule #1): # * Always reads the Default Domain Controllers Policy GUID template, plus any # additional GPOs linked to the Domain Controllers OU when that data is # available from the GroupPolicies collector. # * If NO GptTmpl.inf could be read at all, the right's value is left $null so # the dependent check returns SKIP ("Not Assessed") — never a false PASS on # an unreadable template. # * Each [Privilege Rights] value is a comma-separated list of *<SID> entries; # SIDs are resolved with Resolve-ADSid and compared against an expected # Tier-0 admin allow-list. Any non-Tier-0 principal => FAIL. # # References: MITRE ATT&CK T1078.002 (Valid Accounts: Domain Accounts), # T1021.001 (Remote Services: RDP); ANSSI rule "dc_inappropriate_logon_rights"; # CIS Microsoft Windows Server benchmark (User Rights Assignment on DCs). # ------------------------------------------------------------------------------- function Get-ADUserRightsAssignment { [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$Connection, [switch]$Quiet ) # Default Domain Controllers Policy — fixed, well-known GUID. $defaultDCPolicyGuid = '{6AC1786C-016F-11D2-945F-00C04FB984F9}' # DNS domain name (contoso.com) for the SYSVOL UNC path. $domainDNS = ($Connection.DomainDN -replace '^DC=', '' -replace ',DC=', '.').ToLower() $result = @{ # $null = "template not read" → SKIP. @() = "read, no principals" (only # happens if the section is empty, which is itself notable) — treated as # "no one holds the right". InteractiveLogon = $null # SeInteractiveLogonRight (local logon) RemoteInteractiveLogon = $null # SeRemoteInteractiveLogonRight (RDP) TemplatesRead = [System.Collections.Generic.List[string]]::new() # .Add() below; @() is fixed-size and throws Errors = @{} } # ── Build candidate GptTmpl.inf paths ───────────────────────────────────── $sysvolBase = "\\$domainDNS\SYSVOL\$domainDNS\Policies" $candidateGuids = [System.Collections.Generic.List[string]]::new() [void]$candidateGuids.Add($defaultDCPolicyGuid) # If the GroupPolicies collector recorded GPOs linked to the DC OU, parse # those templates too (best-effort). if ($Connection.ContainsKey('DCOULinkedGpoGuids')) { foreach ($g in @($Connection.DCOULinkedGpoGuids)) { if ([string]::IsNullOrWhiteSpace($g)) { continue } $norm = if ($g -match '^\{') { $g } else { "{$g}" } if (-not $candidateGuids.Contains($norm)) { [void]$candidateGuids.Add($norm) } } } # Expected Tier-0 administrative principals that legitimately hold logon # rights on domain controllers. Compared case-insensitively against the # resolved account names AND raw SIDs (well-known SIDs / RIDs). $expectedTier0Names = @( 'Administrators', 'BUILTIN\Administrators', 'Domain Admins', 'Enterprise Admins', 'Backup Operators', 'BUILTIN\Backup Operators', 'Server Operators', 'BUILTIN\Server Operators', 'Print Operators', 'BUILTIN\Print Operators', 'Account Operators', 'BUILTIN\Account Operators', 'ENTERPRISE DOMAIN CONTROLLERS' ) $expectedTier0Sids = @( 'S-1-5-32-544', # Administrators 'S-1-5-32-551', # Backup Operators 'S-1-5-32-549', # Server Operators 'S-1-5-32-550', # Print Operators 'S-1-5-32-548', # Account Operators 'S-1-5-9' # Enterprise Domain Controllers ) # Domain-relative RIDs that are expected (Domain Admins 512, Enterprise Admins 519). $expectedTier0Rids = @('512', '519') $searchRoot = $null try { $searchRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $Connection.DomainDN } catch { } # Helper: classify a *<SID> token from a [Privilege Rights] line. $classifyPrincipal = { param([string]$Token) $token = $Token.Trim() if ([string]::IsNullOrWhiteSpace($token)) { return $null } # Tokens are prefixed with '*' when they are SIDs; bare names are rare. $isSid = $token.StartsWith('*') $raw = if ($isSid) { $token.Substring(1) } else { $token } $resolvedName = $raw $isExpected = $false if ($isSid) { # Well-known SID match if ($expectedTier0Sids -contains $raw) { $isExpected = $true } # Domain-relative RID match if (-not $isExpected -and $raw -match '-(\d+)$' -and ($expectedTier0Rids -contains $Matches[1])) { $isExpected = $true } # Resolve to a friendly name for reporting / name-based allow-list. try { $resolvedName = Resolve-ADSid -SidString $raw -SearchRoot $searchRoot } catch { $resolvedName = $raw } } else { $resolvedName = $raw } if (-not $isExpected) { $shortName = ($resolvedName -split '\\')[-1] foreach ($exp in $expectedTier0Names) { $expShort = ($exp -split '\\')[-1] if ($resolvedName -ieq $exp -or $shortName -ieq $expShort) { $isExpected = $true; break } } } return @{ Sid = if ($isSid) { $raw } else { '' } Name = $resolvedName IsExpected = $isExpected } } # Helper: parse one GptTmpl.inf, return a hashtable of right => @(principals). $parseTemplate = { param([string]$Path) $rights = @{} $lines = $null try { $lines = Get-Content -LiteralPath $Path -ErrorAction Stop } catch { return $null # could not read } $inPrivSection = $false foreach ($line in $lines) { $trimmed = $line.Trim() if ($trimmed -match '^\[(.+)\]$') { $inPrivSection = ($Matches[1] -ieq 'Privilege Rights') continue } if (-not $inPrivSection) { continue } if ($trimmed -notmatch '=') { continue } $idx = $trimmed.IndexOf('=') $name = $trimmed.Substring(0, $idx).Trim() $valuePart = $trimmed.Substring($idx + 1).Trim() if ($name -ieq 'SeInteractiveLogonRight' -or $name -ieq 'SeRemoteInteractiveLogonRight') { $principals = [System.Collections.Generic.List[hashtable]]::new() foreach ($tok in ($valuePart -split ',')) { $cls = & $classifyPrincipal $tok if ($null -ne $cls) { $principals.Add($cls) } } $rights[$name] = @($principals) } } return $rights } # ── Parse each candidate template ───────────────────────────────────────── $mergedInteractive = $null $mergedRemote = $null $anyTemplateRead = $false foreach ($guid in $candidateGuids) { # Build the UNC path by concatenation. Join-Path mis-parses a leading # "\\host\share" as a drive on non-Windows hosts; string-building is # both portable and works against the real \\<domain>\SYSVOL path on a DC. $path = "$sysvolBase\$guid\MACHINE\Microsoft\Windows NT\SecEdit\GptTmpl.inf" $exists = $false try { $exists = Test-Path -LiteralPath $path -ErrorAction Stop } catch { $exists = $false } if (-not $exists) { Write-Verbose "GptTmpl.inf not found: $path" continue } $parsed = & $parseTemplate $path if ($null -eq $parsed) { $result.Errors[$guid] = "Could not read $path" continue } $anyTemplateRead = $true [void]$result.TemplatesRead.Add($guid) if ($parsed.ContainsKey('SeInteractiveLogonRight')) { if ($null -eq $mergedInteractive) { $mergedInteractive = [System.Collections.Generic.List[hashtable]]::new() } foreach ($p in @($parsed['SeInteractiveLogonRight'])) { $mergedInteractive.Add($p) } } if ($parsed.ContainsKey('SeRemoteInteractiveLogonRight')) { if ($null -eq $mergedRemote) { $mergedRemote = [System.Collections.Generic.List[hashtable]]::new() } foreach ($p in @($parsed['SeRemoteInteractiveLogonRight'])) { $mergedRemote.Add($p) } } } if (-not $anyTemplateRead) { # No template readable at all → leave both rights $null so checks SKIP. $result.Errors['Summary'] = "No Domain Controllers OU GptTmpl.inf could be read under $sysvolBase (insufficient SYSVOL access or not run against the domain)." if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message 'User Rights Assignment: no DC-OU security template readable (ADPRIV-026/027 will Not-Assess)' } $result.TemplatesRead = @($result.TemplatesRead) return $result } # Convert merged lists (which may legitimately be empty if the section was # present-but-blank) to arrays. $null stays $null → SKIP for that one right. if ($null -ne $mergedInteractive) { $result.InteractiveLogon = @($mergedInteractive) } if ($null -ne $mergedRemote) { $result.RemoteInteractiveLogon = @($mergedRemote) } $result.TemplatesRead = @($result.TemplatesRead) if (-not $Quiet) { $ic = if ($null -eq $result.InteractiveLogon) { 'n/a' } else { @($result.InteractiveLogon).Count } $rc = if ($null -eq $result.RemoteInteractiveLogon) { 'n/a' } else { @($result.RemoteInteractiveLogon).Count } Write-ProgressLine -Phase RECON -Message "User Rights Assignment parsed from $($result.TemplatesRead.Count) template(s): InteractiveLogon=$ic principal(s), RDP=$rc principal(s)" } return $result } |