Chapter 10

Persistence Removal

Systematically hunting and removing attacker persistence — scheduled tasks, services, registry runkeys, WMI subscriptions, startup folders, DLL hijacking, and web shells — and how to know when you've found all of it.

Scenario

You've contained the known compromised hosts and reset the known compromised credentials. The incident is in "eradication" phase. 48 hours later, the attacker is active again on the same subnet. The containment worked — but eradication didn't. The attacker had a secondary persistence mechanism: a WMI subscription that triggers a PowerShell download-and-execute payload whenever a specific process runs. You only looked at scheduled tasks and registry run keys during eradication. This chapter is the comprehensive checklist of every persistence location to hunt before declaring eradication complete.

Registry Persistence

PowerShellregistry-persistence.ps1
# Enumerate all common registry persistence locations

$runKeys = @(
    "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run",
    "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce",
    "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run",
    "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce",
    "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Run",
    "HKLM:\SYSTEM\CurrentControlSet\Services",
    "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon",
    "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options"
)

foreach ($key in $runKeys) {
    if (Test-Path $key) {
        Write-Host "`n=== $key ===" -ForegroundColor Cyan
        Get-ItemProperty $key -ErrorAction SilentlyContinue |
            Select-Object * -ExcludeProperty PS*
    }
}

# Winlogon Userinit and Shell (classic persistence via value replacement)
$winlogon = Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
Write-Host "`nUserinit: $($winlogon.Userinit)"
Write-Host "Shell: $($winlogon.Shell)"
# Expected: Userinit = C:\Windows\system32\userinit.exe,
# Expected: Shell = explorer.exe
# Anything extra appended = persistence

Scheduled Task Persistence

PowerShellscheduled-task-audit.ps1
# List all non-Microsoft scheduled tasks — focus on recently created ones
$sinceDate = Get-Date "2026-08-01"

Get-ScheduledTask | Where-Object {
    $_.TaskPath -notlike "\Microsoft\*"
} | ForEach-Object {
    $info = Get-ScheduledTaskInfo $_ -ErrorAction SilentlyContinue
    $actions = ($_.Actions | ForEach-Object { "$($_.Execute) $($_.Arguments)" }) -join "; "
    [PSCustomObject]@{
        Path     = $_.TaskPath + $_.TaskName
        Status   = $info.LastRunTime
        Actions  = $actions
        RunAs    = ($_.Principal.UserId)
    }
} | Where-Object {
    # Flag tasks with suspicious characteristics
    $_.Actions -match "powershell|cmd|wscript|cscript|mshta|rundll32|regsvr32"
} | Format-List

# Raw XML for a specific task (reveals everything, including obfuscated commands)
Export-ScheduledTask -TaskName "SuspiciousTask" | Out-File "task_audit.xml"

Service-Based Persistence

PowerShellservice-audit.ps1
# Enumerate non-Microsoft services — compare against known baseline
Get-WmiObject Win32_Service | Where-Object {
    # Filter to non-Microsoft, non-built-in services
    $_.PathName -notmatch "^[Cc]:\\[Ww]indows\\" -and
    $_.State -eq "Running"
} | Select-Object Name, DisplayName, PathName, StartName, Description |
    Sort-Object Name | Format-List

# Also check for services with ImagePath in suspicious locations
$suspiciousPaths = @("\\Temp\\","\\AppData\\","\\Users\\","\\ProgramData\\")
Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Services\*" |
    Where-Object { $p = $_.ImagePath; $suspiciousPaths | Where-Object { $p -like "*$_*" } } |
    Select-Object PSChildName, ImagePath, Start |
    Format-Table

WMI Subscription Persistence

WMI subscriptions are one of the most missed persistence mechanisms in incident response. They run entirely in the WMI infrastructure, leave no file on disk in obvious locations, and survive reboots.

  WMI Event Subscription: How It Works
  ═══════════════════════════════════════════════════════════════════

  Three WMI objects must exist to create event-driven persistence:

  1. Event Filter — defines the trigger condition
     "When SystemUptime > 200 seconds after boot"
     "When process calc.exe starts"
     "At a time interval (every N seconds)"

  2. Event Consumer — defines what to execute
     CommandLineEventConsumer: runs a command
     ActiveScriptEventConsumer: runs VBScript/JScript directly
     LogFileEventConsumer: writes to a log file

  3. Binding — links filter to consumer

  Attack chain:
    Attacker registers WMI subscription (requires admin)
    System reboots → filter triggers → consumer executes payload
    No scheduled task, no service, no registry runkey

  Defensive detection: Event 5861 (WMI persistence created) in SIEM
PowerShellwmi-persistence-hunt.ps1
# Enumerate all WMI event subscriptions

Write-Host "=== Event Filters ===" -ForegroundColor Cyan
Get-WMIObject -Namespace root\subscription -Class __EventFilter |
    Select-Object Name, Query, QueryLanguage | Format-List

Write-Host "=== Event Consumers ===" -ForegroundColor Cyan
Get-WMIObject -Namespace root\subscription -Class CommandLineEventConsumer |
    Select-Object Name, CommandLineTemplate | Format-List

Get-WMIObject -Namespace root\subscription -Class ActiveScriptEventConsumer |
    Select-Object Name, ScriptText | Format-List

Write-Host "=== Filter-Consumer Bindings ===" -ForegroundColor Cyan
Get-WMIObject -Namespace root\subscription -Class __FilterToConsumerBinding |
    Select-Object Filter, Consumer | Format-List

