From 3fb0f8aa6cc4b5f249fc2fbeb23638ce6c27fff7 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:37:37 +0900 Subject: [PATCH] fix: decode native WEF XML independently of property count --- docs/wef-query.md | 2 +- scripts/WefQuery.ps1 | 3 ++- scripts/WefQueryNative.cs | 22 ++++++++++++++++------ tests/WefQuery.Tests.ps1 | 27 +++++++++++++++++++-------- tests/WefQuery.Windows.Tests.ps1 | 1 + 5 files changed, 39 insertions(+), 16 deletions(-) diff --git a/docs/wef-query.md b/docs/wef-query.md index 5f945f2b..07ef50b3 100644 --- a/docs/wef-query.md +++ b/docs/wef-query.md @@ -37,7 +37,7 @@ The fixed worker uses the same installed PowerShell engine and actual caller con The new output directory grants access to the current user, SYSTEM and local Administrators. Original inputs and parent ACLs are not changed. Paths must be ordinary local paths accepted by WELA's recovery artifact helpers; existing output directories and observed reparse paths are refused. Raw event payloads can contain sensitive operational data, so retain them as evidence under the intended reader's access policy. -Outputs include decoded `source-config.json`, `subscription.xml`, exact `query.xml`, the worker `request.json`, `worker.json`, individual `event-NNN.xml` matches and a final `manifest.json`. The manifest records original file paths/hashes, source fingerprints, query hash, actual host/DNS context, engine hash/version, before/after reader and channel observations, strict/diagnostic query results and artifact hashes. The original byte hashes are distinct from the decoded text artifacts. Unsuccessful runs retain whatever evidence was available; a missing final manifest means the output is incomplete. +Outputs include decoded `source-config.json`, `subscription.xml`, exact `query.xml`, the worker `request.json`, `worker.json`, individual `event-NNN.xml` matches and a final `manifest.json`. The manifest records original file paths/hashes, source fingerprints, query hash, actual host/DNS context, engine hash/version, before/after reader and channel observations, strict/diagnostic query results and artifact hashes. The worker retains each XML render’s native `PropertyCount` as information; the Server 2022/2025 validation runs observed 1 even though the API documentation specifies 0 for XML. XML parsing uses bounded UTF-16 byte length and its final terminator, following the string rendering contract, independently of that values-array count. The original byte hashes are distinct from the decoded text artifacts. Unsuccessful runs retain whatever evidence was available; a missing final manifest means the output is incomplete. The worker's SID, logon, group attributes and privileges must match the caller and remain stable. Host, input bytes, implementation, engine, channel configuration and saved hashes are rechecked before completeness. Returned event channel/record identity and exact observed local computer names must be consistent; no same-label arbitrary DNS suffix is accepted. These checks are observations rather than an atomic channel snapshot, and hashes establish consistency rather than authenticating an evidence author. diff --git a/scripts/WefQuery.ps1 b/scripts/WefQuery.ps1 index 9e071498..206e940d 100644 --- a/scripts/WefQuery.ps1 +++ b/scripts/WefQuery.ps1 @@ -123,9 +123,10 @@ function Start-WelaWefQueryWorker { } function Assert-WelaWefQueryNativeResult { param($Result,[string[]]$Channels,[int]$MaximumEvents) - Assert-WelaArrivalObject $Result @('Opened','Complete','Capped','CleanupConfirmed','NativeError','Diagnostic','Channels','DiagnosticChannels','DiagnosticNativeError','Events') + 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.'} diff --git a/scripts/WefQueryNative.cs b/scripts/WefQueryNative.cs index 067b1beb..4f3523b4 100644 --- a/scripts/WefQueryNative.cs +++ b/scripts/WefQueryNative.cs @@ -15,6 +15,7 @@ namespace Wela.WefQuery { 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__"; @@ -62,11 +63,20 @@ namespace Wela.WefQuery { 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;iMaximumBuffer||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(); - if(ok){if(used<2||used>size||(used&1)!=0)throw new InvalidDataException("Native event XML byte boundary differs: used="+used+", allocated="+size+".");if(count!=0)throw new InvalidDataException("Native XML PropertyCount is "+count+", expected zero.");if(Marshal.ReadInt16(buffer,(int)used-2)!=0)throw new InvalidDataException("Native event XML lacks the final UTF16 terminator: used="+used+", allocated="+size+", finalWord="+Marshal.ReadInt16(buffer,(int)used-2)+".");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;} + // 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."); @@ -74,7 +84,7 @@ namespace Wela.WefQuery { 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 events=new List();IntPtr handle=IntPtr.Zero; + Result result=new Result();List events=new List();List propertyCounts=new List();IntPtr handle=IntPtr.Zero; try{ // Local log query, reverse order. Never tolerate errors for matching evidence. handle=EvtQuery(IntPtr.Zero,null,query,0x201); @@ -91,12 +101,12 @@ namespace Wela.WefQuery { 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;} - string xml=Render(next[0]);bytes+=Encoding.UTF8.GetByteCount(xml);if(bytes>4194304)throw new InvalidDataException("Native matching XML exceeds four MiB aggregate.");events.Add(xml); + 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();} + finally{Close(handle,result);if(!result.CleanupConfirmed)result.Complete=false;result.Events=events.ToArray();result.XmlPropertyCounts=propertyCounts.ToArray();} return result; } public static async Task ReadPipe(TextReader reader,int maximum) { diff --git a/tests/WefQuery.Tests.ps1 b/tests/WefQuery.Tests.ps1 index 79a9bbbf..fcf38201 100644 --- a/tests/WefQuery.Tests.ps1 +++ b/tests/WefQuery.Tests.ps1 @@ -30,9 +30,20 @@ try{ Reject {[Wela.WefQuery.Native]::DecodeNames($buffer,44)} 'Unterminated names refuse.' [Runtime.InteropServices.Marshal]::WriteInt16($buffer,32,[int16]-10240);Reject {[Wela.WefQuery.Native]::DecodeNames($buffer,46)} 'Unpaired Unicode surrogate refuses.' foreach($used in @(0,15,1048577)){Reject {[Wela.WefQuery.Native]::DecodeNames($buffer,$used)} "Invalid buffer length $used"} + $rendered='日本語 Ω';$raw=[Text.Encoding]::Unicode.GetBytes($rendered+[char]0);[Runtime.InteropServices.Marshal]::Copy($raw,0,$buffer,$raw.Length) + Assert ([Wela.WefQuery.Native]::DecodeXml($buffer,128,$raw.Length) -ceq $rendered) 'Bounded native rendered XML preserves exact Unicode.' + foreach($used in @(0,1,3,130)){Reject {[Wela.WefQuery.Native]::DecodeXml($buffer,128,$used)} "Invalid rendered byte boundary $used"} + Reject {[Wela.WefQuery.Native]::DecodeXml([IntPtr]::Zero,128,$raw.Length)} 'Null render buffer refused.' + Reject {[Wela.WefQuery.Native]::DecodeXml($buffer,1048577,$raw.Length)} 'Render allocation cap enforced.' + Reject {[Wela.WefQuery.Native]::DecodeXml($buffer,128,$raw.Length-2)} 'Missing final XML terminator refused.' + [Runtime.InteropServices.Marshal]::WriteInt16($buffer,0,0);Reject {[Wela.WefQuery.Native]::DecodeXml($buffer,128,$raw.Length)} 'Embedded rendered XML NUL refused.' + [Runtime.InteropServices.Marshal]::WriteInt16($buffer,0,[int16]-10240);Reject {[Wela.WefQuery.Native]::DecodeXml($buffer,128,$raw.Length)} 'Invalid rendered UTF16 surrogate refused.' }finally{[Runtime.InteropServices.Marshal]::FreeHGlobal($buffer)} -$result=[pscustomobject]@{Opened=$true;Complete=$true;Capped=$false;CleanupConfirmed=$true;NativeError=$null;Diagnostic='';Channels=@([pscustomobject]@{Channel='System';Error=0});DiagnosticChannels=@();DiagnosticNativeError=$null;Events=@()} +$result=[pscustomobject]@{Opened=$true;Complete=$true;Capped=$false;CleanupConfirmed=$true;NativeError=$null;Diagnostic='';Channels=@([pscustomobject]@{Channel='System';Error=0});DiagnosticChannels=@();DiagnosticNativeError=$null;Events=@();XmlPropertyCounts=@()} Assert-WelaWefQueryNativeResult $result @('System') 16;Assert $true 'Complete empty strict result valid.' +$copy=Clone $result;$copy.Events=@('');$copy.XmlPropertyCounts=@(1);Assert-WelaWefQueryNativeResult $copy @('System') 16;Assert $true 'Observed XML PropertyCount=1 is informational, not a values-array requirement.' +$copy.XmlPropertyCounts=@();Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} 'Every retained XML has a corresponding render observation.' +$copy.XmlPropertyCounts=@($true);Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} 'Render observation must be an actual native unsigned count.' foreach($field in @('Opened','Complete','Capped','CleanupConfirmed')){$copy=Clone $result;$copy.$field='true';Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} "Typed Boolean $field"} foreach($field in @('NativeError','DiagnosticNativeError')){$copy=Clone $result;$copy.$field=$true;Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} "Typed native code $field"} $copy=Clone $result;$copy.Channels=@();Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} 'Missing native per-channel provenance.' @@ -41,9 +52,9 @@ $copy=Clone $result;$copy.Channels[0].Error=$true;Reject {Assert-WelaWefQueryNat foreach($field in @('Capped','Diagnostic','NativeError','CleanupConfirmed')){$copy=Clone $result;switch($field){Capped{$copy.Capped=$true};Diagnostic{$copy.Diagnostic='failure'};NativeError{$copy.NativeError=5};CleanupConfirmed{$copy.CleanupConfirmed=$false}};Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} "Completeness cannot coexist with $field"} $failure=Clone $result;$failure.Opened=$false;$failure.Complete=$false;$failure.NativeError=15001;$failure.Channels=@();$failure.DiagnosticChannels=@([pscustomobject]@{Channel='System';Error=15001}) Assert-WelaWefQueryNativeResult $failure @('System') 16;Assert $true 'Failed strict query retains separate diagnostic errors.' -$failure.Events=@('');Reject {Assert-WelaWefQueryNativeResult $failure @('System') 16} 'Diagnostic records cannot become matches.' -$copy=Clone $result;$copy.Events=@($true);Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} 'Typed XML required.' -$copy=Clone $result;$copy.Events=@('x','y');Reject {Assert-WelaWefQueryNativeResult $copy @('System') 1} 'Event bound enforced.' +$failure.Events=@('');$failure.XmlPropertyCounts=@(1);Reject {Assert-WelaWefQueryNativeResult $failure @('System') 16} 'Diagnostic records cannot become matches.' +$copy=Clone $result;$copy.Events=@($true);$copy.XmlPropertyCounts=@(1);Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} 'Typed XML required.' +$copy=Clone $result;$copy.Events=@('x','y');$copy.XmlPropertyCounts=@(1,1);Reject {Assert-WelaWefQueryNativeResult $copy @('System') 1} 'Event bound enforced.' $xml='142SystemHost.example.test日本語 Ω & value' $hostContext=[pscustomobject]@{Computer='Host';DnsHostName='Host';DnsSuffix='example.test'} $event=Read-WelaWefQueryEvent $xml @('System') $hostContext;Assert ($event.RecordId -eq 42 -and $event.Channel -ceq 'System') 'Native event selected channel/local host provenance.' @@ -75,12 +86,12 @@ function Get-WelaWefQueryToken {[pscustomobject]@{Sid='S-1-5-21-1-2-3-1001';Name function Get-WelaWefQueryChannelState {param($Channels) $script:lifecycle.ChannelReads++;[pscustomobject]@{Name='System';State=$(if($script:lifecycle.Case -eq 'ChannelDrift' -and $script:lifecycle.ChannelReads -gt 1){'Disabled'}else{'Enabled'})}} function Start-WelaWefQueryWorker { param($Engine,$RequestPath,$RequestHash) - $request=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText($RequestPath));$result=[pscustomobject]@{Opened=$true;Complete=$true;Capped=$false;CleanupConfirmed=$true;NativeError=$null;Diagnostic='';Channels=@([pscustomobject]@{Channel='System';Error=0});DiagnosticChannels=@();DiagnosticNativeError=$null;Events=@($script:lifecycle.Xml)} - if($script:lifecycle.Case -eq 'Empty'){$result.Events=@()} + $request=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText($RequestPath));$result=[pscustomobject]@{Opened=$true;Complete=$true;Capped=$false;CleanupConfirmed=$true;NativeError=$null;Diagnostic='';Channels=@([pscustomobject]@{Channel='System';Error=0});DiagnosticChannels=@();DiagnosticNativeError=$null;Events=@($script:lifecycle.Xml);XmlPropertyCounts=@(1)} + if($script:lifecycle.Case -eq 'Empty'){$result.Events=@();$result.XmlPropertyCounts=@()} if($script:lifecycle.Case -eq 'Partial'){$result.Complete=$false;$result.Capped=$true} if($script:lifecycle.Case -eq 'MissingStatus'){$result.Channels=@()} - if($script:lifecycle.Case -eq 'DuplicateEvents'){$result.Events=@($script:lifecycle.Xml,$script:lifecycle.Xml)} - if($script:lifecycle.Case -eq 'FailedQuery'){$result.Opened=$false;$result.Complete=$false;$result.NativeError=5;$result.Channels=@();$result.Events=@()} + if($script:lifecycle.Case -eq 'DuplicateEvents'){$result.Events=@($script:lifecycle.Xml,$script:lifecycle.Xml);$result.XmlPropertyCounts=@(1,1)} + if($script:lifecycle.Case -eq 'FailedQuery'){$result.Opened=$false;$result.Complete=$false;$result.NativeError=5;$result.Channels=@();$result.Events=@();$result.XmlPropertyCounts=@()} $receipt=[pscustomobject]@{SchemaVersion=1;Kind='WelaWefQueryWorker';Nonce=$request.Nonce;ProcessId=4242;Engine=$Engine;ModulePath=$Engine.ModulePath;StartedUtc='2026-01-01T00:00:00Z';CompletedUtc='2026-01-01T00:00:01Z';ReaderBefore=(Get-WelaWefQueryToken);ReaderAfter=(Get-WelaWefQueryToken);Host=$request.Host;Sources=$request.Sources;QuerySha256=$request.QuerySha256;Result=$result} if($script:lifecycle.Case -eq 'DateTimeReceipt'){$receipt.StartedUtc=[DateTime]::SpecifyKind([datetime]'2026-01-01T00:00:00',[DateTimeKind]::Utc);$receipt.CompletedUtc=$receipt.StartedUtc.AddSeconds(1)} if($script:lifecycle.Case -eq 'InvalidTimeReceipt'){$receipt.StartedUtc=$true} diff --git a/tests/WefQuery.Windows.Tests.ps1 b/tests/WefQuery.Windows.Tests.ps1 index 9d109e0c..936512c1 100644 --- a/tests/WefQuery.Windows.Tests.ps1 +++ b/tests/WefQuery.Windows.Tests.ps1 @@ -67,6 +67,7 @@ try{ $query='' $match=Invoke-Public (New-Case 'match' $query) 0 Assert ($match.Status -ceq 'MatchesObserved' -and $match.Matches.Count -eq 1 -and $match.Matches[0].Metadata.RecordId -eq $recordId) 'Actual exact System record selected.' + Assert-WelaWefQueryUInt $match.Query.XmlPropertyCounts[0];Assert ($match.Query.XmlPropertyCounts.Count -eq 1) 'Actual native XML PropertyCount is informational and retained.' $found=[IO.File]::ReadAllText((Join-Path $root 'result-match/event-001.xml')) Assert ((Get-WelaWefXmlKey (Read-WelaWefXml $found).DocumentElement) -ceq (Get-WelaWefXmlKey (Read-WelaWefXml $originalXml).DocumentElement)) 'Actual returned full event matches independent native XML.' $suppressed=$query.Replace('','*[System[EventRecordID='+$recordId+']]')