Integrate outgoing NTLM audit-only behavior for review

This commit is contained in:
Shirofune-Security committed 2026-09-18 21:51:29 +09:00
commit 8cb4804334
4 files changed
+286 -3

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
name: Outgoing NTLM regressions
on:
push:
branches: ['**']
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
test:
runs-on: windows-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- 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
+131 -3
View File
@@ -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 <PreserveOrAudit|Audit|Deny>]"
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" {
+106
View File
@@ -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)."
+30
View File
@@ -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.