mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 09:28:54 +00:00
268 lines
12 KiB
PowerShell
268 lines
12 KiB
PowerShell
|
|
# Please don't delete. This script is referenced in the guide here:
|
||
|
|
# https://fleetdm.com/guides/lock-wipe-hosts
|
||
|
|
#
|
||
|
|
# wipe-windows-device.ps1
|
||
|
|
# Fallback script to wipe a Windows device when the native MDM wipe command
|
||
|
|
# (doWipe/doWipeProtected) fails with status 500 or the device is not wiped.
|
||
|
|
#
|
||
|
|
# When Fleet sends a wipe via the RemoteWipe CSP, the command may fail due to:
|
||
|
|
# - Disabled or missing Windows Recovery Environment (WinRE)
|
||
|
|
# - Broken MDM enrollment state
|
||
|
|
# - Server-side command processing errors (DB timeouts, auth failures)
|
||
|
|
#
|
||
|
|
# This script bypasses the MDM command queue by calling the RemoteWipe CSP
|
||
|
|
# locally via the WMI-to-CSP bridge. Before triggering the wipe, it validates
|
||
|
|
# and repairs WinRE (the confirmed root cause of most failures) and suspends
|
||
|
|
# BitLocker to prevent recovery key prompts.
|
||
|
|
#
|
||
|
|
# Note: Every fully unattended Windows wipe method ultimately calls the same
|
||
|
|
# RemoteWipe CSP. There is no alternative Windows API for triggering "Reset
|
||
|
|
# this PC" programmatically without user interaction. The value of this script
|
||
|
|
# is that it fixes the root causes before calling the wipe, and bypasses the
|
||
|
|
# MDM command queue where server-side failures can occur.
|
||
|
|
#
|
||
|
|
# The OS is never formatted. Windows rebuilds from the local Component Store
|
||
|
|
# (WinSxS) or via Cloud Download, so no USB media is required.
|
||
|
|
#
|
||
|
|
# Usage: Run via Fleet on affected Windows hosts. Fully unattended.
|
||
|
|
|
||
|
|
#Requires -RunAsAdministrator
|
||
|
|
|
||
|
|
# Log output for audit trail
|
||
|
|
$logPath = "$env:ProgramData\fleet-wipe-device-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
|
||
|
|
Start-Transcript -Path $logPath -ErrorAction SilentlyContinue | Out-Null
|
||
|
|
|
||
|
|
$exitCode = 0
|
||
|
|
|
||
|
|
Write-Host "=== Fleet Windows Device Wipe (Fallback) ==="
|
||
|
|
Write-Host "Log file: $logPath"
|
||
|
|
Write-Host ""
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 1. Validate and repair WinRE
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# The RemoteWipe CSP depends on WinRE to perform the reset. If WinRE is
|
||
|
|
# disabled or missing, the CSP returns status 500.
|
||
|
|
# Ref: https://github.com/fleetdm/fleet/issues/34994#issuecomment-2507872412
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
Write-Host "[1/4] Checking Windows Recovery Environment (WinRE)..."
|
||
|
|
|
||
|
|
$reagentInfo = reagentc /info 2>&1 | Out-String
|
||
|
|
if ($reagentInfo -match "Windows RE status:\s+Enabled") {
|
||
|
|
Write-Host " WinRE is enabled"
|
||
|
|
} else {
|
||
|
|
Write-Host " WinRE is disabled or missing - attempting to re-enable..."
|
||
|
|
$enableResult = reagentc /enable 2>&1 | Out-String
|
||
|
|
|
||
|
|
if ($LASTEXITCODE -eq 0) {
|
||
|
|
# Verify WinRE was actually enabled by re-running reagentc /info
|
||
|
|
$reagentInfoAfter = reagentc /info 2>&1 | Out-String
|
||
|
|
if ($reagentInfoAfter -match "Windows RE status:\s+Enabled") {
|
||
|
|
Write-Host " WinRE re-enabled successfully"
|
||
|
|
} else {
|
||
|
|
Write-Host " WARNING: reagentc /enable returned success but WinRE is not enabled"
|
||
|
|
# Fall through to manual recovery attempt
|
||
|
|
$enableResult = ""
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($LASTEXITCODE -ne 0 -or $enableResult -eq "") {
|
||
|
|
$winreFound = $false
|
||
|
|
$winreLocations = @(
|
||
|
|
"$env:SystemDrive\Recovery\WindowsRE",
|
||
|
|
"$env:SystemDrive\Windows\System32\Recovery"
|
||
|
|
)
|
||
|
|
|
||
|
|
foreach ($loc in $winreLocations) {
|
||
|
|
if (Test-Path (Join-Path $loc "winre.wim")) {
|
||
|
|
Write-Host " Found winre.wim at $loc - registering..."
|
||
|
|
reagentc /setreimage /path $loc 2>&1 | Out-Null
|
||
|
|
$retryResult = reagentc /enable 2>&1 | Out-String
|
||
|
|
if ($LASTEXITCODE -eq 0) {
|
||
|
|
# Verify WinRE was actually enabled
|
||
|
|
$reagentInfoRetry = reagentc /info 2>&1 | Out-String
|
||
|
|
if ($reagentInfoRetry -match "Windows RE status:\s+Enabled") {
|
||
|
|
Write-Host " WinRE re-enabled using $loc"
|
||
|
|
$winreFound = $true
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (-not $winreFound) {
|
||
|
|
Write-Host " WARNING: Could not enable WinRE"
|
||
|
|
Write-Host " The wipe will likely fail without WinRE"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Write-Host ""
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 2. Check Component Store health
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# The reset rebuilds Windows from the Component Store (WinSxS). If the store
|
||
|
|
# is corrupted, the reset may fail or produce a broken installation.
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
Write-Host "[2/4] Checking Component Store (WinSxS) integrity..."
|
||
|
|
|
||
|
|
$dismResult = & dism /Online /Cleanup-Image /ScanHealth /English 2>&1 | Out-String
|
||
|
|
if ($dismResult -match "No component store corruption detected") {
|
||
|
|
Write-Host " Component Store is healthy"
|
||
|
|
} elseif ($dismResult -match "The component store is repairable\.") {
|
||
|
|
Write-Host " Corruption detected - attempting repair..."
|
||
|
|
$repairResult = & dism /Online /Cleanup-Image /RestoreHealth /English 2>&1 | Out-String
|
||
|
|
if ($repairResult -match "completed successfully") {
|
||
|
|
Write-Host " Component Store repaired"
|
||
|
|
} else {
|
||
|
|
Write-Host " WARNING: Repair failed - device may use Cloud Download as fallback"
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
Write-Host " WARNING: Component Store state unexpected - continuing"
|
||
|
|
}
|
||
|
|
Write-Host ""
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 3. Suspend BitLocker
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Suspending BitLocker for one reboot cycle prevents the device from prompting
|
||
|
|
# for a recovery key during the reset process.
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
Write-Host "[3/4] Checking BitLocker status..."
|
||
|
|
|
||
|
|
try {
|
||
|
|
$blVolumes = Get-BitLockerVolume -ErrorAction Stop
|
||
|
|
foreach ($vol in $blVolumes) {
|
||
|
|
if ($vol.ProtectionStatus -eq "On") {
|
||
|
|
Write-Host " BitLocker active on $($vol.MountPoint) - suspending for 1 reboot..."
|
||
|
|
$isOSVolume = $vol.MountPoint -eq $env:SystemDrive
|
||
|
|
try {
|
||
|
|
Suspend-BitLocker -MountPoint $vol.MountPoint -RebootCount 1 -ErrorAction Stop
|
||
|
|
Write-Host " BitLocker suspended on $($vol.MountPoint)"
|
||
|
|
} catch {
|
||
|
|
if ($isOSVolume) {
|
||
|
|
Write-Host " ERROR: Failed to suspend BitLocker on OS volume $($vol.MountPoint): $($_.Exception.Message)"
|
||
|
|
Write-Host " Cannot proceed with wipe - BitLocker must be suspended on the OS volume to prevent recovery key prompts"
|
||
|
|
Stop-Transcript -ErrorAction SilentlyContinue | Out-Null
|
||
|
|
exit 1
|
||
|
|
} else {
|
||
|
|
Write-Host " WARNING: Failed to suspend BitLocker on $($vol.MountPoint): $($_.Exception.Message)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
Write-Host " BitLocker already off or suspended on $($vol.MountPoint)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch [System.Management.Automation.CommandNotFoundException] {
|
||
|
|
Write-Host " BitLocker module not available - skipping"
|
||
|
|
} catch {
|
||
|
|
Write-Host " ERROR: BitLocker state could not be determined: $($_.Exception.Message). Terminating to prevent wipe with unknown BitLocker state."
|
||
|
|
Stop-Transcript -ErrorAction SilentlyContinue | Out-Null
|
||
|
|
exit 1
|
||
|
|
}
|
||
|
|
Write-Host ""
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 4. Trigger device wipe via WMI bridge
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Calls the RemoteWipe CSP locally, bypassing the Fleet MDM command queue.
|
||
|
|
# This avoids server-side DB timeouts, auth errors, and command processing
|
||
|
|
# failures that caused the original issue.
|
||
|
|
#
|
||
|
|
# doWipeProtected (build 1703+): Tamper-resistant "Remove everything". If the
|
||
|
|
# reset is interrupted (e.g. power loss), the device keeps trying until
|
||
|
|
# complete. Removes all user data, apps, settings, and enrollment.
|
||
|
|
#
|
||
|
|
# doWipe (all builds): Standard "Remove everything". Same outcome but without
|
||
|
|
# tamper resistance. If interrupted, the device may need manual recovery.
|
||
|
|
#
|
||
|
|
# Note: The WMI bridge requires the MDM_RemoteWipe class to be registered,
|
||
|
|
# which depends on the device having (or having had) an MDM enrollment. If
|
||
|
|
# enrollment is completely gone, the bridge call will fail.
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
Write-Host "[4/4] Triggering device wipe via WMI bridge..."
|
||
|
|
Write-Host " This bypasses the MDM command channel entirely."
|
||
|
|
Write-Host ""
|
||
|
|
|
||
|
|
$namespaceName = "root\cimv2\mdm\dmmap"
|
||
|
|
$className = "MDM_RemoteWipe"
|
||
|
|
$filter = "ParentID='./Vendor/MSFT' and InstanceID='RemoteWipe'"
|
||
|
|
$wipeTriggered = $false
|
||
|
|
|
||
|
|
try {
|
||
|
|
$session = New-CimSession -ErrorAction Stop
|
||
|
|
$instance = Get-CimInstance -Namespace $namespaceName -ClassName $className -Filter $filter -ErrorAction Stop
|
||
|
|
|
||
|
|
$params = New-Object Microsoft.Management.Infrastructure.CimMethodParametersCollection
|
||
|
|
$param = [Microsoft.Management.Infrastructure.CimMethodParameter]::Create("param", "", "String", "In")
|
||
|
|
$params.Add($param)
|
||
|
|
|
||
|
|
$build = [int][System.Environment]::OSVersion.Version.Build
|
||
|
|
|
||
|
|
# Try doWipeProtected first (build 1703+ / 15063+)
|
||
|
|
if ($build -ge 15063) {
|
||
|
|
Write-Host " Trying doWipeProtected..."
|
||
|
|
try {
|
||
|
|
$result = $session.InvokeMethod($namespaceName, $instance, "doWipeProtectedMethod", $params)
|
||
|
|
if ($result.ReturnValue -eq 0) {
|
||
|
|
Write-Host " Wipe command accepted (doWipeProtected)"
|
||
|
|
$wipeTriggered = $true
|
||
|
|
} else {
|
||
|
|
Write-Host " doWipeProtected returned non-zero: $($result.ReturnValue)"
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
Write-Host " doWipeProtected failed: $($_.Exception.Message)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Fallback to doWipe (all builds)
|
||
|
|
if (-not $wipeTriggered) {
|
||
|
|
Write-Host " Trying doWipe..."
|
||
|
|
try {
|
||
|
|
$result = $session.InvokeMethod($namespaceName, $instance, "doWipeMethod", $params)
|
||
|
|
if ($result.ReturnValue -eq 0) {
|
||
|
|
Write-Host " Wipe command accepted (doWipe)"
|
||
|
|
$wipeTriggered = $true
|
||
|
|
} else {
|
||
|
|
Write-Host " doWipe returned non-zero: $($result.ReturnValue)"
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
Write-Host " doWipe failed: $($_.Exception.Message)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
Write-Host " WMI bridge error: $($_.Exception.Message)"
|
||
|
|
}
|
||
|
|
|
||
|
|
Write-Host ""
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Summary
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
Write-Host "=== Wipe Summary ==="
|
||
|
|
if ($wipeTriggered) {
|
||
|
|
Write-Host "Wipe triggered. The device will reboot and reset automatically."
|
||
|
|
Write-Host "No USB media is required."
|
||
|
|
Write-Host "After reset the device boots to OOBE."
|
||
|
|
} else {
|
||
|
|
Write-Host "ERROR: All wipe methods failed."
|
||
|
|
Write-Host ""
|
||
|
|
Write-Host "Possible causes:"
|
||
|
|
Write-Host " - WinRE is missing or corrupted beyond repair"
|
||
|
|
Write-Host " - MDM enrollment is gone (WMI bridge class not registered)"
|
||
|
|
Write-Host " - Component Store is damaged"
|
||
|
|
Write-Host ""
|
||
|
|
Write-Host "Next steps:"
|
||
|
|
Write-Host " - Check WinRE: reagentc /info"
|
||
|
|
Write-Host " - Re-enable WinRE: reagentc /enable"
|
||
|
|
Write-Host " - Repair Component Store: dism /Online /Cleanup-Image /RestoreHealth"
|
||
|
|
Write-Host " - If all else fails, a USB recovery boot is required"
|
||
|
|
$exitCode = 1
|
||
|
|
}
|
||
|
|
|
||
|
|
Write-Host ""
|
||
|
|
Write-Host "Log saved to: $logPath"
|
||
|
|
|
||
|
|
Stop-Transcript -ErrorAction SilentlyContinue | Out-Null
|
||
|
|
|
||
|
|
exit $exitCode
|