How to Automate Application Uninstallation on Windows Using PowerShell

In the IT world, managing software across multiple machines is a complex and critical task. One of the common challenges IT administrators face is uninstalling applications in an efficient and standardized manner.

Manual uninstallation, especially across multiple endpoints, is not only time-consuming but also prone to errors. This is where PowerShell scripts, like the one we’ll be discussing, come into play.

This particular script automates the uninstallation of applications using the UninstallString and custom arguments, providing a streamlined approach to manage software removals.

The Need for an Automated Uninstallation Script

Software uninstallation, especially in large organizations, is not a straightforward task. IT professionals and Managed Service Providers (MSPs) often deal with a diverse set of applications installed across various machines. Each application might require different uninstallation commands or follow different protocols.

The script we’re analyzing is designed to handle these nuances, automatically adding necessary arguments like /qn /norestart or /S, which are essential for silent and restart-free uninstalls. This automation is crucial for maintaining consistency and efficiency in large-scale IT environments.

The Script:

#Requires -Version 5.1

<#
.SYNOPSIS
    Uninstall an application using the UninstallString and custom arguments. This script will auto-add /qn /norestart or /S arguments.

    This script will only uninstall apps that follow typical uninstall patterns such as msiexec /X{GUID} /qn /norestart.
.DESCRIPTION
    Uninstall an application using the UninstallString and custom arguments. This script will auto-add /qn /norestart or /S arguments.

    This script will only uninstall apps that follow typical uninstall patterns such as msiexec /X{GUID} /qn /norestart.
.EXAMPLE
    -Name "VLC media Player"
    
    Beginning uninstall of VLC media player using MsiExec.exe /X{9675011C-2395-4AD7-B1CC-92910F991F58} /qn /norestart...
    Exit Code for VLC media player: 0
    Successfully uninstalled your requested apps!

PARAMETER: -Name "ReplaceMeWithNameOfApp"
    Exact name of the application to uninstall, separated by commas. E.g., 'VLC media player, Everything 1.4.1.1024 (x64)'.

PARAMETER: -Arguments "/SILENT, /NOREBOOT"
    Additional arguments to use when uninstalling the app, separated by commas. E.g., '/SILENT, /NOREBOOT'.

PARAMETER: -Reboot
    Schedules a reboot for 1 minute after the uninstall process succeeds.

PARAMETER: -Timeout "ReplaceMeWithTheNumberOfMinutesToWait"
    Specify the amount of time in minutes to wait for the uninstall process to complete. 
    If the process exceeds this time, the script and uninstall process will be terminated.

.NOTES
    Minimum OS Architecture Supported: Windows 10, Windows Server 2016
    Release Notes: Initial Release
.COMPONENT
    Misc
#>

[CmdletBinding()]
param (
    [Parameter()]
    [String]$Name,
    [Parameter()]
    [String]$Arguments,
    [Parameter()]
    [switch]$Reboot = [System.Convert]::ToBoolean($env:reboot),
    [Parameter()]
    [int]$Timeout = 10
)

