#!/usr/bin/env pwsh <# .SYNOPSIS CVE-2026-42897 - Exchange Health Checker Outbound Rewrite Rule Blind Spot Proof of Concept .DESCRIPTION Demonstrates that Get-URLRewriteRule.ps1 in Exchange Health Checker only reads inbound IIS URL Rewrite rules and silently ignores outbound rules. The EOMT mitigation for CVE-2026-42897 deploys its CSP rule as an outbound rule ("EOMT OWA CSP - outbound"). Because Health Checker never reads outboundRules, the mitigation is invisible to the diagnostic tool - producing a false negative for administrators verifying EOMT deployment. This script: 1. Builds mock IIS config XML (web.config and applicationHost.config) containing both inbound and outbound rewrite rules. 2. Runs the VULNERABLE parsing logic (inbound-only, mirrors Health Checker). 3. Runs the PATCHED parsing logic (inbound + outbound). 4. Prints a diff so the blind spot is visible. Covers all three config paths Health Checker uses: - web.config - applicationHost.config per-location entry - applicationHost.config global system.webServer .NOTES Affected files in CSS-Exchange repo: Diagnostics/HealthChecker/Analyzer/Get-URLRewriteRule.ps1 (L49, L72, L97) Diagnostics/HealthChecker/Analyzer/Invoke-AnalyzerIISInformation.ps1 (L442-459) Related mitigation: Security/src/EOMT/Mitigations/CVE-2026-42897.ps1 (L147-254) FOR AUTHORIZED SECURITY RESEARCH ONLY. Run on your own systems or in isolated lab environments. #> Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" # --------------------------------------------------------------------------- # Mock IIS configuration XML # --------------------------------------------------------------------------- # Represents the structure Health Checker parses from a live Exchange server. # Contains one inbound rule (what Health Checker sees) and one outbound rule # (the EOMT CSP mitigation - what Health Checker misses). [xml]$MockWebConfig = @' '@ # applicationHost.config with a per-location entry (mirrors Health Checker path 2) [xml]$MockAppHostConfig = @' '@ # --------------------------------------------------------------------------- # Helper: display rule names collected by a parsing path # --------------------------------------------------------------------------- function Show-RuleResults { param( [string] $PathLabel, [string[]] $InboundNames, [string[]] $OutboundNames, [bool] $IsVulnerable ) $mode = if ($IsVulnerable) { "VULNERABLE (inbound only)" } else { "PATCHED (inbound + outbound)" } Write-Host "`n [$PathLabel - $mode]" -ForegroundColor Cyan if ($InboundNames.Count -gt 0) { foreach ($n in $InboundNames) { Write-Host " [INBOUND ] $n" -ForegroundColor Green } } else { Write-Host " [INBOUND ] (none)" -ForegroundColor DarkGray } if (-not $IsVulnerable) { if ($OutboundNames.Count -gt 0) { foreach ($n in $OutboundNames) { Write-Host " [OUTBOUND] $n" -ForegroundColor Yellow } } else { Write-Host " [OUTBOUND] (none)" -ForegroundColor DarkGray } } else { $outboundCount = $OutboundNames.Count Write-Host " [OUTBOUND] *** $outboundCount rule(s) silently ignored ***" -ForegroundColor Red foreach ($n in $OutboundNames) { Write-Host " MISSING: $n" -ForegroundColor Red } } } # --------------------------------------------------------------------------- # PATH 1: web.config # Mirrors Get-URLRewriteRule.ps1 L49 # --------------------------------------------------------------------------- function Test-WebConfigPath { Write-Host "`n=== PATH 1: web.config ===" -ForegroundColor White # --- Vulnerable logic (exactly as in Health Checker) --- $rules = $MockWebConfig.configuration.'system.webServer'.rewrite.rules $inbound = @($rules.rule | Where-Object { $_.enabled -ne "false" } | Select-Object -ExpandProperty name) $outbound = @($MockWebConfig.configuration.'system.webServer'.rewrite.outboundRules.rule | Where-Object { $_.enabled -ne "false" } | Select-Object -ExpandProperty name) Show-RuleResults -PathLabel "web.config" -InboundNames $inbound -OutboundNames $outbound -IsVulnerable $true # --- Patched logic --- $inboundFixed = @($MockWebConfig.configuration.'system.webServer'.rewrite.rules.rule | Where-Object { $_.enabled -ne "false" } | Select-Object -ExpandProperty name) $outboundFixed = @($MockWebConfig.configuration.'system.webServer'.rewrite.outboundRules.rule | Where-Object { $_.enabled -ne "false" } | Select-Object -ExpandProperty name) Show-RuleResults -PathLabel "web.config" -InboundNames $inboundFixed -OutboundNames $outboundFixed -IsVulnerable $false } # --------------------------------------------------------------------------- # PATH 2: applicationHost.config - per-location entry # Mirrors Get-URLRewriteRule.ps1 L72 # --------------------------------------------------------------------------- function Test-AppHostPerLocationPath { Write-Host "`n=== PATH 2: applicationHost.config (per-location) ===" -ForegroundColor White $location = $MockAppHostConfig.configuration.location # --- Vulnerable logic --- $rules = $location.'system.webServer'.rewrite.rules $inbound = @($rules.rule | Where-Object { $_.enabled -ne "false" } | Select-Object -ExpandProperty name) $outbound = @($location.'system.webServer'.rewrite.outboundRules.rule | Where-Object { $_.enabled -ne "false" } | Select-Object -ExpandProperty name) Show-RuleResults -PathLabel "appHost/location" -InboundNames $inbound -OutboundNames $outbound -IsVulnerable $true # --- Patched logic --- $inboundFixed = @($location.'system.webServer'.rewrite.rules.rule | Where-Object { $_.enabled -ne "false" } | Select-Object -ExpandProperty name) $outboundFixed = @($location.'system.webServer'.rewrite.outboundRules.rule | Where-Object { $_.enabled -ne "false" } | Select-Object -ExpandProperty name) Show-RuleResults -PathLabel "appHost/location" -InboundNames $inboundFixed -OutboundNames $outboundFixed -IsVulnerable $false } # --------------------------------------------------------------------------- # PATH 3: applicationHost.config - global system.webServer # Mirrors Get-URLRewriteRule.ps1 L97 # --------------------------------------------------------------------------- function Test-AppHostGlobalPath { Write-Host "`n=== PATH 3: applicationHost.config (global) ===" -ForegroundColor White # --- Vulnerable logic --- $rules = $MockAppHostConfig.configuration.'system.webServer'.rewrite.rules $inbound = @($rules.rule | Where-Object { $_.enabled -ne "false" } | Select-Object -ExpandProperty name) $outbound = @($MockAppHostConfig.configuration.'system.webServer'.rewrite.outboundRules.rule | Where-Object { $_.enabled -ne "false" } | Select-Object -ExpandProperty name) Show-RuleResults -PathLabel "appHost/global" -InboundNames $inbound -OutboundNames $outbound -IsVulnerable $true # --- Patched logic --- $inboundFixed = @($MockAppHostConfig.configuration.'system.webServer'.rewrite.rules.rule | Where-Object { $_.enabled -ne "false" } | Select-Object -ExpandProperty name) $outboundFixed = @($MockAppHostConfig.configuration.'system.webServer'.rewrite.outboundRules.rule | Where-Object { $_.enabled -ne "false" } | Select-Object -ExpandProperty name) Show-RuleResults -PathLabel "appHost/global" -InboundNames $inboundFixed -OutboundNames $outboundFixed -IsVulnerable $false } # --------------------------------------------------------------------------- # Simulate what Health Checker JSON output looks like (vulnerable vs patched) # --------------------------------------------------------------------------- function Show-HealthCheckerOutputComparison { Write-Host "`n=== Health Checker Report Output Comparison ===" -ForegroundColor White Write-Host "`n [Vulnerable Health Checker JSON excerpt]" -ForegroundColor Cyan $vulnerableOutput = [PSCustomObject]@{ IISURLRewrite = [PSCustomObject]@{ Rules = @("Redirect to HTTPS") # outbound rule absent } MitigationStatus = [PSCustomObject]@{ "EOMT OWA CSP - outbound" = "NOT DETECTED" Note = "Outbound rules are not enumerated - EOMT mitigation invisible" } } $vulnerableOutput | ConvertTo-Json -Depth 4 Write-Host "`n [Patched Health Checker JSON excerpt]" -ForegroundColor Green $patchedOutput = [PSCustomObject]@{ IISURLRewrite = [PSCustomObject]@{ InboundRules = @("Redirect to HTTPS") OutboundRules = @("EOMT OWA CSP - outbound") } MitigationStatus = [PSCustomObject]@{ "EOMT OWA CSP - outbound" = "DETECTED (enabled)" Note = "Outbound rules enumerated - EOMT mitigation visible" } } $patchedOutput | ConvertTo-Json -Depth 4 } # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- Write-Host @" ╔══════════════════════════════════════════════════════════════════════════╗ ║ CVE-2026-42897 - Exchange Health Checker Outbound Rewrite Blind Spot ║ ║ ║ ║ Affected: Get-URLRewriteRule.ps1 (L49, L72, L97) ║ ║ Invoke-AnalyzerIISInformation.ps1 (L442-459) ║ ║ Impact: EOMT OWA CSP outbound mitigation invisible in HC reports ║ ║ For authorized security research only. ║ ╚══════════════════════════════════════════════════════════════════════════╝ "@ -ForegroundColor Magenta Write-Host "[*] Building mock IIS configuration with inbound + outbound rules..." -ForegroundColor Gray Write-Host " Inbound rule: 'Redirect to HTTPS'" -ForegroundColor Gray Write-Host " Outbound rule: 'EOMT OWA CSP - outbound' <-- EOMT mitigation for CVE-2026-42897" -ForegroundColor Gray Test-WebConfigPath Test-AppHostPerLocationPath Test-AppHostGlobalPath Show-HealthCheckerOutputComparison Write-Host "`n=== Summary ===" -ForegroundColor White Write-Host @" The three code paths in Get-URLRewriteRule.ps1 each access only: .rewrite.rules (inbound) None access: .rewrite.outboundRules (outbound) The EOMT mitigation for CVE-2026-42897 creates its CSP rule in outboundRules. Running Health Checker after EOMT deployment will NOT show this rule in the report. Administrators cannot use Health Checker as a sole verification tool for EOMT outbound mitigation status. Remediation: In Get-URLRewriteRule.ps1 - read both .rewrite.rules and .rewrite.outboundRules at all three config paths (L49, L72, L97). In Invoke-AnalyzerIISInformation.ps1 - iterate .rule from both collections. "@ -ForegroundColor Yellow Write-Host "[!] FOR AUTHORIZED SECURITY RESEARCH ONLY`n" -ForegroundColor DarkRed