Update
I recieved an email identifying an issue and providing a potential solution. The issue was the script would expand environmental variables in paths which could break when the wrong path is expanded (32bit vs 64bit). The solution proposed was elegant however it introduced potential false negatives. With the addtion on an if statement and some other changes I was able to incorporate the solution into the script without introducing false negatives. I have updated the script on this page and also have created a GitHub repo for the script which will have the latest versions should any more changes be made.
Background
Unquoted search paths are a relatively older vulnerability that occurs when the path to an executable service or program (commonly uninstallers) are unquoted and contain spaces. The spaces can allow someone to place their own executable in the path and get it to be executed instead. With services or uninstalls this will allow their executable to be run with escalated privileges.
Microsoft has actually done a pretty good job at not introducing the vulnerability through their products (there are a few that still will do it). Unfortunately, many 3rd party applications have not done a very good job at this. Even worse many will pretend its not a vulnerability in their software and therefore not their problem despite being a CWE.
Nessus Plugin: https://www.tenable.com/plugins/index.php?view=single&id=63155
Nexpose Plugin: https://www.rapid7.com/db/vulnerabilities/windows-unquoted-search-path-or-element
Remediation
Remediating this particular vulnerability is easy at a small scale. You simply open RegEdit
and put double quotes around the executable path in the ImagePath
or UninstallString
property. As you might be thinking already doing this at any large scale such as multiple applications or endpoints could be very time consuming.
PowerShell to the rescue
Thankfully, there is PowerShell and it can definitely help with the problem of quickly remediating this vulnerability on a large scale. It was this particular vulnerability that started my dive into PowerShell and recently some coworkers suggested I share my solution so here we are. My first attempt was messy to say the least. I simply exported to a csv the results from the vulnerability scanners and created a script to read the CSV and correct the entries listed. This took a decent amount of time to run against many endpoints and was only as accurate as the scanners. Thinking back to the a golden rule of scripts I rewrote the script to find and correct the vulnerable entries itself.
The second version of my script worked decent enough. It did unfortunately have some limitations. The script would not correct any entries that did not end with .exe
nor would it be able to tell if the entry had a space in the path to the executable. Not satisfied with the solution I made I revisited it several months later after I gained a lot more experience with PowerShell.
The third times the charm
The third version is the one I am sharing today. This version is able to correct entries that don’t end in .exe
along with being smart enough to only care about the entries that have a space in the executable path. I am going to start by breaking down each part of the script before sharing it as a whole so if you only want the script and nothing else skip ahead.
The breakdown
The first thing the script needs to do is know where to look. In Windows there are three common locations to look at.
$BaseKeys = "HKLM:\System\CurrentControlSet\Services", #Services
"HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall", #32bit Uninstalls
"HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" #64bit Uninstalls
With the script knowing where to look, the next challenge is actually looking.
$DiscKeys = Get-ChildItem -Recurse -Directory $BaseKeys -Exclude $BlackList -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty Name | %{($_.ToString().Split('\') | Select-Object -Skip 1) -join '\'}
The above will recursively find all registry keys in the three locations from earlier. Additionally, it does some formatting on the results to make them usable later on. Some might be noticing a variable called $BlackList
and the -Exclude
flag. Unfortunately, sometimes there are entries you can’t correct because they are “protected” one example would be an endpoint security product I have ran into, which speaks volumes about their own approach to security. To keep the script from trying to do something it can’t there is the blacklist. Now that the script has all the keys it needs to find the ones that have properties.
$Registry = [Microsoft.Win32.RegistryKey]::OpenBaseKey('LocalMachine', 'Default')
ForEach ($RegKey in $DiscKeys)
The script opens the Registry on the local machine (needed for later) and then proceeds to loop through the discovered registry keys.
Try { $ParentKey = $Registry.OpenSubKey($RegKey, $True) }
Catch { Write-Debug "Unable to open $RegKey" }
If ($ParentKey.ValueCount -gt 0)
With each discovered registry key the script opens it in writable mode and checks if it has one or more properties. Since the script now knows if each discovered key has properties it needs to find the properties it actually cares about.
$MatchedValues = $ParentKey.GetValueNames() | ?{ $_ -eq "ImagePath" -or $_ -eq "UninstallString" }
ForEach ($Match in $MatchedValues)
First, the script gets all the registry properties in the current key that are named either ImagePath
or UninstallString
. It will then loop through all of the matching properties it found in the current key. Of course the script needs to find the properties that have the vulnerability so that was the next part to solve.
$ValueRegEx = '(^(?!\u0022).*\s.*\.[Ee][Xx][Ee](?<!\u0022))(.*$)'
$Value = $ParentKey.GetValue($Match)
If ($Value -match $ValueRegEx)
So, this part looks relatively simple but it took sometime to be comfortable with regular expressions enough to be able to write one that replaced a very large set of nested if statements in the previous version of the script. For those who might not understand what the regular expression is doing I will break it down.
First, there are two sets of parenthesizes that encapsulate parts of the regular expression. These are needed for the if statement that checks if the variable $Value
matches the regular expression. In PowerShell when you call -match
it will return a boolean of either true or false. Additionally, it will also store the matched part in a variable called $Matches
. The parenthesizes are sort of like a split in the regular expression and will cause there to be two distinct entries in the $Matches
for each part. This is how the script can handle entries that don’t end in .exe
.
Inside the first set of parenthesizes are two lookarounds one being a negative lookahead, (?!\u0022)
, and a negative lookbehind, (?<!\u0022)
. The unicode 0022 is a double quote. What these accomplish is a check if the path is already encapsulated by double quotes because we don’t want to add more double quotes. There is also a .*\s.*
inside the regular expression. This part matches anything containing a space, and is how the script knows there is a space in the path. Immediately after that, is a \.[Ee][Xx][Ee]
which is a check for .exe
while not being limited to lowercase or uppercase.
The last set of parenthesizes contains a simple .*$
which is a wildcard to match everything else to the end of the entry. An example of a situation where this occurs is when an entry contains a path to an executable along with arguments for the executable.
The script then checks the current property to the regular expression. After this is where correcting the entry comes into play.
$RegType = $ParentKey.GetValueKind($Match)
$Correction = "$([char]34)$($Matches[1])$([char]34)$($Matches[2])"
Try { $ParentKey.SetValue("$Match", "$Correction", [Microsoft.Win32.RegistryValueKind]::$RegType) }
Catch { Write-Debug "Unable to write to $ParentKey" }
First, the type of property it is is stored into a variable for use when applying the correction. If you remember the part I wrote about $Matches
and the two distinct entries that are generated from the regular expression, the second action here is where the script uses those to generate a new entry that has the executable path encapsulated by double quotes. Lastly, the script then attempts to write the changes to the property. One thing to note here unfortunately in entries where environmental variables are used such as %programfiles%
it expands the variable so the new entry would have C:\Program Files
instead of the environmental variable. I haven’t been able to resolve this yet.
The last thing the script does which is more of a precaution than anything else is it will generate a hash table containing the property path, its type, the old value and the new value. I do this for potential logging purposes so that should something go wrong a person can easily revert the key back to its original entry.
$Values.Add((New-Object PSObject -Property @{
"Name" = $Match
"Type" = $RegType
"Value" = $Value
"Correction" = $Correction
"ParentKey" = "HKEY_LOCAL_MACHINE\$RegKey"
})) | Out-Null
Full script
Below is all the parts put together into the full script.
Note: the script requires PowerShell v3 or greater.
$BaseKeys = "HKLM:\System\CurrentControlSet\Services", #Services
"HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall", #32bit Uninstalls
"HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" #64bit Uninstalls
#Blacklist for keys to ignore
$BlackList = $Null
#Create an ArrayList to store results in
$Values = New-Object System.Collections.ArrayList
#Discovers all registry keys under the base keys
$DiscKeys = Get-ChildItem -Recurse -Directory $BaseKeys -Exclude $BlackList -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty Name | %{($_.ToString().Split('\') | Select-Object -Skip 1) -join '\'}
#Open the local registry
$Registry = [Microsoft.Win32.RegistryKey]::OpenBaseKey('LocalMachine', 'Default')
ForEach ($RegKey in $DiscKeys)
{
#Open each key with write permissions
Try { $ParentKey = $Registry.OpenSubKey($RegKey, $True) }
Catch { Write-Debug "Unable to open $RegKey" }
#Test if registry key has values
If ($ParentKey.ValueCount -gt 0)
{
$MatchedValues = $ParentKey.GetValueNames() | ?{ $_ -eq "ImagePath" -or $_ -eq "UninstallString" }
ForEach ($Match in $MatchedValues)
{
#RegEx that matches values containing .exe with a space in the exe path and no double quote encapsulation
$ValueRegEx = '(^(?!\u0022).*\s.*\.[Ee][Xx][Ee](?<!\u0022))(.*$)'
$Value = $ParentKey.GetValue($Match)
#Test if value matches RegEx
If ($Value -match $ValueRegEx)
{
$RegType = $ParentKey.GetValueKind($Match)
If ($RegType -eq "ExpandString")
{
#RegEx to generate an unexpanded string to use for correcting
$ValueRegEx = '(^(?!\u0022).*\.[Ee][Xx][Ee](?<!\u0022))(.*$)'
#Get the value without expanding the environmental names
$Value = $ParentKey.GetValue($Match, $Null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
$Value -match $ValueRegEx
}
#Uses the matches from the RegEx to build a new entry encapsulating the exe path with double quotes
$Correction = "$([char]34)$($Matches[1])$([char]34)$($Matches[2])"
#Attempt to correct the entry
Try { $ParentKey.SetValue("$Match", "$Correction", [Microsoft.Win32.RegistryValueKind]::$RegType) }
Catch { Write-Debug "Unable to write to $ParentKey" }
#Add a hashtable containing details of corrected key to ArrayList
$Values.Add((New-Object PSObject -Property @{
"Name" = $Match
"Type" = $RegType
"Value" = $Value
"Correction" = $Correction
"ParentKey" = "HKEY_LOCAL_MACHINE\$RegKey"
})) | Out-Null
}
}
}
$ParentKey.Close()
}
$Registry.Close()
$Values | Select-Object ParentKey,Value,Correction,Name,Type
Deploying the usage of the script
Now since the script is designed purely to run on the local endpoint automating its use is the final hurdle that has to be accomplished. Luckily, this is pretty easy. The simplest way would to make it run on startup either through a local task schedule or through a GPO. If your enviroment uses SCCM then it can be deployed through a configuration baseline however it will require some tweaking in order to have the compliance check function without actually executing any corrections. If none of those are an option you could always run it manually through PowerShell Remoting or on each endpoint itself.