mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-15 18:55:57 +02:00
Enable full domain NTLM auditing only on domain controllers
This commit is contained in:
1 parent
8ef938f096
commit
16d88a6f1e
4 files changed
+242
-2
No files matched your search
@@ -0,0 +1,19 @@
|
||||
name: Domain 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 domain NTLM policy without changing host settings
|
||||
shell: ${{ matrix.shell }}
|
||||
run: ./tests/DomainNtlm.Tests.ps1
|
||||
@@ -451,6 +451,12 @@ function AuditLogSetting {
|
||||
}
|
||||
}
|
||||
|
||||
$domainNtlm = Get-WelaDomainNtlmState
|
||||
$auditResult += [WELA]::new(
|
||||
"NTLM Authentication", "Domain NTLM auditing", $domainNtlm.Description, @(),
|
||||
"Not configured", "Enable all (7) on domain controllers only", "",
|
||||
"AuditNTLMInDomain; applicability is determined from Win32_OperatingSystem.ProductType."
|
||||
)
|
||||
$auditResult | ForEach-Object { $_.CountByLevel() }
|
||||
|
||||
$auditResult | ForEach-Object {
|
||||
@@ -978,6 +984,90 @@ function UpdateRules {
|
||||
}
|
||||
}
|
||||
|
||||
function Get-WelaDomainNtlmState {
|
||||
# ProductType distinguishes an actual DC from a member server with AD DS tools installed.
|
||||
$state = [pscustomobject]@{
|
||||
Applicable = $false
|
||||
Readable = $false
|
||||
Value = $null
|
||||
Description = 'Unknown (computer role could not be determined)'
|
||||
}
|
||||
try {
|
||||
$os = Get-CimInstance -ClassName Win32_OperatingSystem -Property ProductType -ErrorAction Stop
|
||||
switch ($os.ProductType) {
|
||||
1 { $state.Description = 'Not applicable (Windows client)'; return $state }
|
||||
2 { $state.Applicable = $true }
|
||||
3 { $state.Description = 'Not applicable (member or standalone server, including non-DC AD CS)'; return $state }
|
||||
default { return $state }
|
||||
}
|
||||
} catch {
|
||||
$state.Description = "Unknown (computer role query failed: $($_.Exception.Message))"
|
||||
return $state
|
||||
}
|
||||
try {
|
||||
$path = 'HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters'
|
||||
$state.Description = 'Not configured'
|
||||
if (Test-Path -LiteralPath $path -ErrorAction Stop) {
|
||||
$properties = Get-ItemProperty -LiteralPath $path -ErrorAction Stop
|
||||
$property = $properties.PSObject.Properties['AuditNTLMInDomain']
|
||||
if ($null -ne $property) {
|
||||
$state.Value = $property.Value
|
||||
$state.Description = switch ($state.Value) {
|
||||
0 { 'Disabled (0)' }
|
||||
7 { 'Enable all (7)' }
|
||||
default { "Value $($state.Value) (not interpreted as Enable all)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
$state.Readable = $true
|
||||
} catch {
|
||||
$state.Description = "Unknown (domain NTLM registry read failed: $($_.Exception.Message))"
|
||||
}
|
||||
return $state
|
||||
}
|
||||
|
||||
function Set-WelaDomainNtlmAudit {
|
||||
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||
param ([switch]$Auto)
|
||||
$state = Get-WelaDomainNtlmState
|
||||
Write-Host "Domain NTLM auditing: $($state.Description)"
|
||||
if (-not $state.Applicable) {
|
||||
Write-Host '[SKIPPED] Domain NTLM policy is only changed on a confirmed domain controller.' -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
if (-not $state.Readable) {
|
||||
throw 'Domain NTLM policy was not changed because its current state could not be read.'
|
||||
}
|
||||
if ($state.Value -eq 7) {
|
||||
Write-Host '[SKIPPED] Domain NTLM auditing is already Enable all (7).' -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
$path = 'HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters'
|
||||
if (-not $PSCmdlet.ShouldProcess("$path\AuditNTLMInDomain", 'Set domain NTLM auditing to Enable all (7)')) { return }
|
||||
if (-not $Auto) {
|
||||
$response = Read-Host "Change domain NTLM auditing from '$($state.Description)' to 'Enable all (7)'? (Y/n)"
|
||||
if ($response -ne '' -and $response -ne 'Y') {
|
||||
Write-Host '[SKIPPED] Domain NTLM auditing.' -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 AuditNTLMInDomain -Value 7 -Type DWord -ErrorAction Stop
|
||||
$after = Get-WelaDomainNtlmState
|
||||
if (-not $after.Applicable -or -not $after.Readable -or $after.Value -ne 7) {
|
||||
throw "Read-back did not confirm Enable all (7). Observed: $($after.Description)"
|
||||
}
|
||||
Write-Host '[OK] Domain NTLM auditing: Enable all (7), registry value verified.' -ForegroundColor Green
|
||||
Write-Host 'Group Policy or MDM may reapply a different value; validate events on the domain controller.'
|
||||
} catch {
|
||||
throw "Domain NTLM configuration failed: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function Set-RegistryConfig {
|
||||
# レジストリを変更するため -WhatIf / -Confirm に対応する
|
||||
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
|
||||
@@ -1294,11 +1384,12 @@ function ConfigureAuditSettings {
|
||||
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}
|
||||
@{Path = "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0"; Name = "AuditReceivingNTLMTraffic"; Value = 2}
|
||||
)
|
||||
Set-RegistryConfig -RegPaths $regPaths -Auto:$Auto
|
||||
|
||||
Set-WelaDomainNtlmAudit -Auto:$Auto
|
||||
|
||||
# LDAP query logging (Directory Service EventID 1644) - domain controllers only.
|
||||
# "15 Field Engineering" = 5 makes expensive / inefficient LDAP searches log as 1644, which surfaces
|
||||
# BloodHound / SharpHound-style directory reconnaissance. Only applied where the NTDS role is present.
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Safe unit regressions: load function definitions without dispatching WELA or changing host policy.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot '../WELA.ps1'), [ref]$tokens, [ref]$parseErrors)
|
||||
if ($parseErrors.Count) { throw ($parseErrors | Out-String) }
|
||||
foreach ($functionName in @('Get-WelaDomainNtlmState', 'Set-WelaDomainNtlmAudit')) {
|
||||
$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, $ProductType = 2) {
|
||||
$script:value = $Value
|
||||
$script:productType = $ProductType
|
||||
$script:writes = 0
|
||||
$script:prompts = 0
|
||||
$script:reads = 0
|
||||
$script:keyExists = $true
|
||||
$script:roleFails = $false
|
||||
$script:readFails = $false
|
||||
$script:writeFails = $false
|
||||
$script:ignoreWrite = $false
|
||||
$script:response = 'Y'
|
||||
}
|
||||
function Get-CimInstance {
|
||||
param($ClassName, $Property, $ErrorAction)
|
||||
if ($script:roleFails) { throw 'CIM unavailable' }
|
||||
return [pscustomobject]@{ ProductType = $script:productType }
|
||||
}
|
||||
function Test-Path { param($LiteralPath, $ErrorAction) return $script:keyExists }
|
||||
function Get-ItemProperty {
|
||||
param($LiteralPath, $ErrorAction)
|
||||
$script:reads++
|
||||
if ($script:readFails) { throw 'Access denied' }
|
||||
if ($null -eq $script:value) { return [pscustomobject]@{} }
|
||||
return [pscustomobject]@{ AuditNTLMInDomain = $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' }
|
||||
if ($Name -ne 'AuditNTLMInDomain') { throw "Unexpected write: $Name" }
|
||||
$script:writes++
|
||||
if (-not $script:ignoreWrite) { $script:value = $Value }
|
||||
}
|
||||
function Read-Host { param($Prompt) $script:prompts++; return $script:response }
|
||||
|
||||
$script:assertions = 0
|
||||
foreach ($initial in @($null, 0, 2, 7)) {
|
||||
Reset-Policy $initial
|
||||
Set-WelaDomainNtlmAudit -Auto
|
||||
Assert-Equal $script:value 7 "DC initial value '$initial' reaches Enable all"
|
||||
$expectedWrites = if ($initial -eq 7) { 0 } else { 1 }
|
||||
Assert-Equal $script:writes $expectedWrites 'Already configured DCs are idempotent'
|
||||
}
|
||||
Reset-Policy 2
|
||||
Assert-Equal ((Get-WelaDomainNtlmState).Description) 'Value 2 (not interpreted as Enable all)' 'Migration reports old value without inventing semantics'
|
||||
foreach ($role in @(1, 3)) {
|
||||
Reset-Policy 2 $role
|
||||
Set-WelaDomainNtlmAudit -Auto
|
||||
Assert-Equal $script:writes 0 "No domain policy writes on non-DC ProductType $role"
|
||||
Assert-Equal $script:reads 0 'Non-DC domain registry is not read'
|
||||
Assert-Equal ((Get-WelaDomainNtlmState).Description -like 'Not applicable*') $true 'Non-DC is reported as not applicable'
|
||||
Assert-Equal $script:value 2 'Existing non-DC value is preserved'
|
||||
}
|
||||
foreach ($role in @($null, 0, 42)) {
|
||||
Reset-Policy 2 $role
|
||||
Set-WelaDomainNtlmAudit -Auto
|
||||
Assert-Equal $script:writes 0 'Unknown role never receives domain policy'
|
||||
Assert-Equal ((Get-WelaDomainNtlmState).Description -like 'Unknown*') $true 'Unknown role is not labeled non-DC'
|
||||
}
|
||||
Reset-Policy 2
|
||||
$script:roleFails = $true
|
||||
Set-WelaDomainNtlmAudit -Auto
|
||||
Assert-Equal $script:writes 0 'Failed role discovery never receives domain policy'
|
||||
Assert-Equal ((Get-WelaDomainNtlmState).Description -like 'Unknown*query failed*') $true 'Role errors are visible'
|
||||
Reset-Policy 2
|
||||
$script:readFails = $true
|
||||
Assert-Throws { Set-WelaDomainNtlmAudit -Auto } 'Unreadable DC policy fails visibly'
|
||||
Assert-Equal $script:writes 0 'Unreadable current value is preserved'
|
||||
Reset-Policy $null
|
||||
$script:keyExists = $false
|
||||
Set-WelaDomainNtlmAudit -Auto
|
||||
Assert-Equal $script:keyExists $true 'Missing DC policy key is created'
|
||||
Assert-Equal $script:value 7 'Missing DC key receives Enable all'
|
||||
Reset-Policy 2
|
||||
Set-WelaDomainNtlmAudit -WhatIf
|
||||
Assert-Equal $script:writes 0 'WhatIf preserves policy'
|
||||
Assert-Equal $script:prompts 0 'WhatIf does not prompt without Auto'
|
||||
$script:response = 'n'
|
||||
Set-WelaDomainNtlmAudit
|
||||
Assert-Equal $script:writes 0 'Declining preserves policy'
|
||||
$script:response = ''
|
||||
Set-WelaDomainNtlmAudit
|
||||
Assert-Equal $script:value 7 'Confirming applies policy'
|
||||
Reset-Policy 2
|
||||
$script:writeFails = $true
|
||||
Assert-Throws { Set-WelaDomainNtlmAudit -Auto } 'Write failure propagates'
|
||||
Reset-Policy 2
|
||||
$script:ignoreWrite = $true
|
||||
Assert-Throws { Set-WelaDomainNtlmAudit -Auto } 'Read-back mismatch propagates'
|
||||
Write-Host "PASS: $script:assertions domain NTLM assertions (mocked; no host changes)."
|
||||
@@ -36,6 +36,25 @@ Check the Windows event log file size with Yamato Security's recommendations and
|
||||
## configure
|
||||
The `configure` command sets the recommended Windows event log audit policy and file size.
|
||||
|
||||
Domain NTLM auditing (`AuditNTLMInDomain`) is set to `7` (**Enable all**) only on
|
||||
confirmed domain controllers. Windows clients, member/standalone servers and
|
||||
non-DC AD CS servers report **Not applicable** and retain any existing value.
|
||||
An unavailable or unknown computer role is reported as **Unknown** and skipped.
|
||||
Role detection uses `Win32_OperatingSystem.ProductType=2`, not the presence of
|
||||
AD DS tools or a registry key. Before a change, WELA reports the previous numeric
|
||||
value; legacy value `2` is not described as full auditing. Changes respect the
|
||||
usual confirmation prompt or `-Auto` and are verified by a registry read-back.
|
||||
`audit-settings` includes role applicability and the current value in its console
|
||||
and CSV output. Incoming and outgoing NTLM controls are separate from this policy.
|
||||
|
||||
The [Microsoft NTLM auditing guidance](https://learn.microsoft.com/en-us/defender-for-identity/deploy/configure-windows-event-collection#configure-ntlm-auditing)
|
||||
describes the policy and event collection prerequisites. A registry read-back
|
||||
does not prove that events were generated, and GPO or MDM may overwrite a local
|
||||
change. Validate benign domain NTLM activity and expected Operational events on
|
||||
an isolated DC before deployment. `tests/DomainNtlm.Tests.ps1` uses mocked OS and
|
||||
registry access; the associated Windows workflow runs Windows PowerShell 5.1 and
|
||||
PowerShell 7 without changing host policy.
|
||||
|
||||
#### `configure` command examples
|
||||
Apply Yamato Security's recommended settings (with confirmation prompt before changing settings):
|
||||
```
|
||||
|
||||
Reference in new issue
Block a user