begin {
    # Replace parameters with dynamic script variables
    if ($env:nameOfAppToUninstall -and $env:nameOfAppToUninstall -notlike "null") { $Name = $env:nameOfAppToUninstall }
    if ($env:arguments -and $env:arguments -notlike "null") { $Arguments = $env:arguments }
    if ($env:timeoutInMinutes -and $env:timeoutInMinutes -notlike "null") { $Timeout = $env:timeoutInMinutes }

    # Check if application name is provided
    if (-not $Name) {
        Write-Host -Object "[Error] No name given, please enter in the name of an app to uninstall!"
        exit 1
    }

    # Check if timeout is provided
    if (-not $Timeout) {
        Write-Host -Object "[Error] No timeout given!"
        Write-Host -Object "[Error] Please enter in a timeout that's greater than or equal to 1 minute or less than or equal to 60 minutes."
        exit 1
    }

    # Validate the timeout is within the acceptable range
    if ($Timeout -lt 1 -or $Timeout -gt 60) {
        Write-Host -Object "[Error] An invalid timeout was given of $Timeout minutes."
        Write-Host -Object "[Error] Please enter in a timeout that's greater than or equal to 1 minute or less than or equal to 60 minutes."
        exit 1
    }

    # Create a list to hold application names after splitting
    $AppNames = New-Object System.Collections.Generic.List[String]
    $Name -split ',' | ForEach-Object {
        $AppNames.Add($_.Trim())
    }

    # Function to check if the script is run with elevated permissions
    function Test-IsElevated {
        $id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
        $p = New-Object System.Security.Principal.WindowsPrincipal($id)
        $p.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
    }

    # Get all users registry hive locations
    function Get-UserHives {
        param (
            [Parameter()]
            [ValidateSet('AzureAD', 'DomainAndLocal', 'All')]
            [String]$Type = "All",
            [Parameter()]
            [String[]]$ExcludedUsers,
            [Parameter()]
            [switch]$IncludeDefault
        )
    
        # User account SID's follow a particular pattern depending on if they're Azure AD, a Domain account, or a local "workgroup" account.
        $Patterns = switch ($Type) {
            "AzureAD" { "S-1-12-1-(\d+-?){4}$" }
            "DomainAndLocal" { "S-1-5-21-(\d+-?){4}$" }
            "All" { "S-1-12-1-(\d+-?){4}$" ; "S-1-5-21-(\d+-?){4}$" } 
        }
    
        # We'll need the NTUSER.DAT file to load each user's registry hive. So we grab it if their account SID matches the above pattern. 
        $UserProfiles = Foreach ($Pattern in $Patterns) { 
            Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\*" |
                Where-Object { $_.PSChildName -match $Pattern } | 
                Select-Object @{Name = "SID"; Expression = { $_.PSChildName } },
                @{Name = "UserName"; Expression = { "$($_.ProfileImagePath | Split-Path -Leaf)" } }, 
                @{Name = "UserHive"; Expression = { "$($_.ProfileImagePath)\NTuser.dat" } }, 
                @{Name = "Path"; Expression = { $_.ProfileImagePath } }
        }
    
        # There are some situations where grabbing the .Default user's info is needed.
        switch ($IncludeDefault) {
            $True {
                $DefaultProfile = "" | Select-Object UserName, SID, UserHive, Path
                $DefaultProfile.UserName = "Default"
                $DefaultProfile.SID = "DefaultProfile"
                $DefaultProfile.Userhive = "$env:SystemDrive\Users\Default\NTUSER.DAT"
                $DefaultProfile.Path = "C:\Users\Default"
    
                $DefaultProfile | Where-Object { $ExcludedUsers -notcontains $_.UserName }
            }
        }
    
        $UserProfiles | Where-Object { $ExcludedUsers -notcontains $_.UserName }
    }

    # Function to find the uninstallation key of an application
    function Find-UninstallKey {
        [CmdletBinding()]
        param (
            [Parameter(ValueFromPipeline = $True)]
            [String]$DisplayName,
            [Parameter()]
            [Switch]$UninstallString
        )
        process {
            $UninstallList = New-Object System.Collections.Generic.List[Object]

            # Search for uninstall key in 32-bit registry location
            $Result = Get-ChildItem "Registry::HKEY_USERS\*\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" | Get-ItemProperty | Where-Object { $_.DisplayName -match "$([regex]::Escape($DisplayName))" }
            if ($Result) { $UninstallList.Add($Result) }

            # Search for uninstall key in 32-bit user locations
            $Result = Get-ChildItem HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\* | Get-ItemProperty | Where-Object { $_.DisplayName -match "$([regex]::Escape($DisplayName))" }
            if ($Result) { $UninstallList.Add($Result) }

            # Search for uninstall key in 64-bit registry location
            $Result = Get-ChildItem HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\* | Get-ItemProperty | Where-Object { $_.DisplayName -match "$([regex]::Escape($DisplayName))" }
            if ($Result) { $UninstallList.Add($Result) }

            # Search for uninstall key in 64-bit user locations
            $Result = Get-ChildItem "Registry::HKEY_USERS\*\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" | Get-ItemProperty | Where-Object { $_.DisplayName -match "$([regex]::Escape($DisplayName))" }
            if ($Result) { $UninstallList.Add($Result) }
    
            # Optionally return the DisplayName and UninstallString
            if ($UninstallString) {
                $UninstallList | ForEach-Object { $_ | Select-Object DisplayName, UninstallString -ErrorAction SilentlyContinue }
            }
            else {
                $UninstallList
            }
        }
    }

    # Initialize the exit code variable
    $ExitCode = 0
}
process {
    # Check for administrative privileges
    if (-not (Test-IsElevated)) {
        Write-Host -Object "[Error] Access Denied. Please run with Administrator privileges."
        exit 1
    }

    # Load unloaded profiles
    $UserProfiles = Get-UserHives -Type "All"
    $ProfileWasLoaded = New-Object System.Collections.Generic.List[string]

    # Loop through each profile on the machine.
    Foreach ($UserProfile in $UserProfiles) {
        # Load user's NTUSER.DAT if it's not already loaded.
        If ((Test-Path Registry::HKEY_USERS\$($UserProfile.SID)) -eq $false) {
            Start-Process -FilePath "cmd.exe" -ArgumentList "/C reg.exe LOAD HKU\$($UserProfile.SID) `"$($UserProfile.UserHive)`"" -Wait -WindowStyle Hidden
            $ProfileWasLoaded.Add("$($UserProfile.SID)")
        }
    }

    # Retrieve similar applications based on names provided
    $SimilarAppsToName = $AppNames | ForEach-Object { Find-UninstallKey -DisplayName $_ -UninstallString }
    if (-not $SimilarAppsToName) {
        Write-Host "[Error] The requested app(s) was not found and none were found that are similar!"
        exit 1
    }

    # Unload all hives that were loaded for this script.
    ForEach ($UserHive in $ProfileWasLoaded) {
        If ($ProfileWasLoaded -eq $false) {
            [gc]::Collect()
            Start-Sleep 1
            Start-Process -FilePath "cmd.exe" -ArgumentList "/C reg.exe UNLOAD HKU\$($UserHive)" -Wait -WindowStyle Hidden | Out-Null
        }
    }

    # Create a list to store apps that are confirmed for uninstallation
    $AppsToUninstall = New-Object System.Collections.Generic.List[Object]
    $SimilarAppsToName | ForEach-Object {
        foreach ($AppName in $AppNames) {
            if ($AppName -eq $_.DisplayName) {
                # A matching app has been found
                $ExactMatch = $True
    
                if ($_.UninstallString) {
                    # Uninstall string is available
                    $UninstallStringFound = $True
                    # Add app to uninstall list
                    $AppsToUninstall.Add($_)
                }
            }
        }
    }

    # Check if any exact matches were found
    if (-not $ExactMatch) {
        Write-Host "[Error] Your requested apps were not found. Please see the below list and try again."
        $SimilarAppsToName | Format-Table DisplayName | Out-String | Write-Host
        exit 1
    }

    # Check if uninstall strings were found for the apps
    if (-not $UninstallStringFound) {
        Write-Host "[Error] No uninstall string found for any of your requested apps!"
        exit 1
    }

    # Check if there are apps without uninstall strings or not found at all
    $AppNames | ForEach-Object {
        if ($AppsToUninstall.DisplayName -notcontains $_) {
            Write-Host "[Error] Either the uninstall string was not present or the app itself was not found for one of your selected apps! See the below list of similar apps and try again."
            $SimilarAppsToName | Format-Table DisplayName | Out-String | Write-Host
            $ExitCode = 1
        }
    }

    # Convert timeout from minutes to seconds
    $TimeoutInSeconds = $Timeout * 60
    $StartTime = Get-Date

    # Process each app to uninstall
    $AppsToUninstall | ForEach-Object {
        $AdditionalArguments = New-Object System.Collections.Generic.List[String]

        # If the uninstall string contains msiexec that's what our executable will be.
        if($_.UninstallString -match "msiexec"){
            $Executable = "msiexec.exe"
        }

        # If it contains a filepath we'll use that as our executable.
        if($_.UninstallString -notmatch "msiexec" -and $_.UninstallString -match '[a-zA-Z]:\\(?:[^\\\/:*?"<>|\r\n]+\\)*[^\\\/:*?"<>|\r\n]*'){
            $Executable = $Matches[0]
        }

        # Confirm we have an executable.
        if(-not $Executable){
            Write-Host -Object "[Error] Unable to find uninstall executable!"
            exit 1
        }

        # Split uninstall string into executable and possible arguments
        $PossibleArguments = $_.UninstallString -split ' ' | ForEach-Object { $_.Trim() } | Where-Object { $_ -match "^/"}

        # Decide executable and additional arguments based on uninstall string analysis
        $i = 0
        foreach ($PossibleArgument in $PossibleArguments) {
            if (-not ($PossibleArgument -match "^/I{") -and $PossibleArgument) {
                $AdditionalArguments.Add($PossibleArgument)
            }

            if ($PossibleArgument -match "^/I{") {
                $AdditionalArguments.Add("$($PossibleArgument -replace '/I', '/X')")
            }

            $i++
        }

        # Add custom arguments from the user
        if ($Arguments) {
            $Arguments.Split(',') | ForEach-Object {
                $AdditionalArguments.Add($_.Trim())
            }
        }

        # Add the usual silent uninstall arguments if not present
        if($Executable -match "Msiexec"){
            if($AdditionalArguments -notcontains "/qn"){
                $AdditionalArguments.Add("/qn")
            }

            if($AdditionalArguments -notcontains "/norestart"){
                $AdditionalArguments.Add("/norestart")
            }
        }elseif($Executable -match "\.exe"){
            if($AdditionalArguments -notcontains "/S"){
                $AdditionalArguments.Add("/S")
            }

            if($AdditionalArguments -notcontains "/norestart"){
                $AdditionalArguments.Add("/norestart")
            }
        }

        # Verify that executable for uninstallation is found
        if (-not $Executable) {
            Write-Host "[Error] Could not find the executable from the uninstall string!"
            exit 1
        }

        # Start the uninstallation process
        Write-Host -Object "Beginning uninstall of $($_.DisplayName) using $Executable $AdditionalArguments..."
        try{
            if ($AdditionalArguments) {
                $Uninstall = Start-Process $Executable -ArgumentList $AdditionalArguments -NoNewWindow -PassThru
            }
            else {
                $Uninstall = Start-Process $Executable -NoNewWindow -PassThru
            }
        }catch{
            Write-Host "[Error] $($_.Exception.Message)"
            return
        }

        # Calculate the remaining time for the uninstall process and enforce timeout
        $TimeElapsed = (Get-Date) - $StartTime
        $RemainingTime = $TimeoutInSeconds - $TimeElapsed.TotalSeconds

        # Wait for the uninstall process to complete within the remaining time
        try {
            $Uninstall | Wait-Process -Timeout $RemainingTime -ErrorAction Stop
        }
        catch {
            Write-Host -Object "[Alert] The uninstall process for $($_.DisplayName) has exceeded the specified timeout of $Timeout minutes."
            Write-Host -Object "[Alert] The script is now terminating."
            $Uninstall | Stop-Process -Force
            $ExitCode = 1
        }

        # Check and report the exit code of the uninstallation process
        Write-Host -Object "Exit code for $($_.DisplayName): $($Uninstall.ExitCode)"
        if ($Uninstall.ExitCode -ne 0) {
            Write-Host -Object "[Error] Exit code does not indicate success!"
            $ExitCode = 1
        }
    }

    # Pause for 30 seconds before final checks
    Start-Sleep -Seconds 30

    $UserProfiles = Get-UserHives -Type "All"
    $ProfileWasLoaded = New-Object System.Collections.Generic.List[string]

    # Loop through each profile on the machine.
    Foreach ($UserProfile in $UserProfiles) {
        # Load user's NTUSER.DAT if it's not already loaded.
        If ((Test-Path Registry::HKEY_USERS\$($UserProfile.SID)) -eq $false) {
            Start-Process -FilePath "cmd.exe" -ArgumentList "/C reg.exe LOAD HKU\$($UserProfile.SID) `"$($UserProfile.UserHive)`"" -Wait -WindowStyle Hidden
            $ProfileWasLoaded.Add("$($UserProfile.SID)")
        }
    }

    # Re-check for any remaining apps to confirm they were uninstalled
    $SimilarAppsToName = $AppNames | ForEach-Object { Find-UninstallKey -DisplayName $_ }
    $SimilarAppsToName | ForEach-Object {
        foreach ($AppName in $AppNames) {
            if ($_.DisplayName -eq $AppName) {
                Write-Host -Object "[Error] Failed to uninstall $($_.DisplayName)."
                $UninstallFailure = $True
                $ExitCode = 1
            }
        }
    }

    # Unload all hives that were loaded for this script.
    ForEach ($UserHive in $ProfileWasLoaded) {
        If ($ProfileWasLoaded -eq $false) {
            [gc]::Collect()
            Start-Sleep 1
            Start-Process -FilePath "cmd.exe" -ArgumentList "/C reg.exe UNLOAD HKU\$($UserHive)" -Wait -WindowStyle Hidden | Out-Null
        }
    }

    # Confirm successful uninstallation if no failures detected
    if (-not $UninstallFailure) {
        Write-Host "Successfully uninstalled your requested apps!"
    }

    # Handle reboot if requested and there were no uninstall failures
    if ($Reboot -and -not $UninstallFailure) {
        Write-Host -Object "[Alert] a reboot was requested. Scheduling restart for 60 seconds from now..."
        Start-Process shutdown.exe -ArgumentList "/r /t 60" -Wait -NoNewWindow
    }

    # Exit script with the final exit code
    exit $ExitCode
}
end {
    
    
    
}

 

