## Summary Adds detection and mitigation for YellowKey (CVE-2026-45585), an unpatched BitLocker bypass affecting Windows 11, Server 2022, and Server 2025. This PR provides: 1. **Detection via osquery extension**: A policy that ensures the `windows_yellowkey` extension is loaded, enabling daily reporting on host exposure status 2. **Daily report**: Surfaces per-host YellowKey verdict (not affected, mitigated, mitigated via WinRE disabled, BitLocker off, or exposed) 3. **Mitigation script**: Implements Microsoft's official mitigation by stripping `autofstx.exe` from WinRE's `BootExecute` registry chain 4. **Installation script**: Downloads and registers the upstream `windows_yellowkey` osquery extension from `allenhouchins/fleet-extensions` The user-facing article is tracked in a separate issue and will land in its own PR. The `docs/solutions/all/queries/` → `reports/` directory migration will also be done in a separate PR. ## Changes ### New files - `docs/solutions/windows/scripts/mitigate-windows-yellowkey.ps1` — PowerShell script that mounts WinRE, loads the offline SYSTEM hive, strips `autofstx` from every ControlSet's `BootExecute`, verifies via read-back, unmounts with commit, and re-seals the BitLocker measurement chain via `reagentc /disable` + `/enable`. Writes `HKLM\SOFTWARE\Fleet\YellowKey\BootExecMitigated = 1` on success. Exit codes: 0 (done), 3 (OS not affected), 4 (failed). - `docs/solutions/windows/scripts/install-yellowkey-extension.ps1` — Wrapper that fetches and executes the upstream installer from `allenhouchins/fleet-extensions/main`. The upstream script handles download, PE-header validation, service stop/restart, ACL hardening, and loader registration. This wrapper exists only because Fleet's GitOps `run_script` requires a file on disk. - `docs/solutions/windows/policies/windows-yellowkey-extension.policies.yml` — Policy that checks `osquery_registry` for the `windows_yellowkey` table. Passes when loaded; failing hosts run the installer. - `docs/solutions/windows/reports/windows-yellowkey.reports.yml` — Daily report querying the extension's `state`, `state_reason`, `needs_action`, `winre_enabled`, `tpm_only`, and `mitigated` columns. ## Design notes - **No opt-in gate**: Microsoft's `autofstx` strip is safe on every affected host, so the mitigation script runs unconditionally. - **One-way mitigation**: No unmitigate path. When Microsoft ships a patch, apply it and clear the marker. - **Extension sourcing**: The extension binary and installer live upstream in `allenhouchins/fleet-extensions/windows_yellowkey`. Allen's CI republishes on every push to `main`. The installer always pulls from `releases/latest/download`, so no edits to this repo are needed when the binary updates. - **Loader path**: The installer writes to `C:\Program Files\osquery\extensions.load` (osquery's compiled default), not to orbit's directory, avoiding conflicts with TUF-managed extensions. - **Hive and mount cleanup**: Mount, hive load, edit, and unmount all run inside a single try/finally block to ensure cleanup even on exception. - **Read-back verification**: Each ControlSet's `BootExecute` is verified after the strip to confirm. https://claude.ai/code/session_016QZzDFsd1hTrYkQukGon6Y <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added Windows YellowKey BitLocker bypass vulnerability detection and reporting capabilities * New policy to verify security extension installation and status on Windows hosts * New report surfaces vulnerability exposure and state across Windows fleet * Added automated remediation for vulnerable hosts, including extension installation and bypass mitigation procedures <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/fleetdm/fleet/pull/46358?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude <noreply@anthropic.com>
323 lines
12 KiB
PowerShell
323 lines
12 KiB
PowerShell
<#
|
||
.SYNOPSIS
|
||
Mitigates the YellowKey BitLocker bypass (CVE-2026-45585) by removing
|
||
autofstx.exe from the WinRE image's Session Manager BootExecute value.
|
||
|
||
.DESCRIPTION
|
||
Applies Microsoft's mitigation for YellowKey (CVE-2026-45585): strip
|
||
autofstx.exe from the WinRE image's Session Manager BootExecute.
|
||
Affects Windows 11, Server 2022, Server 2025. Safe to run on every
|
||
affected host; runs unconditionally (no opt-in gate).
|
||
|
||
The flow, from the CVE-2026-45585 MSRC advisory FAQ:
|
||
1. reagentc /mountre to mount the WinRE image
|
||
2. reg load the offline SYSTEM hive
|
||
3. Walk every ControlSet and strip autofstx (any form: `autofstx`,
|
||
`autofstx.exe`, `autocheck autofstx`, `autofstx.exe /flag`),
|
||
verifying each via read-back
|
||
4. reg unload, reagentc /unmountre /commit
|
||
5. reagentc /disable + /enable to re-seal the BitLocker measurement chain
|
||
|
||
Mount, hive, edit, and unmount run in one try/finally so the hive and
|
||
mount are always released. The mount directory is under %SystemRoot%\Temp,
|
||
ACL-locked to Administrators. On success the script writes
|
||
HKLM\SOFTWARE\Fleet\YellowKey\BootExecMitigated = 1, which the
|
||
windows_yellowkey extension reads to report the host as `mitigated`.
|
||
|
||
One-way: no unmitigate. If a patch ships, apply it and clear the marker.
|
||
TPM + PIN raises attacker cost but does not block the withheld variant.
|
||
|
||
.PARAMETER MountPath
|
||
Directory to use as the WinRE mount point. Created if missing and ACL-
|
||
locked to Administrators. Default: %SystemRoot%\Temp\fleet-yk-winre-mount
|
||
|
||
.OUTPUTS
|
||
Structured key:value output to stdout for log capture.
|
||
|
||
.NOTES
|
||
Exit codes:
|
||
0 = autofstx removed, already absent, or WinRE already disabled
|
||
3 = OS not affected (Windows 10 etc.); no action taken
|
||
4 = Mount, edit, unmount, or re-seal failed; manual investigation needed
|
||
|
||
References:
|
||
MSRC CVE-2026-45585 (FAQ section contains the canonical Microsoft script):
|
||
https://msrc.microsoft.com/update-guide/vulnerability/CVE-2026-45585
|
||
Eclypsium technical analysis:
|
||
https://eclypsium.com/blog/yellowkey-bitlocker-bypass-windows-recovery-environment/
|
||
#>
|
||
|
||
[CmdletBinding()]
|
||
param(
|
||
[Parameter(Mandatory = $false)]
|
||
[string]$MountPath = (Join-Path $env:SystemRoot 'Temp\fleet-yk-winre-mount')
|
||
)
|
||
|
||
$ErrorActionPreference = 'Stop'
|
||
|
||
function Write-State {
|
||
param([string]$Label, [string]$Value)
|
||
Write-Output ("{0,-30} : {1}" -f $Label, $Value)
|
||
}
|
||
|
||
function Lock-AdminOnlyAcl {
|
||
# Lock the mount directory to Administrators-only access. Defends against
|
||
# TOCTOU between empty-check and reagentc /mountre, and against non-admin
|
||
# local DoS where a user pre-populates the directory to trip mount_dir_dirty.
|
||
param([string]$Path)
|
||
try {
|
||
$acl = Get-Acl $Path
|
||
$acl.SetAccessRuleProtection($true, $false)
|
||
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
||
'BUILTIN\Administrators','FullControl',
|
||
'ContainerInherit,ObjectInherit','None','Allow')
|
||
$acl.AddAccessRule($rule)
|
||
Set-Acl $Path $acl
|
||
} catch {
|
||
Write-Output "WARN: could not lock ACL on $Path : $($_.Exception.Message)"
|
||
}
|
||
}
|
||
|
||
Write-Output "=== Windows YellowKey mitigation (autofstx strip) ==="
|
||
Write-Output ""
|
||
|
||
# Match every reasonable spelling of the entry: 'autofstx', 'autofstx.exe',
|
||
# 'autocheck autofstx', 'autofstx.exe /flag'. Word-boundary anchored,
|
||
# case-insensitive.
|
||
$AutofstxPattern = '(?i)\bautofstx(\.exe)?\b'
|
||
$HiveName = 'YK_WinREHive'
|
||
|
||
$hiveLoaded = $false
|
||
$imageMounted = $false
|
||
$mountCreated = $false
|
||
$changesMade = $false
|
||
$editClean = $false
|
||
|
||
# --- Fleet: success marker path (BootExecMitigated is written on success;
|
||
# no opt-in gate because Microsoft's autofstx strip is the official
|
||
# mitigation and is safe to apply on every affected host). ---
|
||
$markerPath = 'HKLM:\SOFTWARE\Fleet\YellowKey'
|
||
|
||
# --- Fleet: OS check ---
|
||
$os = (Get-CimInstance Win32_OperatingSystem).Caption
|
||
Write-State "OS" $os
|
||
$affected = ($os -match 'Windows 11' -or $os -match 'Server 2022' -or $os -match 'Server 2025')
|
||
if (-not $affected) {
|
||
Write-Output "SKIP: $os is not in YellowKey's affected OS list."
|
||
Write-State "State" "skipped_os_not_affected"
|
||
exit 3
|
||
}
|
||
|
||
# --- Admin check ---
|
||
try {
|
||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||
$principal = [Security.Principal.WindowsPrincipal]$identity
|
||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||
Write-Output "FAIL: must run as Administrator."
|
||
Write-State "State" "not_admin"
|
||
exit 4
|
||
}
|
||
} catch {
|
||
Write-Output "FAIL: admin check error: $($_.Exception.Message)"
|
||
Write-State "State" "admin_check_failed"
|
||
exit 4
|
||
}
|
||
|
||
# --- WinRE state (CJK-colon tolerant; localized values fall through to error) ---
|
||
$winreOutput = & reagentc /info 2>$null
|
||
if ($LASTEXITCODE -ne 0) {
|
||
Write-Output "FAIL: reagentc /info exit $LASTEXITCODE"
|
||
Write-State "State" "reagentc_info_failed"
|
||
exit 4
|
||
}
|
||
$winreText = $winreOutput -join "`n"
|
||
if ($winreText -match "[::]\s*Disabled\b") {
|
||
Write-Output "OK: WinRE disabled. Stronger mitigation already in place; nothing to do."
|
||
Write-State "State" "winre_already_disabled"
|
||
exit 0
|
||
}
|
||
if ($winreText -notmatch "[::]\s*Enabled\b") {
|
||
Write-Output "FAIL: could not parse reagentc /info output (locale not supported by current regex)."
|
||
Write-State "State" "winre_state_unknown"
|
||
exit 4
|
||
}
|
||
Write-State "WinRE status" "Enabled"
|
||
|
||
# --- All mount/load/edit/unload/unmount work inside one try/finally so the
|
||
# hive handle and the mounted image are always released, even on a
|
||
# thrown exception mid-flow. exit code is decided after finally based
|
||
# on $editClean. ---
|
||
try {
|
||
# --- Prepare mount directory (admin-only path, ACL-locked) ---
|
||
if (-not (Test-Path $MountPath)) {
|
||
New-Item -ItemType Directory -Path $MountPath -Force | Out-Null
|
||
$mountCreated = $true
|
||
Lock-AdminOnlyAcl -Path $MountPath
|
||
} else {
|
||
$existing = Get-ChildItem -Path $MountPath -Force -ErrorAction SilentlyContinue
|
||
if ($existing) {
|
||
Write-Output "FAIL: $MountPath not empty. Clean it or pass -MountPath."
|
||
Write-State "State" "mount_dir_dirty"
|
||
throw "mount_dir_dirty"
|
||
}
|
||
# Lock the ACL even on a pre-existing directory to defend against
|
||
# a non-admin user creating it earlier.
|
||
Lock-AdminOnlyAcl -Path $MountPath
|
||
}
|
||
|
||
# --- Mount WinRE image ---
|
||
$mountOutput = & reagentc /mountre /path $MountPath 2>$null
|
||
if ($LASTEXITCODE -ne 0) {
|
||
Write-Output "FAIL: reagentc /mountre: $mountOutput"
|
||
Write-State "State" "mount_failed"
|
||
throw "mount_failed"
|
||
}
|
||
$imageMounted = $true
|
||
Write-State "Mounted at" $MountPath
|
||
|
||
# --- Locate offline SYSTEM hive ---
|
||
$hivePath = $null
|
||
foreach ($candidate in @(
|
||
"$MountPath\Windows\System32\config\SYSTEM",
|
||
"$MountPath\windows\system32\config\SYSTEM"
|
||
)) {
|
||
if (Test-Path $candidate) { $hivePath = $candidate; break }
|
||
}
|
||
if (-not $hivePath) {
|
||
$found = Get-ChildItem -Path $MountPath -Recurse -Filter 'SYSTEM' -ErrorAction SilentlyContinue |
|
||
Where-Object { $_.FullName -match 'config\\SYSTEM$' } | Select-Object -First 1
|
||
if ($found) { $hivePath = $found.FullName }
|
||
}
|
||
if (-not $hivePath) {
|
||
Write-Output "FAIL: SYSTEM hive not found in mounted image."
|
||
Write-State "State" "hive_not_found"
|
||
throw "hive_not_found"
|
||
}
|
||
|
||
# --- Load offline SYSTEM hive ---
|
||
& reg load "HKLM\$HiveName" $hivePath 2>$null | Out-Null
|
||
if ($LASTEXITCODE -ne 0) {
|
||
Write-Output "FAIL: reg load exit $LASTEXITCODE"
|
||
Write-State "State" "reg_load_failed"
|
||
throw "reg_load_failed"
|
||
}
|
||
$hiveLoaded = $true
|
||
Write-State "Hive loaded" "HKLM\$HiveName"
|
||
|
||
# --- Enumerate every ControlSet child key directly. Covers Current,
|
||
# Default, LastKnownGood, Failed, and any rolled-back snapshots. ---
|
||
$controlSets = @()
|
||
try {
|
||
$controlSets = @(
|
||
Get-ChildItem "Registry::HKEY_LOCAL_MACHINE\$HiveName" -ErrorAction Stop |
|
||
Where-Object { $_.PSChildName -like 'ControlSet*' } |
|
||
ForEach-Object { $_.PSChildName }
|
||
)
|
||
} catch {
|
||
$controlSets = @()
|
||
}
|
||
if ($controlSets.Count -eq 0) {
|
||
$controlSets = @('ControlSet001')
|
||
}
|
||
Write-State "ControlSets" ($controlSets -join ', ')
|
||
|
||
# --- Strip autofstx from each ControlSet's BootExecute, verify read-back ---
|
||
foreach ($cs in $controlSets) {
|
||
$regPath = "Registry::HKEY_LOCAL_MACHINE\$HiveName\$cs\Control\Session Manager"
|
||
$cur = (Get-ItemProperty -Path $regPath -Name 'BootExecute' -ErrorAction SilentlyContinue).BootExecute
|
||
if (-not $cur) {
|
||
Write-State "$cs" "no_bootexecute"
|
||
continue
|
||
}
|
||
$curArr = @($cur)
|
||
$newArr = @($curArr | Where-Object { $_ -and ($_ -notmatch $AutofstxPattern) })
|
||
if ($newArr.Count -eq $curArr.Count) {
|
||
Write-State "$cs" "autofstx absent"
|
||
continue
|
||
}
|
||
Set-ItemProperty -Path $regPath -Name 'BootExecute' -Value $newArr -Type MultiString
|
||
|
||
# Verify read-back. Refuse to claim success if the strip did not stick.
|
||
$verify = (Get-ItemProperty -Path $regPath -Name 'BootExecute' -ErrorAction Stop).BootExecute
|
||
if (@($verify) | Where-Object { $_ -match $AutofstxPattern }) {
|
||
Write-State "$cs" "verify_failed_autofstx_still_present"
|
||
throw "verify_failed_$cs"
|
||
}
|
||
$changesMade = $true
|
||
Write-State "$cs" "stripped autofstx"
|
||
}
|
||
|
||
# The edit loop finished without throw. Set the clean flag last so any
|
||
# exception above leaves it false.
|
||
$editClean = $true
|
||
}
|
||
catch {
|
||
# State has already been written inside the try block before each throw.
|
||
# If a cmdlet inside the edit loop raised an unexpected error (no prior
|
||
# State write), surface it as edit_error.
|
||
if ($_.Exception.Message -notmatch '^(mount_dir_dirty|mount_failed|hive_not_found|reg_load_failed|verify_failed_)') {
|
||
Write-Output "FAIL: $($_.Exception.Message)"
|
||
Write-State "State" "edit_error"
|
||
}
|
||
}
|
||
finally {
|
||
# Always release the hive and unmount, regardless of how we got here.
|
||
if ($hiveLoaded) {
|
||
[gc]::Collect()
|
||
[gc]::WaitForPendingFinalizers()
|
||
Start-Sleep -Seconds 2
|
||
& reg unload "HKLM\$HiveName" 2>$null | Out-Null
|
||
if ($LASTEXITCODE -ne 0) {
|
||
[gc]::Collect()
|
||
Start-Sleep -Seconds 3
|
||
& reg unload "HKLM\$HiveName" 2>$null | Out-Null
|
||
}
|
||
}
|
||
if ($imageMounted) {
|
||
$flag = if ($editClean -and $changesMade) { '/commit' } else { '/discard' }
|
||
& reagentc /unmountre /path $MountPath $flag 2>$null | Out-Null
|
||
}
|
||
if ($mountCreated -and (Test-Path $MountPath)) {
|
||
Remove-Item -Path $MountPath -Recurse -Force -ErrorAction SilentlyContinue
|
||
}
|
||
}
|
||
|
||
# --- If anything in the try block threw, bail without writing the marker ---
|
||
if (-not $editClean) {
|
||
exit 4
|
||
}
|
||
|
||
# --- Re-seal BitLocker measurement chain (only when changes were committed) ---
|
||
# disable and enable are checked independently because $LASTEXITCODE is
|
||
# overwritten by each external command.
|
||
if ($changesMade) {
|
||
& reagentc /disable 2>$null | Out-Null
|
||
$disableExit = $LASTEXITCODE
|
||
& reagentc /enable 2>$null | Out-Null
|
||
$enableExit = $LASTEXITCODE
|
||
if ($disableExit -ne 0 -or $enableExit -ne 0) {
|
||
Write-Output "FAIL: reseal failed (disable=$disableExit, enable=$enableExit). Run reagentc /enable manually if needed."
|
||
Write-State "State" "reseal_failed"
|
||
exit 4
|
||
}
|
||
Write-State "WinRE re-sealed" "disable + enable"
|
||
}
|
||
|
||
# --- Fleet: success marker. Only written when the edit loop completed
|
||
# cleanly AND every ControlSet read-back verified autofstx absent. ---
|
||
try {
|
||
if (-not (Test-Path $markerPath)) {
|
||
New-Item -Path $markerPath -Force | Out-Null
|
||
}
|
||
Set-ItemProperty -Path $markerPath -Name 'BootExecMitigated' -Value 1 -Type DWord -Force
|
||
} catch {
|
||
Write-Output "WARN: could not write BootExecMitigated marker: $($_.Exception.Message)"
|
||
}
|
||
|
||
if ($changesMade) {
|
||
Write-State "State" "bootexec_stripped"
|
||
} else {
|
||
Write-State "State" "bootexec_already_stripped"
|
||
}
|
||
exit 0
|