function Invoke-ServiceImagePathAudit { <# .SYNOPSIS Scans, analyzes, and optionally repairs Windows service ImagePath values for unquoted paths with spaces. .DESCRIPTION Single entry-point for the classic workflow: - Scan: Retrieve service ImagePath values from HKLM:\SYSTEM\CurrentControlSet\Services - Analyze: Identify unquoted service paths with spaces and generate a FixedKey suggestion - Repair: Apply FixedKey back to the registry for items marked BadKey="Yes" - ScanAnalyze: Scan then Analyze (default) - ScanFix: Scan then Analyze then Repair Supports -WhatIf and -Confirm for repairs. .PARAMETER Operation What to do: - Scan Output ComputerName/Key/ImagePath records - Analyze Add BadKey/FixedKey to incoming records - Repair Write FixedKey back for incoming records where BadKey="Yes" - ScanAnalyze Scan then Analyze (default) - ScanFix Scan then Analyze then Repair .PARAMETER ComputerName One or more computer names to scan (used by Scan/ScanAnalyze/ScanFix). Defaults to the local computer. .PARAMETER InputObject Pipeline input (used by Analyze/Repair). Expected properties: - Analyze: ComputerName, Key, ImagePath - Repair: ComputerName, Key, BadKey, FixedKey .PARAMETER ShowProgress Show progress bars during scanning/analyzing/repair. Default is enabled if you do not specify it. .EXAMPLE Invoke-ServiceImagePathAudit Scans and analyzes the local computer (default Operation is ScanAnalyze). .EXAMPLE Invoke-ServiceImagePathAudit -Operation Scan -ComputerName Server1,Server2 Scans Server1 and Server2 and returns raw ImagePath records. .EXAMPLE Invoke-ServiceImagePathAudit -ComputerName Server1 | Where-Object BadKey -eq 'Yes' Scans and analyzes Server1 and filters to vulnerable entries. .EXAMPLE Invoke-ServiceImagePathAudit -Operation ScanFix -ComputerName Server1 -WhatIf Shows what would be repaired on Server1 without making changes. .EXAMPLE Import-Csv .\scan.csv | Invoke-ServiceImagePathAudit -Operation Analyze Analyzes previously exported scan results. .EXAMPLE Import-Csv .\scan.csv | Invoke-ServiceImagePathAudit -Operation Repair -WhatIf Dry-run repairs from previously analyzed records. .NOTES Save as UTF-8 (no BOM). Remote scan/repair requires Remote Registry access and permissions to read/write HKLM on targets. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High', DefaultParameterSetName = 'ScanSet')] param( [Parameter(Position = 0)] [ValidateSet('Scan','Analyze','Repair','ScanAnalyze','ScanFix')] [string]$Operation = 'ScanAnalyze', [Parameter(ParameterSetName = 'ScanSet')] [Alias('Name','Computer','Server','__ServerName')] [string[]]$ComputerName = $env:COMPUTERNAME, [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'PipelineSet')] $InputObject, [switch]$ShowProgress ) begin { # Default ShowProgress to ON if user didn't specify it if (-not $PSBoundParameters.ContainsKey('ShowProgress')) { $ShowProgress = $true } function _Scan { param([string[]]$Targets) foreach ($computer in $Targets) { $collection = @() if ($ShowProgress) { Write-Progress -Id 1 -Activity "Scanning services on $computer" -Status "Connecting..." Write-Progress -Id 2 -Activity "Parsing results" -Status "Waiting..." -PercentComplete 0 } $result = & reg.exe QUERY "\\$computer\HKLM\SYSTEM\CurrentControlSet\Services" /v ImagePath /s 2>&1 if (-not $result -or $result[0] -match 'ERROR|Denied') { if ($ShowProgress) { Write-Progress -Id 1 -Activity "Scanning services on $computer" -Status "Connection Failed" Write-Progress -Id 2 -Activity "Parsing results" -Completed Write-Progress -Id 1 -Activity "Scanning services on $computer" -Completed } $collection += [pscustomobject]@{ ComputerName = $computer Status = "REG Failed" Key = "Unavailable" ImagePath = "Unavailable" } $collection continue } if ($ShowProgress) { Write-Progress -Id 1 -Activity "Scanning services on $computer" -Status "Connected" } $lines = $result | Where-Object { $_ -and $_.Trim() -ne '' } $currentKey = $null $i = 0 foreach ($line in $lines) { $i++ if ($ShowProgress) { $pct = [math]::Min([math]::Round(($i / [math]::Max($lines.Count, 1)) * 100), 100) Write-Progress -Id 2 -Activity "Parsing results" -Status "Reading $computer" -PercentComplete $pct } if ($line -match '^HKEY_') { $currentKey = $line.Trim() continue } if ($line -match '^\s*ImagePath\s+REG_\w+\s+(?.+)$') { $collection += [pscustomobject]@{ ComputerName = $computer Status = "Retrieved" Key = $currentKey ImagePath = $Matches['val'].Trim() } } } if ($ShowProgress) { Write-Progress -Id 2 -Activity "Parsing results" -Completed Write-Progress -Id 1 -Activity "Scanning services on $computer" -Completed } $collection } } function _AnalyzeOne { param($Obj) $outObj = $Obj | Select-Object * $img = [string]$outObj.ImagePath if ($ShowProgress) { Write-Progress -Activity "Analyzing ImagePath" -Status "Checking $($outObj.ComputerName)\$($outObj.Key)" } # Default outputs $badKey = "No" $fixed = "N/A" # Can't analyze if ($outObj.Key -eq "Unavailable" -or $img -eq "Unavailable" -or [string]::IsNullOrWhiteSpace($img)) { $outObj | Add-Member NoteProperty BadKey "Unknown" -Force $outObj | Add-Member NoteProperty FixedKey "Can't Fix" -Force return $outObj } $trim = $img.Trim() # Already quoted or special prefix we don't touch if ($trim.StartsWith('"') -or $trim.StartsWith('\??')) { $outObj | Add-Member NoteProperty BadKey $badKey -Force $outObj | Add-Member NoteProperty FixedKey $fixed -Force return $outObj } # If no spaces, not vulnerable in the classic sense if ($trim -notmatch '\s') { $outObj | Add-Member NoteProperty BadKey $badKey -Force $outObj | Add-Member NoteProperty FixedKey $fixed -Force return $outObj } # Quote only the executable portion (best practice for service ImagePath) # Matches: # C:\Path With Spaces\app.exe # \\server\share\Path With Spaces\app.exe # Also tolerates env-var rooted paths like: # %ProgramFiles%\App\app.exe $exeRegex = '^(?(?:(?:[A-Za-z]:\\)|(?:\\\\[^\\]+\\[^\\]+\\)|(?:%[^%]+%\\))[^"]*?\.(?:exe|com|bat|cmd))(?\s+.*)?$' if ($trim -match $exeRegex) { $exe = $Matches['exe'] $args = $Matches['args'] # Only "bad" if the exe path contains whitespace and is unquoted (it is) if ($exe -match '\s') { $badKey = "Yes" $fixed = '"' + $exe + '"' + ($args ?? '') } else { $badKey = "No" $fixed = "N/A" } } else { # Fallback: if we can't confidently isolate an exe, quote the whole string # (better than producing broken quotes) $badKey = "Yes" $fixed = '"' + $exe + '"' + ($(if ($null -ne $args) { $args } else { '' })) #PoSH 7 -> $fixed = '"' + $trim.Trim('"') + '"' } $outObj | Add-Member NoteProperty BadKey $badKey -Force $outObj | Add-Member NoteProperty FixedKey $fixed -Force return $outObj } function _RepairOne { param($Obj) $outObj = $Obj | Select-Object * if ($outObj.BadKey -ne 'Yes') { return $outObj } # Normalize root names so reg.exe remote paths are valid $regKey = [string]$outObj.Key $regKey = $regKey -replace '^HKEY_LOCAL_MACHINE', 'HKLM' $regKey = $regKey -replace '^HKEY_USERS', 'HKU' $target = "\\$($outObj.ComputerName)\$regKey" $data = [string]$outObj.FixedKey if ($ShowProgress) { Write-Progress -Activity "Repairing ImagePath" -Status "Fixing $($outObj.ComputerName)\$regKey" } if ([string]::IsNullOrWhiteSpace($data) -or $data -eq 'N/A' -or $data -eq "Can't Fix") { $outObj.Status = "Skipped (no FixedKey)" return $outObj } # reg.exe needs embedded quotes escaped, and the whole /d value wrapped in quotes # Example: "C:\Program Files\App\app.exe" /arg # becomes: "\"C:\Program Files\App\app.exe\" /arg" $dataEscaped = $data -replace '"', '\"' $dataForReg = '"' + $dataEscaped + '"' if ($PSCmdlet.ShouldProcess($target, "Set ImagePath to: $data")) { try { $args = @( 'ADD', $target, '/v', 'ImagePath', '/t', 'REG_EXPAND_SZ', '/d', $dataForReg, '/f' ) $output = & reg.exe @args 2>&1 if ($LASTEXITCODE -eq 0) { $outObj.Status = "Fixed" } else { $msg = ($output | Out-String).Trim() if ([string]::IsNullOrWhiteSpace($msg)) { $msg = "reg.exe exit code $LASTEXITCODE" } $outObj.Status = "Failed: $msg" } } catch { $outObj.Status = "Failed: $($_.Exception.Message)" } } else { $outObj.Status = "WhatIf" } return $outObj } } process { switch ($Operation) { 'Scan' { if ($PSCmdlet.ParameterSetName -ne 'ScanSet') { throw "Operation 'Scan' requires -ComputerName (not pipeline input)." } _Scan -Targets $ComputerName } 'Analyze' { if ($PSCmdlet.ParameterSetName -ne 'PipelineSet') { throw "Operation 'Analyze' requires pipeline input (-InputObject)." } if ($null -ne $InputObject) { _AnalyzeOne -Obj $InputObject } } 'Repair' { if ($PSCmdlet.ParameterSetName -ne 'PipelineSet') { throw "Operation 'Repair' requires pipeline input (-InputObject)." } if ($null -ne $InputObject) { _RepairOne -Obj $InputObject } } 'ScanAnalyze' { if ($PSCmdlet.ParameterSetName -ne 'ScanSet') { throw "Operation 'ScanAnalyze' requires -ComputerName (not pipeline input)." } _Scan -Targets $ComputerName | ForEach-Object { _AnalyzeOne -Obj $_ } } 'ScanFix' { if ($PSCmdlet.ParameterSetName -ne 'ScanSet') { throw "Operation 'ScanFix' requires -ComputerName (not pipeline input)." } _Scan -Targets $ComputerName | ForEach-Object { _AnalyzeOne -Obj $_ } | ForEach-Object { _RepairOne -Obj $_ } } } } }