Access over 300+ scripts in the NinjaOne Dojo

Get Access

How the Script Works

This script is designed to uninstall applications based on their names and handles a variety of scenarios that might arise during the uninstallation process. Below is a step-by-step breakdown of how the script operates:

1. Parameter Definition and Input Handling:

  • The script starts by defining several parameters, such as -Name for the application name, -Arguments for additional uninstallation arguments, -Reboot to schedule a reboot, and -Timeout to specify how long the script should wait for the uninstallation process.
  • The script then checks if these parameters are provided and validates them, ensuring that they meet specific criteria, like ensuring the timeout value is between 1 and 60 minutes.

2. Profile and User Hive Handling:

  • It includes a function to load and handle user profiles and registry hives, necessary to access the uninstallation strings for each application. This is crucial because uninstalling an application often requires accessing the registry to locate the correct uninstallation path.

3. Application Identification and Uninstall Key Retrieval:

  • The script uses a function called Find-UninstallKey to search through the registry and locate the uninstall key associated with the application name provided. This is done across different registry locations, both 32-bit and 64-bit, to ensure comprehensive coverage.

4. Uninstallation Process:

  • Once the uninstall string is identified, the script processes the string to extract the executable path and arguments needed for uninstallation.
  • It ensures that necessary silent uninstall arguments like /qn, /norestart, or /S are added if they are not already present. This ensures the uninstallation is performed without user interaction and without forcing a system reboot unless specified.