# Remove a specific WMI persistence entry
# Get-WMIObject -Namespace root\subscription -Class __EventFilter -Filter "Name='EvilFilter'" | Remove-WMIObject
# Get-WMIObject -Namespace root\subscription -Class CommandLineEventConsumer -Filter "Name='EvilConsumer'" | Remove-WMIObject
# Get-WMIObject -Namespace root\subscription -Class __FilterToConsumerBinding | Remove-WMIObject

Web Shell Identification and Removal

If the initial access vector was a web application, a web shell may persist even after host isolation. Web shells are files placed on the web server that provide remote code execution via HTTP.

PowerShellweb-shell-hunt.ps1
# Find recently modified files in IIS web root (web shells often written by web server user)
$webRoot = "C:\inetpub\wwwroot"
$sinceDate = Get-Date "2026-08-01"

Get-ChildItem $webRoot -Recurse -Include "*.php","*.asp","*.aspx","*.ashx","*.asmx","*.config" |
    Where-Object { $_.LastWriteTime -gt $sinceDate } |
    Select-Object FullName, LastWriteTime, Length |
    Sort-Object LastWriteTime -Descending

# Look for web shell signatures in PHP files
Get-ChildItem $webRoot -Recurse -Include "*.php" | ForEach-Object {
    $content = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue
    if ($content -match "eval\(|exec\(|system\(|passthru\(|shell_exec\(|base64_decode\(") {
        Write-Warning "Suspicious: $($_.FullName)"
        Select-String -InputObject $content -Pattern "eval\(|exec\(|system\(|passthru\(|shell_exec\(" |
            ForEach-Object { Write-Host "  Line $($_.LineNumber): $($_.Line.Trim())" }
    }
}

# Check web server access logs for web shell access patterns
# Requests to .php/.aspx files returning 200 with POST body are suspicious
# Log location: C:\inetpub\logs\LogFiles\W3SVC1\

Startup Folder and DLL Hijacking

PowerShellstartup-persistence.ps1
# Check all startup folders (all users)
$startupPaths = @(
    "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup",
    "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp",
    "$env:ALLUSERSPROFILE\Microsoft\Windows\Start Menu\Programs\Startup"
)

foreach ($path in $startupPaths) {
    if (Test-Path $path) {
        Write-Host "`n=== $path ===" -ForegroundColor Cyan
        Get-ChildItem $path | Select-Object Name, LastWriteTime, Length | Format-Table
    }
}

# Check all user profiles' startup folders
Get-ChildItem "C:\Users" -Directory | ForEach-Object {
    $startup = "$($_.FullName)\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup"
    if (Test-Path $startup) {
        $items = Get-ChildItem $startup
        if ($items) {
            Write-Host "`n=== $startup ===" -ForegroundColor Yellow
            $items | Select-Object Name, LastWriteTime | Format-Table
        }
    }
}

Persistence Removal Verification

After removing persistence, verify it's gone — and then monitor for 24-48 hours for re-establishment before declaring eradication complete.

Persistence typeRemoval actionVerification
Registry run keyRemove-ItemProperty on the specific valueRe-run registry audit; confirm value absent; reboot and re-check
Scheduled taskUnregister-ScheduledTask -Confirm:$falseGet-ScheduledTask for the task name returns nothing; check Task Scheduler UI
ServiceStop-Service + sc delete [name]Get-Service returns nothing; check registry HKLM\SYSTEM\CurrentControlSet\Services
WMI subscriptionRemove-WMIObject on all three objects (filter, consumer, binding)Re-run WMI enumeration; all three objects return empty; monitor Event 5861
Web shellDelete the file; restore from known-good backupHash check vs. original; access log shows no more requests to that path
Startup folder shortcutDelete the .lnk fileStartup folder empty; no process launch on next user logon
Common mistake: declaring eradication complete after clearing only the known mechanism

Sophisticated attackers deploy multiple independent persistence mechanisms precisely because they expect one to be found. Finding and removing the WMI subscription doesn't mean the scheduled task they also created is gone. The eradication checklist must be comprehensive and applied to every confirmed compromised host — not just the ones where you found specific persistence. Running the full persistence audit (this chapter's scripts) on all contained hosts, even if you've already identified one mechanism on each, is the minimum bar for eradication confidence. After completing all removal actions, continue monitoring for 24-48 hours before declaring the environment clean.

Q & A

Q: You find a persistence mechanism but don't know what payload it ran. Do you remove it or let it run so you can see the payload?

Remove it — but capture the artifact first. Document the persistence mechanism fully (WMI subscription text, scheduled task XML, registry value content), extract any encoded or obfuscated command, and then remove the mechanism. If the payload is a URL, don't access it from within your corporate environment. If it's a base64-encoded script, decode it in an isolated analysis sandbox (Flare VM, REMnux). The risk of letting it run again to "see the payload" is not worth it — you already have the payload in the persistence object itself. The only exception is controlled deception operations, which are out of scope for most IR teams.

Q: You found 12 persistence mechanisms across 6 hosts. What's the safest removal order?

Remove all persistence simultaneously across all hosts, ideally as part of the broader simultaneous eradication action. For a single host, the order matters less than completeness — but generally: (1) services first (they're running and could re-establish other persistence), (2) scheduled tasks (similar reason), (3) registry run keys, (4) WMI subscriptions, (5) startup folder items. After removal on all hosts, do a full re-scan before concluding. The key constraint is not order within a host — it's ensuring you've identified all persistence before starting removal, rather than finding it incrementally while the attacker re-establishes what you remove.