In my previous posts about the GWX agent, I provided a list of steps required for removing the agent and discussed how to handle the first half of them:
- Verify that the current session is running with administrative privileges
- Install (if necessary) and load the PSWindowsUpdate module.
- Create a restore point
- Kill any running GWX-related processes.
- Uninstall KB3035583 (and any other GWX-related updates).
- Check if reboot is required and schedule hiding the updates.
- Configure policy for disabling OS upgrade display in the Windows Update interface.
Along the way I defined functions for downloading & extracting compressed files, and for creating a scheduled task for post-reboot actions.
In this post I will primarily be discussing the remaining tasks, but I will first revisit some of the previously-defined functions. This is necessary as I have since modified them to either work around certain issues or make them more flexible.
Revised functions
I’ve updated Part 1‘s Get-UpdateModule function slightly – it was already a simple file-download function but the logging was specific to the PSWindowsUpdate module. I have also updated the Extract-Zip function more significantly – it now supports partial extraction of files based on filename, as well as a post-extraction cleanup to remove the source zip file. The revised version of the function is as follows:
# A generalised function for extracting compressed files to a specified location.
Function Extract-Zip {
param(
[string]$file,
[string]$location,
[array]$extractlist,
[boolean]$cleanup=$false
)
if (!(Test-Path $location)) {
try {
mkdir $location | out-Null
} catch {
Write-Host "Unable to create folder $location, error was:`n$($_.Exception.Message)" -foregroundcolor red
"Unable to create folder $location, error was:`n$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
}
}
if ($extractlist) {
Write-Host "Specific file extraction selected. Only the following files will be extracted:";$extractlist
} else {
Write-Host "Default mode selected, extracting all files..."
}
if ((Test-Path $file) -and (Test-Path $location)) {
# Instantiate a new shell object and namespace
$shell=New-Object -com Shell.Application
$zip=$shell.NameSpace($file)
# Check if the $extractlist parameter is set, and extract files accordingly.
if (!$extractlist) {
# Extract list is not set so default to extracting all files.
try {
foreach ($item in $zip.items()) {
$shell.Namespace($location).Copyhere($item)
}
Write-Host "Finished extracting contents of $file to $location." -foregroundcolor green
"Finished extracting contents of $file to $location." | Out-file -Filepath $global:logfile -append
} catch {
Write-Host "An error occured while extracting the contents of $file to $location; the error message was:`n$($_.Exception.Message)" -foregroundcolor red
"An error occured while extracting the contents of $file to $location; the error message was:", "`n", "$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
}
} else {
# Extract list is set, so iterate through each name in the array and extract that file from the zip. Items in extractlist are not assumed to be unique matches, so a list of matching contents is generated for each item and a foreach loop iterates through the list, extracting each match individually.
foreach ($e in $extractlist) {
$list=@($zip.Items() | Where-Object {$_.Name -like $e})
if ($list) {
foreach ($l in $list) {
try {
$shell.Namespace($location).Copyhere($l)
Write-Host "Extracted file $($e) successfully." -foregroundcolor green
"Finished extracting contents of $file to $location." | Out-file -Filepath $global:logfile -append
} catch {
Write-Host "Unable to extract file $($e), error was:`n$($_.Exception.Message)" -foregroundcolor red
"Unable to extract file $($e), error was:",$_.Exception.Message | Out-file -Filepath $global:logfile -append
}
}
} else {
Write-Host "No file with name $($e) found in specified archive." -foregroundcolor yellow
"No file with name $($e) found in specified archive." | Out-file -Filepath $global:logfile -append
}
Remove-Variable -Name list -Force -ErrorAction SilentlyContinue
}
}
if ($cleanup) {
Write-Host "Cleanup enabled: deleting compressed file..." -foregroundcolor Green
Remove-Item -Path $file -Force
}
} else {
Write-Host "Unable to proceed with extraction, invalid input specified!" -foregroundcolor red
"Unable to proceed with extraction, invalid input specified!" | Out-file -Filepath $global:logfile -append
if (!(Test-Path $file)) {
Write-Host "Could not find file $file!" -foregroundcolor red
"Could not find file $file!" | Out-file -Filepath $global:logfile -append
}
if (!(Test-Path $location)) {
Write-Host "Could not find or create folder path $location!" -foregroundcolor red
"Could not find or create folder path $location!" | Out-file -Filepath $global:logfile -append
}
}
}
I’ve also revised Part 2‘s Schedule-HideUpdates function to use a job for each attempt at hiding an update. This was motivated by issues I have been experiencing when testing my script against a fresh install of Windows 7 SP1, whereby Windows Update appears not to detect updates unless specific patches are manually applied.
To avoid a situation of the non-interactive powershell instance started by the scheduled task running indefinitely, I restructured the job so that it first checks whether the update is available from Windows Update and then attempts to hide it. Each job is passed through Wait-Job to ensure that it has 5 minutes in which to run – this should be plenty of time for querying Windows Update and completing the hide request on a normal machine.
If the job is not complete after that time, the log file is updated to record this and the job is forcibly terminated. I have also made the invocation of this function a non-default parametrised option on the main script.
The revised function is as follows:
Function Schedule-HideUpdates {
param(
[array]$updatelist,
[string]$scriptroot
)
# Export the array of updates to be hidden to a text file.
$updatelist | Out-file -Filepath $($scriptroot+"\kbarray.txt")
# Create a here-string containing the script to be executed post-rebooot to hide the required updates.
$taskscript=@"
`$global:logfile="$($global:logfile)"
(Get-Date -format "yyyy-mm-dd_HH:mm")+": Started post-reboot task to hide GWX-related updates in Windows Update." | Out-File -Filepath `$global:logfile -append
ipmo PSWindowsUpdate
`$timeout=300
`$kbarray=Get-Content "$($scriptroot+"\kbarray.txt")"
foreach (`$kbnumber in `$kbarray) {
`$job = Start-Job -ScriptBlock {
`$kbnumber=`$args[0];
`$global:logfile=`$args[1];
ipmo PSWindowsUpdate;
if (Get-WUList -KBArticleID `$kbnumber) {
"Update `$kbnumber available from Windows Update, attempting to hide..." | Out-file -Filepath `$global:logfile -append
try {
Hide-WUUpdate -KBArticleID `$kbnumber -Confirm:`$false -ErrorAction Stop
"Update `$kbnumber should now be hidden." | Out-file -Filepath `$global:logfile -append
} catch {
"Unable to hide update `$kbnumber, error was:",`$_.Exception.Message | Out-file -Filepath `$global:logfile -append
}
} else {
"Update `$kbnumber is not being offered by Windows Update, no action needed." | Out-file -Filepath `$global:logfile -append
}
} -argumentlist $kbnumber,$global:logfile
Wait-Job `$job -Timeout `$timeout | Out-Null;
if (`$job.State -ne "Completed") {
Write-Host "Job seems to have stalled, force-quitting..." -foregroundcolor red;
Stop-Job `$job -Confirm:`$false
Remove-Job `$job
} else {
Write-Host "Job completed successfully" -foregroundcolor green
}
}
"Finished hiding updates.","" | Out-file -Filepath `$global:logfile -append
# Remove scheduled task so that it doesn't run again.
`$deletetask='schtasks /delete /tn "Hide GWX Updates" /f'
`$d=Start-Process -Filepath `$env:systemroot\system32\cmd.exe -ArgumentList "/c `$deletetask" -Wait -NoNewWindow -Passthru
if (`$d.ExitCode -eq 0) {
"Successfully deleted scheduled task for hiding GWX-related updates." | Out-File -Filepath `$global:logfile -append
} else {
"Unable to delete scheduled task for hiding GWX-related updates." | Out-File -Filepath `$global:logfile -append
}
"@
# Output the script to a file in the script root location.
$scriptfile=$scriptroot+"\HideGWXUpdates.ps1"
$taskscript | Out-file -Filepath $scriptfile
# Create a new scheduled task as the current user
Write-Host "Creating scheduled task to run at next logon..."
$password=Read-Host -Prompt "Please enter the password for $($env:username)" -AsSecureString
$credential=New-Object -TypeName System.Management.Automation.PSCredential $env:username,$password
$createtask='schtasks /create /tn "Hide GWX Updates" /tr "C:\windows\system32\WindowsPowerShell\v1.0\powershell.exe -File \"'+$scriptfile+'\"" /rl highest /sc onlogon /ru "'+$($env:username)+'" /rp "'+$($credential.GetNetworkCredential().Password)+'"'
Remove-Variable -Name password,credential -Force -ErrorAction SilentlyContinue
$c=Start-Process -Filepath $env:systemroot\system32\cmd.exe -ArgumentList "/c $createtask" -Wait -NoNewWindow -Passthru
Remove-Variable -Name createtask -Force -ErrorAction SilentlyContinue
if ($c.ExitCode -eq 0) {
Write-Host "Successfully created scheduled task for hiding GWX-related updates."
"Successfully created scheduled task for hiding GWX-related updates." | Out-File -Filepath $global:logfile -append
} else {
Write-Host "Failed to create scheduled task for hiding GWX-related updates."
"Failed to create scheduled task for hiding GWX-related updates." | Out-File -Filepath $global:logfile -append
}
Remove-Variable -Name createtask,c -Force -ErrorAction SilentlyContinue
}
New functions
That covers the main changes to the code from my previous posts, so it’s time to look at the remaining steps for the script overall. They are:
8. Take ownership of C:\Windows\System32\GWX, from TrustedInstaller
9. Give Administrators group full control of the folder
10. Rename the GWX folder to something else
11. Replace it with an empty folder named GWX and deny access to TrustedInstaller
12. Take a full backup of the whole registry now, to be paranoid, regedit again, find the GWX registry keys and delete them
13. Delete all the GWX scheduled tasks.
Steps 8-11 are all dealt with by the Restrict-Folder function. It takes three parameters – the directory path to be restricted, a user group to which ownership can be transferred, and a user to whom access will be denied.
Function Restrict-Folder {
param(
[string]$directory,
[string]$group,
[string]$user
)
if ($directory) {
$global:directory=$directory
} else {
$valid=$false
while (!$valid) {
$global:directory=Read-Host("Enter the directory to be restricted")
if (Test-Path $global:directory) {
$valid=$true
} else {
Write-Host "Specified directory is invalid, please try again." -foregroundcolor red
}
}
}
if ($group) {
$global:group=$group
} else {
$valid=$false
while (!$valid) {
$global:group=Read-Host("Enter the group name for the user account to block, e.g. NT Service or $($env:computername)")
if ([ADSI]::Exists('WinNT://./$global:group')) {
$valid=$true
} else {
Write-Host "Specified group does not exist, please try again." -foregroundcolor red
}
}
}
if ($user) {
$global:user=$user
} else {
$valid=$false
while (!$valid) {
$global:user=Read-Host("Enter the username for the user account to block, e.g. TrustedInstaller, or $($env:Username)")
if ([ADSI]::Exists('WinNT://./$global:user')) {
$valid=$true
} else {
Write-Host "Specified user does not exist, please try again." -foregroundcolor red
}
}
}
The initial block here handles the parameters and checking that they are valid; if no value is passed, the user is prompted for values. These values are then tested for validity – in the case of the directory Test-Path checks that it exists, and in the case of the user and group an ADSI static method is used to check the users and groups on the local machine (a very useful trick I discovered from [url=http://powershell.com/cs/blogs/tips/archive/2010/12/30/checking-whether-user-or-group-exists.aspx]this page[/url]. This method assumes a standalone workstation – for domain-bound systems the same method could be used replacing the WinNT check with an LDAP check; the ActiveDirectory PS module also provides another method for querying user and group membership.
Write-Host "Attempting to reconfigure the specified directory..."
"Attempting to reconfigure the specified directory..." | Out-file -Filepath $global:logfile -append
$NewOwner=New-Object System.Security.AccessControl.DirectorySecurity
$admin=New-Object System.Security.Principal.NTAccount("BUILTIN","Administrators")
$NewOwner.SetOwner($admin)
$folder="\\localhost\\"+($global:directory -split ":")[0]+"$"+(($global:directory -split ":")[1] -replace "\\","\\")
[System.IO.Directory]::SetAccessControl($folder,$NewOwner)
This block handles the creation of System Security objects that can be used to manipulate the security descriptor for the target directory. Notice that the $admin object is hard-coded to represent the security group BUILTIN\Administrators – this could be trivially generalised with additional parameters to allow the function to assign new ownership as desired. After creating the $admin object, it is used to set the Owner attribute of the $NewOwner object.
You may be wondering why I didn’t just use Set-ACL for this task. I found that attempting to change the folder ownership via Set-ACL on Windows 7 when the account being used is not already the owner of the folder resulted in “Permission Denied” errors; using the SetAccessControl method from the [url=https://msdn.microsoft.com/en-us/library/system.io.directory%28v=vs.110%29.aspx]System.IO.Directory .NET class[/url] does not trigger these errors.
At this point, steps 8 and 9 of the list are complete.
$rand=$global:directory+"."+(Get-Random).ToString()
try {
Rename-Item $global:directory $rand
Write-Host "Renamed existing directory to $($rand)." -foregroundcolor green
"Renamed existing directory to $($rand)." | Out-file -Filepath $global:logfile -append
} catch {
Write-Host "Unable to rename existing directory, error was:`n$($_.Exception.Message)" -foregroundcolor red
"Unable to rename existing directory, error was:","$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
}
Once ownership of the directory has been changed, the directory is renamed by adding a randomised suffix. During testing I found that this operation would sometimes fail due to an unspecified pending task relating to the directory; the most reliable way to avoid this appears to be to run the script shortly after a reboot, but I haven’t bothered investigating further (though I suspect Process Explorer & Process Monitor would quickly pinpoint the root cause).
At this point, step 10 is complete.
if (Test-Path $global:directory) {
Write-Host "Original GWX directory has not been renamed." -foregroundcolor red
"Original GWX directory has not been renamed." | Out-file -Filepath $global:logfile -append
} else {
Write-Host "Original directory has been renamed. Creating new, empty GWX directory..." -foregroundcolor green
"Original directory has been renamed. Creating new, empty GWX directory..." | Out-file -Filepath $global:logfile -append
New-Item $global:directory -Type Directory -Force
}
try {
$colRights = [System.Security.AccessControl.FileSystemRights]"Read"
$InheritanceFlag = [System.Security.AccessControl.InheritanceFlags]::None
$PropagationFlag = [System.Security.AccessControl.PropagationFlags]::None
$objType =[System.Security.AccessControl.AccessControlType]::Allow
$objUser = New-Object System.Security.Principal.NTAccount($global:group,$global:user)
$remACE = New-Object System.Security.AccessControl.FileSystemAccessRule ($objUser, $colRights, $InheritanceFlag, $PropagationFlag, $objType)
$colRights = [System.Security.AccessControl.FileSystemRights]"FullControl"
$objType =[System.Security.AccessControl.AccessControlType]::Deny
$denyACE = New-Object System.Security.AccessControl.FileSystemAccessRule ($objUser, $colRights, $InheritanceFlag, $PropagationFlag, $objType)
$objGroup = New-Object System.Security.Principal.NTAccount("BUILTIN","Administrators")
$objACL = Get-ACL $global:directory
$objACL.SetAccessRuleProtection($true,$true)
$objACL.RemoveAccessRuleAll($remACE)
$objACL.AddAccessRule($denyACE)
$objACL.SetOwner($objGroup)
Set-ACL -Path $global:directory -Aclobject $objACL
Write-Host "Finished reconfiguring directory $($global:directory). The account $($global:user) should no longer have access to this directory."
"Finished reconfiguring directory $($global:directory). The account $($global:user) should no longer have access to this directory." | Out-file -Filepath $global:logfile -append
} catch {
Write-Host "Unable to complete reconfiguration of directory $($global:directory). The error was:`n$($_.Exception.Message)." -foregroundcolor red
"Unable to complete reconfiguration of directory $($global:directory). The error was:","$($_.Exception.Message)." | Out-file -Filepath $global:logfile -append
}
}
After renaming the original folder a new, empty folder is created and it is this folder to which the TrustedInstaller account will be denied access.
Next, I created two new Access Control Entries – one to remove the existing “Allow Read” access, and another to add “Deny FullControl” access. To do this, I started from the instructions in [url=https://technet.microsoft.com/en-us/library/ff730951.aspx]this PowerShell Tip of the Week article[/url] and made a few changes as necessary. Having created the ACEs I then read the ACL from the newly-created GWX directory and used its built-in methods to apply the new ACEs, ensure that the new owner was set as “Builtin\Administrators”, and lastly apply the modified ACL object to the directory.
At this point, step 11 is complete.
Function Remove-RegistryKeys {
param(
[array]$locations,
[string]$key,
[string]$psexec
)
$sortedlist=@()
foreach ($location in $locations) {
if ((Test-Path $location) -and ($key)) {
$list=Get-ChildItem -path $location
foreach ($l in $list) {
if ($l.PSPath) {
$pspath=$l.PSPath
} else {
$pspath=$null
}
try {
$taskpath=(Get-ItemProperty -Path $l.PSPath -Name Path -ErrorAction Stop).Path
} catch {
$taskpath=$null
}
if ($l.Name) {
$name=$l.Name
} else {
$name=$null
}
$obj = New-object -TypeName PSObject -Property @{"Name"=$name;"TaskPath"=$taskpath;"PSPath"=$pspath}
$sortedlist+=$obj
Remove-Variable -Name obj -Force -ErrorAction SilentlyContinue
}
Remove-Variable -Name list -Force -ErrorAction SilentlyContinue
} else {
Write-Host "Unable to proceed with registry key deletion for location $location, invalid input specified!" -foregroundcolor red
"Unable to proceed with with registry key deletion for location $location, invalid input specified!" | Out-file -Filepath $global:logfile -append
if (!(Test-Path $location)) {
Write-Host "Could not find registry location $location!" -foregroundcolor red
"Could not find registry location $location!" | Out-file -Filepath $global:logfile -append
}
if (!$key) {
Write-Host "No search key specified!" -foregroundcolor red
"No search key specified!" | Out-file -Filepath $global:logfile -append
}
}
}
Step 12 has multiple tasks folded into one, so we’ll start with them here. The first task should be creating a registry backup – however, as detailed in Part 1, the script creates a System Restore snapshopt at the beginning of the operation, we don’t need to perform any additional work here. The remainder of the task is to find and remove the GWX-related keys.
The function fragment above shows that three parameters are accepted – an array of locations, a key and a psexec string. The latter string is used to specify the location of the SysInternals PsExec.exe executable – this is used to allow running tasks as the System user. While testing the script I encountered several permission restrictions when removing keys and scheduled tasks; using PsExec was the most efficient way around the problem – and since I already had functions that could take care of downloading and extracting the executable if it was needed, it didn’t take much work to add it into the script.
When invoked, the function creates an empty array named $sortedlist, then iterates through each location specified. If the location is valid and a key has been provided, a temporary list of child items in that location is created. For each item in that temporary list a new object is created containing the PowerShell Path, Name, and the TaskPath. Most of the objects being found here relate to scheduled tasks and will have a relative path for the task location within Task Scheduler. The exceptions are task folders themselves, hence using try-catch to assign a null taskpath where necessary.
$sortedlist = $sortedlist | Sort-Object -Property TaskPath -Descending
foreach ($k in $sortedlist) {
if (($k.Name -like "*$($key)*") -or ($k.TaskPath -like "*$($key)*")) {
Write-Host "Found registry entry matching the search key.`nName:`t$($k.Name)`nTaskPath:`t$($k.Taskpath)`nPSPath:`t$($k.PSPath)`n"
"Found registry entry matching the search key.","Name: $($k.name)","Taskpath: $($k.Taskpath)","PSPath: $($k.PSPath)","" | Out-file -Filepath $global:logfile -append
# The backtick serves to escape the quote marks which are to be included in the string. The backslash will serve as an escape character to preserve the quote marks in the string when it is passed to PSExec, so that spaces in the path do not cause problems.
$command="Remove-Item -Path \`""+$($k.Name -replace "HKEY_LOCAL_MACHINE","HKLM:")+"\`" -Recurse -Force -Confirm:`$false"
if ($psexec) {
Write-Host "PsExec is available; command will be executed as the SYSTEM account."
"PsExec is available; command will be executed as the SYSTEM account." | Out-file -Filepath $global:logfile -append
$filepath=$psexec
$pspath="$env:systemroot\system32\WindowsPowerShell\v1.0\powershell.exe"
$arglist="-s /accepteula $pspath -InputFormat None -command $command"
Write-Host "The command being executed is:`n$($psexec) $($arglist)"
"The command being executed is:","$($psexec) $($arglist)" | Out-file -Filepath $global:logfile -append
# Use Start-Process to run the command so that we can check the return code afterwards.
$remkey=Start-Process -Filepath $filepath -ArgumentList $arglist -Wait -NoNewWindow -PassThru
if ($remkey.Exitcode -eq "0") {
Write-Host "Successfully deleted registry key $($k.PSPath)." -foregroundcolor green
"Successfully deleted registry key $($k.PSPath)." | Out-file -Filepath $global:logfile -append
} else {
Write-Host "Failed to delete registry key $($k.PSPath)." -foregroundcolor red
"Failed to delete registry key $($k.PSPath)." | Out-file -Filepath $global:logfile -append
}
Remove-Variable -Name command,filepath,pspath,arglist,remkey -Force -ErrorAction SilentlyContinue
} else {
try {
Invoke-Expression $command -ErrorAction Stop
Write-Host "Successfully deleted registry key $($k.PSPath)." -foregroundcolor green
"Successfully deleted registry key $($k.PSPath)." | Out-file -Filepath $global:logfile -append
} catch {
Write-Host "Unable to delete registry key $($k.PSPath), error was:`n$($_.Exception.Message)" -foregroundcolor red
"Unable to delete registry key $($k.PSPath), error was:","$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
}
Remove-Variable -Name command -Force -ErrorAction SilentlyContinue
}
}
}
}
Having now iterated through all specified locations and identified the objects of interest, the $sortedlist array is piped through Sort-Object to sort its contents in descending order by the TaskPath value. This is not required, given that the Remove-Item cmdlet does support the Recurse parameter, but by doing so the script will log the details of each key which has been identified before attempting to delete them. In the unlikely event of a false positive this will help with remediation.
After sorting the list, each item is evaluated against the specified key in either the Name or TaskPath values. When a match is found the details are written to the logfile and a removal command is defined.
If the PsExec parameter has been specified when invoking the function, additional values are declared which are used in conjunction with Start-Process to run the removal command as the system account. Note the tuse of the “InputFormat -None” parameter when invoking powershell with PsExec – this is required for the process to terminate cleanly. Using the Wait parameter when running Start-Process means that the script waits for the new process to complete before proceeding further.
Once the process is complete, the Exitcode value is checked to determine whether the removal command executed successfully.
If the PsExec parameter has not been specified when invoking the function, the removal comand is executed using Invoke-Expression within a try-catch statement.
At the end of this function, step 12 on the list is complete.
Function Remove-ScheduledTasks {
param(
[string]$key,
[string]$psexec
)
$alltasks=Schtasks.exe /query /fo table /NH | Where-Object {$_ -notlike ""}
Write-Host "Retrieved list of all scheduled tasks and folders."
"Retrieved list of all scheduled tasks and folders." | Out-file -Filepath $global:logfile -append
$folderlist=@();
foreach ($f in ($alltasks | Where-Object {($_ -match "^Folder:") -and ($_ -like "*$($key)*")})) {
$folderlist+=[string]($f -replace ("Folder: ",""))
}
$folderlist=$folderlist | Sort-Object -Descending
if ($folderlist.count -gt 0) {
Write-Host "Found $($folderlist.count) folders matching the search key. `nFolders:";
"Found $($folderlist.count) folders matching the search key.","Folders:" | Out-file -Filepath $global:logfile -append
"Folders:" | Out-file -Filepath $global:logfile -append
foreach ($f in $folderlist) {
Write-Host $f;
$f | Out-file -Filepath $global:logfile -append
}
}
$foundtasks=@()
foreach ($f in $folderlist) {
[boolean]$found=$false
foreach ($t in $alltasks) {
if ($found) {
if (($t -notmatch "^Folder: ") -and ($t -notmatch "^INFO: ")) {
$foundtasks+=[string]($f+"\"+(($t -split " ")[0]).TrimEnd(" "))
} else {
break
}
}
if ($($t -replace ("Folder: ","")) -eq $f) {
$found=$true
}
}
}
$foundtasks = $foundtasks | Sort-Object -Descending
The last step in the list, number 13, is to remove the GWX-related scheduled tasks. While developing this script I found that the registry keys identified in step 12 mostly correlate to the scheduled tasks, but in the interests of being thorough and the possibility of new tasks being added in different locations, I decided to play it safe and assume that numerous scheduled tasks remain in place at this point.
This function takes two parameters, similarly to the Remove-RegistryKeys function – a key for matching against task names or folder locations, and the optional location of the PsExec executable.
Windows 8 & Server 2012 and beyond have native cmdlets for scheduled task handling, but those cmdlets are not available on Windows 7. To this end, all OSs use the schtasks.exe exectuable for task manipulation. Schtasks.exe uses switches to determine the mode in which it functions – so /query is essentially for listing existing tasks. The other parameters in the command specify that the output format will be tabular and that no headers should be supplied. By piping through Where-Object any empty lines can be removed, making subsequent matching tasks easier.
Next, an array of folders which match the specified search key is generated. Folders are easily identified in the output of schtasks as the line will start with “Folder: “, so a regular expression checking the start of line is enough. Once all relevant folders have been identified, they are written to the console and to the logfile. As with the registry keys, the $folderlist array is sorted in descending order. Unlike in the Remove-RegistryKeys function, here it is a requirement – schtasks will not allow the deletion of a scheduled task or folder which contains subtasks or subfolders, so by sorting the folders to be deleted in descending order the script ensures that tasks are deleted in the right order.
With the $folderlist array populated, a new array is declared for all relevant tasks. Iterating through the folders, a $found boolean is declared and set as false. Each line in in alltasks is checked; while $found is false, only folder names are checked.
When a line matching the folder name is found, the $found boolean is set to true – at this point, the full name for the task (including the folder) is constructed. In order to understand the requirement for the split operation, it’s easiest to consider a sample of the output of schtasks:
PS C:\Windows\system32> Schtasks.exe /query /fo table /NH | Where-Object {$_ -notlike ""}
Folder: \
Adobe Acrobat Update Task N/A Unknown
Adobe Flash Player Updater 01/06/2016 13:39:00 Ready
AdobeAAMUpdater-1.0-Winfruity-Kyle 02/06/2016 02:00:00 Ready
CCleanerSkipUAC N/A Ready
GoogleUpdateTaskMachineCore 02/06/2016 08:45:00 Ready
GoogleUpdateTaskMachineUA 01/06/2016 12:45:00 Ready
User_Feed_Synchronization-{76A88F07-031B 15/05/2019 22:28:33 Ready
Folder: \Microsoft
INFO: There are no scheduled tasks presently available at your access level.
For each value, a TaskName, Next Run Time and Status value are returned. Since only the taskname is relevant here, the line is split. The split character is a set of 4 spaces – while the output sample above shows that only 1 space will reliably be present between the task name and the Next Run Time value, two spaces are required to avoid partial task names in cases where the name contains one or more non-sequential spaces. While testing the script I found that a minimum of 4 spaces were available to use as a split-string. To be on the safe side, I use the TrimEnd method to remove any trailing spaces from the task name. If I were to generalise a function for parsing task information from Schtasks.exe, I would most likely do so using logic which identified either “N/A” or a regular expression matching the date format used for Next Run Time.
Once a line is found which matches either a folder name or an Info statement, a break is used to exit the for loop as no further iteration is required for that folder.
After all task names have been identified and added to the $foundtasks array, the array itself is sorted in descending order, as with the
if ($foundtasks.count -gt 0) {
# Write a list of the tasks identified for deletion to the screen and the logfile.
Write-Host "The following scheduled tasks were found:";
"The following scheduled tasks were found:" | Out-file -Filepath $global:logfile -append
foreach ($t in $foundtasks) {
Write-Host $t
$t | Out-file -Filepath $global:logfile -append
}
# Set a template for the schtasks.exe command to use.
$template="schtasks.exe /Delete /S $env:computername /TN `"TASKNAME`" /F"
foreach ($t in $foundtasks) {
Write-Host "Proceeding with deletion of task $($g)..."
"Proceeding with deletion of task $($g)..." | Out-file -Filepath $global:logfile -append
# Copy the template and adapt it for the task being deleted, then declare variables based on the availability of PsExec.
$cmd=$template -replace "TASKNAME",$t
if ($psexec) {
$arglist="-s /accepteula $cmd"
$filepath=$psexec
} else {
$arglist="/c $cmd"
$filepath="$env:systemroot\system32\cmd.exe"
}
Write-Host "Executing command:`t$($cmd)"
"Executing command:`t$($cmd)" | Out-file -Filepath $global:logfile -append
# Use Start-Process to run the command so that we can check the return code afterwards.
$remtask=Start-Process -Filepath $filepath -ArgumentList $arglist -Wait -NoNewWindow -PassThru
if ($remtask.ExitCode -eq "0") {
Write-Host "Successfully deleted scheduled task $($t)." -foregroundcolor green
"Successfully deleted scheduled task $($t)." | Out-file -Filepath $global:logfile -append
} else {
Write-Host "Failed to delete scheduled task $($t)." -foregroundcolor red
"Failed to delete scheduled task $($t)." | Out-file -Filepath $global:logfile -append
}
Remove-Variable -Name cmd,arglist,filepath,remtask -Force -ErrorAction SilentlyContinue
}
Assuming tasks are found, the contents of the $foundtasks array are written to the display and the logfile.
Next, a template command for deleting a scheduled task on the local computer is defined. Note that in order to accept spaces in the task name, the template includes escaped quotes around the TASKNAME string.
With the template prepared, the script iterates through each entry in the $foundtasks array and for each task, creates a new command string based on the template.
Arguments are defined for a Start-Process command based on whether the PsExec executable has been specified as a parameter or not. As before Start-Process is run with the Wait switch to ensure that the process completes before the script proceeds. The outcome is evaluated by checking the ExitCode value for the removal task and the display and logfile are updated accordingly. After this, variables used within the loop are removed.
foreach ($f in $folderlist) {
$cmd=$template -replace "TASKNAME",$f
if ($psexec) {
$arglist="-s /accepteula $cmd"
$filepath=$psexec
} else {
$arglist="/c $cmd"
$filepath="$env:systemroot\system32\cmd.exe"
}
Write-Host "Executing command:`t$($cmd)"
"Executing command:`t$($cmd)" | Out-file -Filepath $global:logfile -append
$remfolder=Start-Process -Filepath $filepath -ArgumentList $arglist -Wait -NoNewWindow -PassThru
if ($remfolder.Exitcode -eq "0") {
Write-Host "Successfully deleted scheduled task folder $($f)." -foregroundcolor green
"Successfully deleted scheduled task folder $($f)." | Out-file -Filepath $global:logfile -append
} else {
Write-Host "Failed to delete scheduled task folder $($f). Error was:`n$($_.Exception.Message)" -foregroundcolor red
"Failed to delete scheduled task folder $($f). Error was:","$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
}
Remove-Variable -Name cmd,arglist,filepath,remfolder -Force -ErrorAction SilentlyContinue
}
} else {
Write-Host "No relevant scheduled tasks were found.`n"
"No relevant scheduled tasks were found." | Out-file -Filepath $global:logfile -append
}
}
The final section of this function is very similar to the previous section – it iterates through the folderlist and attempts to delete them.
One thing to note about this function is that the key-matching is done against the folder name. In practice while testing this, I did not run into any issues as all GWX scheduled tasks appear to be contained within a folder with the term GWX somewhere in the name. However, generalising this function would require a change to the task identification logic so that all tasks were checked against the key, instead of identifying folders of interest and enumerating all tasks within them.
The main script body
Having finished dissecting the functions used by the script, the last thing to do is review the remaining section of the main script body to see how the functions are called.
# Restrict the folder so that TrustedInstaller cannot repair the changes.
Restrict-Folder -directory "C:\Windows\system32\GWX" -group "NT SERVICE" -user "TrustedInstaller"
# Retrieve PsExec to allow running certain actions as the System account to bypass permission issues
Download-File -outfilepath $($scriptroot+"\PSTools.zip") -URI "https://download.sysinternals.com/files/PSTools.zip"
Extract-Zip -file ($scriptroot+"\PSTools.zip") -location $($scriptroot+"\PSTools") -extractlist @("PsExec.exe") -cleanup $true
# Identify each registry key involving a GWX component, and manually iterate through them to remove each one.
Write-Host "Commencing registry modifications."
"Commencing registry modifications." | Out-file -Filepath $global:logfile -append
if (Test-Path ($scriptroot+"\PSTools\PsExec.exe")) {
Remove-RegistryKeys -locations @("HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache\Tasks","HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache\Tree\Microsoft\Windows\Setup") -key "gwx" -psexec $($scriptroot+"\PSTools\PsExec.exe")
} else {
Remove-RegistryKeys -locations @("HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache\Tasks","HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache\Tree\Microsoft\Windows\Setup") -key "gwx"
}
Write-Host "Registry modifications completed."
"Registry modifications completed." | Out-file -Filepath $global:logfile -append
# Identify related Task Scheduler tasks and delete them. Check if PsExec is available and modify the process invocation accordingly.
if (Test-Path ($scriptroot+"\PSTools\PsExec.exe")) {
Remove-ScheduledTasks -key "gwx" -psexec $($scriptroot+"\PSTools\PsExec.exe")
} else {
Remove-ScheduledTasks -key "gwx"
}
# Check if a post-reboot scheduled task has been requested for hiding updates, and if so invoke the relevant function.
If ($hidetask -and $global:loaded) {
Write-Host "Creating scheduled task for post-reboot automated hiding of GWX-related updates."
"Creating scheduled task for post-reboot automated hiding of GWX-related updates." | Out-File -Filepath $global:logfile -append
Schedule-HideUpdates -updatelist $kbarray -scriptroot $scriptroot
}
# Finished!
if ($restart) {
Write-Host "Script actions completed. A reboot is required to complete the uninstallation of one or more updates."
"Script actions completed. A reboot is required to complete the uninstallation of one or more updates." | Out-file -Filepath $global:logfile -append
} else {
Write-Host "All script actions are now complete."
"All script actions are now complete." | Out-file -Filepath $global:logfile -append
}
First the Restrict-Folder function is invoked.
Next, Download-File and Extract-Zip are used to retrieve and extract the PsExec executable.
Once that’s complete, Remove-RegistryKeys is invoked – the paths to check and key to use are specified here, and an if-else statement is used to modify the invocation depending on whether PsExec.exe was obtained successfully.
The same approach is used when invoking Remove-ScheduledTasks.
Lastly, the $hidetask parameter and $global:loaded variable values are checked. If both are true, Schedule-HideUpdates is invoked to create a post-reboot task to attempt to hide the GWX-related updates from Windows Update. After this is done, a message is written to the screen and the log file based on whether a reboot is pending.
Given the sheer length of the script, I’ll follow up this post with another post containing just the script code, along with the (very simple) instructions for using it.