fix: require running WMI before scoped NTLM observations

This commit is contained in:
Shirofune-Security committed 2026-09-22 12:06:11 +09:00
1 parent b162c4e598
commit 6f713e7497
4 files changed
+29 -7

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
# Scoped incoming and domain NTLM auditing
`ntlm-auditing` reads or configures two distinct audit values without invoking the broad `configure` workflow. It never writes an NTLM restriction or exception. Configure requires an explicit selection and elevated native 64-bit PowerShell on reviewed Windows 11 builds 22000/22621/22631/26100/26200 or Server 2022/2025 builds 20348/26100. Product type, domain role and join state must agree; role/build overrides are refused.
`ntlm-auditing` reads or configures two distinct audit values without invoking the broad `configure` workflow. It never writes an NTLM restriction or exception. Configure requires an explicit selection and elevated native 64-bit PowerShell on reviewed Windows 11 builds 22000/22621/22631/26100/26200 or Server 2022/2025 builds 20348/26100. Product type, domain role and join state must agree; role/build overrides are refused. Winmgmt must already be running before any CIM observation.
| Selection | Exact value | Requested setting | Applicability |
| --- | --- | --- | --- |
+1
View File
@@ -1,6 +1,7 @@
# Explicit incoming/domain audit values. Authentication restrictions are separate controls.
function Get-WelaNtlmAuditHost {
if($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitProcess){throw 'Use native64-bit PowerShell on Windows.'}
if((Get-Service -Name Winmgmt -ErrorAction Stop).Status -ne 'Running'){throw 'Winmgmt must already be running; this command never starts services.'}
$os=Get-CimInstance Win32_OperatingSystem -Property BuildNumber,ProductType -ErrorAction Stop
$computer=Get-CimInstance Win32_ComputerSystem -Property Name,Domain,DomainRole,PartOfDomain -ErrorAction Stop
if([string]$os.BuildNumber -notmatch '^\d+$' -or $os.ProductType -notin @(1,2,3) -or $computer.PartOfDomain -isnot [bool] -or $computer.DomainRole -notin @(0,1,2,3,4,5) -or [string]::IsNullOrWhiteSpace($computer.Name)){throw 'Incomplete Windows role/build identity.'}
+4 -1
View File
@@ -83,7 +83,9 @@ try{
}}
# Exercise actual CIM role validation independently of the policy fixture.
$savedOs=$env:OS;$env:OS='Windows_NT'
function Get-CimInstance {param($ClassName,$Property,$ErrorAction)if($ClassName -eq 'Win32_OperatingSystem'){$script:osFixture}else{$script:computerFixture}}
$script:wmiStatus='Running';$script:cimReads=0
function Get-Service {param($Name,$ErrorAction)if($Name -cne 'Winmgmt'){throw 'Unexpected service'};[pscustomobject]@{Status=$script:wmiStatus}}
function Get-CimInstance {param($ClassName,$Property,$ErrorAction)$script:cimReads++;if($ClassName -eq 'Win32_OperatingSystem'){$script:osFixture}else{$script:computerFixture}}
try{
foreach($case in @(@(1,0,$false,26100),@(1,1,$true,26200),@(3,2,$false,20348),@(3,3,$true,26100),@(2,4,$true,20348),@(2,5,$true,26100))){
$script:osFixture=[pscustomobject]@{ProductType=$case[0];BuildNumber=[string]$case[3]};$script:computerFixture=[pscustomobject]@{Name='fixture';Domain='fixture';DomainRole=$case[1];PartOfDomain=$case[2]};$h=&$hostValidator;Assert ($h.ProductType -eq $case[0]) 'Coherent native role/build accepted.'
@@ -91,6 +93,7 @@ try{
foreach($case in @(@(2,2,$false,26100),@(3,4,$true,26100),@(1,1,$false,26100),@(3,3,$false,26100),@(2,5,$true,99999))){
$script:osFixture=[pscustomobject]@{ProductType=$case[0];BuildNumber=[string]$case[3]};$script:computerFixture=[pscustomobject]@{Name='fixture';Domain='fixture';DomainRole=$case[1];PartOfDomain=$case[2]};$refused=$false;try{&$hostValidator}catch{$refused=$true};Assert $refused 'Conflicting or unsupported observed host is refused.'
}
$script:wmiStatus='Stopped';$script:cimReads=0;$refused=$false;try{&$hostValidator}catch{$refused=$_.Exception.Message -match 'must already be running'};Assert ($refused -and $script:cimReads -eq 0) 'Stopped WMI is refused before a CIM observation can start its service.'
}finally{$env:OS=$savedOs}
}finally{Remove-Item -LiteralPath $root -Recurse -Force}
Write-Host "PASS: $count scoped incoming/domain NTLM assertions."
+23 -5
View File
@@ -24,12 +24,30 @@ function Other {
}finally{if($k){$k.Dispose()};$base.Dispose()}
[pscustomobject][ordered]@{Values=$values;Children=$children;Access=$acl;DomainPolicy=Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters' AuditNTLMInDomain;NtlmChannel=Get-WelaNativeChannel 'Microsoft-Windows-NTLM/Operational';SecurityChannel=Get-WelaNativeChannel Security;NetlogonService=[string](Get-Service Netlogon).Status}
}
Add-Type -TypeDefinition @'
using System;using System.IO;using System.Text;using System.Threading.Tasks;
public static class WelaNtlmFixturePipe {
public static async Task<string> Read(TextReader reader){var text=new StringBuilder();var buffer=new char[1024];while(true){int n=await reader.ReadAsync(buffer,0,buffer.Length).ConfigureAwait(false);if(n==0)return text.ToString();if(n>1048576-text.Length)throw new InvalidDataException("Fixture output exceeds one Mi character bound.");text.Append(buffer,0,n);}}
}
'@
function Public([string]$Label,[string[]]$Arguments){
$prior=$ErrorActionPreference
try{$ErrorActionPreference='Continue';$output=& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') ntlm-auditing @Arguments 2>&1|Out-String;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior}
$output|Set-Content -LiteralPath (Join-Path $root ($Label+'.txt')) -Encoding UTF8
Assert ($code -eq 0) "Public $Label exited $code : $output"
Get-Content -Raw -LiteralPath (Join-Path $root ($Label+'.json'))|ConvertFrom-Json
$all=@('-NoLogo','-NoProfile','-NonInteractive','-File',(Join-Path $repo 'WELA.ps1'),'ntlm-auditing')+$Arguments
foreach($a in $all){if($a.Contains('"') -or $a.EndsWith('\') -or $a -match '[\x00-\x1f]'){throw 'Ambiguous fixture argument.'}}
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$engine;$info.Arguments=(@($all|ForEach-Object {'"'+$_+'"'}) -join ' ');$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true
$process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false
try{
if(-not $process.Start()){throw 'Public process did not start.'};$started=$true
$stdout=[WelaNtlmFixturePipe]::Read($process.StandardOutput);$stderr=[WelaNtlmFixturePipe]::Read($process.StandardError)
if(-not $process.WaitForExit(180000)){throw 'Public command exceeded three minutes.'}
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Public output drain timed out.'}
$output=$stdout.Result+"`n"+$stderr.Result
$output|Set-Content -LiteralPath (Join-Path $root ($Label+'.txt')) -Encoding UTF8
Assert ($process.ExitCode -eq 0) "Public $Label exited $($process.ExitCode) : $output"
Get-Content -Raw -LiteralPath (Join-Path $root ($Label+'.json'))|ConvertFrom-Json
}finally{
if($started){$exited=$false;try{$exited=$process.HasExited}catch{$script:errors+=$_.Exception.Message};if(-not $exited){try{$process.Kill()}catch{$script:errors+=$_.Exception.Message};try{$exited=$process.WaitForExit(5000)}catch{$script:errors+=$_.Exception.Message}};if(-not $exited){$script:errors+='Owned public process termination unconfirmed.'}}
$process.Dispose()
}
}
$original=Get-WelaNtlmAuditSnapshot Incoming
Assert ($original.Host.ProductType -eq 3 -and $original.Host.DomainRole -eq 2 -and -not $original.Host.PartOfDomain) 'Actual unjoined disposable Server is required.'