5. Error Handling and Final Checks:

  • The script includes robust error handling, ensuring that if something goes wrong during the uninstallation, the administrator is informed, and the script exits with an appropriate error code.
  • After the uninstallation, the script re-checks to confirm that the application has indeed been removed, ensuring that no residual components are left behind.

Real-World Application: A Case Study

Consider a scenario where an IT administrator needs to uninstall an outdated version of VLC Media Player from 200 computers in an organization. Doing this manually would be incredibly time-consuming. Instead, the administrator could deploy this PowerShell script across all machines, specifying “VLC Media Player” as the application name.

The script would automatically find the appropriate uninstall string, execute the uninstallation silently, and even schedule a reboot if necessary. The entire process, which could have taken days, is completed within minutes.

Comparison to Other Methods

Traditionally, administrators might use the Control Panel or a third-party uninstallation tool to remove applications. However, these methods require manual intervention, are not scalable, and often lack the flexibility to add custom arguments or handle silent uninstalls effectively.

This script, on the other hand, offers a highly automated and customizable approach, allowing IT professionals to manage uninstalls across multiple machines efficiently and with minimal manual input.

Frequently Asked Questions

  1. What happens if the application name is not found? If the script cannot find an application with the exact name provided, it will search for similar names and provide a list of possible matches. This allows the administrator to adjust the input and try again.
  2. Can the script handle multiple applications at once? Yes, the -Name parameter can accept a list of applications separated by commas, enabling the script to uninstall multiple applications in one go.
  3. What if the uninstallation process exceeds the timeout period? The script will terminate the uninstallation process and exit with an error code, ensuring that no process runs indefinitely.

