mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-08 15:25:24 +02:00
Integrate outgoing NTLM audit-only behavior for review
This commit is contained in:
commit
8cb4804334
4 files changed
+286
-3
No files matched your search
@@ -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
|
||||
@@ -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" {
|
||||
|
||||
@@ -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)."
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user