Guard WMI inheritance with bounded descendant observations

This commit is contained in:
Shirofune-Security committed 2026-09-22 14:33:54 +09:00
1 parent 7ef29df61f
commit ea184e5e95
6 files changed
+313 -5

No files matched your search

+33 -5
View File
@@ -1,4 +1,5 @@
# Opt-in local namespace SACLs. No namespace DACL, audit policy, or remote-access changes.
. (Join-Path $PSScriptRoot 'WmiNamespaceDescendants.ps1')
function Get-WelaWmiAuditDefinitions {
param([string[]]$Namespace, [switch]$IncludeChildren)
$source = 'https://github.com/AustralianCyberSecurityCentre/windows_event_logging/blob/59041b5d4586789a751171fb752be1624ad5e3b4/events/wmi_auditing/wmi_auditing.ps1'
@@ -256,10 +257,14 @@ function Get-WelaWmiAuditPlan {
foreach ($name in @($definitions | Select-Object -ExpandProperty Namespace -Unique)) {
$selected = @($definitions | Where-Object Namespace -eq $name)
try {
$snapshot = Get-WelaWmiNamespaceSnapshot $name
$tree=$null
if(@($selected|Where-Object {($_.AceFlags -band 2) -ne 0}).Count){
$tree=Get-WelaWmiStableDescendants $name
$snapshot=$tree.Root
} else {$snapshot = Get-WelaWmiNamespaceSnapshot $name}
$descriptor = $snapshot.DescriptorJson | ConvertFrom-Json
$missing = @(Get-WelaWmiMissingAces $descriptor $selected)
[pscustomobject]@{ Namespace = $name; Status = $(if ($missing.Count) { 'ChangeRequired' } else { 'AlreadyCompliant' }); Before = $snapshot; Definitions = $selected; Missing = $missing; Diagnostic = '' }
[pscustomobject]@{ Namespace = $name; Status = $(if ($missing.Count) { 'ChangeRequired' } else { 'AlreadyCompliant' }); Before = $snapshot; Definitions = $selected; Missing = $missing; Descendants=$tree; Diagnostic = '' }
} catch { [pscustomobject]@{ Namespace = $name; Status = 'Unknown'; Before = $null; Definitions = $selected; Missing = @(); Diagnostic = $_.Exception.Message } }
}
}
@@ -267,16 +272,34 @@ function Get-WelaWmiAuditPlan {
function Set-WelaWmiAuditControls {
param($Context, [array]$Plan)
foreach ($entry in $Plan) {
$callback = @{ Namespace = $entry.Namespace; Definitions = $entry.Definitions; Original = $null; ExpectedJson = $null; Applied = $false; VerifiedJson = $null }
$inherit=@($entry.Definitions|Where-Object {($_.AceFlags -band 2) -ne 0}).Count -gt 0
$callback = @{ Namespace = $entry.Namespace; Definitions = $entry.Definitions; Original = $null; ExpectedJson = $null; Applied = $false; VerifiedJson = $null; Inherit=$inherit; PlannedTree=$entry.Descendants; OriginalTree=$null; VerifiedTree=$null; DescendantVerification=[pscustomobject]@{Observation=$null} }
$read = {
param($state)
$snapshot = Get-WelaWmiNamespaceSnapshot $state.Namespace
if($state.Inherit){
$tree=Get-WelaWmiStableDescendants $state.Namespace
if($null -eq $state.OriginalTree){
if((Get-WelaWmiDescendantKey $tree) -cne (Get-WelaWmiDescendantKey $state.PlannedTree)){throw 'WMI descendant tree changed after planning; no SACL was written.'}
$state.OriginalTree=$tree
}
$snapshot=$tree.Root|Select-Object *
$snapshot|Add-Member NoteProperty Descendants $tree -Force
} else {$snapshot = Get-WelaWmiNamespaceSnapshot $state.Namespace}
if ($null -eq $state.Original) { $state.Original = $snapshot.DescriptorJson | ConvertFrom-Json; $state.ExpectedJson = $snapshot.DescriptorJson }
return $snapshot
}
$test = {
param($snapshot, $state)
$descriptor = $snapshot.DescriptorJson | ConvertFrom-Json
if($state.Inherit){
$state.DescendantVerification.Observation=Test-WelaWmiDescendantOutcomes $state.OriginalTree $snapshot.Descendants $state.Definitions
if($state.Applied -or -not @(Get-WelaWmiMissingAces $descriptor $state.Definitions).Count){
if($state.DescendantVerification.Observation.Status -cne 'Observed'){throw ('WMI descendant outcome is unverified: '+($state.DescendantVerification.Observation.Diagnostics -join '; '))}
$key=Get-WelaWmiDescendantKey $snapshot.Descendants
if($null -eq $state.VerifiedTree){$state.VerifiedTree=$key}
if($key -cne $state.VerifiedTree){throw 'WMI descendant descriptor changed after verification.'}
}
}
if (@(Get-WelaWmiMissingAces $descriptor $state.Definitions).Count) { return $false }
if ($state.Applied) {
if (-not (Test-WelaWmiDescriptorPreserved $state.Original $descriptor)) { return $false }
@@ -288,13 +311,18 @@ function Set-WelaWmiAuditControls {
}
$apply = {
param($state)
if($state.Inherit){
$fresh=Get-WelaWmiStableDescendants $state.Namespace
if((Get-WelaWmiDescendantKey $fresh) -cne (Get-WelaWmiDescendantKey $state.OriginalTree)){throw 'WMI descendant topology or descriptor changed before the parent setter; no SACL was written.'}
}
Set-WelaWmiNamespaceDescriptor -Namespace $state.Namespace -ExpectedJson $state.ExpectedJson -Definitions $state.Definitions
$state.Applied = $true
}
Invoke-WelaConfigurationControl -Context $Context -Id "WmiNamespace/$($entry.Namespace)/SACL" -Kind WmiNamespaceSacl `
-Target @{ Namespace = $entry.Namespace; Computer = 'Local'; Operation = 'Append audit ACEs only' } -Desired $entry.Definitions `
-Read $read -Compliant $test -Apply $apply -CallbackState $callback `
-Description ('Append missing success audit ACEs. Scope: ' + (($entry.Definitions.Scope | Select-Object -Unique) -join ', '))
-Description ('Append missing success audit ACEs. Scope: ' + (($entry.Definitions.Scope | Select-Object -Unique) -join ', ') + $(if($inherit){'; reviewed existing descendants: '+@($entry.Descendants.Entries).Count+'. Only the parent is written; unsupported propagation fails verification.'}else{''}))
if($inherit){$Context.Results[$Context.Results.Count-1]|Add-Member NoteProperty DescendantVerification $callback.DescendantVerification}
}
}
+137
View File
@@ -0,0 +1,137 @@
# Read-only bounded observations. A descendant is never passed to a setter.
function Get-WelaWmiDescendantContext {
if(-not (Get-Command Initialize-WelaWmiProbeNative -ErrorAction SilentlyContinue)){. (Join-Path $PSScriptRoot 'WmiProbe.ps1')}
Initialize-WelaWmiProbeNative
if((Get-Service Winmgmt -ErrorAction Stop).Status -ne 'Running'){throw 'Winmgmt must already be running before descendant observation.'}
$sources=[ordered]@{}
foreach($name in @('WmiNamespaceAuditing.ps1','WmiNamespaceDescendants.ps1','WmiProbeNative.cs')){$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash}
[ordered]@{Computer=[Environment]::MachineName;Version=[Environment]::OSVersion.VersionString;Token=(Get-WelaWmiProbeTokenKey ([Wela.WmiProbe.Native]::Snapshot()));Sources=$sources}|ConvertTo-Json -Compress -Depth 5
}
function Get-WelaWmiChildNames {
param([string]$Namespace,[int]$Maximum)
Initialize-WelaWmiInterop
$options=New-Object System.Management.ConnectionOptions
$options.EnablePrivileges=$false;$options.Impersonation=[System.Management.ImpersonationLevel]::Impersonate
$scope=New-Object System.Management.ManagementScope -ArgumentList "\\.\$Namespace",$options
$query=New-Object System.Management.ObjectQuery -ArgumentList 'SELECT Name FROM __Namespace'
$enumeration=New-Object System.Management.EnumerationOptions
$enumeration.ReturnImmediately=$true;$enumeration.Rewindable=$false;$enumeration.BlockSize=1
$enumeration.Timeout=[TimeSpan]::FromSeconds(10)
$searcher=New-Object System.Management.ManagementObjectSearcher -ArgumentList $scope,$query,$enumeration
$collection=$null;$names=New-Object 'System.Collections.Generic.List[string]'
try {
$collection=$searcher.Get()
foreach($item in $collection){
try {
if($names.Count -ge $Maximum){throw 'WMI descendant count exceeds the reviewed maximum of 64.'}
$name=$item.Name
if($name -isnot [string] -or $name -cnotmatch '^[A-Za-z_][A-Za-z0-9_]{0,63}$'){throw 'Unsupported or ambiguous native child namespace name.'}
$names.Add($name)
} finally {$item.Dispose()}
}
@($names.ToArray()|Sort-Object)
} finally {if($collection){$collection.Dispose()};$searcher.Dispose()}
}
function Get-WelaWmiDescendantKey {
param($Tree)
if($null -eq $Tree -or $Tree.Status -cne 'Complete' -or $Tree.Maximum -ne 64 -or $Tree.MaximumDepth -ne 8 -or $Tree.Entries -isnot [array] -or $Tree.Entries.Count -gt 64){throw 'Complete bounded WMI descendant evidence is required.'}
$parts=@($Tree.Context,$Tree.Root.Namespace,$Tree.Root.DescriptorJson)
foreach($entry in $Tree.Entries){
if($entry.Namespace -cne $entry.Snapshot.Namespace -or $entry.Depth -lt 1 -or $entry.Depth -gt 8 -or $entry.ProtectedBarrier -isnot [bool]){throw 'Malformed WMI descendant evidence.'}
$parts+=@($entry.Namespace,$entry.Parent,[string]$entry.Depth,[string]$entry.ProtectedBarrier,$entry.Snapshot.DescriptorJson)
}
ConvertTo-Json -InputObject $parts -Compress -Depth 4
}
function Get-WelaWmiDescendants {
param([string]$Namespace)
$entries=New-Object 'System.Collections.Generic.List[object]'
$diagnostics=New-Object 'System.Collections.Generic.List[string]'
$queue=New-Object 'System.Collections.Generic.Queue[object]'
$seen=New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
$root=$null;$started=[DateTime]::UtcNow;$bytes=0
try {
if($Namespace -cnotmatch '^root(\\[A-Za-z_][A-Za-z0-9_]{0,63}){1,8}$'){throw 'An exact local WMI namespace is required.'}
$root=Get-WelaWmiNamespaceSnapshot $Namespace
$queue.Enqueue([pscustomobject]@{Namespace=$Namespace;Depth=0;ProtectedBarrier=$false})
$null=$seen.Add($Namespace)
while($queue.Count){
if(([DateTime]::UtcNow-$started).TotalSeconds -gt 30){throw 'WMI tree scan time budget exceeded; individual provider calls are not forcibly cancellable.'}
$parent=$queue.Dequeue()
$children=@(Get-WelaWmiChildNames $parent.Namespace (64-$entries.Count))
if($entries.Count+$children.Count -gt 64){throw 'WMI descendant count exceeds 64.'}
if($parent.Depth -ge 8 -and $children.Count){throw 'WMI descendant depth exceeds eight.'}
foreach($name in $children){
if($name -isnot [string] -or $name -cnotmatch '^[A-Za-z_][A-Za-z0-9_]{0,63}$'){throw 'Unsupported native child namespace name.'}
$path=$parent.Namespace+'\'+$name
if(-not $seen.Add($path)){throw 'Duplicate or ambiguous WMI namespace during enumeration.'}
$snapshot=Get-WelaWmiNamespaceSnapshot $path
if($snapshot.Namespace -cne $path -or -not $snapshot.DescriptorMof){throw 'Incomplete or mismatched namespace descriptor.'}
$bytes+=[Text.Encoding]::UTF8.GetByteCount($snapshot.DescriptorJson+$snapshot.DescriptorMof)
if($bytes -gt 2097152){throw 'WMI descendant evidence exceeds two MiB.'}
$descriptor=$snapshot.DescriptorJson|ConvertFrom-Json -ErrorAction Stop
if($null -eq $descriptor.ControlFlags){throw 'Incomplete child descriptor controls.'}
$barrier=$parent.ProtectedBarrier -or (([uint32]$descriptor.ControlFlags -band 8192) -ne 0)
$entry=[pscustomobject]@{Namespace=$path;Parent=$parent.Namespace;Depth=$parent.Depth+1;ProtectedBarrier=[bool]$barrier;Snapshot=$snapshot}
$entries.Add($entry);$queue.Enqueue($entry)
}
}
} catch {$diagnostics.Add($_.Exception.Message)}
[pscustomobject]@{Status=$(if($diagnostics.Count){'Incomplete'}else{'Complete'});Maximum=64;MaximumDepth=8;StartedUtc=$started.ToString('o');CompletedUtc=[DateTime]::UtcNow.ToString('o');Root=$root;Entries=@($entries.ToArray());Diagnostics=@($diagnostics.ToArray())}
}
function Get-WelaWmiStableDescendants {
param([string]$Namespace)
$context=Get-WelaWmiDescendantContext
try {
$first=Get-WelaWmiDescendants $Namespace
if($first.Status -cne 'Complete'){throw ('Incomplete WMI descendant inventory: '+($first.Diagnostics -join '; '))}
$second=Get-WelaWmiDescendants $Namespace
if((Get-WelaWmiDescendantKey $first) -cne (Get-WelaWmiDescendantKey $second)){throw 'WMI descendant topology or full descriptor changed between observations.'}
$second|Add-Member NoteProperty Context $context
$second
} finally {
if((Get-WelaWmiDescendantContext) -cne $context){throw 'Full caller token, host, source or Winmgmt state changed while observing WMI descendants.'}
}
}
function Test-WelaWmiDescendantOutcomes {
param($Before,$After,[array]$Definitions)
$outcomes=New-Object 'System.Collections.Generic.List[object]'
$diagnostics=New-Object 'System.Collections.Generic.List[string]'
if($After.Status -cne 'Complete'){$diagnostics.Add('Post-write descendant inventory is incomplete: '+($After.Diagnostics -join '; '))}
$map=@{};foreach($entry in $After.Entries){$map[$entry.Namespace]=$entry}
foreach($entry in $Before.Entries){
$status='Unverified';$actual=$null;$message=''
try {
if(-not $map.ContainsKey($entry.Namespace)){throw 'Reviewed namespace disappeared or could not be observed.'}
$actual=$map[$entry.Namespace];$map.Remove($entry.Namespace)
if($actual.Parent -cne $entry.Parent -or $actual.Depth -ne $entry.Depth -or $actual.ProtectedBarrier -ne $entry.ProtectedBarrier){throw 'Descendant topology or observed protection changed.'}
$a=$entry.Snapshot.DescriptorJson|ConvertFrom-Json;$b=$actual.Snapshot.DescriptorJson|ConvertFrom-Json
if($entry.ProtectedBarrier){
if($entry.Snapshot.DescriptorJson -cne $actual.Snapshot.DescriptorJson){throw 'Protected namespace or its subtree changed.'}
$status='ProtectedUnchanged'
} else {
# Allow only SACL_PRESENT to appear. Every other control and full
# owner/group/DACL/unknown descriptor property remains identical.
foreach($property in $a.PSObject.Properties){
if($property.Name -eq 'SACL'){continue}
if($property.Name -eq 'ControlFlags'){
if(([uint32]$a.ControlFlags -band (-bnot 16)) -ne ([uint32]$b.ControlFlags -band (-bnot 16)) -or (([uint32]$a.ControlFlags -band 16) -ne 0 -and ([uint32]$b.ControlFlags -band 16) -eq 0)){throw 'Child descriptor controls changed.'}
} elseif((ConvertTo-WelaWmiJson $property.Value) -cne (ConvertTo-WelaWmiJson $b.($property.Name))){throw 'Child access or unknown descriptor field changed.'}
}
$remaining=New-Object 'System.Collections.Generic.List[object]'
foreach($ace in @($b.SACL)){if($null -ne $ace){$remaining.Add($ace)}}
foreach($ace in @($a.SACL)){
if($null -eq $ace){continue};$found=-1
for($i=0;$i -lt $remaining.Count;$i++){if((ConvertTo-WelaWmiJson $remaining[$i]) -ceq (ConvertTo-WelaWmiJson $ace)){$found=$i;break}}
if($found -lt 0){throw 'Original child audit/unknown ACE changed or disappeared.'};$remaining.RemoveAt($found)
}
$expected=@($Definitions|Where-Object {($_.AceFlags -band 2) -ne 0}|ForEach-Object {[pscustomobject]@{Sid=$_.Sid;AccessMask=$_.AccessMask;AceFlags=([uint32]$_.AceFlags -bor 16)}})
foreach($ace in $remaining){if(-not @($expected|Where-Object {Test-WelaWmiAceMatch $ace $_}).Count){throw 'Unexplained child audit entry appeared.'}}
foreach($definition in $expected){if(-not @($b.SACL|Where-Object {Test-WelaWmiAceMatch $_ $definition}).Count){throw 'Requested inherited ACE was not observed; existing-child propagation is unverified.'}}
$status='InheritedAceObserved'
}
} catch {$message=$_.Exception.Message;$diagnostics.Add($entry.Namespace+': '+$message)}
$outcomes.Add([pscustomobject]@{Namespace=$entry.Namespace;Status=$status;Diagnostic=$message;Before=$entry.Snapshot;After=$(if($actual){$actual.Snapshot}else{$null})})
}
foreach($entry in $map.Values){$diagnostics.Add('New unreviewed descendant: '+$entry.Namespace);$outcomes.Add([pscustomobject]@{Namespace=$entry.Namespace;Status='NewUnreviewedNamespace';Diagnostic='No pre-write snapshot or ownership established.';Before=$null;After=$entry.Snapshot})}
[pscustomobject]@{Status=$(if($diagnostics.Count){'Unverified'}else{'Observed'});Outcomes=@($outcomes.ToArray());Diagnostics=@($diagnostics.ToArray());Scope='Observed existing namespaces only. No atomic tree, namespace recreation identity, future-child, event, recovery ownership or Sigma claim.'}
}