<# .SYNOPSIS Uninstalls a Windows update by KB number with DISM, logging everything to a transcript. .DESCRIPTION Looks up the installed servicing packages with Get-WindowsPackage and finds the ones that belong to each KB. Older updates carry the KB in the package name. Cumulative updates don't (they show up as Package_for_RollupFix~...), so for those it checks each package's description and support link for the KB number instead. Each match is removed with Remove-WindowsPackage -NoRestart. Every run writes a transcript, including -WhatIf runs, so there's a record of what was found. With -Hide it also asks Windows Update to hide that KB so it isn't simply reinstalled tonight. With -Restart it restarts the machine if a removal needs one. Returns one object per package. .PARAMETER KB One or more KB numbers, with or without the KB prefix (KB5000000 or 5000000). .PARAMETER LogFolder Where the transcript goes. Default: %ProgramData%\UpdateRemoval. .PARAMETER Hide After removal, hide the update in Windows Update so it isn't offered again. Often only works after the restart, once Windows Update sees the update as not installed. Run the script again then; a KB that's already gone is skipped straight to the hide step. .PARAMETER Restart Restart the computer at the end if any removal asked for it. .EXAMPLE .\Remove-WindowsUpdatePackage.ps1 -KB KB5000000 -WhatIf .EXAMPLE .\Remove-WindowsUpdatePackage.ps1 -KB 5000000 -Hide -Restart #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] param( [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('HotFixID')] [ValidatePattern('^(KB)?\d{6,8}$')] [string[]]$KB, [string]$LogFolder = (Join-Path $env:ProgramData 'UpdateRemoval'), [switch]$Hide, [switch]$Restart ) begin { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() if (-not ([Security.Principal.WindowsPrincipal]$identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Removing updates needs an elevated PowerShell. Right-click, Run as administrator, and try again.' } if (-not (Test-Path -LiteralPath $LogFolder)) { $null = New-Item -ItemType Directory -Path $LogFolder -Force -WhatIf:$false -Confirm:$false } $logFile = Join-Path $LogFolder ('Remove-Update-{0}-{1:yyyyMMdd-HHmmss}.log' -f $env:COMPUTERNAME, (Get-Date)) Start-Transcript -Path $logFile -WhatIf:$false -Confirm:$false | Out-Null Write-Verbose 'Reading installed packages. This takes a minute on a machine with a long update history.' try { $installed = @(Get-WindowsPackage -Online -ErrorAction Stop | Where-Object { $_.PackageState -in 'Installed', 'InstallPending' }) } catch { Stop-Transcript -WhatIf:$false -Confirm:$false | Out-Null; throw "Couldn't read the installed packages: $($_.Exception.Message)" } $rollupDetail = $null # filled in on first need; reading package details is slow $numbers = [System.Collections.Generic.List[string]]::new() $restartNeeded = $false } process { foreach ($item in $KB) { $num = $item -replace '^KB', '' if ($numbers.Contains($num)) { continue } $numbers.Add($num) $found = @($installed | Where-Object { $_.PackageName -match "KB$num\b" }) if (-not $found) { if ($null -eq $rollupDetail) { $rollupDetail = @($installed | Where-Object { $_.PackageName -like 'Package_for_RollupFix*' -or $_.PackageName -like 'Package_for_DotNetRollup*' } | ForEach-Object { Get-WindowsPackage -Online -PackageName $_.PackageName -ErrorAction SilentlyContinue }) } $found = @($rollupDetail | Where-Object { "$($_.Description) $($_.SupportInformation) $($_.InstallPackageName)" -match "(KB|kbid=)$num\b" }) } if (-not $found) { $hotfix = Get-HotFix -Id "KB$num" -ErrorAction SilentlyContinue [pscustomobject]@{ KB = "KB$num" PackageName = $null Action = if ($hotfix) { 'NoRemovablePackage' } else { 'NotInstalled' } RestartNeeded = $false Detail = if ($hotfix) { 'Listed by Get-HotFix but no matching package. Servicing stack updates, for one, can''t be removed.' } else { $null } } continue } foreach ($pkg in $found) { $row = [pscustomobject]@{ KB = "KB$num"; PackageName = $pkg.PackageName; Action = $null; RestartNeeded = $false; Detail = $null } if (-not $PSCmdlet.ShouldProcess("$($pkg.PackageName) (KB$num)", 'Remove Windows package')) { $row.Action = 'WhatIf' $row continue } try { Write-Verbose "Removing $($pkg.PackageName). DISM gives no progress here; 5-20 minutes is normal." $removal = Remove-WindowsPackage -Online -PackageName $pkg.PackageName -NoRestart -ErrorAction Stop $row.Action = 'Removed' $row.RestartNeeded = [bool]$removal.RestartNeeded if ($row.RestartNeeded) { $restartNeeded = $true } } catch { $row.Action = 'Failed' $row.Detail = $_.Exception.Message Write-Warning "KB$num / $($pkg.PackageName): $($_.Exception.Message)" } $row } } } end { try { if ($Hide -and $numbers.Count) { try { Write-Verbose 'Searching Windows Update for the removed KBs so they can be hidden. This does an online scan.' $searcher = (New-Object -ComObject Microsoft.Update.Session).CreateUpdateSearcher() $offered = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'").Updates $hidden = @() foreach ($update in $offered) { $match = @($update.KBArticleIDs) | Where-Object { $numbers -contains $_ } | Select-Object -First 1 if ($match -and $PSCmdlet.ShouldProcess($update.Title, 'Hide in Windows Update')) { $update.IsHidden = $true $hidden += $match [pscustomobject]@{ KB = "KB$match"; PackageName = $null; Action = 'Hidden'; RestartNeeded = $false; Detail = $update.Title } } } foreach ($n in $numbers | Where-Object { $hidden -notcontains $_ }) { Write-Warning "KB$n isn't being offered by Windows Update yet, so it couldn't be hidden. Run again with -Hide after the restart." } } catch { Write-Warning "Couldn't hide updates: $($_.Exception.Message)" } } if ($restartNeeded) { if ($Restart -and $PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Restart to finish removing updates')) { Write-Verbose 'Restarting in 10 seconds.' Start-Sleep -Seconds 10 Restart-Computer -Force -Confirm:$false } else { Write-Warning 'A restart is needed to finish the removal.' } } } finally { Stop-Transcript -WhatIf:$false -Confirm:$false | Out-Null Write-Verbose "Log: $logFile" } }