From ade681ff1b9af78ed1c15c5f955d3830f9519770 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:43:17 +0900 Subject: [PATCH 1/2] Make outgoing NTLM configuration audit-only by default --- .github/workflows/test-outgoing-ntlm.yml | 19 ++++ WELA.ps1 | 134 ++++++++++++++++++++++- tests/OutgoingNtlm.Tests.ps1 | 106 ++++++++++++++++++ website/docs/commands/usage.md | 30 +++++ 4 files changed, 286 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/test-outgoing-ntlm.yml create mode 100644 tests/OutgoingNtlm.Tests.ps1 diff --git a/.github/workflows/test-outgoing-ntlm.yml b/.github/workflows/test-outgoing-ntlm.yml new file mode 100644 index 00000000..73df9a8a --- /dev/null +++ b/.github/workflows/test-outgoing-ntlm.yml @@ -0,0 +1,19 @@ +name: Outgoing NTLM regressions +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + test: + runs-on: windows-latest + strategy: + matrix: + shell: [powershell, pwsh] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Test outgoing NTLM policy without changing host settings + shell: ${{ matrix.shell }} + run: ./tests/OutgoingNtlm.Tests.ps1 diff --git a/WELA.ps1 b/WELA.ps1 index a5e00cd6..601a2a32 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -4,6 +4,8 @@ [switch]$Debug, [string]$Baseline, [switch]$Auto, + [ValidateSet("PreserveOrAudit", "Audit", "Deny")] + [string]$OutgoingNtlmMode = "PreserveOrAudit", [switch]$Help ) @@ -437,6 +439,12 @@ function AuditLogSetting { $_ | Add-Member -MemberType NoteProperty -Name "ideal" -Value $false } $auditResult = BuildAuditResult -all_rules $all_rules -Baseline $Baseline -enabledguid $enabledguid + $outgoingNtlm = Get-WelaOutgoingNtlmState + $auditResult += [WELA]::new( + "NTLM Authentication", "Outgoing NTLM policy", $outgoingNtlm.Description, @(), + "Not configured (Allow all)", "Audit all (1); preserve intentional Deny all (2)", "", + "RestrictSendingNTLMTraffic. Policy source: $($outgoingNtlm.PolicySource)" + ) # ベースラインが扱っていないサブカテゴリでも、そのサブカテゴリが有効ならルールは動く。 # ルール自身が持つ subcategory_guids を見て救済する。 @@ -1030,9 +1038,126 @@ function Set-RegistryConfig { } +function Get-WelaOutgoingNtlmPolicySource { + # RSoP is a last-applied policy snapshot, not proof of the current registry writer. + $key = 'SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0' + $name = 'RestrictSendingNTLMTraffic' + $matches = @() + foreach ($class in @('RSOP_RegistryPolicySetting', 'RSOP_SecuritySettingNumeric')) { + try { + $matches += @(Get-CimInstance -Namespace 'root\RSOP\Computer' -ClassName $class -ErrorAction Stop | + Where-Object { + $normalizedKey = $_.keyName -replace '^(MACHINE|HKEY_LOCAL_MACHINE|HKLM)\\', '' + ($normalizedKey -eq $key -and $_.valueName -eq $name) -or + $normalizedKey -eq "$key\$name" + }) + } catch { + # RSoP may be unavailable, including on standalone computers. Never infer "local". + } + } + $policy = $matches | Sort-Object precedence | Select-Object -First 1 + if ($policy -and $policy.GPOID) { + return "Last-applied RSoP GPO: $($policy.GPOID) (may be stale; current registry writer unknown)" + } + return 'Unknown (no matching RSoP source available; local, GPO or MDM provenance is not established)' +} + +function Get-WelaOutgoingNtlmState { + $path = 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0' + $name = 'RestrictSendingNTLMTraffic' + $value = $null + $readable = $true + $description = 'Not configured (Allow all)' + try { + if (Test-Path -LiteralPath $path -ErrorAction Stop) { + # Reading the key distinguishes an absent value from a failed read. + $properties = Get-ItemProperty -LiteralPath $path -ErrorAction Stop + $property = $properties.PSObject.Properties[$name] + if ($null -ne $property) { + $value = $property.Value + $description = switch ($value) { + 0 { 'Allow all (0)' } + 1 { 'Audit all (1)' } + 2 { 'Deny all (2): authentication restriction, with block events' } + default { "Unknown registry value ($value)" } + } + } + } + } catch { + $readable = $false + $description = "Unknown (registry read failed: $($_.Exception.Message))" + } + [pscustomobject]@{ + Value = $value + Readable = $readable + Description = $description + PolicySource = Get-WelaOutgoingNtlmPolicySource + } +} + +function Set-WelaOutgoingNtlmPolicy { + [CmdletBinding(SupportsShouldProcess = $true)] + param ( + [ValidateSet('PreserveOrAudit', 'Audit', 'Deny')] + [string]$Mode = 'PreserveOrAudit', + [switch]$Auto + ) + $path = 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0' + $name = 'RestrictSendingNTLMTraffic' + $state = Get-WelaOutgoingNtlmState + Write-Host "Outgoing NTLM: $($state.Description)" + Write-Host "Policy source: $($state.PolicySource)" + if (-not $state.Readable) { + throw 'Outgoing NTLM was not changed because its current state could not be read.' + } + if ($Mode -eq 'PreserveOrAudit' -and $state.Value -eq 2) { + Write-Host '[PRESERVED] Existing Deny all enforcement. Use -OutgoingNtlmMode Audit to explicitly replace it.' -ForegroundColor Yellow + return + } + if ($Mode -eq 'PreserveOrAudit' -and $null -ne $state.Value -and $state.Value -notin @(0, 1, 2)) { + Write-Warning 'Unknown outgoing NTLM value was preserved. Select an explicit -OutgoingNtlmMode after reviewing policy.' + return + } + $desired = if ($Mode -eq 'Deny') { 2 } else { 1 } + $description = if ($desired -eq 2) { 'Deny all (2): restrict outgoing NTLM authentication' } else { 'Audit all (1): log outgoing NTLM without denying it' } + if ($state.Value -eq $desired) { + Write-Host "[SKIPPED] Outgoing NTLM is already $description." -ForegroundColor Yellow + return + } + if ($desired -eq 2) { + Write-Warning 'Explicit Deny mode can break NTLM authentication. This is enforcement, not audit-only configuration.' + } + if (-not $PSCmdlet.ShouldProcess("$path\$name", $description)) { return } + if (-not $Auto) { + $response = Read-Host "Change outgoing NTLM from '$($state.Description)' to '$description'? (Y/n)" + if ($response -ne '' -and $response -ne 'Y') { + Write-Host '[SKIPPED] Outgoing NTLM.' -ForegroundColor Yellow + return + } + } + try { + if (-not (Test-Path -LiteralPath $path -ErrorAction Stop)) { + New-Item -Path $path -Force -ErrorAction Stop | Out-Null + } + Set-ItemProperty -LiteralPath $path -Name $name -Value $desired -Type DWord -ErrorAction Stop + $after = Get-WelaOutgoingNtlmState + if (-not $after.Readable -or $after.Value -ne $desired) { + throw "Read-back did not match requested value $desired. Observed: $($after.Description)" + } + Write-Host "[OK] Outgoing NTLM: $($after.Description)" -ForegroundColor Green + Write-Host "Policy source: $($after.PolicySource)" + Write-Host 'Registry state was verified; Group Policy or MDM may reapply a different value.' + } catch { + throw "Outgoing NTLM configuration failed: $($_.Exception.Message)" + } +} + + function ConfigureAuditSettings { param ( [switch] $Auto, + [ValidateSet("PreserveOrAudit", "Audit", "Deny")] + [string] $OutgoingNtlmMode = "PreserveOrAudit", [switch] $Debug ) @@ -1289,11 +1414,13 @@ function ConfigureAuditSettings { } Write-Host "" + # Outgoing restriction and audit-only modes must be selected independently. + Set-WelaOutgoingNtlmPolicy -Mode $OutgoingNtlmMode -Auto:$Auto + # NTLM認証の監査設定 Write-Host "Configuring NTLM Audit Settings..." Write-Host "" $regPaths = @( - @{Path = "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0"; Name = "RestrictSendingNTLMTraffic"; Value = 2}, @{Path = "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0"; Name = "AuditReceivingNTLMTraffic"; Value = 2}, @{Path = "HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters"; Name = "AuditNTLMInDomain"; Value = 2} ) @@ -1774,10 +1901,11 @@ switch ($Cmd.ToLower()) { if ($Help){ Write-Host "Configure Windows Event Log audit settings based on the YamatoSecurity baseline" Write-Host "" - Write-Host "Usage: ./WELA.ps1 configure [-Auto]" + Write-Host "Usage: ./WELA.ps1 configure [-Auto] [-OutgoingNtlmMode ]" Write-Host "" Write-Host "Options:" Write-Host " -Auto Automatically configure without prompts" + Write-Host " -OutgoingNtlmMode PreserveOrAudit (default): audit, preserving existing deny; Audit: explicitly replace deny; Deny: opt into enforcement" Write-Host "" Write-Host "Note: only the YamatoSecurity baseline is currently supported for 'configure'." Write-Host "" @@ -1788,7 +1916,7 @@ switch ($Cmd.ToLower()) { Write-Host "Re-run with '-Baseline YamatoSecurity' (or omit -Baseline) if that is what you want." break } - ConfigureAuditSettings -Auto:$Auto -Debug:$Debug + ConfigureAuditSettings -Auto:$Auto -Debug:$Debug -OutgoingNtlmMode $OutgoingNtlmMode } "configure-sacl" { diff --git a/tests/OutgoingNtlm.Tests.ps1 b/tests/OutgoingNtlm.Tests.ps1 new file mode 100644 index 00000000..fe26668f --- /dev/null +++ b/tests/OutgoingNtlm.Tests.ps1 @@ -0,0 +1,106 @@ +# Safe unit regressions: load only function definitions, never dispatch WELA or touch Windows policy. +$ErrorActionPreference = 'Stop' +$sourcePath = Join-Path $PSScriptRoot '../WELA.ps1' +$tokens = $null +$parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($sourcePath, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count) { throw ($parseErrors | Out-String) } +foreach ($functionName in @('Get-WelaOutgoingNtlmPolicySource', 'Get-WelaOutgoingNtlmState', 'Set-WelaOutgoingNtlmPolicy')) { + $definition = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $functionName }, $true) + if (-not $definition) { throw "Missing function $functionName" } + . ([scriptblock]::Create($definition.Extent.Text)) +} +function Assert-Equal($Actual, $Expected, [string]$Message) { + if ($Actual -cne $Expected) { throw "$Message. Expected '$Expected', got '$Actual'." } + $script:assertions++ +} +function Assert-Throws([scriptblock]$Action, [string]$Message) { + $threw = $false + try { & $Action } catch { $threw = $true } + Assert-Equal $threw $true $Message +} +function Reset-Policy($Value) { + $script:value = $Value + $script:keyExists = $true + $script:writes = 0 + $script:prompts = 0 + $script:readFails = $false + $script:writeFails = $false + $script:ignoreWrite = $false + $script:response = 'Y' + $script:rsop = @() +} +function Test-Path { param($LiteralPath, $ErrorAction) return $script:keyExists } +function Get-ItemProperty { + param($LiteralPath, $ErrorAction) + if ($script:readFails) { throw 'Access denied' } + if ($null -eq $script:value) { return [pscustomobject]@{} } + return [pscustomobject]@{ RestrictSendingNTLMTraffic = $script:value } +} +function New-Item { param($Path, [switch]$Force, $ErrorAction) $script:keyExists = $true } +function Set-ItemProperty { + param($LiteralPath, $Name, $Value, $Type, $ErrorAction) + if ($script:writeFails) { throw 'Access denied' } + $script:writes++ + 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 } + +$script:assertions = 0 +foreach ($initial in @($null, 0, 1)) { + Reset-Policy $initial + Set-WelaOutgoingNtlmPolicy -Auto + Assert-Equal $script:value 1 "Default audits initial value '$initial'" + $expectedWrites = if ($initial -eq 1) { 0 } else { 1 } + Assert-Equal $script:writes $expectedWrites 'Already audited hosts are idempotent' +} +Reset-Policy $null +$script:keyExists = $false +Set-WelaOutgoingNtlmPolicy -Auto +Assert-Equal $script:keyExists $true 'Missing key is created' +Assert-Equal $script:value 1 'Missing key gets audit mode' +Reset-Policy 2 +Set-WelaOutgoingNtlmPolicy -Auto +Assert-Equal $script:value 2 'Auto preserves intentional deny' +Assert-Equal $script:writes 0 'Auto does not rewrite deny' +Assert-Equal ((Get-WelaOutgoingNtlmState).Description -like 'Deny all*authentication restriction*') $true 'Deny is reported as enforcement' +Set-WelaOutgoingNtlmPolicy -Mode Audit -Auto +Assert-Equal $script:value 1 'Explicit Audit may replace deny' +Set-WelaOutgoingNtlmPolicy -Mode Deny -Auto +Assert-Equal $script:value 2 'Only explicit Deny opts into enforcement' +Reset-Policy 42 +Set-WelaOutgoingNtlmPolicy -Auto +Assert-Equal $script:writes 0 'Unknown value is preserved' +Assert-Equal ((Get-WelaOutgoingNtlmState).Description) 'Unknown registry value (42)' 'Unknown values are reported honestly' +Reset-Policy 0 +$script:readFails = $true +Assert-Throws { Set-WelaOutgoingNtlmPolicy -Auto } 'Unreadable policy fails visibly' +Assert-Equal $script:writes 0 'Unreadable policy is never overwritten' +Reset-Policy 0 +Set-WelaOutgoingNtlmPolicy -WhatIf +Assert-Equal $script:writes 0 'WhatIf does not mutate policy' +Assert-Equal $script:prompts 0 'WhatIf does not prompt without Auto' +Reset-Policy 2 +$script:response = 'n' +Set-WelaOutgoingNtlmPolicy -Mode Audit +Assert-Equal $script:writes 0 'Declining preserves deny' +$script:response = '' +Set-WelaOutgoingNtlmPolicy -Mode Audit +Assert-Equal $script:value 1 'Confirmed explicit override succeeds' +Reset-Policy 0 +$script:writeFails = $true +Assert-Throws { Set-WelaOutgoingNtlmPolicy -Auto } 'Write failure propagates' +Reset-Policy 0 +$script:ignoreWrite = $true +Assert-Throws { Set-WelaOutgoingNtlmPolicy -Auto } 'Read-back mismatch propagates' +Reset-Policy 0 +Assert-Equal ((Get-WelaOutgoingNtlmState).PolicySource -like 'Unknown*') $true 'No RSoP does not imply local provenance' +$script:rsop = @( + [pscustomobject]@{ keyName = 'SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0'; valueName = 'RestrictSendingNTLMTraffic'; precedence = 2; GPOID = 'Lower priority GPO' }, + [pscustomobject]@{ keyName = 'HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0'; valueName = 'RestrictSendingNTLMTraffic'; precedence = 1; GPOID = 'Winning GPO' }, + [pscustomobject]@{ keyName = 'SYSTEM\Other'; valueName = 'RestrictSendingNTLMTraffic'; precedence = 0; GPOID = 'Unrelated GPO' } +) +$source = (Get-WelaOutgoingNtlmState).PolicySource +Assert-Equal ($source -like 'Last-applied RSoP GPO: Winning GPO*may be stale*') $true 'Matching RSoP priority and freshness limits are reported' +Write-Host "PASS: $script:assertions outgoing NTLM assertions (mocked; no host changes)." diff --git a/website/docs/commands/usage.md b/website/docs/commands/usage.md index 7843242e..d2747921 100644 --- a/website/docs/commands/usage.md +++ b/website/docs/commands/usage.md @@ -53,3 +53,33 @@ Update WELA's Sigma rules config files: ``` ./WELA.ps1 update-rules ``` + +### Outgoing NTLM auditing and restrictions + +`configure` defaults to audit-only outgoing NTLM (`RestrictSendingNTLMTraffic=1`). +An existing `Deny all` value (`2`) is preserved, including with `-Auto`. Unknown +values and unreadable policy are also preserved for review. + +```powershell +# Audit outgoing NTLM, preserving an existing restriction. +./WELA.ps1 configure -Auto +# Explicitly replace an existing restriction with audit-only mode. +./WELA.ps1 configure -OutgoingNtlmMode Audit -Auto +# Explicitly opt into denying outgoing NTLM (can break authentication). +./WELA.ps1 configure -OutgoingNtlmMode Deny +``` + +`-OutgoingNtlmMode PreserveOrAudit` is the default. `Audit` and `Deny` are explicit +operator choices; omitting `-Auto` asks before changing the policy. This option +only affects outgoing NTLM. Incoming and domain auditing remain separate controls. +`audit-settings` includes the current outgoing NTLM value and distinguishes audit +from enforcement in its console and CSV results. Policy provenance is reported as +last-applied RSoP GPO data when available, otherwise **Unknown**. RSoP can be stale, +and neither it nor a registry read proves which component last wrote a value. +After a change WELA verifies the registry value; GPO or MDM can subsequently +reapply another value. Validate benign NTLM events in +`Microsoft-Windows-NTLM/Operational` on an isolated Windows host before deployment. + +See [Microsoft's outgoing NTLM policy documentation](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/security-policy-settings/network-security-restrict-ntlm-outgoing-ntlm-traffic-to-remote-servers). +The safe mocked regression script is `tests/OutgoingNtlm.Tests.ps1`; its Windows +workflow runs both Windows PowerShell 5.1 and PowerShell 7. From ca54b5cf742e6f3c959d2009d32c4473cfa2b7b7 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:49:46 +0900 Subject: [PATCH 2/2] Use explicit PowerShell shells in regression workflow --- .github/workflows/test-outgoing-ntlm.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test-outgoing-ntlm.yml b/.github/workflows/test-outgoing-ntlm.yml index 73df9a8a..6d0a6b8a 100644 --- a/.github/workflows/test-outgoing-ntlm.yml +++ b/.github/workflows/test-outgoing-ntlm.yml @@ -9,11 +9,11 @@ permissions: jobs: test: runs-on: windows-latest - strategy: - matrix: - shell: [powershell, pwsh] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Test outgoing NTLM policy without changing host settings - shell: ${{ matrix.shell }} + - name: Test outgoing NTLM policy in Windows PowerShell 5.1 + shell: powershell + run: ./tests/OutgoingNtlm.Tests.ps1 + - name: Test outgoing NTLM policy in PowerShell 7 + shell: pwsh run: ./tests/OutgoingNtlm.Tests.ps1