From bc9e098c8113588d183531a32a3b4923c704449f Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:09:37 +0900 Subject: [PATCH] Recheck outgoing NTLM enforcement after confirmation --- WELA.ps1 | 18 ++++++++++++++++++ tests/OutgoingNtlm.Tests.ps1 | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/WELA.ps1 b/WELA.ps1 index 601a2a32..f018d149 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -1136,6 +1136,24 @@ function Set-WelaOutgoingNtlmPolicy { } } try { + # A prompt or ShouldProcess confirmation may outlive a Group Policy refresh. + # Recheck immediately before mutation so default audit setup cannot undo new enforcement. + $freshState = Get-WelaOutgoingNtlmState + if (-not $freshState.Readable) { + throw 'Outgoing NTLM was not changed because its current state became unreadable.' + } + if ($Mode -eq 'PreserveOrAudit' -and $freshState.Value -eq 2) { + Write-Host '[PRESERVED] Deny all enforcement appeared before the write. Select explicit Audit mode to replace it.' -ForegroundColor Yellow + return + } + if ($Mode -eq 'PreserveOrAudit' -and $null -ne $freshState.Value -and $freshState.Value -notin @(0, 1, 2)) { + Write-Warning "Outgoing NTLM changed to an unknown value ($($freshState.Value)); it was preserved." + return + } + if ($freshState.Value -eq $desired) { + Write-Host "[SKIPPED] Outgoing NTLM is now already $description." -ForegroundColor Yellow + return + } if (-not (Test-Path -LiteralPath $path -ErrorAction Stop)) { New-Item -Path $path -Force -ErrorAction Stop | Out-Null } diff --git a/tests/OutgoingNtlm.Tests.ps1 b/tests/OutgoingNtlm.Tests.ps1 index fe26668f..e2065b8b 100644 --- a/tests/OutgoingNtlm.Tests.ps1 +++ b/tests/OutgoingNtlm.Tests.ps1 @@ -29,10 +29,15 @@ function Reset-Policy($Value) { $script:ignoreWrite = $false $script:response = 'Y' $script:rsop = @() + $script:onPrompt = $null + $script:onRead = $null + $script:reads = 0 } function Test-Path { param($LiteralPath, $ErrorAction) return $script:keyExists } function Get-ItemProperty { param($LiteralPath, $ErrorAction) + $script:reads++ + if ($script:onRead) { & $script:onRead } if ($script:readFails) { throw 'Access denied' } if ($null -eq $script:value) { return [pscustomobject]@{} } return [pscustomobject]@{ RestrictSendingNTLMTraffic = $script:value } @@ -45,7 +50,7 @@ function Set-ItemProperty { if (-not $script:ignoreWrite) { $script:value = $Value } } function Get-CimInstance { param($Namespace, $ClassName, $ErrorAction) if ($ClassName -eq 'RSOP_RegistryPolicySetting') { return $script:rsop } } -function Read-Host { param($Prompt) $script:prompts++; return $script:response } +function Read-Host { param($Prompt) $script:prompts++; if ($script:onPrompt) { & $script:onPrompt }; return $script:response } $script:assertions = 0 foreach ($initial in @($null, 0, 1)) { @@ -88,6 +93,34 @@ Assert-Equal $script:writes 0 'Declining preserves deny' $script:response = '' Set-WelaOutgoingNtlmPolicy -Mode Audit Assert-Equal $script:value 1 'Confirmed explicit override succeeds' +# A user confirmation must not authorize replacing enforcement that appeared during the prompt. +foreach ($changed in @(2, 42)) { + Reset-Policy 0 + $script:changedDuringPrompt = $changed + $script:onPrompt = { $script:value = $script:changedDuringPrompt } + Set-WelaOutgoingNtlmPolicy + Assert-Equal $script:prompts 1 'State changes while the user is confirming' + Assert-Equal $script:value $changed 'Default mode preserves newly applied deny or unknown policy' + Assert-Equal $script:writes 0 'A stale initial read never authorizes replacing new enforcement' +} +Reset-Policy 0 +$script:onPrompt = { $script:readFails = $true } +Assert-Throws { Set-WelaOutgoingNtlmPolicy } 'State becoming unreadable during confirmation aborts' +Assert-Equal $script:writes 0 'Unreadable refreshed state is not overwritten' +Reset-Policy 0 +$script:onPrompt = { $script:value = 2 } +Set-WelaOutgoingNtlmPolicy -Mode Audit +Assert-Equal $script:value 1 'Explicit Audit still authorizes replacing deny introduced during confirmation' +Assert-Equal $script:writes 1 'Explicit override writes once after a fresh readable state' +Reset-Policy 0 +$script:onPrompt = { $script:value = 1 } +Set-WelaOutgoingNtlmPolicy +Assert-Equal $script:writes 0 'A concurrently applied audit setting is not redundantly rewritten' +Reset-Policy 0 +$script:onRead = { if ($script:reads -eq 2) { $script:value = 2 } } +Set-WelaOutgoingNtlmPolicy -Auto +Assert-Equal $script:value 2 'Auto also preserves deny introduced after the initial read' +Assert-Equal $script:writes 0 'Auto rechecks immediately before writing' Reset-Policy 0 $script:writeFails = $true Assert-Throws { Set-WelaOutgoingNtlmPolicy -Auto } 'Write failure propagates'