Stack intended-reader EVTX recovery on the approved native auditing batch

This commit is contained in:
Shirofune-Security committed 2026-09-22 14:56:09 +09:00
commit 1dfd3a92b9
37 files changed
+1933 -8

No files matched your search

+1 -1
View File
@@ -108,7 +108,7 @@ function Invoke-WelaConfigurationControl {
function Complete-WelaConfiguration {
param($Context, [string]$ResultsPath, $Plan,
[ValidateSet("native-windows-configuration", "outgoing-ntlm-audit-policy-only", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only", "windows-powershell-transcription-policy-only", "wef-source-configuration-only", "wec-collector-subscriptions-only", "audit-integrity-local-policy-only", "adcs-audit-settings-only", "disabled-unlinked-gpo-creation-only")]
[ValidateSet("native-windows-configuration", "outgoing-ntlm-audit-policy-only", "incoming-domain-ntlm-audit-policy-only", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only", "windows-powershell-transcription-policy-only", "wef-source-configuration-only", "wec-collector-subscriptions-only", "audit-integrity-local-policy-only", "adcs-audit-settings-only", "disabled-unlinked-gpo-creation-only")]
[string]$Scope = "native-windows-configuration",
[string]$SuccessMessage = 'Configuration completed; all requested controls verified.')
if ($Context.PSObject.Properties['CustomProfileGuard']) {
+101
View File
@@ -0,0 +1,101 @@
# 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.'}
$build=[int]$os.BuildNumber;$product=[int]$os.ProductType;$role=[int]$computer.DomainRole;$joined=$computer.PartOfDomain
$coherent=($product -eq 1 -and (($role -eq 0 -and -not $joined) -or ($role -eq 1 -and $joined))) -or ($product -eq 3 -and (($role -eq 2 -and -not $joined) -or ($role -eq 3 -and $joined))) -or ($product -eq 2 -and $role -in @(4,5) -and $joined)
if(-not $coherent){throw 'Conflicting native product/domain-role/join observations.'}
if(-not (($product -eq 1 -and $build -in @(22000,22621,22631,26100,26200)) -or ($product -in @(2,3) -and $build -in @(20348,26100)))){throw 'This Windows role/build has not been reviewed.'}
[pscustomobject][ordered]@{Computer=[string]$computer.Name;Domain=[string]$computer.Domain;Build=$build;ProductType=$product;DomainRole=$role;PartOfDomain=$joined}
}
function Get-WelaNtlmAuditDefinition {
param([ValidateSet('Incoming','Domain')][string]$Selection)
if($Selection -eq 'Incoming'){return [pscustomobject]@{Selection='Incoming';Path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0';Name='AuditReceivingNTLMTraffic';Value=2;Known=@(0,1,2);Meaning='Enable auditing for all accounts'}}
[pscustomobject]@{Selection='Domain';Path='HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters';Name='AuditNTLMInDomain';Value=7;Known=@(0,1,2,3,5,7);Meaning='Enable all domain NTLM auditing on the observed domain controller'}
}
function Get-WelaNtlmAuditSnapshot {
param([ValidateSet('Incoming','Domain')][string]$Selection)
$observedHost=Get-WelaNtlmAuditHost
if($Selection -eq 'Domain' -and $observedHost.ProductType -ne 2){return [pscustomobject][ordered]@{Host=$observedHost;Applicable=$false;Policy=$null}}
$definition=Get-WelaNtlmAuditDefinition $Selection
$policy=Get-WelaRegistryState $definition.Path $definition.Name
if(-not $policy.KeyExists){throw 'The existing native policy key is required; no parent key will be created.'}
[pscustomobject][ordered]@{Host=$observedHost;Applicable=$true;Policy=$policy}
}
function Get-WelaNtlmAuditDisposition {
param($Snapshot,$Definition)
if(-not $Snapshot.Applicable){return 'NotApplicable'}
$p=$Snapshot.Policy
if($p.ValueExists -and ($p.Type -cne 'DWord' -or ($p.Value -isnot [int] -and $p.Value -isnot [long] -and $p.Value -isnot [uint32]) -or $p.Value -notin $Definition.Known)){return 'Unknown'}
if($p.ValueExists -and $p.Value -eq $Definition.Value){return 'AlreadyCompliant'}
if($Definition.Selection -eq 'Domain' -and $p.ValueExists -and $p.Value -eq 2){return 'LegacyValue2'}
return 'ChangeRequired'
}
function Get-WelaNtlmAuditPolicySource {
param($Definition)
$key='MACHINE\'+$Definition.Path.Substring(6)
try{
$rows=@(Get-CimInstance -Namespace 'root\RSOP\Computer' -ClassName RSOP_RegistryValue -ErrorAction Stop|Where-Object {$_.KeyName -ieq $key -and $_.ValueName -ieq $Definition.Name}|Sort-Object precedence)
[pscustomobject]@{Status=$(if($rows.Count){'Observed'}else{'NotObserved'});Class='RSOP_RegistryValue';Matches=@($rows|Select-Object KeyName,ValueName,Type,Data,GPOID,precedence);Diagnostic='Last-applied RSoP may be stale or incomplete. This does not establish the current registry writer, local ownership or policy persistence.'}
}catch{[pscustomobject]@{Status='Unknown';Class='RSOP_RegistryValue';Matches=@();Diagnostic=$_.Exception.Message+' RSoP is potentially stale and is not current policy ownership evidence.'}}
}
function Get-WelaNtlmAuditPlan {
param([ValidateSet('Incoming','Domain','Both')][string]$Selection='Both')
$rows=@()
foreach($selected in @($(if($Selection -eq 'Both'){'Incoming';'Domain'}else{$Selection}))){
$definition=Get-WelaNtlmAuditDefinition $selected
try{
$snapshot=Get-WelaNtlmAuditSnapshot $selected;$status=Get-WelaNtlmAuditDisposition $snapshot $definition
$diagnostic=switch($status){
NotApplicable {'Domain NTLM auditing is not applicable to this observed non-DC host. No domain policy value was read or selected for writing.'}
Unknown {'Unknown registry type/value is preserved. Inspect it before configuration.'}
LegacyValue2 {'Historical WELA value2 is not credited as Enable all. Its undocumented meaning is not inferred; selected Configure requests DWORD7.'}
AlreadyCompliant {'The requested audit value is configured. Actual authentication events and policy persistence are unverified.'}
default {$definition.Meaning}
}
$rows+=[pscustomobject]@{Selection=$selected;Definition=$definition;Status=$status;Before=$snapshot;Diagnostic=$diagnostic;PolicySource=$(if($snapshot.Applicable){Get-WelaNtlmAuditPolicySource $definition}else{$null})}
}catch{$rows+=[pscustomobject]@{Selection=$selected;Definition=$definition;Status='Unknown';Before=$null;Diagnostic=$_.ToString();PolicySource=$null}}
}
[pscustomobject]@{Selection=$Selection;Controls=$rows;Mode='Audit only';PlanKind='Live assessment; not an importable authorization file'}
}
function Invoke-WelaNtlmAuditCommand {
param([ValidateSet('Audit','Plan','Configure')][string]$Action='Audit',[ValidateSet('Incoming','Domain','Both')][string]$Selection='Both',[switch]$Auto,[switch]$DryRun,[string]$BackupPath,[string]$ResultsPath)
if($Action -ne 'Configure' -and ($Auto -or $DryRun -or $BackupPath)){throw 'Consent, dry-run and backup options require Configure.'}
if($Action -eq 'Configure' -and -not $PSBoundParameters.ContainsKey('Selection')){throw 'Configure requires an explicit Incoming, Domain or Both selection.'}
$plan=Get-WelaNtlmAuditPlan $Selection
if($Action -eq 'Configure'){
$context=New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath
foreach($row in $plan.Controls){
$definition=$row.Definition;$target=@{Path=$definition.Path;Name=$definition.Name};$desired=@{Value=$definition.Value;Type='DWord'};$id="Registry/$($definition.Path)/$($definition.Name)"
if($row.Status -in @('Unknown','NotApplicable')){
$context.Results.Add([pscustomobject]@{Id=$id;Kind='Registry';Target=$target;Desired=$desired;Before=$row.Before;After=$null;Status=$(if($row.Status -eq 'Unknown'){'Failed'}else{'Skipped'});Diagnostic=$row.Diagnostic})
continue
}
$state=@{Observed=$null;PlannedHost=($row.Before.Host|ConvertTo-Json -Compress);Definition=$definition}
$read={param($s)
$snapshot=Get-WelaNtlmAuditSnapshot $s.Definition.Selection
if(($snapshot.Host|ConvertTo-Json -Compress) -cne $s.PlannedHost -or -not $snapshot.Applicable){throw 'Native host role/context changed; review a new plan.'}
if((Get-WelaNtlmAuditDisposition $snapshot $s.Definition) -eq 'Unknown'){throw 'Unknown registry type/value is preserved.'}
$s.Observed=$snapshot;return $snapshot
}
$test={param($snapshot,$s) $snapshot.Applicable -and $snapshot.Policy.ValueExists -and $snapshot.Policy.Type -ceq 'DWord' -and $snapshot.Policy.Value -eq $s.Definition.Value}
$apply={param($s)
$fresh=Get-WelaNtlmAuditSnapshot $s.Definition.Selection
if(($fresh|ConvertTo-Json -Depth 8 -Compress) -cne ($s.Observed|ConvertTo-Json -Depth 8 -Compress)){throw 'NTLM audit state changed after the original journal snapshot; no write attempted.'}
if((Get-WelaNtlmAuditDisposition $fresh $s.Definition) -notin @('ChangeRequired','LegacyValue2')){throw 'The current state no longer authorizes this write.'}
Set-ItemProperty -LiteralPath $s.Definition.Path -Name $s.Definition.Name -Value $s.Definition.Value -Type DWord -ErrorAction Stop
'Only the selected NTLM audit DWORD was requested. Authentication restrictions and exceptions were not changed.'
}
Invoke-WelaConfigurationControl -Context $context -Id $id -Kind Registry -Target $target -Desired $desired -Read $read -Compliant $test -Apply $apply -CallbackState $state -Description $row.Diagnostic
}
$report=Complete-WelaConfiguration -Context $context -Scope 'incoming-domain-ntlm-audit-policy-only' -SuccessMessage 'Selected NTLM audit results recorded; inspect failed/skipped controls separately.'
$report|Add-Member NoteProperty Plan $plan
}else{$report=[pscustomobject]@{ExitCode=$(if(@($plan.Controls|Where-Object Status -eq 'Unknown').Count){1}else{0});Scope='incoming-domain-ntlm-audit-policy-only';Action=$Action;Plan=$plan}}
$report|Add-Member NoteProperty EventGeneration 'Unverified. Audit registry values do not prove authentication, NTLM events, GPO persistence, forwarding or Sigma readiness.'
$report|Add-Member NoteProperty ReadyRuleCredit 0
if($ResultsPath){try{$report|ConvertTo-Json -Depth 18|Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop}catch{$report.ExitCode=1;Write-Host "[Failed] Writing NTLM audit results: $_" -ForegroundColor Red}}
return $report
}
+194
View File
@@ -0,0 +1,194 @@
# Exact selected QueryList, current primary token, local read-only native execution.
function Get-WelaWefQueryKey {
param($Value)
(ConvertTo-Json -InputObject $Value -Depth 32 -Compress).Replace('<','\u003c').Replace('>','\u003e').Replace('&','\u0026').Replace("'",'\u0027')
}
function Initialize-WelaWefQueryNative {
$bytes=[IO.File]::ReadAllBytes((Join-Path $PSScriptRoot 'WefQueryNative.cs'))
if($bytes.Length -gt 131072){throw 'Native query source exceeds its bound.'};$hash=Get-WelaArrivalHash $bytes
if(-not('Wela.WefQuery.Native' -as [type])){
$source=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff)
if([regex]::Matches($source,'__WELA_WEF_QUERY_SHA256__').Count -ne 1){throw 'Native query source marker is missing or ambiguous.'}
Add-Type -TypeDefinition $source.Replace('__WELA_WEF_QUERY_SHA256__',$hash) -ErrorAction Stop
}
if([Wela.WefQuery.Native]::SourceSha256 -cne $hash){throw 'Loaded query helper differs from current source.'}
}
function Get-WelaWefQuerySources {
$result=[ordered]@{}
foreach($name in @('WELA.ps1','scripts/WefQuery.ps1','scripts/WefQueryNative.cs','scripts/WefQueryWorker.ps1','scripts/ChannelRead.ps1','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','scripts/CustomAuditProfiles.ps1','modules/WefSubscriptions.psm1','modules/AuditProfiles.psm1','modules/NativeProviders.psm1','config/native_channel_profile.json')){
$result[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
}
[pscustomobject]$result
}
function Get-WelaWefQueryToken {
Initialize-WelaWefQueryNative
ConvertTo-WelaWefQueryTokenObservation ([Wela.WefQueryToken.Native]::Snapshot())
}
function ConvertTo-WelaWefQueryTokenObservation {
param($Token)
if($Token -isnot [Wela.WefQueryToken.Token]){throw 'Expected the native query token observation.'}
# Normalize native DTOs at the boundary, using the same strict shape as worker receipts.
$observed=ConvertFrom-WelaArrivalJson (Get-WelaWefQueryKey $Token)
$null=Get-WelaWefQueryTokenKey $observed
$observed
}
function Get-WelaWefQueryTokenKey {
param($Token)
Assert-WelaArrivalObject $Token @('Sid','Name','AuthenticationId','AuthenticationType','ImpersonationLevel','TokenSource','Groups','Privileges')
foreach($name in @('Sid','Name','AuthenticationId','AuthenticationType','ImpersonationLevel','TokenSource')){if($Token.$name -isnot [string]){throw 'Mistyped query token text.'}}
if($Token.Sid -cnotmatch '^S-1-\d+(-\d+)+$' -or $Token.AuthenticationId -cnotmatch '^0x[0-9a-f]+$' -or -not $Token.Name -or $Token.TokenSource -cnotin @('Process','EquivalentSelfThread') -or $Token.Groups -isnot [array] -or -not $Token.Groups.Count -or $Token.Privileges -isnot [array]){throw 'Incomplete query token observation.'}
foreach($group in $Token.Groups){Assert-WelaArrivalObject $group @('Sid','Attributes');if($group.Sid -isnot [string] -or $group.Sid -cnotmatch '^S-1-\d+(-\d+)+$'){throw 'Invalid group SID.'};Assert-WelaWefQueryUInt $group.Attributes}
foreach($privilege in $Token.Privileges){Assert-WelaArrivalObject $privilege @('Luid','Attributes');if($privilege.Luid -isnot [string] -or $privilege.Luid -cnotmatch '^0x[0-9a-f]+$'){throw 'Invalid token privilege.'};Assert-WelaWefQueryUInt $privilege.Attributes}
Get-WelaWefQueryKey ([pscustomobject][ordered]@{Sid=$Token.Sid;Name=$Token.Name;AuthenticationId=$Token.AuthenticationId;AuthenticationType=$Token.AuthenticationType;Groups=$Token.Groups;Privileges=$Token.Privileges})
}
function Assert-WelaWefQueryUInt {param($Value) if(($Value -isnot [int] -and $Value -isnot [long] -and $Value -isnot [uint32]) -or $Value -lt 0 -or $Value -gt [uint32]::MaxValue){throw 'Expected a native unsigned integer.'}}
function Assert-WelaWefQuerySourceConfig {
param($Config)
Assert-WelaArrivalObject $Config @('SchemaVersion','Role','CollectorFqdn','CollectorUri','Authentication','SourceSids','SubscriptionFiles','Hardening','SubscriptionManagerSlot','RefreshSeconds','GrantNetworkServiceRead','ApplyChannelProfile','GrantCapi2Read')
foreach($name in @('SchemaVersion','SubscriptionManagerSlot','RefreshSeconds')){if($Config.$name -isnot [int] -and $Config.$name -isnot [long]){throw 'Source config requires integer schema/slot/refresh fields.'}}
foreach($name in @('Role','CollectorFqdn','CollectorUri','Authentication','Hardening')){if($Config.$name -isnot [string]){throw 'Source config requires typed text fields.'}}
foreach($name in @('SourceSids','SubscriptionFiles')){if($Config.$name -isnot [array]){throw 'Source config requires explicit SID/file arrays.'};foreach($value in $Config.$name){if($value -isnot [string] -or -not $value){throw 'Source config requires nonempty SID/file strings.'}}}
foreach($name in @('GrantNetworkServiceRead','ApplyChannelProfile','GrantCapi2Read')){if($Config.$name -isnot [bool]){throw 'Source config requires explicit Boolean permission settings.'}}
}
function Import-WelaWefQuerySelection {
param([string]$ConfigPath,[string]$SubscriptionId)
if(-not $ConfigPath -or -not $SubscriptionId -or $SubscriptionId.Length -gt 256 -or $SubscriptionId -match '[\x00-\x1f]'){throw 'An exact source config path and subscription ID are required.'}
$capture=@{Files=[Collections.Generic.List[object]]::new();Bytes=0;Texts=[Collections.Generic.List[string]]::new()}
# This synchronous callback retains the caller's script scope. GetNewClosure
# creates a dynamic module that cannot see script-local artifact helpers.
$reader={param($path)
$file=Read-WelaWecUpdateFile $path 1048576
if($capture.Files.Path -contains $file.Path){throw 'Duplicate input file path.'}
if($capture.Files.Count -eq 0){$json=ConvertFrom-WelaArrivalJson $file.Text;Assert-WelaWefQuerySourceConfig $json}
$capture.Bytes+=[Text.Encoding]::UTF8.GetByteCount($file.Text);if($capture.Bytes -gt 4194304){throw 'WEF input text exceeds four MiB aggregate.'}
$capture.Files.Add([pscustomobject]@{Path=$file.Path;Sha256=$file.Hash});$capture.Texts.Add($file.Text)
$file.Text
}
$model=Import-WelaWefConfig -Path $ConfigPath -Role Source -ReadText $reader
$selected=@($model.Subscriptions|Where-Object Id -CEQ $SubscriptionId)
if($selected.Count -ne 1){throw 'Select one exact subscription ID from the source config.'};$selected=$selected[0]
$doc=Read-WelaWefXml $selected.Xml;$query=[string]$doc.DocumentElement.SelectSingleNode('*[local-name()="Query"]').InnerText
$parsed=ConvertFrom-WelaWefQuery $query
if($query.Length -gt 65536 -or $parsed.Channels.Count -gt 16 -or $parsed.Filters.Count -gt 128){throw 'Selected QueryList exceeds 65536 characters, 16 channels or 128 filters.'}
$index=0;while($model.Subscriptions[$index].Id -cne $SubscriptionId){$index++}
[pscustomobject][ordered]@{Id=$SubscriptionId;RequestedEnabled=$selected.Definition.Enabled;CollectorFqdn=$model.Config.CollectorFqdn;CollectorUri=$model.Config.CollectorUri;Query=$query;QuerySha256=(Get-WelaArrivalHash ([Text.UTF8Encoding]::new($false).GetBytes($query)));Channels=@($parsed.Channels);Filters=@($parsed.Filters);Files=@($capture.Files.ToArray());ConfigText=$capture.Texts[0];SubscriptionText=$capture.Texts[$index+1]}
}
function Get-WelaWefQueryHost {
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Native 64-bit Windows is required.'}
foreach($service in @('Winmgmt','EventLog')){if((Get-Service -Name $service -ErrorAction Stop).Status -ne 'Running'){throw 'Observation services must already be running.'}}
$observed=Get-WelaChannelReadHost;$dns=[Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties()
$observed|Add-Member NoteProperty DnsHostName ([string]$dns.HostName)
$observed|Add-Member NoteProperty DnsSuffix ([string]$dns.DomainName)
$observed
}
function Get-WelaWefQueryChannelState {
param([string[]]$Channels)
foreach($channel in $Channels){Get-WelaNativeChannel -Name $channel}
}
function Assert-WelaWefQueryInputs {
param($Selection)
foreach($file in $Selection.Files){if((Read-WelaWecUpdateFile $file.Path 1048576).Hash -cne $file.Sha256){throw 'Original WEF configuration or subscription bytes changed.'}}
}
function Get-WelaWefQueryEngine {
$path=(Get-Process -Id $PID -ErrorAction Stop).Path
if([IO.Path]::GetFileName($path) -notin @('powershell.exe','pwsh.exe') -or $PSVersionTable.PSVersion.Major -notin @(5,7)){throw 'Native Windows PowerShell 5.1 or PowerShell 7 is required.'}
[pscustomobject]@{Path=$path;Sha256=(Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant();Version=$PSVersionTable.PSVersion.ToString();ModulePath=[IO.Path]::Combine($PSHOME,'Modules')}
}
function Close-WelaWefQueryWorker {
param($Process,$Result)
if($Result.Started){
$exited=$false;try{$exited=$Process.HasExited}catch{$Result.Diagnostic+=' Exit observation failed: '+$_.Exception.Message}
if(-not $exited){try{$Process.Kill()}catch{$Result.Diagnostic+=' Termination request failed: '+$_.Exception.Message};try{$exited=$Process.WaitForExit(5000)}catch{$Result.Diagnostic+=' Termination wait failed: '+$_.Exception.Message}}
$Result.TerminationConfirmed=[bool]$exited;if(-not $exited){$Result.Diagnostic+=' Worker termination unconfirmed.'}
}
try{$Process.Dispose()}catch{$Result.Diagnostic+=' Process cleanup failed: '+$_.Exception.Message}
}
function Start-WelaWefQueryWorker {
param($Engine,[string]$RequestPath,[string]$RequestHash)
$worker=Join-Path $PSScriptRoot 'WefQueryWorker.ps1'
foreach($path in @($Engine.Path,$worker,$RequestPath)){if($path.Contains('"') -or $path.EndsWith('\') -or $path -match '[\x00-\x1f]'){throw 'Ambiguous query worker path.'}}
Initialize-WelaWefQueryNative
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$Engine.Path;$info.Arguments='-NoLogo -NoProfile -NonInteractive -File "'+$worker+'" -RequestPath "'+$RequestPath+'" -RequestHash '+$RequestHash
$info.EnvironmentVariables['PSModulePath']=$Engine.ModulePath;$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true;$info.StandardOutputEncoding=[Text.UTF8Encoding]::new($false);$info.StandardErrorEncoding=[Text.UTF8Encoding]::new($false)
$result=[pscustomobject]@{Started=$false;ProcessId=$null;ExitCode=$null;TimedOut=$false;TerminationConfirmed=$false;Receipt=$null;Diagnostic=''};$process=[Diagnostics.Process]::new();$process.StartInfo=$info
try{
if(-not $process.Start()){throw 'Query worker did not start.'};$result.Started=$true;$result.ProcessId=$process.Id
$stdout=[Wela.WefQuery.Native]::ReadPipe($process.StandardOutput,33554432);$stderr=[Wela.WefQuery.Native]::ReadPipe($process.StandardError,65536)
if(-not $process.WaitForExit(45000)){$result.TimedOut=$true;throw 'Native query worker exceeded 45 seconds.'};$result.ExitCode=$process.ExitCode
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Native query output drain timed out.'}
if($stderr.Result){throw ('Query worker error output: '+$stderr.Result)}
$result.Receipt=ConvertFrom-WelaArrivalJson $stdout.Result
}catch{$result.Diagnostic=$_.Exception.Message}finally{Close-WelaWefQueryWorker $process $result}
$result
}
function Assert-WelaWefQueryNativeResult {
param($Result,[string[]]$Channels,[int]$MaximumEvents)
Assert-WelaArrivalObject $Result @('Opened','Complete','Capped','CleanupConfirmed','NativeError','Diagnostic','Channels','DiagnosticChannels','DiagnosticNativeError','Events','XmlPropertyCounts')
foreach($name in @('Opened','Complete','Capped','CleanupConfirmed')){if($Result.$name -isnot [bool]){throw 'Mistyped native query outcome.'}}
if($Result.Diagnostic -isnot [string] -or $Result.Events -isnot [array] -or $Result.Events.Count -gt $MaximumEvents){throw 'Invalid native query evidence count or diagnostic.'}
if($Result.XmlPropertyCounts -isnot [array] -or $Result.XmlPropertyCounts.Count -ne $Result.Events.Count){throw 'Native XML render observations do not match retained records.'};foreach($count in $Result.XmlPropertyCounts){Assert-WelaWefQueryUInt $count}
foreach($name in @('NativeError','DiagnosticNativeError')){if($null -ne $Result.$name){Assert-WelaWefQueryUInt $Result.$name}}
foreach($field in @('Channels','DiagnosticChannels')){
$entries=$Result.$field;if($entries -isnot [array] -or $entries.Count -gt 128){throw 'Invalid native query status list.'}
foreach($entry in $entries){Assert-WelaArrivalObject $entry @('Channel','Error');if($entry.Channel -isnot [string] -or $entry.Channel -cnotin $Channels){throw 'Native query status refers to an unselected channel.'};Assert-WelaWefQueryUInt $entry.Error}
}
if(-not $Result.Opened -and ($Result.Events.Count -or $Result.Channels.Count -or $Result.Complete -or $Result.Capped -or $null -eq $Result.NativeError)){throw 'An unopened strict query cannot have matching evidence.'}
if($Result.Opened -and ($Result.DiagnosticChannels.Count -or $null -ne $Result.DiagnosticNativeError)){throw 'Successful strict query has unexpected alternate diagnostic evidence.'}
if($Result.Complete -and ($Result.Capped -or -not $Result.CleanupConfirmed -or $null -ne $Result.NativeError -or $Result.Diagnostic)){throw 'Native completeness contradicts an error/cap/cleanup outcome.'}
if($Result.Opened){foreach($channel in $Channels){if(-not @($Result.Channels|Where-Object Channel -CEQ $channel).Count){throw 'Native query status omits a selected channel.'}}}
$bytes=0
foreach($xml in $Result.Events){if($xml -isnot [string] -or $xml.Length -gt 524287){throw 'Invalid or oversized event XML.'};$bytes+=[Text.Encoding]::UTF8.GetByteCount($xml);if($bytes -gt 4194304){throw 'Matching event XML exceeds four MiB.'}}
}
function Read-WelaWefQueryEvent {
param([string]$Xml,[string[]]$Channels,$HostContext)
$doc=Read-WelaWefXml $Xml;$root=$doc.DocumentElement
if($root.LocalName -cne 'Event' -or $root.NamespaceURI -cne 'http://schemas.microsoft.com/win/2004/08/events/event'){throw 'Native result is not Windows Event XML.'}
$ns=[Xml.XmlNamespaceManager]::new($doc.NameTable);$ns.AddNamespace('e',$root.NamespaceURI)
$system=@($root.SelectNodes('e:System',$ns));if($system.Count -ne 1){throw 'Event System identity is missing or ambiguous.'}
foreach($name in @('Provider','EventID','EventRecordID','Channel','Computer','TimeCreated')){if(@($system[0].SelectNodes('e:'+$name,$ns)).Count -ne 1){throw 'Event identity is missing or duplicated.'}}
$channel=[string]$system[0].SelectSingleNode('e:Channel',$ns).InnerText;$machine=[string]$system[0].SelectSingleNode('e:Computer',$ns).InnerText;$record=[string]$system[0].SelectSingleNode('e:EventRecordID',$ns).InnerText;$provider=$system[0].SelectSingleNode('e:Provider',$ns).GetAttribute('Name');$eventId=[string]$system[0].SelectSingleNode('e:EventID',$ns).InnerText
$names=@([string]$HostContext.Computer);if($HostContext.DnsHostName){$names+=[string]$HostContext.DnsHostName;if($HostContext.DnsSuffix){$names+=([string]$HostContext.DnsHostName+'.'+[string]$HostContext.DnsSuffix)}}
if($channel -cnotin $Channels -or -not $machine -or $machine -inotIn $names -or $record -cnotmatch '^[1-9][0-9]{0,18}$' -or -not $provider -or $eventId -cnotmatch '^[0-9]{1,5}$'){throw 'Returned event identity differs from selected local provenance.'}
$time=ConvertTo-WelaArrivalUtc $system[0].SelectSingleNode('e:TimeCreated',$ns).GetAttribute('SystemTime')
[pscustomobject]@{Channel=$channel;Computer=$machine;RecordId=[long]$record;Provider=$provider;EventId=[int]$eventId;TimeCreatedUtc=$time.ToString('o')}
}
function Invoke-WelaWefQuery {
param([string]$ConfigPath,[string]$SubscriptionId,[string]$OutputPath,[ValidateRange(1,64)][int]$MaximumEvents=16)
$selection=Import-WelaWefQuerySelection $ConfigPath $SubscriptionId
$hostState=Get-WelaWefQueryHost;$sources=Get-WelaWefQuerySources;$engine=Get-WelaWefQueryEngine
if(-not $OutputPath){throw 'wef-query requires a new output directory.'};$output=New-WelaArrivalOutput $OutputPath $script:ScriptRoot
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaWefQueryPreflight';Status='Unverified';ExitCode=1;SubscriptionId=$selection.Id;RequestedEnabled=$selection.RequestedEnabled;CollectorFqdn=$selection.CollectorFqdn;CollectorUri=$selection.CollectorUri;QuerySha256=$selection.QuerySha256;MaximumEvents=$MaximumEvents;Sources=$sources;Inputs=$selection.Files;Host=$hostState;Engine=$engine;ReaderBefore=$null;ReaderAfter=$null;ChannelBefore=@();ChannelAfter=@();Worker=$null;Query=$null;Matches=@();Artifacts=@();Diagnostic='';ConfigurationChanges=0;ReadyRuleCredit=0;Forwarding='Not tested';SourceServiceTokenAccess='Not tested; actual caller token only';Scope='Exact selected local QueryList at observation time; disabled selection may read historical events.'}
try{
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'source-config.json' $selection.ConfigText
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'subscription.xml' $selection.SubscriptionText
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'query.xml' $selection.Query
$report.ChannelBefore=@(Get-WelaWefQueryChannelState $selection.Channels)
$report.ReaderBefore=Get-WelaWefQueryToken;$tokenKey=Get-WelaWefQueryTokenKey $report.ReaderBefore
$request=[pscustomobject]@{SchemaVersion=1;Kind='WelaWefQueryRequest';Nonce=[guid]::NewGuid().ToString('N');Query=$selection.Query;QuerySha256=$selection.QuerySha256;Channels=$selection.Channels;MaximumEvents=$MaximumEvents;Sources=$sources;Host=$hostState;Reader=$report.ReaderBefore;Engine=$engine}
$artifact=Write-WelaWecUpdateArtifact $output 'request.json' (Get-WelaWefQueryKey $request);$report.Artifacts+=$artifact
Assert-WelaWefQueryInputs $selection
if((Get-WelaWefQueryKey (Get-WelaWefQuerySources)) -cne (Get-WelaWefQueryKey $sources) -or (Get-WelaWefQueryTokenKey (Get-WelaWefQueryToken)) -cne $tokenKey){throw 'Sources or actual reader changed before query.'}
$worker=Start-WelaWefQueryWorker $engine (Join-Path $output 'request.json') $artifact.Sha256;$report.Worker=$worker
if(-not $worker.Started -or -not $worker.TerminationConfirmed -or $worker.TimedOut -or $worker.Diagnostic -or $worker.ExitCode -ne 0 -or -not $worker.Receipt){throw ('Query worker did not complete verified observation. '+$worker.Diagnostic)}
$receipt=$worker.Receipt;Assert-WelaArrivalObject $receipt @('SchemaVersion','Kind','Nonce','ProcessId','Engine','ModulePath','StartedUtc','CompletedUtc','ReaderBefore','ReaderAfter','Host','Sources','QuerySha256','Result')
foreach($name in @('Kind','Nonce','ModulePath','QuerySha256')){if($receipt.$name -isnot [string]){throw 'Mistyped worker receipt identity.'}}
if(($receipt.SchemaVersion -isnot [int] -and $receipt.SchemaVersion -isnot [long]) -or $receipt.SchemaVersion -ne 1 -or $receipt.Kind -cne 'WelaWefQueryWorker' -or $receipt.Nonce -cne $request.Nonce -or ($receipt.ProcessId -isnot [int] -and $receipt.ProcessId -isnot [long]) -or $receipt.ProcessId -ne $worker.ProcessId -or $receipt.ModulePath -cne $engine.ModulePath -or $receipt.QuerySha256 -cne $selection.QuerySha256 -or (Get-WelaWefQueryKey $receipt.Engine) -cne (Get-WelaWefQueryKey $engine) -or (Get-WelaWefQueryKey $receipt.Host) -cne (Get-WelaWefQueryKey $hostState) -or (Get-WelaWefQueryKey $receipt.Sources) -cne (Get-WelaWefQueryKey $sources)){throw 'Worker receipt differs from actual reviewed query/engine/host/source context.'}
if((Get-WelaWefQueryTokenKey $receipt.ReaderBefore) -cne $tokenKey -or (Get-WelaWefQueryTokenKey $receipt.ReaderAfter) -cne $tokenKey){throw 'Worker token differs from the actual caller or changed during query.'}
if((ConvertTo-WelaArrivalUtc $receipt.StartedUtc) -gt (ConvertTo-WelaArrivalUtc $receipt.CompletedUtc)){throw 'Worker time interval is invalid.'}
Assert-WelaWefQueryNativeResult $receipt.Result $selection.Channels $MaximumEvents
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'worker.json' (Get-WelaWefQueryKey $receipt)
$report.Query=$receipt.Result;$number=0;$seen=@{}
foreach($xml in $receipt.Result.Events){$metadata=Read-WelaWefQueryEvent $xml $selection.Channels $hostState;$key=$metadata.Channel+':'+$metadata.RecordId;if($seen[$key]){throw 'Duplicate native event identity.'};$seen[$key]=$true;$number++;$name='event-{0:d3}.xml' -f $number;$report.Artifacts+=Write-WelaWecUpdateArtifact $output $name $xml;$report.Matches+=[pscustomobject]@{Artifact=$name;Metadata=$metadata}}
# XML is retained in named artifacts/worker evidence, not repeated in the manifest.
$report.Query.Events=@();$worker.Receipt=$null
$report.ChannelAfter=@(Get-WelaWefQueryChannelState $selection.Channels);$report.ReaderAfter=Get-WelaWefQueryToken
Assert-WelaWefQueryInputs $selection
if((Get-WelaWefQueryKey (Get-WelaWefQueryHost)) -cne (Get-WelaWefQueryKey $hostState) -or (Get-WelaWefQueryKey (Get-WelaWefQuerySources)) -cne (Get-WelaWefQueryKey $sources) -or (Get-WelaWefQueryTokenKey $report.ReaderAfter) -cne $tokenKey -or (Get-WelaWefQueryKey (Get-WelaWefQueryEngine)) -cne (Get-WelaWefQueryKey $engine) -or (Get-WelaWefQueryKey $report.ChannelAfter) -cne (Get-WelaWefQueryKey $report.ChannelBefore)){throw 'Host/token/source/engine or channel configuration changed during query.'}
foreach($file in $report.Artifacts){if((Get-FileHash -LiteralPath (Join-Path $output $file.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -cne $file.Sha256){throw 'Retained query evidence changed.'}}
$result=$report.Query
if($result.Opened -and $result.Complete -and $result.CleanupConfirmed -and -not $result.Capped -and $null -eq $result.NativeError -and -not $result.Diagnostic -and -not @($result.Channels|Where-Object Error -NE 0).Count){$report.Status=if($report.Matches.Count){'MatchesObserved'}else{'ReadAllowedEmpty'};$report.ExitCode=0}
elseif($result.Opened){$report.Status='Partial'}else{$report.Status='QueryFailed'}
}catch{$report.Diagnostic=$_.Exception.Message}
$null=Write-WelaWecUpdateArtifact $output 'manifest.json' (Get-WelaWefQueryKey $report)
$report
}
+187
View File
@@ -0,0 +1,187 @@
// Read-only native Event Log query and bounded output helpers.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Security.Principal;
using System.Threading.Tasks;
namespace Wela.WefQuery {
public sealed class LogStatus { public string Channel; public uint Error; }
public sealed class Result {
public bool Opened, Complete, Capped, CleanupConfirmed=true;
public uint? NativeError; public string Diagnostic="";
public LogStatus[] Channels=new LogStatus[0], DiagnosticChannels=new LogStatus[0];
public uint? DiagnosticNativeError;
public string[] Events=new string[0];
public uint[] XmlPropertyCounts=new uint[0];
}
public static class Native {
public const string SourceSha256="__WELA_WEF_QUERY_SHA256__";
const int MaximumBuffer=1048576;
[DllImport("wevtapi.dll",CharSet=CharSet.Unicode,ExactSpelling=true,SetLastError=true)] static extern IntPtr EvtQuery(IntPtr session,string path,string query,uint flags);
[DllImport("wevtapi.dll",ExactSpelling=true,SetLastError=true)] static extern bool EvtGetQueryInfo(IntPtr query,int property,uint size,IntPtr buffer,out uint used);
[DllImport("wevtapi.dll",ExactSpelling=true,SetLastError=true)] static extern bool EvtNext(IntPtr query,uint size,[Out] IntPtr[] events,uint timeout,uint flags,out uint returned);
[DllImport("wevtapi.dll",ExactSpelling=true,SetLastError=true)] static extern bool EvtRender(IntPtr context,IntPtr value,uint flags,uint size,IntPtr buffer,out uint used,out uint count);
[DllImport("wevtapi.dll",ExactSpelling=true,SetLastError=true)] static extern bool EvtClose(IntPtr value);
static int Offset(IntPtr buffer,int used,IntPtr value,long length) {
long offset=value.ToInt64()-buffer.ToInt64();
if(value==IntPtr.Zero||offset<16||length<0||offset>used||length>used-offset)throw new InvalidDataException("Native pointer escapes its returned query buffer.");
return (int)offset;
}
static string Text(IntPtr buffer,int used,IntPtr value,int maximum) {
int offset=Offset(buffer,used,value,2);if((offset&1)!=0)throw new InvalidDataException("Unaligned native UTF16 string.");
int length=0;while(length<=maximum&&offset+2L*length+2<=used){if(Marshal.ReadInt16(buffer,offset+2*length)==0){byte[] bytes=new byte[length*2];Marshal.Copy(value,bytes,0,bytes.Length);return new UnicodeEncoding(false,false,true).GetString(bytes);}length++;}
throw new InvalidDataException("Unterminated or oversized native query name.");
}
static int Header(IntPtr buffer,int used,int expected) {
if(buffer==IntPtr.Zero||used<16||used>MaximumBuffer||Marshal.ReadInt32(buffer,12)!=expected)throw new InvalidDataException("Unexpected native query variant type or size.");
int count=Marshal.ReadInt32(buffer,8);if(count<0||count>128)throw new InvalidDataException("Native query status count exceeds 128.");return count;
}
// EVT (not EC) UInt32 is 8; arrays require the exact array bit.
public static string[] DecodeNames(IntPtr buffer,int used) {
int count=Header(buffer,used,129);IntPtr values=Marshal.ReadIntPtr(buffer);string[] result=new string[count];
if(count>0){Offset(buffer,used,values,(long)count*IntPtr.Size);for(int i=0;i<count;i++){result[i]=Text(buffer,used,Marshal.ReadIntPtr(values,i*IntPtr.Size),1024);if(result[i].Length==0)throw new InvalidDataException("Empty native query channel.");}}
return result;
}
public static uint[] DecodeStatuses(IntPtr buffer,int used) {
int count=Header(buffer,used,136);IntPtr values=Marshal.ReadIntPtr(buffer);uint[] result=new uint[count];
if(count>0){Offset(buffer,used,values,(long)count*4);for(int i=0;i<count;i++)result[i]=unchecked((uint)Marshal.ReadInt32(values,i*4));}return result;
}
static object Info(IntPtr query,int property) {
uint size=0;for(int attempt=0;attempt<4;attempt++){
IntPtr buffer=size==0?IntPtr.Zero:Marshal.AllocHGlobal((int)size);
try{uint used;bool ok=EvtGetQueryInfo(query,property,size,buffer,out used);int error=Marshal.GetLastWin32Error();
if(ok){if(used<16||used>size)throw new InvalidDataException("Native query returned an invalid used length.");return property==0?(object)DecodeNames(buffer,(int)used):DecodeStatuses(buffer,(int)used);}
if(error!=122)throw new Win32Exception(error);if(used<=size||used>MaximumBuffer)throw new InvalidDataException("Native query buffer bound exceeded.");size=used;
}finally{if(buffer!=IntPtr.Zero)Marshal.FreeHGlobal(buffer);}
}throw new InvalidDataException("Native query buffer did not stabilize.");
}
static LogStatus[] Statuses(IntPtr query) {
string[] names=(string[])Info(query,0);uint[] codes=(uint[])Info(query,1);
if(names.Length!=codes.Length||names.Length==0)throw new InvalidDataException("Incomplete native query channel status arrays.");
LogStatus[] result=new LogStatus[names.Length];for(int i=0;i<names.Length;i++)result[i]=new LogStatus {Channel=names[i],Error=codes[i]};return result;
}
public static string DecodeXml(IntPtr buffer,uint allocated,uint used) {
if(buffer==IntPtr.Zero||allocated>MaximumBuffer||used<2||used>allocated||(used&1)!=0)throw new InvalidDataException("Native event XML byte boundary differs: used="+used+", allocated="+allocated+".");
if(Marshal.ReadInt16(buffer,(int)used-2)!=0)throw new InvalidDataException("Native event XML lacks the final UTF16 terminator.");
byte[] bytes=new byte[used-2];Marshal.Copy(buffer,bytes,0,bytes.Length);string xml=new UnicodeEncoding(false,false,true).GetString(bytes);
if(xml.IndexOf('\0')>=0)throw new InvalidDataException("Embedded NUL in event XML.");return xml;
}
static string Render(IntPtr value,out uint propertyCount) {
propertyCount=0;uint size=0;for(int attempt=0;attempt<4;attempt++){
IntPtr buffer=size==0?IntPtr.Zero:Marshal.AllocHGlobal((int)size);
try{uint used,count;bool ok=EvtRender(IntPtr.Zero,value,1,size,buffer,out used,out count);int error=Marshal.GetLastWin32Error();
// XML is a Unicode string, not an EVT_VARIANT array. Reviewed Server 2022/2025 runs returned
// PropertyCount=1 here despite the documented zero. Retain it as information;
// like .NET EventLogReader, never use it to size or interpret XML.
if(ok){propertyCount=count;return DecodeXml(buffer,size,used);}
if(error!=122)throw new Win32Exception(error);if(used<=size||used>MaximumBuffer)throw new InvalidDataException("Native event XML exceeds one MiB.");size=used;
}finally{if(buffer!=IntPtr.Zero)Marshal.FreeHGlobal(buffer);}
}throw new InvalidDataException("Native event XML buffer did not stabilize.");
}
static void Close(IntPtr handle,Result result) {if(handle!=IntPtr.Zero&&!EvtClose(handle)){result.CleanupConfirmed=false;result.Complete=false;result.Diagnostic+=" Native query/event handle close failed.";}}
public static Result Read(string query,int maximum) {
if(String.IsNullOrEmpty(query)||query.Length>65536||maximum<1||maximum>64)throw new ArgumentException("Query text/event count exceeds the explicit bound.");
Result result=new Result();List<string> events=new List<string>();List<uint> propertyCounts=new List<uint>();IntPtr handle=IntPtr.Zero;
try{
// Local log query, reverse order. Never tolerate errors for matching evidence.
handle=EvtQuery(IntPtr.Zero,null,query,0x201);
if(handle==IntPtr.Zero){result.NativeError=unchecked((uint)Marshal.GetLastWin32Error());
// Diagnostic-only alternate query. Windows may recover parts of invalid XPath.
IntPtr diagnostic=EvtQuery(IntPtr.Zero,null,query,0x1201);
if(diagnostic==IntPtr.Zero)result.DiagnosticNativeError=unchecked((uint)Marshal.GetLastWin32Error());
else try{result.DiagnosticChannels=Statuses(diagnostic);}catch(Exception e){result.Diagnostic+=" Diagnostic status read failed: "+e.Message;}finally{Close(diagnostic,result);}
return result;
}
result.Opened=true;result.Channels=Statuses(handle);long bytes=0;
while(true){IntPtr[] next=new IntPtr[1];uint returned=0;bool ok=EvtNext(handle,1,next,5000,0,out returned);int error=Marshal.GetLastWin32Error();
try{
if(!ok){if(returned!=0||next[0]!=IntPtr.Zero)throw new InvalidDataException("Failed EvtNext returned an unexpected event.");if(error==259)result.Complete=true;else result.NativeError=unchecked((uint)error);break;}
if(returned!=1||next[0]==IntPtr.Zero)throw new InvalidDataException("EvtNext returned an invalid count or handle.");
if(events.Count==maximum){result.Capped=true;break;}
uint propertyCount;string xml=Render(next[0],out propertyCount);bytes+=Encoding.UTF8.GetByteCount(xml);if(bytes>4194304)throw new InvalidDataException("Native matching XML exceeds four MiB aggregate.");events.Add(xml);propertyCounts.Add(propertyCount);
}finally{Close(next[0],result);}
}
}catch(Win32Exception e){result.NativeError=unchecked((uint)e.NativeErrorCode);result.Complete=false;result.Diagnostic+=e.Message;}
catch(Exception e){result.Complete=false;result.Diagnostic+=e.Message;}
finally{Close(handle,result);if(!result.CleanupConfirmed)result.Complete=false;result.Events=events.ToArray();result.XmlPropertyCounts=propertyCounts.ToArray();}
return result;
}
public static async Task<string> ReadPipe(TextReader reader,int maximum) {
var text=new StringBuilder();var buffer=new char[2048];while(true){int count=await reader.ReadAsync(buffer,0,buffer.Length).ConfigureAwait(false);if(count==0)return text.ToString();if(count>maximum-text.Length)throw new InvalidDataException("Worker output exceeds its bound.");text.Append(buffer,0,count);}
}
}
}
namespace Wela.WefQueryToken {
public sealed class Group { public string Sid; public uint Attributes; }
public sealed class Privilege { public string Luid; public uint Attributes; }
public sealed class Token {
public string Sid, Name, AuthenticationId, AuthenticationType, ImpersonationLevel, TokenSource;
public Group[] Groups; public Privilege[] Privileges;
}
public static class Native {
[DllImport("kernel32.dll",ExactSpelling=true)] static extern void GetSystemTimePreciseAsFileTime(out long value);
public static DateTime UtcNow() {long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value);}
[StructLayout(LayoutKind.Sequential)] struct Luid {public uint Low; public int High;}
[StructLayout(LayoutKind.Sequential)] struct Statistics {public Luid TokenId,AuthenticationId;public long Expiration;public int Type,Level;public uint Charged,Available,Groups,Privileges;public Luid Modified;}
[StructLayout(LayoutKind.Sequential)] struct SidAndAttributes {public IntPtr Sid;public uint Attributes;}
[StructLayout(LayoutKind.Sequential)] struct TokenGroups {public uint Count;public SidAndAttributes First;}
[StructLayout(LayoutKind.Sequential)] struct LuidAndAttributes {public Luid Luid;public uint Attributes;}
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess();
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentThread();
[DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr h);
[DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr p,uint access,out IntPtr t);
[DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenThreadToken(IntPtr p,uint access,bool self,out IntPtr t);
[DllImport("advapi32.dll",SetLastError=true)] static extern bool GetTokenInformation(IntPtr t,int cls,IntPtr data,int length,out int needed);
static string Hex(Luid id) {return "0x"+(((ulong)(uint)id.High<<32)|id.Low).ToString("x");}
static IntPtr Read(IntPtr token,int cls,out int length) {
GetTokenInformation(token,cls,IntPtr.Zero,0,out length);
if(Marshal.GetLastWin32Error()!=122||length<4||length>65536)throw new InvalidOperationException("Unknown or oversized token information.");
IntPtr data=Marshal.AllocHGlobal(length);
if(!GetTokenInformation(token,cls,data,length,out length)){int error=Marshal.GetLastWin32Error();Marshal.FreeHGlobal(data);throw new Win32Exception(error);}
return data;
}
static Token ReadToken(IntPtr token,string source) {
Token result=new Token();result.TokenSource=source;using(WindowsIdentity identity=new WindowsIdentity(token)){result.Sid=identity.User.Value;result.Name=identity.Name;result.AuthenticationType=identity.AuthenticationType;result.ImpersonationLevel=identity.ImpersonationLevel.ToString();}
int length;IntPtr p=Read(token,10,out length);
try {if(length<Marshal.SizeOf(typeof(Statistics)))throw new InvalidOperationException("Truncated token statistics.");result.AuthenticationId=Hex(((Statistics)Marshal.PtrToStructure(p,typeof(Statistics))).AuthenticationId);}finally{Marshal.FreeHGlobal(p);}
p=Read(token,2,out length);
try {int count=Marshal.ReadInt32(p),offset=(int)Marshal.OffsetOf(typeof(TokenGroups),"First"),size=Marshal.SizeOf(typeof(SidAndAttributes));if(count<0||count>4096||offset+(long)count*size>length)throw new InvalidOperationException("Invalid token groups.");List<Group> groups=new List<Group>();for(int i=0;i<count;i++){SidAndAttributes g=(SidAndAttributes)Marshal.PtrToStructure(IntPtr.Add(p,offset+i*size),typeof(SidAndAttributes));groups.Add(new Group{Sid=new SecurityIdentifier(g.Sid).Value,Attributes=g.Attributes});}groups.Sort((a,b)=>String.CompareOrdinal(a.Sid,b.Sid));result.Groups=groups.ToArray();}finally{Marshal.FreeHGlobal(p);}
p=Read(token,3,out length);
try {int count=Marshal.ReadInt32(p),size=Marshal.SizeOf(typeof(LuidAndAttributes));if(count<0||count>4096||4+(long)count*size>length)throw new InvalidOperationException("Invalid token privileges.");List<Privilege> privileges=new List<Privilege>();for(int i=0;i<count;i++){LuidAndAttributes v=(LuidAndAttributes)Marshal.PtrToStructure(IntPtr.Add(p,4+i*size),typeof(LuidAndAttributes));privileges.Add(new Privilege{Luid=Hex(v.Luid),Attributes=v.Attributes});}privileges.Sort((a,b)=>String.CompareOrdinal(a.Luid,b.Luid));result.Privileges=privileges.ToArray();}finally{Marshal.FreeHGlobal(p);}
return result;
}
static bool Equivalent(Token a,Token b) {
if(a.Sid!=b.Sid||a.AuthenticationId!=b.AuthenticationId||a.Groups.Length!=b.Groups.Length||a.Privileges.Length!=b.Privileges.Length)return false;
for(int i=0;i<a.Groups.Length;i++)if(a.Groups[i].Sid!=b.Groups[i].Sid||a.Groups[i].Attributes!=b.Groups[i].Attributes)return false;
for(int i=0;i<a.Privileges.Length;i++)if(a.Privileges[i].Luid!=b.Privileges[i].Luid||a.Privileges[i].Attributes!=b.Privileges[i].Attributes)return false;
return true;
}
static string Difference(Token a,Token b) {
if(a.Sid!=b.Sid)return "user SID differs";
if(a.AuthenticationId!=b.AuthenticationId)return "logon LUID differs";
if(a.Groups.Length!=b.Groups.Length)return "group count differs";
for(int i=0;i<a.Groups.Length;i++)if(a.Groups[i].Sid!=b.Groups[i].Sid||a.Groups[i].Attributes!=b.Groups[i].Attributes)return "group "+a.Groups[i].Sid+" process="+a.Groups[i].Attributes+" effective="+b.Groups[i].Attributes;
if(a.Privileges.Length!=b.Privileges.Length)return "privilege count differs";
for(int i=0;i<a.Privileges.Length;i++)if(a.Privileges[i].Luid!=b.Privileges[i].Luid||a.Privileges[i].Attributes!=b.Privileges[i].Attributes)return "privilege "+a.Privileges[i].Luid+" process="+a.Privileges[i].Attributes+" effective="+b.Privileges[i].Attributes;
return "unknown difference";
}
[DllImport("advapi32.dll")] static extern bool IsTokenRestricted(IntPtr token);
public static Token Snapshot() {
IntPtr thread=IntPtr.Zero,process=IntPtr.Zero;
if(!OpenThreadToken(GetCurrentThread(),8,true,out thread)){int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error);}
try {
if(!OpenProcessToken(GetCurrentProcess(),8,out process))throw new Win32Exception(Marshal.GetLastWin32Error());
if(IsTokenRestricted(process)||(thread!=IntPtr.Zero&&IsTokenRestricted(thread)))throw new InvalidOperationException("Restricted tokens are unsupported.");
Token primary=ReadToken(process,"Process");
if(thread==IntPtr.Zero)return primary;
Token effective=ReadToken(thread,"EquivalentSelfThread");
if(!Equivalent(primary,effective))throw new InvalidOperationException("Effective thread token differs from the process token ("+Difference(primary,effective)+"); an ordinary child cannot preserve this caller context.");
return effective;
} finally {if(process!=IntPtr.Zero)CloseHandle(process);if(thread!=IntPtr.Zero)CloseHandle(thread);}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
param([Parameter(Mandatory)][string]$RequestPath,[Parameter(Mandatory)][ValidatePattern('^[a-f0-9]{64}$')][string]$RequestHash)
# Startup can reconstruct PSModulePath. Reset before any cmdlet/module can load.
$env:PSModulePath=[IO.Path]::Combine($PSHOME,'Modules')
$ErrorActionPreference='Stop';[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
$script:ScriptRoot=[IO.Path]::GetFullPath([IO.Path]::Combine($PSScriptRoot,'..'))
Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -ErrorAction Stop
Import-Module (Join-Path $script:ScriptRoot 'modules/WefSubscriptions.psm1') -ErrorAction Stop
foreach($name in @('WefArrival','WecUpdate','ChannelRead','WefQuery')){. (Join-Path $PSScriptRoot ($name+'.ps1'))}
$file=Read-WelaWecUpdateFile $RequestPath 2097152
if($file.Hash -cne $RequestHash){throw 'Query request hash differs.'};$request=ConvertFrom-WelaArrivalJson $file.Text
Assert-WelaArrivalObject $request @('SchemaVersion','Kind','Nonce','Query','QuerySha256','Channels','MaximumEvents','Sources','Host','Reader','Engine')
if(($request.SchemaVersion -isnot [int] -and $request.SchemaVersion -isnot [long]) -or $request.SchemaVersion -ne 1 -or $request.Kind -isnot [string] -or $request.Kind -cne 'WelaWefQueryRequest' -or $request.Nonce -isnot [string] -or $request.Nonce -cnotmatch '^[a-f0-9]{32}$' -or $request.Query -isnot [string] -or $request.QuerySha256 -isnot [string] -or $request.QuerySha256 -cne (Get-WelaArrivalHash ([Text.UTF8Encoding]::new($false).GetBytes($request.Query)))){throw 'Invalid native query request identity.'}
Assert-WelaWefQueryUInt $request.MaximumEvents
if($request.MaximumEvents -lt 1 -or $request.MaximumEvents -gt 64 -or $request.Query.Length -gt 65536){throw 'Native query request exceeds its bound.'}
$query=ConvertFrom-WelaWefQuery $request.Query
if($query.Filters.Count -gt 128 -or $query.Channels.Count -gt 16 -or (Get-WelaWefQueryKey @($query.Channels)) -cne (Get-WelaWefQueryKey $request.Channels)){throw 'Native query channel selection differs.'}
$hostState=Get-WelaWefQueryHost;$sources=Get-WelaWefQuerySources;$engine=Get-WelaWefQueryEngine;$before=Get-WelaWefQueryToken
if((Get-WelaWefQueryKey $hostState) -cne (Get-WelaWefQueryKey $request.Host) -or (Get-WelaWefQueryKey $sources) -cne (Get-WelaWefQueryKey $request.Sources) -or (Get-WelaWefQueryKey $engine) -cne (Get-WelaWefQueryKey $request.Engine) -or (Get-WelaWefQueryTokenKey $before) -cne (Get-WelaWefQueryTokenKey $request.Reader)){throw 'Worker actual context differs from request.'}
$started=[Wela.WefQueryToken.Native]::UtcNow()
$result=[Wela.WefQuery.Native]::Read($request.Query,$request.MaximumEvents)
$completed=[Wela.WefQueryToken.Native]::UtcNow();$after=Get-WelaWefQueryToken
if((Get-WelaWefQueryTokenKey $before) -cne (Get-WelaWefQueryTokenKey $after) -or (Read-WelaWecUpdateFile $RequestPath 2097152).Hash -cne $RequestHash -or (Get-WelaWefQueryKey (Get-WelaWefQuerySources)) -cne (Get-WelaWefQueryKey $sources)){throw 'Worker token, request or source changed during query.'}
[pscustomobject]@{SchemaVersion=1;Kind='WelaWefQueryWorker';Nonce=$request.Nonce;ProcessId=$PID;Engine=$engine;ModulePath=$env:PSModulePath;StartedUtc=$started.ToString('o');CompletedUtc=$completed.ToString('o');ReaderBefore=$before;ReaderAfter=$after;Host=$hostState;Sources=$sources;QuerySha256=$request.QuerySha256;Result=$result}|ConvertTo-Json -Depth 32 -Compress
exit 0