Files
WELA/scripts/AppLockerProbe.ps1
T
田中ザック Isaac Mathis 5ba53fbcfb Validate native AppLocker EXE event generation with an opt-in probe (#423)
* Add native AppLocker EXE event validation probe

* Reference PR 423 in changelogs

* Isolate AppLocker native fixture and preserve prerequisite diagnostics

* Report an integer zero for an empty AppLocker policy

* Prepare disposable AppLocker probe policy without bypassing production importer guards

* Retain bounded native AppLocker channel diagnostics on probe failure

* Require native policy application before the disposable AppLocker probe

* Preserve exact timestamp strings in native evidence fixtures

* Record actual runner session and AppLocker publication diagnostics

* Activate and restore the native policy converter on disposable AppLocker hosts

* Compare native task freshness without guessing its timestamp timezone

* Verify effective policy and borrowed converter inactivity during fixture cleanup

* Track the actual native policy-converter task instance instead of cached timestamps
2026-09-20 22:46:56 +09:00

105 lines
12 KiB
PowerShell

# Fixed benign EXE probe. Does not change AppLocker, services, channels or audit policy.
function Get-WelaAppLockerProbeState {
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess) {throw 'A native 64-bit Windows process is required.'}
$identity=[Security.Principal.WindowsIdentity]::GetCurrent()
try {$reader=[ordered]@{Name=$identity.Name;Sid=$identity.User.Value;Groups=@($identity.Groups.Value|Sort-Object);AuthenticationType=$identity.AuthenticationType}} finally {$identity.Dispose()}
$hostState=Get-WelaAppLockerHost
$computer=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
$channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Microsoft-Windows-AppLocker/EXE and DLL')
try {$log=[ordered]@{Name=$channel.LogName;Enabled=$channel.IsEnabled;SecurityDescriptor=$channel.SecurityDescriptor;MaximumSize=$channel.MaximumSizeInBytes;Mode=[string]$channel.LogMode}} finally {$channel.Dispose()}
$source=Resolve-WelaArrivalPath (Join-Path ([Environment]::SystemDirectory) 'cmd.exe')
[pscustomobject][ordered]@{Host=$hostState;Computer=[Environment]::MachineName;Domain=[string]$computer.Domain;Reader=$reader;EffectivePolicy=(Get-WelaAppLockerPolicySnapshot Effective);Service=(Get-WelaAppLockerService);Channel=$log;Source=$source;SourceHash=(Get-FileHash -LiteralPath $source -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()}
}
function Get-WelaAppLockerProbeKey {
param($State)
if (-not $State.Host.Is64BitProcess -or $State.Host.Status -ne 'Candidate') {throw 'An observed supported client/member Windows host is required.'}
if ($State.EffectivePolicy.Status -ne 'Observed' -or $State.EffectivePolicy.Policy.HasUnknownPolicyData) {throw 'Effective Group Policy AppLocker settings must be readable and understood.'}
$collection=@($State.EffectivePolicy.Policy.Collections|Where-Object Type -eq Exe)
if ($collection.Count -ne 1 -or $collection[0].EnforcementMode -cne 'AuditOnly' -or $collection[0].RuleCount -lt 1) {throw 'A nonempty effective EXE AuditOnly collection is required.'}
if ($State.Service.Status -ne 'Observed' -or $State.Service.State -ne 'Running') {throw 'AppIDSvc must already be running.'}
if ($State.Channel.Enabled -isnot [bool] -or -not $State.Channel.Enabled -or -not $State.Channel.SecurityDescriptor -or $State.Channel.Name -cne 'Microsoft-Windows-AppLocker/EXE and DLL') {throw 'The readable EXE and DLL channel must already be enabled.'}
if ($State.Reader.Sid -notmatch '^S-1-\d+(-\d+)+$' -or $State.SourceHash -cnotmatch '^[a-f0-9]{64}$' -or [string]::IsNullOrWhiteSpace($State.Computer)) {throw 'Incomplete reader, source or computer identity.'}
[ordered]@{Host=$State.Host;Computer=$State.Computer;Domain=$State.Domain;Reader=$State.Reader;Policy=(Get-WelaAppLockerXmlKey $State.EffectivePolicy.Policy.Xml);Service=$State.Service;Channel=$State.Channel;Source=$State.Source;SourceHash=$State.SourceHash}|ConvertTo-Json -Depth 12 -Compress
}
function Start-WelaAppLockerProbeProcess {
param([string]$Root,$State)
$nonce=[guid]::NewGuid().ToString('N');$path=Join-Path $Root ('wela-applocker-'+$nonce+'.exe')
$source=$null;$target=$null;$lock=$null;$process=$null
try {
$source=[IO.File]::Open($State.Source,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
$target=[IO.File]::Open($path,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
$source.CopyTo($target);$target.Flush();$target.Dispose();$target=$null
$lock=[IO.File]::Open($path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
if ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -cne $State.SourceHash) {throw 'Native executable bytes changed before launch.'}
$fresh=Get-WelaAppLockerProbeState
if ((Get-WelaAppLockerProbeKey $fresh) -cne (Get-WelaAppLockerProbeKey $State)) {throw 'AppLocker prerequisites changed before launch.'}
$arguments='/d /c echo WELA_APPLOCKER_'+$nonce
$info=New-Object Diagnostics.ProcessStartInfo;$info.FileName=$path;$info.Arguments=$arguments;$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true;$info.WorkingDirectory=$Root
$started=[DateTime]::UtcNow;$process=[Diagnostics.Process]::Start($info);$processId=$process.Id
if (-not $process.WaitForExit(10000)) {$process.Kill();throw 'The owned fixed probe exceeded its ten-second process limit.'}
$stdout=$process.StandardOutput.ReadToEnd();$stderr=$process.StandardError.ReadToEnd()
if ($process.ExitCode -ne 0 -or $stdout.Trim() -cne ('WELA_APPLOCKER_'+$nonce) -or $stderr) {throw 'The fixed native executable did not complete with its expected marker.'}
[pscustomobject]@{Executable=$path;ExecutableHash=$State.SourceHash;Arguments=$arguments;ProcessId=$processId;UserSid=$State.Reader.Sid;StartedUtc=$started.ToString('o');CompletedUtc=[DateTime]::UtcNow.ToString('o');ExitCode=$process.ExitCode;Marker=$stdout.Trim()}
} finally {foreach ($item in @($process,$lock,$target,$source)) {if ($item) {$item.Dispose()}}}
}
function Read-WelaAppLockerProbeEvents {
param([datetime]$StartUtc,[datetime]$EndUtc)
$query="*[System[Provider[@Name='Microsoft-Windows-AppLocker'] and (EventID=8002 or EventID=8003) and TimeCreated[@SystemTime>='$($StartUtc.ToString('o'))' and @SystemTime<='$($EndUtc.ToString('o'))']]]"
$records=@();$xml=@()
try {
try {$records=@(Get-WinEvent -LogName 'Microsoft-Windows-AppLocker/EXE and DLL' -FilterXPath $query -MaxEvents 512 -ErrorAction Stop)} catch {if ($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*') {throw}}
foreach ($record in $records) {$xml+=[string]$record.ToXml()}
[pscustomobject]@{Xml=$xml;Capped=($records.Count -ge 512);Query=$query}
} finally {foreach ($record in $records) {$record.Dispose()}}
}
function Test-WelaAppLockerProbeEvent {
param([string]$Xml,$Process,$State,[datetime]$EndUtc)
$reader=$null
try {
$settings=New-Object Xml.XmlReaderSettings;$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=4194304
$reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Xml),$settings);$doc=New-Object Xml.XmlDocument;$doc.XmlResolver=$null;$doc.Load($reader)
$ns=New-Object Xml.XmlNamespaceManager($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event');$ns.AddNamespace('a','http://schemas.microsoft.com/schemas/event/Microsoft.Windows/1.0.0.0')
if ($doc.DocumentElement.LocalName -cne 'Event' -or $doc.DocumentElement.NamespaceURI -cne $ns.LookupNamespace('e') -or $doc.SelectNodes('/e:Event/e:System',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:UserData/a:RuleAndFileData',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:EventData',$ns).Count) {return $false}
$system=@{};foreach ($name in @('Provider','EventID','Version','EventRecordID','Channel','Computer','TimeCreated')) {$nodes=$doc.SelectNodes("/e:Event/e:System/e:$name",$ns);if($nodes.Count -ne 1){return $false};$system[$name]=$nodes[0]}
if ($system.Provider.GetAttribute('Name') -cne 'Microsoft-Windows-AppLocker' -or $system.Provider.GetAttribute('Guid').Trim('{}') -ine 'cbda4dbf-8d5d-4f69-9578-be14aa540d22' -or $system.EventID.InnerText -cnotin @('8002','8003') -or $system.Version.InnerText -cne '0' -or $system.EventRecordID.InnerText -notmatch '^[1-9][0-9]*$' -or $system.Channel.InnerText -cne 'Microsoft-Windows-AppLocker/EXE and DLL') {return $false}
$computers=@($State.Computer);if($State.Host.PartOfDomain){$computers+=$State.Computer+'.'+$State.Domain};if($system.Computer.InnerText -notin $computers){return $false}
$time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime')
if($time.UtcDateTime -lt ([DateTimeOffset]::Parse($Process.StartedUtc)).UtcDateTime -or $time.UtcDateTime -gt $EndUtc){return $false}
$data=@{};foreach($node in $doc.SelectSingleNode('/e:Event/e:UserData/a:RuleAndFileData',$ns).ChildNodes){if($node.NodeType -eq 'Whitespace'){continue};if($node.NodeType -ne 'Element' -or $node.NamespaceURI -cne $ns.LookupNamespace('a') -or $data.ContainsKey($node.LocalName) -or @($node.ChildNodes|Where-Object NodeType -eq Element).Count){return $false};$data[$node.LocalName]=$node.InnerText}
if($data.PolicyName -cne 'EXE' -or $data.TargetUser -cne $Process.UserSid -or $data.TargetProcessId -notmatch '^[1-9][0-9]*$' -or [long]$data.TargetProcessId -ne $Process.ProcessId){return $false}
# AppLocker may render the exact Windows directory through this documented path variable.
$eventPath=$data.FilePath
if($eventPath -imatch '^%OSDRIVE%\\'){$eventPath=[IO.Path]::GetPathRoot($State.Source).TrimEnd('\')+$eventPath.Substring(9)}
return $eventPath -ieq $Process.Executable
} catch {return $false} finally {if($reader){$reader.Dispose()}}
}
function Invoke-WelaAppLockerProbe {
param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15)
if (($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))) {throw 'Run requires a new AppLockerProbeOutputPath; Plan does not write files.'}
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaAppLockerExeProbe';Action=$Action;Status='Unverified';ExitCode=0;RecordedUtc=[DateTime]::UtcNow.ToString('o');Before=$null;After=$null;Process=$null;EventId=$null;Artifacts=@();Diagnostic='';OutputPath=$null;PolicyChanges=0;ReadyRuleCredit=0;CspPolicyState='Unknown';Scope='One fixed native EXE event only; scripts, MSI, DLL, packaged apps, forwarding and Sigma/backend validation are not tested. Sysmon excluded.'}
if($Action -eq 'Run'){$sourceDirectory=[Environment]::SystemDirectory;if(-not $sourceDirectory){$sourceDirectory=$PSScriptRoot};$report.OutputPath=New-WelaArrivalOutput -Path $OutputPath -SourcePath $sourceDirectory}
try {
$before=Get-WelaAppLockerProbeState;$report.Before=$before;$key=Get-WelaAppLockerProbeKey $before
if($Action -eq 'Plan'){$report.Status='PrerequisitesObserved';return $report}
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'before.json' ($before|ConvertTo-Json -Depth 20)
$process=Start-WelaAppLockerProbeProcess $report.OutputPath $before;$report.Process=$process
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'process.json' ($process|ConvertTo-Json -Depth 6)
$timer=[Diagnostics.Stopwatch]::StartNew();$matches=@()
do {
$end=[DateTime]::UtcNow;$batch=Read-WelaAppLockerProbeEvents ([DateTimeOffset]::Parse($process.StartedUtc)).UtcDateTime $end
if($batch.Capped -isnot [bool] -or $batch.Capped){throw 'The 512-event query cap was reached or completeness is unknown.'}
$matches=@($batch.Xml|Where-Object {Test-WelaAppLockerProbeEvent $_ $process $before $end})
if($matches.Count -gt 1){throw 'Multiple exact AppLocker events make the result ambiguous.'}
if($matches.Count -eq 1){break}
Start-Sleep -Milliseconds 250
} while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds)
if($matches.Count -ne 1){foreach($xml in @($batch.Xml|Select-Object -First 4)){$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath ('candidate-'+[guid]::NewGuid().ToString('N')+'.xml') $xml};throw 'No exact AppLocker EXE event arrived within the timeout.'}
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'event.xml' $matches[0]
$after=Get-WelaAppLockerProbeState;$report.After=$after;$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'after.json' ($after|ConvertTo-Json -Depth 20)
if((Get-WelaAppLockerProbeKey $after) -cne $key -or (Get-FileHash -LiteralPath $process.Executable -Algorithm SHA256).Hash.ToLowerInvariant() -cne $process.ExecutableHash){throw 'Host, policy, service, channel, reader or probe bytes changed during collection.'}
$event=[xml]$matches[0];$report.EventId=[int]$event.Event.System.EventID;$report.Status='NativeExeEventObserved'
} catch {$report.ExitCode=1;$report.Diagnostic=$_.Exception.Message}
if($report.OutputPath){$null=Write-WelaArrivalArtifact $report.OutputPath 'manifest.json' ($report|ConvertTo-Json -Depth 24)}
$report
}