🧪 Offline Boot Testing & Generalization of VHDX OS Images
1. 🛠️ Manual Prep: Enter Audit Mode First
Before proceeding with automation, you'll need to enter System Audit Mode:
- Open Terminal, PowerShell, or Command Prompt as Administrator.
- Run the following to clear or set the Administrator password:
net user administrator *
- Press Enter twice for a blank password, or set a complex one that meets Windows password requirements.
- (Don’t worry—this will be automatically reset within a week in production.)
- Then launch Sysprep in Audit Mode:
C:\Windows\System32\Sysprep\sysprep.exe
- Choose:
- System Audit Mode
- Reboot
- Click OK and wait for the system to reboot into Audit Mode.
2. ⚙️ Automated Preparation & Generalization
This section provides the comprehensive PowerShell script that performs all the final cleanup and generalization steps.
🚀 AUTOMATED OPTION: Complete PowerShell Script
https://portal.dtctoday.com/link/424#bkmrk-for-a-fully-automate
For a fully automated approach that handles all cleanup steps including the sysprep generalization loop with AppxPackage removal, you can use this comprehensive script.
2. ⚙️ Automated Preparation & Generalization
This section provides the comprehensive PowerShell script that performs all the final cleanup and generalization steps.
🚀 AUTOMATED OPTION: Complete PowerShell Script
For a fully automated approach that handles all cleanup steps including the sysprep generalization loop with AppxPackage removal, you can use this comprehensive script:
# Complete-WindowsImagePrep.ps1
# Comprehensive Windows Image Preparation Script
# Combines all cleanup, generalization, and AppxPackage removal steps
# Run as Administrator
param(
[switch]$SkipUserCleanup,
[switch]$SkipAgentCleanup,
[switch]$SkipLogCleanup
)
function Write-StepHeader {
param([string]$Title)
Write-Host ""
Write-Host "=" * 60 -ForegroundColor Cyan
Write-Host $Title -ForegroundColor Cyan
Write-Host "=" * 60 -ForegroundColor Cyan
}
function Write-Success {
param([string]$Message)
Write-Host "✓ $Message" -ForegroundColor Green
}
function Write-Error {
param([string]$Message)
Write-Host "✗ $Message" -ForegroundColor Red
}
function Write-Warning {
param([string]$Message)
Write-Host "⚠ $Message" -ForegroundColor Yellow
}
function Write-Info {
param([string]$Message)
Write-Host "ℹ $Message" -ForegroundColor Blue
}
function Confirm-Step {
param([string]$Message)
do {
$response = Read-Host "$Message (y/N)"
$response = $response.ToLower()
} while ($response -notin @('y', 'yes', 'n', 'no', ''))
return ($response -in @('y', 'yes'))
}
function Clear-SysprepLogs {
Write-Info "Clearing Sysprep Panther logs for fresh run..."
try {
$pantherPath = "C:\Windows\System32\Sysprep\Panther"
if (Test-Path $pantherPath) {
Remove-Item -Path "$pantherPath\*" -Force -Recurse -ErrorAction Stop
Write-Success "Cleared Sysprep Panther logs"
} else {
Write-Info "Sysprep Panther directory doesn't exist yet"
}
} catch {
Write-Warning "Could not clear Sysprep logs: $($_.Exception.Message)"
}
}
function Remove-UserAccountsAndProfiles {
if ($SkipUserCleanup) {
Write-Warning "Skipping user cleanup (parameter specified)"
return
}
Write-StepHeader "STEP 1: Remove User Accounts and Profiles"
Write-Info "This will remove all local user accounts and profiles except:"
Write-Info "• System accounts (Administrator, Guest, etc.)"
Write-Info "• Current user: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name.Split('\')[-1])"
if (-not (Confirm-Step "Continue with user cleanup?")) {
Write-Warning "Skipped user cleanup"
return
}
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name.Split('\')[-1]
$systemAccounts = @('Administrator','DefaultAccount','Guest','WDAGUtilityAccount',$currentUser)
$systemProfiles = @('Administrator','DefaultAccount','Guest','WDAGUtilityAccount','Public',$currentUser)
# Remove user accounts
Write-Info "Removing local user accounts..."
$users = Get-LocalUser | Where-Object { $_.Name -notin $systemAccounts }
if ($users.Count -eq 0) {
Write-Success "No additional user accounts found"
} else {
foreach ($user in $users) {
try {
Remove-LocalUser -Name $user.Name -ErrorAction Stop
Write-Success "Removed user: $($user.Name)"
} catch {
Write-Error "Failed to remove user: $($user.Name) - $($_.Exception.Message)"
}
}
}
# Remove user profiles
Write-Info "Removing local user profiles..."
$profileFolders = Get-ChildItem -Path 'C:\Users' -Directory | Where-Object {
$_.Name -notin $systemProfiles
}
if ($profileFolders.Count -eq 0) {
Write-Success "No additional user profiles found"
} else {
foreach ($folder in $profileFolders) {
try {
# Remove profile registry entry
$profileSid = (Get-CimInstance Win32_UserProfile | Where-Object { $_.LocalPath -eq $folder.FullName }).SID
if ($profileSid) {
Remove-CimInstance -InputObject (Get-CimInstance Win32_UserProfile | Where-Object { $_.SID -eq $profileSid }) -ErrorAction SilentlyContinue
Write-Info "Removed registry entry for: $($folder.Name)"
}
# Force remove profile folder
Remove-Item -Path $folder.FullName -Recurse -Force -ErrorAction Stop
Write-Success "Removed profile folder: $($folder.FullName)"
} catch {
Write-Error "Failed to remove profile: $($folder.FullName) - $($_.Exception.Message)"
}
}
}
}
function Remove-ProblematicApplications {
Write-StepHeader "STEP 2: Remove Problematic Applications"
Write-Info "This will silently uninstall applications that interfere with sysprep:"
Write-Info "• Veeam applications (all variants)"
Write-Info "• SnapAgent (Blackpoint Cyber)"
Write-Info "• These applications cause sysprep failures and must be removed"
if (-not (Confirm-Step "Continue with application removal?")) {
Write-Warning "Skipped application removal"
return
}
# Function to silently uninstall applications by name pattern
function Uninstall-ApplicationByName {
param([string]$AppNamePattern, [string]$DisplayName)
try {
# Get applications from both 32-bit and 64-bit registry
$apps = @()
$regPaths = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
foreach ($regPath in $regPaths) {
try {
$apps += Get-ItemProperty $regPath -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -like "*$AppNamePattern*" -and $_.UninstallString }
} catch { }
}
if ($apps.Count -eq 0) {
Write-Info "${DisplayName}: No installations found"
return
}
Write-Info "${DisplayName}: Found $($apps.Count) installation(s)"
foreach ($app in $apps) {
try {
Write-Info " Uninstalling: $($app.DisplayName)"
$uninstallString = $app.UninstallString
if ($uninstallString -like "*msiexec*") {
# MSI installation - extract product code and use quiet uninstall
if ($uninstallString -match '\{[A-F0-9\-]+\}') {
$productCode = $matches[0]
$process = Start-Process -FilePath "msiexec.exe" -ArgumentList "/x", $productCode, "/quiet", "/norestart" -Wait -PassThru -WindowStyle Hidden
if ($process.ExitCode -eq 0) {
Write-Success " Successfully uninstalled: $($app.DisplayName)"
} else {
Write-Warning " Uninstall completed with exit code: $($process.ExitCode)"
}
}
} elseif ($uninstallString -like "*uninstall*" -or $uninstallString -like "*setup*") {
# Try to add silent switches for common installers
$silentArgs = @()
if ($uninstallString -like "*uninstall.exe*") {
$silentArgs = @("/S", "/silent", "/quiet")
} elseif ($uninstallString -like "*setup.exe*") {
$silentArgs = @("/S", "/silent", "/quiet", "/uninstall")
}
# Parse executable and arguments
if ($uninstallString -match '^"([^"]+)"(.*)$') {
$executable = $matches[1]
$existingArgs = $matches[2].Trim()
} else {
$parts = $uninstallString.Split(' ', 2)
$executable = $parts[0]
$existingArgs = if ($parts.Length -gt 1) { $parts[1] } else { "" }
}
# Combine existing args with silent args
$allArgs = @($existingArgs.Split(' ') + $silentArgs) | Where-Object { $_ -ne "" }
if (Test-Path $executable) {
$process = Start-Process -FilePath $executable -ArgumentList $allArgs -Wait -PassThru -WindowStyle Hidden -ErrorAction Stop
if ($process.ExitCode -eq 0) {
Write-Success " Successfully uninstalled: $($app.DisplayName)"
} else {
Write-Warning " Uninstall completed with exit code: $($process.ExitCode)"
}
} else {
Write-Warning " Uninstaller not found: $executable"
}
} else {
Write-Warning " Unsupported uninstall string format: $uninstallString"
}
} catch {
Write-Error " Failed to uninstall $($app.DisplayName): $($_.Exception.Message)"
}
}
} catch {
Write-Error "Failed to process ${DisplayName} applications: $($_.Exception.Message)"
}
}
# Remove Veeam applications
Uninstall-ApplicationByName -AppNamePattern "Veeam" -DisplayName "Veeam"
# Remove SnapAgent (Blackpoint Cyber)
Uninstall-ApplicationByName -AppNamePattern "SnapAgent" -DisplayName "SnapAgent (Blackpoint)"
Uninstall-ApplicationByName -AppNamePattern "Blackpoint" -DisplayName "Blackpoint Cyber"
}
function Clear-AgentIdentityData {
if ($SkipAgentCleanup) {
Write-Warning "Skipping agent cleanup (parameter specified)"
return
}
Write-StepHeader "STEP 3: Clear Agent Identity Data"
Write-Info "This will clear identity data for:"
Write-Info "• NinjaRMM (NodeId registry value and data folder)"
Write-Info "• Veeam (registry keys and data folder)"
Write-Info "• Services will be preserved"
if (-not (Confirm-Step "Continue with agent cleanup?")) {
Write-Warning "Skipped agent cleanup"
return
}
# Remove NinjaRMM NodeId
try {
Remove-ItemProperty -Path 'HKLM:\SOFTWARE\WOW6432Node\NinjaRMM LLC\NinjaRMMAgent\Agent' -Name 'NodeId' -Force -ErrorAction Stop
Write-Success "Removed NinjaRMM NodeId registry value"
} catch {
Write-Info "NinjaRMM NodeId registry value not found or already removed"
}
# Remove Veeam registry key
try {
Remove-Item -Path 'HKLM:\SOFTWARE\Veeam' -Recurse -Force -ErrorAction Stop
Write-Success "Removed Veeam registry key"
} catch {
Write-Info "Veeam registry key not found or already removed"
}
# Remove NinjaRMM folder
try {
Remove-Item -Path 'C:\ProgramData\NinjaRMMAgent' -Recurse -Force -ErrorAction Stop
Write-Success "Removed NinjaRMM data folder"
} catch {
Write-Info "NinjaRMM data folder not found or already removed"
}
# Remove Veeam folder
try {
Remove-Item -Path 'C:\ProgramData\Veeam' -Recurse -Force -ErrorAction Stop
Write-Success "Removed Veeam data folder"
} catch {
Write-Info "Veeam data folder not found or already removed"
}
}
function Disable-BitLockerVolumes {
Write-StepHeader "STEP 4: Disable BitLocker"
Write-Info "This will disable BitLocker on all encrypted volumes"
Write-Warning "BitLocker decryption may take time depending on drive size"
if (-not (Confirm-Step "Continue with BitLocker disable?")) {
Write-Warning "Skipped BitLocker disable"
return
}
try {
# Get all BitLocker volumes
$bitlockerVolumes = Get-BitLockerVolume -ErrorAction SilentlyContinue
if (-not $bitlockerVolumes) {
Write-Success "No BitLocker volumes found"
return
}
$encryptedVolumes = $bitlockerVolumes | Where-Object {
$_.VolumeStatus -eq 'FullyEncrypted' -or
$_.VolumeStatus -eq 'EncryptionInProgress' -or
$_.VolumeStatus -eq 'DecryptionInProgress'
}
if ($encryptedVolumes.Count -eq 0) {
Write-Success "No encrypted BitLocker volumes found"
return
}
Write-Info "Found $($encryptedVolumes.Count) encrypted BitLocker volume(s):"
foreach ($volume in $encryptedVolumes) {
Write-Info " • $($volume.MountPoint) - Status: $($volume.VolumeStatus)"
}
foreach ($volume in $encryptedVolumes) {
try {
Write-Info "Disabling BitLocker on volume: $($volume.MountPoint)"
Disable-BitLocker -MountPoint $volume.MountPoint -ErrorAction Stop
Write-Success "BitLocker disable initiated for: $($volume.MountPoint)"
} catch {
Write-Error "Failed to disable BitLocker on $($volume.MountPoint): $($_.Exception.Message)"
}
}
# Check if any volumes are still decrypting
$decryptingVolumes = Get-BitLockerVolume | Where-Object {
$_.VolumeStatus -eq 'DecryptionInProgress'
}
if ($decryptingVolumes.Count -gt 0) {
Write-Warning "BitLocker decryption is in progress on $($decryptingVolumes.Count) volume(s)"
Write-Info "Decryption will continue in the background"
Write-Info "You can check status with: Get-BitLockerVolume"
}
} catch {
Write-Error "Failed to process BitLocker volumes: $($_.Exception.Message)"
Write-Info "BitLocker management may not be available on this system"
}
}
function Clear-WindowsLogs {
if ($SkipLogCleanup) {
Write-Warning "Skipping log cleanup (parameter specified)"
return
}
Write-StepHeader "STEP 5: Clear Windows Logs"
Write-Info "This will remove:"
Write-Info "• Major Windows Event Logs (Application, System, Security)"
Write-Info "• Panther setup logs"
if (-not (Confirm-Step "Continue with log cleanup?")) {
Write-Warning "Skipped log cleanup"
return
}
# Clear Windows event logs using wevtutil
Write-Info "Clearing Windows Event Logs..."
$logsToClear = @('Application', 'Security', 'Setup', 'System')
foreach ($log in $logsToClear) {
try {
Write-Info " Clearing $log log..."
wevtutil.exe cl $log /q:true
Write-Success " Cleared $log log"
} catch {
Write-Warning " Could not clear $log log: $($_.Exception.Message)"
}
}
# Remove Panther logs recursively
Write-Info "Clearing Panther logs..."
try {
$pantherPath = "$env:SystemRoot\Panther"
if (Test-Path $pantherPath) {
Remove-Item -Path "$pantherPath\*" -Recurse -Force -ErrorAction Stop
Write-Success "Removed Panther logs"
} else {
Write-Info "Panther directory does not exist."
}
} catch {
Write-Error "Failed to remove Panther logs: $($_.Exception.Message)"
}
}
function Create-UnattendXml {
Write-StepHeader "STEP 6: Create unattend.xml"
$unattendPath = 'C:\Windows\System32\Sysprep\unattend.xml'
Write-Info "Creating a new, validated unattend.xml at: $unattendPath"
if (-not (Confirm-Step "Continue with unattend.xml creation?")) {
Write-Warning "Skipped unattend.xml creation"
return $false
}
$unattendContent = @'
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<UserAccounts>
<LocalAccounts>
<LocalAccount wcm:action="add">
<Name>installadmin</Name>
<Group>Administrators</Group>
<Password>
<Value>DTC@dental2025</Value>
<PlainText>true</PlainText>
</Password>
</LocalAccount>
</LocalAccounts>
</UserAccounts>
<AutoLogon>
<Enabled>true</Enabled>
<Username>installadmin</Username>
<Password>
<Value>DTC@dental2025</Value>
<PlainText>true</PlainText>
</Password>
</AutoLogon>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideLocalAccountScreen>true</HideLocalAccountScreen>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<ProtectYourPC>1</ProtectYourPC>
<SkipMachineOOBE>true</SkipMachineOOBE>
</OOBE>
</component>
</settings>
</unattend>
'@
try {
$unattendContent | Set-Content -Path $unattendPath -Encoding UTF8 -ErrorAction Stop
Write-Success "unattend.xml created successfully"
return $true
} catch {
Write-Error "Failed to create unattend.xml: $($_.Exception.Message)"
return $false
}
}
function Get-AppxBlockers {
$logPath = 'C:\Windows\System32\Sysprep\Panther\setuperr.log'
if (!(Test-Path $logPath)) {
return @()
}
try {
$content = Get-Content $logPath -Raw -ErrorAction Stop
# Match the actual sysprep error format for AppxPackages
# Example: "SYSPRP Package Microsoft.WidgetsPlatformRuntime_1.6.2.0_arm64__8wekyb3d8bbwe was installed for a user, but not provisioned for all users"
$matches = [regex]::Matches($content, 'SYSPRP Package ([^\s]+) was installed for a user, but not provisioned for all users')
$blockers = @()
foreach ($match in $matches) {
$packageFullName = $match.Groups[1].Value
# Store both the full name and the base name for better removal chances
$blockers += $packageFullName
# Also try to extract just the base package name for broader matching
# Example: Microsoft.WidgetsPlatformRuntime_1.6.2.0_arm64__8wekyb3d8bbwe -> Microsoft.WidgetsPlatformRuntime
if ($packageFullName -match '^([^_]+)') {
$baseName = $Matches[1]
if ($baseName -ne $packageFullName) {
$blockers += $baseName
}
}
}
# Also check for the older error format as fallback
$legacyMatches = [regex]::Matches($content, 'Package (.*?) cannot be removed and is preventing Sysprep')
foreach ($match in $legacyMatches) {
$blockers += $match.Groups[1].Value
}
return ($blockers | Select-Object -Unique)
} catch {
Write-Warning "Could not read or parse Sysprep error log: $($_.Exception.Message)"
return @()
}
}
function Remove-AppxBlockers {
param([string[]]$Blockers)
Write-Info "Attempting to remove $($Blockers.Count) blocking AppxPackages..."
$totalRemoved = 0
foreach ($blocker in $Blockers) {
$removedInLoop = $false
try {
# Try removing the provisioned package(s) first
$provisionedPackages = Get-AppxProvisionedPackage -Online | Where-Object { $_.PackageName -like "*$blocker*" }
if ($provisionedPackages) {
foreach ($package in $provisionedPackages) {
Write-Info " Removing provisioned package: $($package.DisplayName)"
Remove-AppxProvisionedPackage -Online -PackageName $package.PackageName -ErrorAction Stop
Write-Success " Removed provisioned package: $($package.DisplayName)"
$removedInLoop = $true
}
}
# Try removing the user-specific package(s)
$appPackages = Get-AppxPackage -AllUsers | Where-Object { $_.Name -like "*$blocker*" -or $_.PackageFullName -like "*$blocker*" }
if ($appPackages) {
foreach ($package in $appPackages) {
Write-Info " Removing AppxPackage: $($package.Name)"
Remove-AppxPackage -Package $package.PackageFullName -AllUsers -ErrorAction Stop
Write-Success " Removed AppxPackage: $($package.Name)"
$removedInLoop = $true
}
}
if ($removedInLoop) {
$totalRemoved++
} else {
Write-Warning " No installed or provisioned package found for blocker: '$blocker'"
}
} catch {
Write-Error " Failed during removal for blocker '$blocker': $($_.Exception.Message)"
}
}
Write-Info "Total packages removed in this pass: $totalRemoved"
return $totalRemoved
}
function Run-SysprepLoop {
Write-StepHeader "STEP 7: Run Sysprep Generalization"
Write-Info "This will run sysprep and handle AppxPackage errors in a loop."
if (-not (Test-Path 'C:\Windows\System32\Sysprep\unattend.xml')) {
Write-Warning "unattend.xml not found. Sysprep will run without it."
if (-not (Confirm-Step "Continue without unattend.xml?")) {
Write-Error "Sysprep aborted."
return
}
}
$sysprepPath = "C:\Windows\System32\Sysprep\sysprep.exe"
$maxAttempts = 10
$attempt = 1
while ($attempt -le $maxAttempts) {
Write-Info "---"
Write-Info "Sysprep Attempt #$attempt"
Write-Info "---"
Clear-SysprepLogs
$arguments = @(
"/generalize",
"/oobe",
"/shutdown",
"/unattend:C:\Windows\System32\Sysprep\unattend.xml"
)
try {
# Sysprep should shut down the machine on success. If the process exits and
# the script continues, it means it failed.
Write-Info "Executing Sysprep... The system should shut down on success."
Start-Process -FilePath $sysprepPath -ArgumentList $arguments -Wait -PassThru -ErrorAction Stop
# If we get here, Sysprep failed because a successful run would have shut down the PC.
Write-Warning "Sysprep process completed without shutting down, indicating a failure."
# Give logs a moment to be written
Start-Sleep -Seconds 5
} catch {
Write-Error "Failed to execute Sysprep: $($_.Exception.Message)"
}
# If we are here, sysprep likely failed. Check for blockers.
$blockers = Get-AppxBlockers
if ($blockers.Count -eq 0) {
Write-Error "Sysprep failed, but no AppxPackage blockers were found in the logs."
Write-Info "Please check the logs manually for other errors: C:\Windows\System32\Sysprep\Panther\setuperr.log"
break
}
Write-Warning "Sysprep failed. Found $($blockers.Count) potential blockers."
$removedCount = Remove-AppxBlockers -Blockers $blockers
if ($removedCount -eq 0) {
Write-Error "Could not remove any of the blocking packages. Manual intervention required."
break
}
$attempt++
if ($attempt -gt $maxAttempts) {
Write-Error "Reached max attempts. Sysprep failed."
} else {
Write-Info "Packages removed. Preparing to retry."
if (-not (Confirm-Step "Ready to retry sysprep?")) {
Write-Error "Sysprep retry aborted by user."
break
}
Write-Info "Retrying sysprep..."
}
}
}
# Main execution
function Main {
Write-StepHeader "Windows Image Preparation Script"
Write-Info "This script will prepare a Windows image for generalization"
Write-Info "Run as Administrator in the VM you want to generalize"
Write-Warning "Create a VM checkpoint/snapshot before running!"
if (-not (Confirm-Step "Continue with image preparation?")) {
Write-Warning "Image preparation cancelled"
exit 0
}
# Check if running as administrator
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")
if (-not $isAdmin) {
Write-Error "This script must be run as Administrator"
exit 1
}
try {
# Execute all steps
Remove-UserAccountsAndProfiles
Remove-ProblematicApplications
Clear-AgentIdentityData
Disable-BitLockerVolumes
Clear-WindowsLogs
if (Create-UnattendXml) {
Run-SysprepLoop
} else {
Write-Error "Cannot proceed without unattend.xml"
exit 1
}
} catch {
Write-Error "Script execution failed: $($_.Exception.Message)"
exit 1
}
}
# Run main function
Main
7. ✅ Final Checklist
- Booted offline on hypervisor
- Converted to GPT with valid partitions
- Optional: user profiles backed up and deleted
- VM Checkpoint Created
- Agent identities reset (binaries remain)
- Problematic packages removed based on setuperr.log
- unattend.xml disables OOBE + telemetry
- Sysprep executed successfully and VM is shut down.