Wes Ellis./ a personal notebook
Technology. Stories. Side projects.
A few things worth writing down.
← Back to Script Library

SCRIPT LIBRARY · POWERSHELL

Uninstall a Problem Windows Update by KB Number with DISM

Find the servicing packages behind a KB number, including cumulative updates that hide it, remove them with DISM, keep a transcript, and optionally hide the update so it doesn't come straight back.

AT A GLANCERemove-WindowsUpdatePackage.ps1
What it does
Maps each KB number to its installed servicing packages with Get-WindowsPackage (checking package details for cumulative updates, whose names don't include the KB), removes them with Remove-WindowsPackage, writes a transcript of the whole run, and can hide the update in Windows Update and restart when it's done.
Requires
  • Windows PowerShell 5.1 or PowerShell 7+
  • The Dism module (built into Windows)
Permissions
Local administrator, in an elevated PowerShell.
Runs on
Windows 10/11, Windows Server 2016+
Tested
Parse-checked, then run in PowerShell 7.4 with mocked Get-WindowsPackage, Remove-WindowsPackage and Get-HotFix, covering a KB in the package name, a KB found only in a rollup package's details, a failed removal, a KB listed by Get-HotFix with no package, a KB that isn't installed, and -WhatIf. The real DISM calls and the Windows Update hide step weren't run

Part 10 of the thread Windows Update, untangled

Patch Tuesday, Wednesday morning: one update has broken printing, or a VPN client, or a line-of-business app, and you need it off a handful of machines before lunch. The old answer was wusa /uninstall /kb:..., and on current versions of Windows it mostly doesn't work any more. Cumulative updates now ship combined with the servicing stack update, and Microsoft's own advice is to remove the cumulative part with DISM.

That's awkward by hand. dism /online /get-packages gives you a long list of names like Package_for_RollupFix~31bf3856ad364e35~amd64~~22621.4317.1.12, and the KB number you're after isn't in any of them. This started as a one-off script for exactly one bad update, with the KB number baked in. This version takes any KB, works out which packages belong to it, and removes them.

Every run leaves a transcript, -WhatIf shows exactly which packages would go, and -Hide asks Windows Update not to offer it again. The examples use KB5000000 as a stand-in; put in the one that's hurting you.

Remove-WindowsUpdatePackage.ps1Download
<#
.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"
    }
}

Parameters

ParameterTypeDefaultWhat it's for
-KBstring[]—One or more KB numbers, with or without the KB prefix. Required. Takes pipeline input, including HotFixID from Get-HotFix.
-LogFolderstring%ProgramData%\UpdateRemovalWhere the transcript is written. One file per run, named after the computer and time.
-Hideswitch—Hide the update in Windows Update afterwards so it isn't reinstalled. Often needs a second run after the restart.
-Restartswitch—Restart at the end if any removal asked for it. Otherwise you get a warning and restart when it suits you.

Run it

See which packages belong to the KB, without removing anything.

.\Remove-WindowsUpdatePackage.ps1 -KB KB5000000 -WhatIf

Remove it, no prompt, and restart if needed.

.\Remove-WindowsUpdatePackage.ps1 -KB 5000000 -Restart -Confirm:$false

After the restart, run it again to hide the update from Windows Update.

.\Remove-WindowsUpdatePackage.ps1 -KB 5000000 -Hide -Confirm:$false

The same on a few remote machines over PowerShell remoting, without copying the file anywhere.

$code = Get-Content .\Remove-WindowsUpdatePackage.ps1 -Raw; Invoke-Command -ComputerName PC-0142, PC-0187 -ScriptBlock { & ([scriptblock]::Create($using:code)) -KB KB5000000 -Confirm:$false }

What you'll see

Example outputvalues are illustrative
KB        PackageName                                                   Action       RestartNeeded Detail
--        -----------                                                   ------       ------------- ------
KB5000000 Package_for_RollupFix~31bf3856ad364e35~amd64~~22621.4317.1.12 Removed               True
KB5000123                                                               NotInstalled         False
WARNING: A restart is needed to finish the removal.

How it works

  1. Check elevation and start logging. DISM needs admin rights, so it stops early with a clear message if it doesn't have them. Then it starts a transcript in -LogFolder, even for a -WhatIf run, so there's always a record of what was found.
  2. Read the installed packages once. Get-WindowsPackage -Online lists every servicing package; only ones in the Installed or InstallPending state are kept.
  3. Match each KB. First the easy way: a package name containing KB5000000. If nothing matches, it reads the details of each Package_for_RollupFix and Package_for_DotNetRollup package and checks the description and support link for the KB number. That's slower, so it only happens when it's needed, and only once per run.
  4. Remove, one package at a time. Each match goes through ShouldProcess and then Remove-WindowsPackage -NoRestart. A failure is recorded against that package and the rest carry on.
  5. Explain the misses. A KB with no package gets checked with Get-HotFix, so you can tell "not installed" from "installed but not removable".
  6. Hide and restart, if asked. -Hide uses the Windows Update Agent API to find the KB among available updates and marks it hidden. -Restart restarts only if a removal said it needs one.

If updates are going wrong often enough that you need this regularly, it's worth reading how to roll updates out in rings with a way back. And if you're not sure whether a machine listens to Windows Update, WSUS or Intune (which decides where to block the update), check where it gets its updates.

Take it further

  • Leave a marker for your inventory. Write a registry value or a file with the KB and date after a successful removal, and your inventory tool can report which machines have been rolled back.
  • Target by symptom. Pipe Get-HotFix | Where-Object InstalledOn -gt (Get-Date).AddDays(-3) into the script to remove whatever landed in the last three days, with -WhatIf first.
  • Package it. It runs fine as SYSTEM from ConfigMgr, Intune or any other deployment tool. Deploy it with the KB as an argument, and the transcripts in ProgramData tell you what happened on each machine.

Things that'll trip you up

  • Servicing stack updates can't be removed. The servicing stack part of a combined update is permanent by design. If Get-HotFix lists a KB but there's no matching package, you'll see NoRemovablePackage, and that's usually why.
  • It will come back. A removed update is just a missing update as far as Windows Update is concerned, and it'll reinstall at the next scan. Hide it with -Hide, pause updates, or, if the machine gets its updates from WSUS, Intune or ConfigMgr, decline or pause it there instead. Hiding on the client does nothing about a WSUS approval.
  • Removal is slow and quiet. Remove-WindowsPackage shows no progress for a cumulative update, and 10 to 20 minutes is normal. The restart afterwards can take a while too, with "Working on updates" on screen. Don't pull the plug.
  • A newer update may already cover the problem. If a later cumulative update is installed, removing an older one might not be possible or might not help. Check what's actually current with Get-HotFix before you start.