From 2973eafb98095a5beba0eeb8ca8d67243c6bbae8 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:47:06 +0900 Subject: [PATCH] Use precise native DNS operation timestamps and nonce-only event reads --- docs/dns-client-probe.md | 6 ++++-- scripts/DnsClientProbe.ps1 | 11 ++++++++--- scripts/DnsClientProbeNative.cs | 12 +++++++++--- scripts/DnsClientProbeWorker.ps1 | 6 +++--- tests/DnsClientProbe.Tests.ps1 | 6 ++++++ 5 files changed, 30 insertions(+), 11 deletions(-) diff --git a/docs/dns-client-probe.md b/docs/dns-client-probe.md index d45abb43..2034a96d 100644 --- a/docs/dns-client-probe.md +++ b/docs/dns-client-probe.md @@ -14,12 +14,14 @@ The example address is documentation-only: replace it with an approved resolver. Plan creates no files and sends no probe lookup. Run generates exactly one application request for `wela-.wela.test.` type A; `.test` is reserved for DNS testing by [RFC 2606](https://www.rfc-editor.org/rfc/rfc2606.html). There is no caller-selected domain, record type or application connection to a returned address. A same-engine 64-bit worker uses synchronous `DnsQueryEx` with one explicit IPv4 DNS server, TCP port 53, recursion disabled, cache bypass, no hosts/local-name/NetBT/multicast fallback, fully qualified naming and IDN disabled. DNS retry/internal processing and normal response caching are OS behavior; this is not a promise of one wire packet, cache immutability or resolver-side enforcement. The query name, selected resolver and exact flags are retained. Only canonical unicast IPv4 literals are accepted; there is no hostname or configurable port. -The bounded worker has twenty seconds to finish. Terminating it does not prove cancellation of DNS service or network work; timed-out completion remains unverified. The separate event wait defaults to fifteen seconds (`-DnsClientProbeTimeoutSeconds 1..30`). Native status 0 (A answers), 9003 (NXDOMAIN) and 9501 (no records) are reviewed completion outcomes. A negative response is not reported as successful name resolution. Missing events, unknown outcomes/versions/types, caps, token or configuration/source drift and incomplete reads remain `Unverified` with a nonzero exit. No setup is automatically performed to make the test pass. +The bounded worker has twenty seconds to finish. Parent/worker timestamps use [GetSystemTimePreciseAsFileTime](https://learn.microsoft.com/en-us/windows/win32/api/sysinfoapi/nf-sysinfoapi-getsystemtimepreciseasfiletime), with no coarse-clock fallback or positive-match time padding. Terminating the worker does not prove cancellation of DNS service or network work; timed-out completion remains unverified. The separate event wait defaults to fifteen seconds (`-DnsClientProbeTimeoutSeconds 1..30`). Native status 0 (A answers), 9003 (NXDOMAIN) and 9501 (no records) are reviewed completion outcomes. A negative response is not reported as successful name resolution. Missing events, unknown outcomes/versions/types, caps, token or configuration/source drift and incomplete reads remain `Unverified` with a nonzero exit. No setup is automatically performed to make the test pass. -Evidence includes observed build/patch/role, token and same-engine context, exact provider GUID, live event/version/field types and template hashes, original pinned rule hashes, channel metadata, a pre-query record boundary, bounded original worker JSON (also retained if its validation fails), worker timestamps/status/answers and hashed original matched XML. Matching requires event 3008 version 0 on **Microsoft-Windows-DNS-Client/Operational**, source computer, unique query name/type, native completion status, requested option bits, record boundary and operation time. The emitter PID is retained in original XML; it may belong to the DNS service broker, so it is not assumed to equal the requesting worker PID. This correlation does not prove exclusive request attribution, the wire destination, DNSSEC validation or absence of simultaneous unrelated events. Full caller token snapshots bracket actual query/event I/O and are compared before final metadata inventory; the worker has its own exact before/after token checks. Metadata inventories are outside this interval because DISM and channel inspection may temporarily adjust privileges. Native event-query status is retained separately from its records. Artifact hashes detect byte changes; they are not signatures or historical host authentication. +Evidence includes observed build/patch/role, token and same-engine context, exact provider GUID, live event/version/field types and template hashes, original pinned rule hashes, channel metadata, a pre-query record boundary, bounded original worker JSON (also retained if its validation fails), worker timestamps/status/answers and hashed original matched XML. Matching requires event 3008 version 0 on **Microsoft-Windows-DNS-Client/Operational**, source computer, unique query name/type, native completion status, requested option bits, record boundary and operation time. The emitter PID is retained in original XML; it may belong to the DNS service broker, so it is not assumed to equal the requesting worker PID. This correlation does not prove exclusive request attribution, the wire destination, DNSSEC validation or absence of simultaneous unrelated events. Full caller token snapshots bracket actual query/event I/O and are compared before final metadata inventory; the worker has its own exact before/after token checks. Metadata inventories are outside this interval because DISM and channel inspection may temporarily adjust privileges. The native read is limited to this random query name, record boundary and last sixty seconds; any retained candidate outside the exact operation interval is diagnostic only. Native event-query status is retained separately from its records. Artifact hashes detect byte changes; they are not signatures or historical host authentication. `PrerequisitesObserved` means only that Plan observed supported metadata. `NativeDnsLookupObserved` means that a native completion and matching local event were observed. Neither proves forwarding, downstream parsing, detection execution or retention capacity. In particular, all six pinned DNS Client rules refer to **Microsoft-Windows-DNS Client Events/Operational**, a different channel string. WELA retains that mismatch and does not rewrite it. `ReadyRuleCredit` remains **0**; there is no six-rule Sigma uplift. Native acceptance uses a separately opt-in fixture on disposable GitHub-hosted workgroup Server 2022/2025 under Windows PowerShell 5.1 and PowerShell 7. The fixture refuses an existing DNS role, installs its own standalone role, creates authoritative `wela.test` with a wildcard A record to `192.0.2.1`, and queries only loopback. It temporarily enables the Client channel if needed, restores its exact original settings, checks audit policies, removes its owned zone/records and removes only newly installed DNS features. Feature removal may require VM disposal rather than a live reboot; the cleanup receipt records that boundary. Fixture setup is not part of the product. Windows 11, domain-joined/DC/ADCS hosts and external resolver/network behavior still require their own acceptance evidence. +The P/Invoke entry point is exactly `DnsQueryEx`, preventing [Unicode suffix probing](https://learn.microsoft.com/en-us/dotnet/standard/native-interop/specifying-a-character-set). The server-address buffer follows [Microsoft’s DNSAsyncQuery sample](https://github.com/microsoft/Windows-classic-samples/blob/main/Samples/DNSAsyncQuery/cpp/DnsQueryEx.cpp): one element, zero aggregate family, and the sockaddr default DNS port. + Native API references: [DnsQueryEx](https://learn.microsoft.com/en-us/windows/win32/api/windns/nf-windns-dnsqueryex), [DNS_QUERY_REQUEST](https://learn.microsoft.com/en-us/windows/win32/api/windns/ns-windns-dns_query_request), [DNS_ADDR_ARRAY](https://learn.microsoft.com/en-us/windows/win32/api/windnsdef/ns-windnsdef-dns_addr_array), [DNS query flags](https://learn.microsoft.com/en-us/windows/win32/dns/dns-constants), and the [Microsoft Windows SDK declarations](https://github.com/microsoft/win32metadata/blob/main/generation/WinSDK/RecompiledIdlHeaders/um/WinDNS.h). diff --git a/scripts/DnsClientProbe.ps1 b/scripts/DnsClientProbe.ps1 index 4f64ca48..fc4f76ab 100644 --- a/scripts/DnsClientProbe.ps1 +++ b/scripts/DnsClientProbe.ps1 @@ -59,6 +59,7 @@ function Get-WelaDnsClientProbeWatermark { } function Start-WelaDnsClientProbeQuery { param($State,[string]$Resolver,[string]$QueryName,$Report) + Initialize-WelaDnsClientProbeNative $fresh=Get-WelaDnsClientProbeState if((Get-WelaDnsClientProbeStateKey $fresh) -cne (Get-WelaDnsClientProbeStateKey $State)){throw 'DNS prerequisites changed before query.'} $boundary=Get-WelaDnsClientProbeWatermark @@ -67,7 +68,7 @@ function Start-WelaDnsClientProbeQuery { $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Engine;$info.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$worker+'" -Resolver "'+$Resolver+'" -QueryName "'+$QueryName+'"';$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true;$info.StandardOutputEncoding=[Text.UTF8Encoding]::new($false,$true);$info.StandardErrorEncoding=[Text.UTF8Encoding]::new($false,$true) $process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false try{ - $launch=[DateTimeOffset]::UtcNow;$started=$process.Start();if(-not $started){throw 'DNS probe worker did not start.'} + $launch=[DateTimeOffset][Wela.DnsClientProbe.Native]::UtcNow();$started=$process.Start();if(-not $started){throw 'DNS probe worker did not start.'} $output=$process.StandardOutput.ReadToEndAsync();$errorText=$process.StandardError.ReadToEndAsync() if(-not $process.WaitForExit(20000)){throw 'DNS query worker exceeded twenty seconds; operation completion is unverified.'} if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($output,$errorText),5000)){throw 'DNS worker output did not finish.'} @@ -79,7 +80,7 @@ function Start-WelaDnsClientProbeQuery { if($operation.ProcessId -ne $process.Id -or $operation.Query.QueryName -cne $QueryName -or $operation.Query.Resolver -cne $Resolver -or $operation.Query.Options -ne 2103790 -or $operation.Query.Status -ne $operation.Query.ResultStatus -or $operation.Query.Status -notin @(0,9003,9501)){throw ('Unexpected DNS worker response or unsupported native outcome: PID='+$operation.ProcessId+' expectedPID='+$process.Id+' options='+$operation.Query.Options+' APIstatus='+$operation.Query.Status+' resultStatus='+$operation.Query.ResultStatus)} $begin=ConvertTo-WelaArrivalUtc $operation.StartedUtc;$end=ConvertTo-WelaArrivalUtc $operation.CompletedUtc $operation.StartedUtc=$begin.UtcDateTime.ToString('o');$operation.CompletedUtc=$end.UtcDateTime.ToString('o') - if($begin -lt $launch -or $end -lt $begin -or $end -gt [DateTimeOffset]::UtcNow -or ($end-$begin).TotalSeconds -gt 20){throw 'Invalid DNS operation timestamps.'} + if($operation.Clock -cne 'GetSystemTimePreciseAsFileTime' -or $begin -lt $launch -or $end -lt $begin -or $end -gt [DateTimeOffset][Wela.DnsClientProbe.Native]::UtcNow() -or ($end-$begin).TotalSeconds -gt 20){throw 'Invalid DNS operation timestamps.'} if((Get-WelaChannelReadKey $operation.BeforeToken) -cne (Get-WelaChannelReadKey $operation.AfterToken) -or (Get-WelaDnsClientProbeReaderKey $operation.BeforeToken) -cne (Get-WelaDnsClientProbeReaderKey $callerBefore)){throw 'DNS worker token differs from observed caller or changed.'} $operation|Add-Member NoteProperty RecordIdBefore $boundary $operation|Add-Member NoteProperty CallerBefore $callerBefore @@ -89,7 +90,11 @@ function Start-WelaDnsClientProbeQuery { function Read-WelaDnsClientProbeEvents { param($Operation) $channel='Microsoft-Windows-DNS-Client/Operational' - $xpath="*[System[Provider[@Name='Microsoft-Windows-DNS-Client'] and EventID=3008 and EventRecordID>$($Operation.RecordIdBefore) and TimeCreated[@SystemTime>='$($Operation.StartedUtc)' and @SystemTime<='$($Operation.CompletedUtc)']]]" + # Read only this nonce in a bounded recent interval. The validator still requires + # exact operation timestamps; outside-interval XML is useful failure evidence only. + $name=$Operation.Query.QueryName + if($name -cnotmatch '^wela-[a-f0-9]{32}\.wela\.test\.$'){throw 'Unexpected DNS event query name.'} + $xpath="*[System[Provider[@Name='Microsoft-Windows-DNS-Client'] and EventID=3008 and EventRecordID>$($Operation.RecordIdBefore) and TimeCreated[timediff(@SystemTime)<=60000]] and EventData[Data[@Name='QueryName']='$name' or Data[@Name='QueryName']='$($name.TrimEnd('.'))']]" $reader=$null;$record=$null;$xml=@();$timer=[Diagnostics.Stopwatch]::StartNew() try{ $query=[Diagnostics.Eventing.Reader.EventLogQuery]::new($channel,[Diagnostics.Eventing.Reader.PathType]::LogName,$xpath);$query.TolerateQueryErrors=$false diff --git a/scripts/DnsClientProbeNative.cs b/scripts/DnsClientProbeNative.cs index 369da352..a43a0638 100644 --- a/scripts/DnsClientProbeNative.cs +++ b/scripts/DnsClientProbeNative.cs @@ -20,15 +20,15 @@ namespace Wela.DnsClientProbe { // DnsQueryEx is the documented exact export; do not allow a W-suffixed name probe. [DllImport("dnsapi.dll",EntryPoint="DnsQueryEx",ExactSpelling=true)] static extern uint DnsQueryEx(ref Request request,ref QueryResult result,IntPtr cancel); [DllImport("dnsapi.dll")] static extern void DnsRecordListFree(IntPtr records,int freeType); + [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); } public static string ValidateResolver(string resolver) { if(resolver==null||!Regex.IsMatch(resolver,@"^(0|[1-9][0-9]{0,2})(\.(0|[1-9][0-9]{0,2})){3}$"))throw new ArgumentException("One canonical dotted-decimal IPv4 resolver is required."); IPAddress address;if(!IPAddress.TryParse(resolver,out address)||address.AddressFamily!=System.Net.Sockets.AddressFamily.InterNetwork||address.ToString()!=resolver)throw new ArgumentException("Invalid IPv4 resolver."); byte[] bytes=address.GetAddressBytes();if(bytes[0]==0||bytes[0]>=224||resolver=="255.255.255.255")throw new ArgumentException("Unspecified, multicast and reserved/broadcast resolver addresses are refused."); return resolver; } - public static Result Query(string name,string resolver) { - if(IntPtr.Size!=8)throw new InvalidOperationException("Native 64-bit process required."); - if(name==null||!Regex.IsMatch(name,@"^wela-[a-f0-9]{32}\.wela\.test\.\z"))throw new ArgumentException("Only the fixed random probe name is accepted."); + static byte[] BuildServerArray(string resolver) { ValidateResolver(resolver); // SDK DNS_ADDR_ARRAY header32 + one DNS_ADDR64; sockaddr_in in its first16 bytes. // Match Microsoft Windows-classic-samples/DNSAsyncQuery CreateDnsServerList: @@ -36,6 +36,12 @@ namespace Wela.DnsClientProbe { byte[] server=new byte[96];BitConverter.GetBytes((uint)1).CopyTo(server,0);BitConverter.GetBytes((uint)1).CopyTo(server,4); BitConverter.GetBytes((ushort)2).CopyTo(server,32); IPAddress.Parse(resolver).GetAddressBytes().CopyTo(server,36); + return server; + } + public static Result Query(string name,string resolver) { + if(IntPtr.Size!=8)throw new InvalidOperationException("Native 64-bit process required."); + if(name==null||!Regex.IsMatch(name,@"^wela-[a-f0-9]{32}\.wela\.test\.\z"))throw new ArgumentException("Only the fixed random probe name is accepted."); + byte[] server=BuildServerArray(resolver); IntPtr servers=Marshal.AllocHGlobal(server.Length);QueryResult result=new QueryResult {Version=1}; try { Marshal.Copy(server,0,servers,server.Length); diff --git a/scripts/DnsClientProbeWorker.ps1 b/scripts/DnsClientProbeWorker.ps1 index 46731ca0..c73fee82 100644 --- a/scripts/DnsClientProbeWorker.ps1 +++ b/scripts/DnsClientProbeWorker.ps1 @@ -7,9 +7,9 @@ $ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Paren Initialize-WelaDnsClientProbeNative if((Get-Service Dnscache -ErrorAction Stop).Status -ne 'Running'){throw 'DNS Client must already be running.'} $before=Get-WelaChannelReader -$start=[DateTime]::UtcNow.ToString('o') +$start=[Wela.DnsClientProbe.Native]::UtcNow().ToString('o') $query=[Wela.DnsClientProbe.Native]::Query($QueryName,$Resolver) -$end=[DateTime]::UtcNow.ToString('o') +$end=[Wela.DnsClientProbe.Native]::UtcNow().ToString('o') $after=Get-WelaChannelReader if((Get-WelaChannelReadKey $before) -cne (Get-WelaChannelReadKey $after)){throw 'Worker primary token changed during DNS query.'} -[pscustomobject]@{Query=$query;StartedUtc=$start;CompletedUtc=$end;ProcessId=$PID;BeforeToken=$before;AfterToken=$after}|ConvertTo-Json -Depth 12 -Compress +[pscustomobject]@{Query=$query;StartedUtc=$start;CompletedUtc=$end;Clock='GetSystemTimePreciseAsFileTime';ProcessId=$PID;BeforeToken=$before;AfterToken=$after}|ConvertTo-Json -Depth 12 -Compress diff --git a/tests/DnsClientProbe.Tests.ps1 b/tests/DnsClientProbe.Tests.ps1 index ed8737d8..ffcf3186 100644 --- a/tests/DnsClientProbe.Tests.ps1 +++ b/tests/DnsClientProbe.Tests.ps1 @@ -12,6 +12,12 @@ foreach($entry in @(@('Request',64),@('QueryResult',32),@('Record',32))){$native Assert ([Wela.DnsClientProbe.Native].GetField('SourceSha256').IsLiteral) 'Compiled source fingerprint cannot be reassigned.' $queryImport=[Wela.DnsClientProbe.Native].GetMethod('DnsQueryEx',[Reflection.BindingFlags]'NonPublic,Static').GetCustomAttributes([Runtime.InteropServices.DllImportAttribute],$false)[0] Assert ($queryImport.ExactSpelling -and $queryImport.EntryPoint -ceq 'DnsQueryEx') 'Bind the documented DnsQueryEx export exactly; no W suffix with a different ABI.' +$clockImport=[Wela.DnsClientProbe.Native].GetMethod('GetSystemTimePreciseAsFileTime',[Reflection.BindingFlags]'NonPublic,Static').GetCustomAttributes([Runtime.InteropServices.DllImportAttribute],$false)[0] +Assert ($clockImport.ExactSpelling -and $clockImport.EntryPoint -ceq 'GetSystemTimePreciseAsFileTime') 'Precise native UTC has an exact entry point and no coarse fallback.' +$server=[Wela.DnsClientProbe.Native].GetMethod('BuildServerArray',[Reflection.BindingFlags]'NonPublic,Static').Invoke($null,@('192.0.2.53')) +Assert ($server.Length -eq 96 -and [BitConverter]::ToUInt32($server,0) -eq 1 -and [BitConverter]::ToUInt32($server,4) -eq 1 -and [BitConverter]::ToUInt16($server,32) -eq 2) 'SDK header/address storage and sample element counts are exact.' +Assert (([Net.IPAddress]::new([byte[]]$server[36..39])).ToString() -ceq '192.0.2.53') 'Explicit resolver address is encoded in network order.' +Assert (@(8..31 + 34..35 + 40..95|Where-Object {$server[$_] -ne 0}).Count -eq 0) 'Aggregate family/default DNS port and all reserved address bytes remain zero.' Assert ([Wela.DnsClientProbe.Native]::Options -eq 2103790) 'Fixed documented DNS flags retained.' $fields=@(foreach($name in @('QueryName','QueryType','QueryOptions','QueryStatus','QueryResults')){[pscustomobject]@{Name=$name;InType=$(if($name -in @('QueryName','QueryResults')){'win:UnicodeString'}elseif($name -eq 'QueryOptions'){'win:UInt64'}else{'win:UInt32'})}}) $state=[pscustomobject]@{Computer='host';Host=[pscustomobject]@{DomainJoined=$false;Domain='WORKGROUP'};Service='Running';Channel=[pscustomobject]@{State='Enabled';Name='Microsoft-Windows-DNS-Client/Operational';SecurityDescriptor='O:SYG:SYD:(A;;0x1;;;SY)';MetadataErrors=@{};Error=$null;IsEnabled=$true;MaximumSizeInBytes=1048576;LogMode='Circular'};Schema=[pscustomobject]@{State='Observed';Provider='Microsoft-Windows-DNS-Client';ProviderGuid='1c95126e-7eea-49a9-a3fe-a378b03ddb4d';ChannelType='Operational';Events=@([pscustomobject]@{Id=3008;Version=0;Channel='Microsoft-Windows-DNS-Client/Operational';Fields=$fields})}}