Dargslan.WinServiceSecurity.psm1
|
<# .SYNOPSIS Audit Windows services for unquoted paths, weak ACLs and risky service accounts. JSON / HTML report. .DESCRIPTION Part of the Dargslan Windows Admin Tools collection. Free Cheat Sheet: https://dargslan.com/cheat-sheets/windows-service-security-audit-2026 Full Guide: https://dargslan.com/blog/windows-service-security-audit-powershell-2026 More tools: https://dargslan.com .LINK https://dargslan.com .LINK https://github.com/Dargslan/powershell-admin-scripts #> $script:Banner = @" +----------------------------------------------------------+ | Dargslan Windows Service Security Audit | https://dargslan.com - Free cheat sheets & eBooks | +----------------------------------------------------------+ "@ function Get-DargslanServiceInventory { <# .SYNOPSIS Return every service with start mode, run-as account and binary path. #> [CmdletBinding()] param() Get-CimInstance Win32_Service | Select-Object @{N='Computer';E={$env:COMPUTERNAME}}, Name, DisplayName, State, StartMode, StartName, PathName } function Get-DargslanUnquotedServicePaths { <# .SYNOPSIS Detect unquoted service paths that contain spaces (classic priv-esc vector). #> [CmdletBinding()] param() Get-CimInstance Win32_Service | Where-Object { $_.PathName -and $_.PathName -notmatch '^"' -and $_.PathName -match ' ' -and $_.PathName -notmatch '^[A-Za-z]:\\Windows\\' } | Select-Object Name, StartName, StartMode, PathName } function Get-DargslanRiskyServiceAccounts { <# .SYNOPSIS Find services running as LocalSystem from a user-writable directory. #> [CmdletBinding()] param() $risky = @() foreach ($svc in Get-CimInstance Win32_Service) { if ($svc.StartName -notin 'LocalSystem','NT AUTHORITY\\LocalService','NT AUTHORITY\\NetworkService') { continue } $bin = ($svc.PathName -split '"')[1] if (-not $bin) { $bin = ($svc.PathName -split ' ')[0] } if (-not (Test-Path $bin)) { continue } $dir = Split-Path $bin -Parent try { $acl = Get-Acl $dir -ErrorAction Stop $writable = $acl.Access | Where-Object { $_.IdentityReference -match 'Users|Authenticated Users|Everyone|INTERACTIVE' -and $_.FileSystemRights -match 'Write|Modify|FullControl' -and $_.AccessControlType -eq 'Allow' } if ($writable) { $risky += [pscustomobject]@{ Service = $svc.Name StartName = $svc.StartName Binary = $bin Folder = $dir Writable = ($writable.IdentityReference -join ',') } } } catch {} } $risky } function Get-DargslanServiceAuditReport { <# .SYNOPSIS Combined report with PASS / WARN / FAIL verdict. #> [CmdletBinding()] param() $inv = @(Get-DargslanServiceInventory) $unq = @(Get-DargslanUnquotedServicePaths) $risky = @(Get-DargslanRiskyServiceAccounts) $score = 0 if ($unq.Count -eq 0) { $score++ } if ($risky.Count -eq 0) { $score++ } $autoStarts = ($inv | Where-Object StartMode -eq 'Auto').Count if ($autoStarts -gt 0) { $score++ } $verdict = if ($score -eq 3) { 'PASS' } elseif ($score -ge 1) { 'WARN' } else { 'FAIL' } [pscustomobject]@{ ComputerName = $env:COMPUTERNAME ServiceCount = $inv.Count UnquotedCount = $unq.Count RiskyAcctCount = $risky.Count Score = $score Verdict = $verdict Unquoted = $unq Risky = $risky TimeStamp = (Get-Date).ToString('s') } } function Export-DargslanServiceAuditReport { <# .SYNOPSIS Export the service audit to HTML and JSON. #> [CmdletBinding()] param([string]$OutDir = (Join-Path $env:TEMP 'DargslanServiceAudit')) if (-not (Test-Path $OutDir)) { New-Item -Type Directory -Path $OutDir | Out-Null } $r = Get-DargslanServiceAuditReport $json = Join-Path $OutDir ('services-' + $env:COMPUTERNAME + '.json') $html = Join-Path $OutDir ('services-' + $env:COMPUTERNAME + '.html') $r | ConvertTo-Json -Depth 6 | Set-Content $json -Encoding UTF8 $body = "<h1>Service Audit - $($r.ComputerName)</h1>" $body += "<p>Verdict: <b>$($r.Verdict)</b> ($($r.Score)/3)</p>" $body += '<h2>Unquoted paths</h2>' + ($r.Unquoted | ConvertTo-Html -Fragment) $body += '<h2>Risky service accounts</h2>' + ($r.Risky | ConvertTo-Html -Fragment) ConvertTo-Html -Body $body -Title 'Service Audit' | Set-Content $html -Encoding UTF8 [pscustomobject]@{ Json = $json; Html = $html; Verdict = $r.Verdict } } |