This post is a culmination for the previous 3 script posts. All three of the scripts are here, updated with progress output so you’re not staring at the blank screen for hours during runs against dozens or hundreds of computers, or over low-quality connections.
In addition, I’ve included a custom Parallel wrapper that will take the longest part (Getting paths from machines) and allow it to run in configurable parallel. All outputs are PSCustom Objects and can be sorted/manipulated/filtered by standard PowerShell cmdlets or exported to CSV/HTML as desired.
Remember to check your results! I cannot be liable for any damage caused by running these scripts. It’s your environment and you ran them. That said I am very confident this will correctly fix all but the most extreme cases.
I recommend:
.\Get-SVCPath.ps1 | .\Find-BADSVCPath.ps1 | Export-CSV report.csv
Open in excel and sort on the column that says “BadKey”
Make sure all “FixedKey” values look correct. You can change any that do not look correct in Excel as long as you do not move the header. Retain the CSV file type so Excel doesn’t feed additional garbage into your file.
When satisfied your results are good, actually run the fix:
Import-CSV report.csv | .\Fix-BADSVCPath.ps1
.\Get-SVCPath.ps1
This script will contact a remote machine via the network protocols built into REG.exe. The remote host does not need to be running PowerShell, only the host this script is executed on does. You must be respected as an administrator on the target machine. It will create a custom PowerShell object for each key it locates. Offline machines will have their fields marked “Unavailable”
Computername: Name
Status: Retrieved
Key: \Path\Name
ImagePath: Ltr:\PathValue\Executable Argument(s)
Usage
You can specify a collection of computers on the command line to interrogate, you can get-content from a text file, or you can pipe objects in that have name/computername parameters. There is an optional parameter to turn progress bars off (-progress “No”), which is used by my parallel wrapper. If you do not include any arguments, it assumes the computername environment variable on the host running the script and only runs locally.
.\Get-SVCPath.ps1 computer1,computer2,computer3
.\Get-SVCPath.ps1 (Get-Content textfile)
Get-ADComputer -filter * | .\Get-SVCPath.ps1
#GET-SVCpath.ps1 [cmdletbinding()] Param ( #Define a Mandatory name input [Parameter( ValueFromPipeline=$true, ValueFromPipelinebyPropertyName=$true, Position=0)] [Alias('Computer', 'ComputerName', 'Server', '__ServerName')] [string[]]$name = $ENV:Computername, [Parameter(Position=1)] [string]$progress = "Yes" ) #End Param Process { #Process Each object on Pipeline ForEach ($computer in $name) { #ForEach for singular or arrayed input on the shell #Try to get SVC Paths from $computer Write-Progress "Done" "Done" -Completed #clear progress bars inherited from the pipeline if ($progress -eq "Yes"){ Write-Progress -Id 1 -Activity "Getting keys for $computer" -Status "Connecting..."} $result = REG QUERY "\\$computer\HKLM\SYSTEM\CurrentControlSet\Services" /v ImagePath /s 2>&1 #Error output from this command doesn't catch, so we need to test for it... if ($result[0] -like "*ERROR*" -or $result[0] -like "*Denied*") { #Only evals true when return from reg is exception if ($progress -eq "Yes"){ Write-Progress -Id 1 -Activity "Getting keys for $computer" -Status "Connection Failed"} $obj = New-Object -TypeName PSObject $obj | Add-Member -MemberType NoteProperty -Name ComputerName -Value $computer $obj | Add-Member -MemberType NoteProperty -Name Status -Value "REG Failed" $obj | Add-Member -MemberType NoteProperty -Name Key -Value "Unavailable" $obj | Add-Member -MemberType NoteProperty -Name ImagePath -Value "Unavailable" [array]$collection += $obj } else { #Clean up the format of the results array if ($progress -eq "Yes"){ Write-Progress -Id 1 -Activity "Getting keys for $computer" -Status "Connected"} $result = $result[0..($result.length -2)] #remove last (blank line and REG Summary) $result = $result | ? {$_ -ne ""} #Removes Blank Lines $count = 0 While ($count -lt $result.length) { if ($progress -eq "Yes"){ Write-Progress -Id 2 -Activity "Processing keys..." -Status "Formatting $computer\$($result[$count])"} $obj = New-Object -Typename PSObject $obj | Add-Member -Membertype NoteProperty -Name ComputerName -Value $computer $obj | Add-Member -MemberType NoteProperty -Name Status -Value "Retrieved" $obj | Add-Member -MemberType NoteProperty -Name Key -Value $result[$count] $pathvalue = $($result[$count+1]).Split("", 11) #split ImagePath return $pathvalue = $pathvalue[10].Trim(" ") #Trim out white space, left with just value data $obj | Add-Member -MemberType NoteProperty -Name ImagePath -Value $pathvalue [array]$collection += $obj $count = $count + 2 } #End While } #End Else if ($progress -eq "Yes"){Write-Progress -Id 2 "Done" "Done" -Completed} Write-Output $collection $collection = $null #reset collection } #End ForEach if ($progress -eq "Yes"){Write-Progress -Id 1 "Done" "Done" -Completed} } #End Process |
.\Find-BADSVCPath.ps1
This script inspects the objects that result from .\Get-SVCPath for unquoted/improperly quoted service. It will amend the object and mark it “Badkey = Yes” or “BadKey = No”. If a bad key is detected, it will update the “FixedKey” field with the properly quoted path value. This script accepts its input from the pipeline. Values marked unavailable (by offline) are passed through the pipeline.
Useage
From the pipeline only:
.\Get-SVCPath.ps1 | .\Find-BADSVCPath.ps1
-or
$Var = .\Get-SVCPath.ps1
$Var | .\Find-BADSVCPath.ps1
#Find-BADSVCPath.ps1 [cmdletbinding()] Param ( #Define a Mandatory input [Parameter( ValueFromPipeline=$true, ValueFromPipelinebyPropertyName=$true, Position=0)] $obj ) #End Param Process { #Process Each object on Pipeline Write-Progress -Activity "Checking for bad keys: " -status "Checking $($obj.computername)\$($obj.key)" if ($obj.key -eq "Unavailable") { #The keys were unavailable, I just append object and continue $obj | Add-Member –MemberType NoteProperty –Name BadKey -Value "Unknown" $obj | Add-Member –MemberType NoteProperty –Name FixedKey -Value "Can't Fix" Write-Output $obj $obj = $nul #clear $obj } #end if else { #If we get here, I have a key to examine and fix #We're looking for keys with spaces in the path and unquoted #the Path is always the first thing on the line, even with embedded arguments $examine = $obj.ImagePath if (!($examine.StartsWith('"'))) { #Doesn't start with a quote if (!($examine.StartsWith("\??"))) { #Some MS Services start with this but don't appear vulnerable if ($examine.contains(" ")) { #If contains space #when I get here, I can either have a good path with arguments, or a bad path if ($examine.contains("-") -or $examine.contains("/")) { #found arguments, might still be bad #split out arguments $split = $examine -split " -", 0, "simplematch" $split = $split[0] -split " /", 0, "simplematch" $newpath = $split[0].Trim(" ") #Path minus flagged args if ($newpath.contains(" ")){ #check for unflagged argument $eval = $newpath -Replace '".*"', '' #drop all quoted arguments $detunflagged = $eval -split "\", 0, "simplematch" #split on foler delim if ($detunflagged[-1].contains(" ")){ #last elem is executable and any unquoted args $fixarg = $detunflagged[-1] -split " ", 0, "simplematch" #split out args $quoteexe = $fixarg[0] + '"' #quote that EXE and insert it back $examine = $examine.Replace($fixarg[0], $quoteexe) $examine = $examine.Replace($examine, '"' + $examine) $badpath = $true } #end detect unflagged $examine = $examine.Replace($newpath, '"' + $newpath + '"') $badpath = $true } #end if newpath else { #if newpath doesn't have spaces, it was just the argument tripping the check $badpath = $false } #end else } #end if parameter else {#check for unflagged argument $eval = $examine -Replace '".*"', '' #drop all quoted arguments $detunflagged = $eval -split "\", 0, "simplematch" if ($detunflagged[-1].contains(" ")){ $fixarg = $detunflagged[-1] -split " ", 0, "simplematch" $quoteexe = $fixarg[0] + '"' $examine = $examine.Replace($fixarg[0], $quoteexe) $examine = $examine.Replace($examine, '"' + $examine) $badpath = $true } #end detect unflagged else {#just a bad path #surround path in quotes $examine = $examine.replace($examine, '"' + $examine + '"') $badpath = $true }#end else }#end else }#end if contains space else { $badpath = $false } } #end if starts with \?? else { $badpath = $false } } #end if startswith quote else { $badpath = $false } #Update Objects if ($badpath -eq $false){ $obj | Add-Member -MemberType NoteProperty -Name BadKey -Value "No" $obj | Add-Member -MemberType NoteProperty -Name FixedKey -Value "N/A" Write-Output $obj $obj = $nul #clear $obj } if ($badpath -eq $true){ $obj | Add-Member -MemberType NoteProperty -Name BadKey -Value "Yes" #sometimes we catch doublequotes if ($examine.endswith('""')){ $examine = $examine.replace('""','"') } $obj | Add-Member -MemberType NoteProperty -Name FixedKey -Value $examine Write-Output $obj $obj = $nul #clear $obj } } #end top else } #End Process |
.\Fix-BADSVCPath.ps1
This script accepts input on the pipeline from .\Find-BADSVCPath. The “FixedKey” value from the object is read, properly escaped, and fed to a forced REG ADD. It then updates the object status to “Fixed.” Values not marked bad are simply passed through the pipeline.
Useage
From the Pipeline only:
.\Get-SVCPath.ps1 | .\Find-BADSVCPath.ps1 | .\Fix-BADSVCPath.ps1
-or-
.\Get-SVCPath.ps1 | .\Find-BADSVCPath.ps1 | Export-CSV result.csv
Import-CSV result.csv | .\Fix-BADSVCPath.ps1
#Fix-BADSVCPath.ps1 [cmdletbinding()] Param ( #Define a Mandatory input [Parameter( ValueFromPipeline=$true, ValueFromPipelinebyPropertyName=$true, Position=0)] $obj ) #End Param Process { #Process Each object on Pipeline if ($obj.badkey -eq "Yes"){ Write-Progress -Activity "Fixing $($obj.computername)\$($obj.key)" -Status "Working..." $regpath = $obj.Fixedkey $regpath = '"' + $regpath.replace('"', '\"') + '"' + ' /f' $obj.status = "Fixed" REG ADD "\\$($obj.computername)\$($obj.key)" /v ImagePath /t REG_EXPAND_SZ /d $regpath } Write-Output $obj } #End Process |
.\WrapParallel.ps1
This script is a parallel wrapped for the .\Get-SVCTag script, which is the longest running element of the workflow due to network constraints. This wrapper is configurable, and can accept input either from the pipeline or from the shell directly. The script is throttled by checking for configured number of running jobs, and sleeping for the configured interval. It’s not a “true” throttle, but its close enough. The Progress bar reflects which machine was last sent, the number of running jobs, and the number of jobs detected complete in the background. This script could be used to wrap other scripts in parallel if you remove the “-progress No” argument from its start-job call. It waits until all objects are jobs are completed before feeding the pipeline.
Parameters
-scriptpath (Mandatory) The path to the script you want wrap parallel. Can be local to the path of execution and dot sourced.
-name (Mandatory) Can be fed either directly, via (Get-Content textfile) or from the pipeline
-throttle (optional) Defaults to 10. When the script encounters more than 10 jobs who are state running, it sleeps for the specified timer before sending another job. It checks before each job is sent.
-sleep (optional) Defaults to 30 seconds. When the throttle limit is hit, this parameter defines the sleep-interval before sending another job.
Useage
.\WrapParallel.ps1 -scriptpath .\Get-SVCPath.ps1 -name (Get-Content textfile) -throttle 20 | .\Find.....
-or-
Get-ADComputer -filter * | .\WrapParallel.ps1 -scriptpath .\Get-SVCPath.ps1 -sleep 60 | .\Find.....
###This is a parallel wrapper for script processes [cmdletbinding()] Param ( #Define a Mandatory name input [Parameter(ValueFromPipeline=$true,ValueFromPipelinebyPropertyName=$true,Position=0,Mandatory=$true)] [Alias('Computer', 'ComputerName', 'Server', '__ServerName')] [string[]]$name = $ENV:Computername, [Parameter(Mandatory=$true)] [string]$scriptpath, [int]$throttle = 10, [int]$sleep = 30 ) #End Param ## Start Parallel Processing: $jobcount = 0 $completecount = 0 $totaljobs = $name.count ForEach ($pc in $name) { If ($jobcount -gt $throttle){Start-Sleep -Seconds $sleep} Start-Job -filepath $scriptpath -ArgumentList "$pc", "-progress no" > $nul $jobs = Get-Job | Where {$_.state -eq "Running"} $jobcount = $jobs.count $jobs = Get-Job | Where {$_.state -eq "Completed"} $completecount = $jobs.count $sentjobs = (Get-Job).count Write-Progress -id 1 -Activity "Sending Job to $pc" -Status "Sent $sentjobs of $totaljobs ($jobcount jobs 'Running') ($completecount jobs 'Completed')" -PercentComplete ((($sentjobs) / ($totaljobs))*100) } #End ForEach #Wait for Jobs to finish $jobcount = $totaljobs Write-Progress -id 1 "Done" "Done" -completed While ($jobcount -ne $null){ $countwait = $totaljobs - $jobcount Write-Progress -id 1 -Activity "Jobs are still running..." -Status "Waiting for $jobcount jobs" -PercentComplete (($countwait / $totaljobs)*100) $jobs = Get-Job | Where {$_.state -eq "Running"} $jobcount = $jobs.Count Start-Sleep -Seconds 3 } Start-Sleep -Seconds 3 #Get-Data $Global:TotalReport = @() $jobs = Get-Job | Where {$_.state -eq "Completed"} ForEach ($job in $jobs) { $result = Receive-Job $job.id -keep $Global:TotalReport += $result } Write-Progress -id 1 "Done" "Done" -completed $Global:TotalReport #Clean up jobs Get-Job | Remove-Job -force |