Implications for IT Security and Management

Automating the uninstallation process with scripts like this has significant implications for IT security and management. By ensuring that outdated or vulnerable software is removed swiftly and consistently across all machines, organizations can reduce the risk of security breaches. Moreover, automation reduces human error, ensuring that no application is left uninstalled due to oversight.

Best Practices for Using This Script

  • Always Test in a Controlled Environment: Before deploying the script across multiple machines, test it in a controlled environment to ensure it works as expected.
  • Keep Logs: Maintain logs of uninstallation processes for audit purposes and troubleshooting.
  • Backup Before Uninstalling: Ensure that important data is backed up before running uninstallation scripts, particularly for applications that might store data locally.
  • Update the Script Regularly: As new applications and updates come out, update the script to handle new uninstallation strings and methods.

Final Thoughts

PowerShell scripts like the one discussed are indispensable tools for IT professionals managing large networks. By automating application uninstalls, they save time, reduce errors, and enhance security. NinjaOne can further complement these scripts with its comprehensive IT management tools, providing additional layers of automation, monitoring, and control, ensuring that your IT infrastructure remains robust, secure, and up-to-date.

Add a Comment

Your email address will not be published. Required fields are marked *

Next Steps

Building an efficient and effective IT team requires a centralized solution that acts as your core service deliver tool. NinjaOne enables IT teams to monitor, manage, secure, and support all their devices, wherever they are, without the need for complex on-premises infrastructure.

Learn more about NinjaOne Remote Script Deployment, check out a live tour, or start your free trial of the NinjaOne platform.

Categories:

You might also like

×

See NinjaOne in action!

By submitting this form, I accept NinjaOne's privacy policy.

NinjaOne Terms & Conditions

By clicking the “I Accept” button below, you indicate your acceptance of the following legal terms as well as our Terms of Use:

  • Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms.
  • Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party.
  • Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library belonging to or under the control of any other software provider.
  • Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations.
  • Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks.
  • Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script.
  • EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).