Public/windowsupdate/Uninstall-WindowsUpdate.ps1

#Requires -Version 5.1
function Uninstall-WindowsUpdate {
    <#
        .SYNOPSIS
            Uninstalls previously installed Windows Updates by KB article ID
 
        .DESCRIPTION
            Removes one or more Windows Updates from local or remote computers using
            wusa.exe in quiet mode. Each KB is validated as installed via Get-HotFix
            before attempting uninstallation. Provides detailed exit code mapping for
            troubleshooting.
            Use this function to rollback problematic updates that cause issues in
            your environment.
 
        .PARAMETER ComputerName
            One or more computer names to target. Defaults to the local computer.
            Accepts pipeline input by value and by property name.
 
        .PARAMETER Credential
            Optional PSCredential for authenticating to remote computers.
            Not required for local operations.
 
        .PARAMETER KBArticleID
            One or more KB article IDs to uninstall. Accepts values with or without
            the KB prefix (e.g., 'KB5034441' or '5034441').
 
        .PARAMETER AutoReboot
            When specified, uses /forcerestart instead of /norestart with wusa.exe.
            The computer will restart automatically after successful uninstallation.
 
        .EXAMPLE
            Uninstall-WindowsUpdate -KBArticleID 'KB5034441'
 
            Uninstalls KB5034441 from the local computer without automatic reboot.
 
        .EXAMPLE
            Uninstall-WindowsUpdate -ComputerName 'SRV01' -KBArticleID 'KB5034441' -AutoReboot
 
            Uninstalls KB5034441 from SRV01 with automatic reboot.
 
        .EXAMPLE
            'SRV01', 'SRV02' | Uninstall-WindowsUpdate -KBArticleID 'KB5034441', 'KB5035432'
 
            Uninstalls two KBs from two servers via pipeline.
 
        .OUTPUTS
            PSWinOps.WindowsUpdateUninstallResult
            Returns objects with ComputerName, KBArticle, Result, ExitCode,
            RebootRequired, and Timestamp properties.
 
        .NOTES
            Author: Franck SALLET
            Version: 1.0.0
            Last Modified: 2026-04-08
            Requires: PowerShell 5.1+ / Windows only
            Requires: Administrator privileges
 
            wusa.exe exit codes:
                0 Success
                3010 Success, reboot required
                1641 Success, reboot initiated
                2359303 Not applicable / not uninstallable
                87 Invalid parameter
 
        .LINK
            https://github.com/k9fr4n/PSWinOps
 
        .LINK
            https://learn.microsoft.com/en-us/windows/win32/wua_sdk/portal-client
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    [OutputType('PSWinOps.WindowsUpdateUninstallResult')]
    param(
        [Parameter(Mandatory = $false, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('CN', 'DNSHostName')]
        [string[]]$ComputerName = $env:COMPUTERNAME,

        [Parameter(Mandatory = $false)]
        [ValidateNotNull()]
        [System.Management.Automation.PSCredential]
        [System.Management.Automation.Credential()]
        $Credential,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string[]]$KBArticleID,

        [Parameter(Mandatory = $false)]
        [switch]$AutoReboot
    )

    begin {
        Write-Verbose -Message "[$($MyInvocation.MyCommand)] Starting"

        $normalizedKBIds = $KBArticleID | ForEach-Object -Process { $_ -replace '^KB', '' }
        $kbCsv = $normalizedKBIds -join ','
        $kbDisplay = ($normalizedKBIds | ForEach-Object -Process { "KB$_" }) -join ', '

        $uninstallScriptBlock = {
            param(
                [string]$KBCsv,
                [bool]$UseForceRestart
            )

            $kbList = $KBCsv -split ','

            foreach ($kb in $kbList) {
                $kbId = "KB$kb"

                # Check if KB is installed
                $installed = $null
                try {
                    $installed = Get-HotFix -Id $kbId -ErrorAction SilentlyContinue
                } catch {
                    $installed = $null
                }

                if (-not $installed) {
                    [PSCustomObject]@{
                        KBArticle      = $kbId
                        Result         = 'NotInstalled'
                        ExitCode       = -1
                        RebootRequired = $false
                    }
                    continue
                }

                # Build wusa.exe arguments
                $restartFlag = if ($UseForceRestart) {
                    '/forcerestart'
                } else {
                    '/norestart'
                }

                try {
                    $process = Start-Process -FilePath 'wusa.exe' `
                        -ArgumentList @('/uninstall', "/kb:$kb", '/quiet', $restartFlag) `
                        -Wait -PassThru -NoNewWindow -ErrorAction Stop

                    $exitCode = $process.ExitCode
                } catch {
                    [PSCustomObject]@{
                        KBArticle      = $kbId
                        Result         = 'Failed'
                        ExitCode       = -2
                        RebootRequired = $false
                    }
                    continue
                }

                $result = switch ($exitCode) {
                    0 {
                        'Succeeded'
                    }
                    3010 {
                        'SucceededRebootRequired'
                    }
                    1641 {
                        'SucceededRebootRequired'
                    }
                    2359303 {
                        'NotUninstallable'
                    }
                    default {
                        'Failed'
                    }
                }

                $rebootNeeded = ($exitCode -eq 3010) -or ($exitCode -eq 1641)

                [PSCustomObject]@{
                    KBArticle      = $kbId
                    Result         = $result
                    ExitCode       = $exitCode
                    RebootRequired = $rebootNeeded
                }
            }
        }
    }

    process {
        foreach ($computer in $ComputerName) {
            Write-Verbose -Message "[$($MyInvocation.MyCommand)] Processing '$computer'"

            if (-not $PSCmdlet.ShouldProcess("$computer — $kbDisplay", 'Uninstall Windows Update')) {
                continue
            }

            try {
                $invokeParams = @{
                    ComputerName = $computer
                    ScriptBlock  = $uninstallScriptBlock
                    ArgumentList = @($kbCsv, [bool]$AutoReboot)
                }
                if ($PSBoundParameters.ContainsKey('Credential')) {
                    $invokeParams['Credential'] = $Credential
                }

                $results = Invoke-RemoteOrLocal @invokeParams

                $rebootNeeded = $false
                foreach ($entry in $results) {
                    if ($entry.RebootRequired) {
                        $rebootNeeded = $true
                    }

                    Write-Verbose -Message "[$($MyInvocation.MyCommand)] '$computer' — $($entry.KBArticle): $($entry.Result) (exit: $($entry.ExitCode))"

                    [PSCustomObject]@{
                        PSTypeName     = 'PSWinOps.WindowsUpdateUninstallResult'
                        ComputerName   = $computer
                        KBArticle      = $entry.KBArticle
                        Result         = $entry.Result
                        ExitCode       = $entry.ExitCode
                        RebootRequired = $entry.RebootRequired
                        Timestamp      = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
                    }
                }

                if ($rebootNeeded) {
                    Write-Warning -Message "[$($MyInvocation.MyCommand)] '$computer' requires a reboot to complete uninstallation"
                }
            } catch {
                Write-Error -Message "[$($MyInvocation.MyCommand)] Failed on '${computer}': $_"
            }
        }
    }

    end {
        Write-Verbose -Message "[$($MyInvocation.MyCommand)] Completed"
    }
}