From 5967a6bc1e83f9d30fea51823b21e40b4bea94bb Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:33:53 +0900 Subject: [PATCH 01/10] Add fixed native DNS Client completion probe and acceptance fixture --- .github/workflows/dns-client-probe.yml | 34 +++++++ CHANGELOG-Japanese.md | 2 + CHANGELOG.md | 2 + WELA.ps1 | 16 ++- docs/dns-client-probe.md | 25 +++++ docs/native-provider-packs.md | 2 + scripts/DnsClientProbe.ps1 | 132 +++++++++++++++++++++++++ scripts/DnsClientProbeNative.cs | 62 ++++++++++++ scripts/DnsClientProbeWorker.ps1 | 15 +++ tests/DnsClientProbe.Cli.Tests.ps1 | 14 +++ tests/DnsClientProbe.Tests.ps1 | 29 ++++++ tests/DnsClientProbe.Windows.Tests.ps1 | 67 +++++++++++++ website/docs/resources/changelog.ja.md | 2 + website/docs/resources/changelog.md | 2 + 14 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/dns-client-probe.yml create mode 100644 docs/dns-client-probe.md create mode 100644 scripts/DnsClientProbe.ps1 create mode 100644 scripts/DnsClientProbeNative.cs create mode 100644 scripts/DnsClientProbeWorker.ps1 create mode 100644 tests/DnsClientProbe.Cli.Tests.ps1 create mode 100644 tests/DnsClientProbe.Tests.ps1 create mode 100644 tests/DnsClientProbe.Windows.Tests.ps1 diff --git a/.github/workflows/dns-client-probe.yml b/.github/workflows/dns-client-probe.yml new file mode 100644 index 00000000..67f91955 --- /dev/null +++ b/.github/workflows/dns-client-probe.yml @@ -0,0 +1,34 @@ +name: Native DNS Client completion probe +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + dns-client-probe: + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Fixed query validators and public CLI guards in Windows PowerShell5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/DnsClientProbe.Tests.ps1 + ./tests/DnsClientProbe.Cli.Tests.ps1 + - name: Fixed query validators and public CLI guards in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/DnsClientProbe.Tests.ps1 + ./tests/DnsClientProbe.Cli.Tests.ps1 + - name: Owned authoritative loopback DNS and real native3008 + shell: powershell + run: ./tests/DnsClientProbe.Windows.Tests.ps1 -AllowDisposableDns -TestEngine '${{ matrix.engine }}' diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index fbb5f432..b40f1d81 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- 明示した IPv4 リゾルバーに固定の無害な A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (@Shirofune-Security) + - 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security) - 専用 CA 監査設定の再起動待ちを確認し、明示的に再開する `adcs-resume` を追加。元の記録・結果、出典、現在の CA と実行者、永続的な意図記録、変更直前・最終確認により古い計画の再実行を拒否し、観測した設定を保持します。ドライランと失敗を区別し、イベントや Sigma の証明は加算しません。使い捨て CA テストでは最初の再起動拒否のみを注入し、公開 CLI による実際の再起動と要求イベントを検証します。既存の専用 CA Configure のドライラン引数制限も修正しました。 (#431) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f47af61..880f0ca6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Added opt-in `dns-client-probe` for one fixed benign A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (@Shirofune-Security) + - Added read-only `channel-read` to test actual current-token access to selected built-in local logs. Native query outcomes distinguish denied, missing, empty and observed-event reads independently of metadata/ACL inspection; token/context checks and bounded private reports preserve uncertainty without changing Windows settings or granting Sigma credit. Disposable standard-user denial/read tests cover Server 2022/2025 and PowerShell 5.1/7. (#432) (@Shirofune-Security) - Added `adcs-resume` to review and explicitly resume a dedicated pending CA auditing restart. Original journal/results, source and current CA/operator fingerprints, durable intent receipts and fresh/final checks prevent stale-plan replay and preserve observed settings. Dry-run and failed attempts remain explicit, with no event/Sigma credit. Disposable CA tests inject the initial refusal then verify an actual public-CLI restart and request events. Also fixed the existing dedicated CA Configure dry-run CLI guard. (#431) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index fb027456..b5a203d3 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -42,6 +42,10 @@ [ValidateSet('Audit', 'Plan', 'Import')][string]$AppLockerAction = 'Audit', [string]$AppLockerPolicyPath, [ValidateSet('List', 'Audit', 'Plan', 'Configure')][string]$WmiAction = 'List', + [ValidateSet('Plan','Run')][string]$DnsClientProbeAction = 'Plan', + [string]$DnsClientProbeResolver, + [string]$DnsClientProbeOutputPath, + [ValidateRange(1,30)][int]$DnsClientProbeTimeoutSeconds = 15, [ValidateSet('Plan','Run')][string]$WmiProbeAction = 'Plan', [string]$WmiProbeNamespace, [string]$WmiProbeOutputPath, @@ -167,6 +171,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/AppLockerProbe.ps1") . (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1") . (Join-Path $ScriptRoot "scripts/WmiProbe.ps1") +. (Join-Path $ScriptRoot "scripts/DnsClientProbe.ps1") . (Join-Path $ScriptRoot "scripts/PowerShellTranscription.ps1") Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop Import-Module (Join-Path $ScriptRoot "modules/RuleEligibility.psm1") -ErrorAction Stop @@ -1962,6 +1967,7 @@ Usage: ./WELA.ps1 intune-export -Help # Offline native audit OMA-URI/Graph artifacts; no tenant changes ./WELA.ps1 adcs-resume -Help # Review a pending CA auditing restart ./WELA.ps1 wec-update -Help # Review query/description updates on a disabled subscription + ./WELA.ps1 dns-client-probe -Help # Fixed native DNS lookup and matched Operational3008 evidence ./WELA.ps1 wmi-probe -Help # Fixed local read and matched namespace Security4662 evidence ./WELA.ps1 applocker-probe -Help # Collect a fixed native AppLocker EXE event ./WELA.ps1 wef-arrival -Help # Verify exact native probe presence on the local collector @@ -1982,7 +1988,7 @@ if ($Cmd -eq 'channel-read' -and @($PSBoundParameters.Keys | Where-Object { $_ - if ($Cmd -ne 'event-measurement' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Measurement*'}).Count) {throw 'Measurement options require event-measurement. No command was run.'} if ($Cmd -eq 'event-measurement' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','MeasurementAction','MeasurementChannel','MeasurementSeconds','MeasurementMaximumEvents','MeasurementOutputPath','MeasurementExportEvtx','Help')}).Count) {throw 'event-measurement accepts only its dedicated options. No command was run.'} -if ($Cmd -ne 'dns-analytical' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'Dns*' -or $_ -eq 'AllowDnsTraceReset' }).Count) { +if ($Cmd -ne 'dns-analytical' -and @($PSBoundParameters.Keys | Where-Object { ($_ -like 'Dns*' -and $_ -notlike 'DnsClientProbe*') -or $_ -eq 'AllowDnsTraceReset' }).Count) { throw 'DNS analytical options require dns-analytical. No command was run.' } if ($Cmd -eq 'dns-analytical' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','DnsAction','DnsState','DnsRetention','DnsMinimumBytes','DnsArchiveMaximumBytes','AllowDnsTraceReset','Auto','DryRun','BackupPath','ResultsPath','Help') }).Count) { @@ -2045,6 +2051,8 @@ if ($Cmd -ne 'wec-runtime' -and @($PSBoundParameters.Keys | Where-Object {$_ -li if ($Cmd -eq 'wec-runtime' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecRuntimeId','WecRuntimeMaximumSources','ResultsPath','Help')}).Count) { throw 'wec-runtime accepts only selected runtime IDs, source cap and a new result path. No command was run.' } +if ($Cmd -ne 'dns-client-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'DnsClientProbe*'}).Count) {throw 'DnsClientProbe options require dns-client-probe.'} +if ($Cmd -eq 'dns-client-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','DnsClientProbeAction','DnsClientProbeResolver','DnsClientProbeOutputPath','DnsClientProbeTimeoutSeconds','Help')}).Count) {throw 'dns-client-probe accepts only dedicated probe options.'} if ($Cmd -ne 'wmi-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WmiProbe*'}).Count) {throw 'WmiProbe options require wmi-probe.'} if ($Cmd -eq 'wmi-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WmiProbeAction','WmiProbeNamespace','WmiProbeOutputPath','WmiProbeTimeoutSeconds','Help')}).Count) {throw 'wmi-probe accepts only dedicated probe options.'} if ($Cmd -ne 'applocker-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -in @('AppLockerProbeAction','AppLockerProbeOutputPath','AppLockerProbeTimeoutSeconds')}).Count) {throw 'AppLocker probe options require applocker-probe.'} @@ -2246,6 +2254,12 @@ switch ($Cmd.ToLower()) { $report if($report.ExitCode){exit $report.ExitCode} } + 'dns-client-probe' { + if ($Help) {Write-Host 'Usage: dns-client-probe [-DnsClientProbeAction Plan|Run] -DnsClientProbeResolver approved-IPv4 [-DnsClientProbeOutputPath new-private-directory] [-DnsClientProbeTimeoutSeconds 1..30]. Fixed benign A lookup to wela-.wela.invalid. via explicit DNS TCP53 resolver; no configuration changes or Sigma credit. Plan observes prerequisites only. See docs/dns-client-probe.md.';return} + $report=Invoke-WelaDnsClientProbe -Action $DnsClientProbeAction -Resolver $DnsClientProbeResolver -OutputPath $DnsClientProbeOutputPath -TimeoutSeconds $DnsClientProbeTimeoutSeconds + $report + if($report.ExitCode){exit $report.ExitCode} + } 'wmi-probe' { if ($Help) {Write-Host 'Usage: wmi-probe [-WmiProbeAction Plan|Run] -WmiProbeNamespace root\default [-WmiProbeOutputPath new-private-directory] [-WmiProbeTimeoutSeconds 1..30]. Fixed local read only; requires existing matching SACL and auditing. No policy changes, remote access or Sigma credit. See docs/wmi-probe.md.';return} $report=Invoke-WelaWmiProbe -Action $WmiProbeAction -Namespace $WmiProbeNamespace -OutputPath $WmiProbeOutputPath -TimeoutSeconds $WmiProbeTimeoutSeconds diff --git a/docs/dns-client-probe.md b/docs/dns-client-probe.md new file mode 100644 index 00000000..1173c21f --- /dev/null +++ b/docs/dns-client-probe.md @@ -0,0 +1,25 @@ +# Native DNS Client completion probe + +`dns-client-probe` advances #386 with a fixed benign native DNS lookup and correlation to Windows event 3008. It does **not** implement a Sigma rule test. Sysmon is excluded. Windows DNS Client Operational logging and `Dnscache` must already be enabled/running; the command never changes DNS configuration, channel settings, audit policy or service state. + +```powershell +# Observe prerequisites only. Choose a resolver you are authorized to query. +./WELA.ps1 dns-client-probe -DnsClientProbeResolver 192.0.2.53 + +# Explicit network operation; use a NEW local output directory. +./WELA.ps1 dns-client-probe -DnsClientProbeAction Run ` + -DnsClientProbeResolver 192.0.2.53 ` + -DnsClientProbeOutputPath C:\WelaEvidence\dns-client-01 +``` + +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.invalid.` type A. 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. 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, 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. 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.invalid` 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. + +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 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/docs/native-provider-packs.md b/docs/native-provider-packs.md index 1061ba35..30510ef5 100644 --- a/docs/native-provider-packs.md +++ b/docs/native-provider-packs.md @@ -52,3 +52,5 @@ The mocked regression suite exercises missing fields/providers, unsupported type Primary references: [Microsoft WEF Appendix C/F](https://learn.microsoft.com/en-us/windows/security/operating-system-security/device-management/use-windows-event-forwarding-to-assist-in-intrusion-detection), [DNS logging and diagnostics](https://learn.microsoft.com/en-us/windows-server/networking/dns/dns-logging-and-diagnostics), [EventMetadata](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.eventing.reader.eventmetadata?view=windowsdesktop-10.0), [EventLogLink](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.eventing.reader.eventloglink?view=windowsdesktop-10.0), [Windows 11 release families](https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information), and [Windows Server release families](https://learn.microsoft.com/en-us/windows/release-health/windows-server-release-info). The WEF sample identifies event/channel candidates; it does not validate these rule definitions or this implementation on every build. For an explicitly reviewed DNS Server analytical transition with stopped-trace archival, use the separate [DNS analytical lifecycle](dns-analytical.md). The ordinary provider-pack setter continues to refuse Analytical/Debug configuration. + +The separate [`dns-client-probe`](dns-client-probe.md) can collect a fixed native DNS Client lookup completion and exact Operational3008 XML. The six pinned rule channel strings remain mismatched; the probe grants no Sigma readiness credit. diff --git a/scripts/DnsClientProbe.ps1 b/scripts/DnsClientProbe.ps1 new file mode 100644 index 00000000..b247a990 --- /dev/null +++ b/scripts/DnsClientProbe.ps1 @@ -0,0 +1,132 @@ +# Fixed native DNS Client event3008 collection; no policy/channel/DNS configuration. +function Initialize-WelaDnsClientProbeNative { + if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'DNS Client probe requires native 64-bit Windows.'} + $bytes=[IO.File]::ReadAllBytes((Join-Path $PSScriptRoot 'DnsClientProbeNative.cs'));$hash=Get-WelaArrivalHash $bytes + if(-not ('Wela.DnsClientProbe.Native' -as [type])){Add-Type -TypeDefinition ([Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff)) -ErrorAction Stop;[Wela.DnsClientProbe.Native]::SourceSha256=$hash} + if([Wela.DnsClientProbe.Native]::SourceSha256 -cne $hash){throw 'Loaded DNS helper differs from source; start a fresh PowerShell process.'} +} +function Assert-WelaDnsClientResolver { + param([string]$Resolver) + $ip=$null + if($Resolver -cnotmatch '^(0|[1-9][0-9]{0,2})(\.(0|[1-9][0-9]{0,2})){3}$' -or -not [Net.IPAddress]::TryParse($Resolver,[ref]$ip) -or $ip.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork -or $ip.ToString() -cne $Resolver -or $ip.GetAddressBytes()[0] -eq 0 -or $ip.GetAddressBytes()[0] -ge 224){throw 'Select one approved canonical unicast IPv4 DNS resolver; no hostname, port, multicast or unspecified address.'} +} +function Get-WelaDnsClientProbeSources { + $sources=[ordered]@{} + foreach($name in @('WELA.ps1','scripts/DnsClientProbe.ps1','scripts/DnsClientProbeWorker.ps1','scripts/DnsClientProbeNative.cs','scripts/WefArrival.ps1','scripts/AuditRecovery.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/NativeProviderPacks.ps1','scripts/ControlApplicability.ps1','config/native_provider_packs.json','config/security_rules.json','modules/NativeProviders.psm1','modules/AuditProfiles.psm1')){$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot ('../'+$name)) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()} + $catalog=Get-WelaProviderPackCatalog + foreach($rule in $catalog.ruleReviews){$sources['config/'+$rule.localPath]=$rule.sha256} + [pscustomobject]$sources +} +function Get-WelaDnsClientProbeState { + $service=Get-Service Dnscache -ErrorAction Stop + if($service.Status -ne 'Running'){throw 'DNS Client must already be running; no service is started.'} + $hostState=Get-WelaDefaultContext + if(-not(Test-WelaDefaultContextComplete $hostState) -or ($hostState.ProductType -eq 1 -and $hostState.Build -notin @(22000,22621,22631,26100,26200)) -or ($hostState.ProductType -in @(2,3) -and $hostState.Build -notin @(20348,26100))){throw 'Complete reviewed Windows 11/Server2022/2025 context required.'} + $catalog=Get-WelaProviderPackCatalog;$pack=@($catalog.packs|Where-Object id -ceq 'dns-client')[0] + $schema=Get-WelaProviderPackSchema $pack + $channel=Get-WelaNativeChannel $pack.channel + $engine=(Get-Process -Id $PID -ErrorAction Stop).Path + [pscustomobject][ordered]@{Computer=[Environment]::MachineName;Host=$hostState;Service=[string]$service.Status;Schema=$schema;Channel=$channel;Engine=$engine;EngineSha256=(Get-FileHash $engine -Algorithm SHA256).Hash.ToLowerInvariant();Sources=(Get-WelaDnsClientProbeSources);RuleReviews=@($catalog.ruleReviews|Where-Object {$pack.ruleIds -contains $_.id}|Select-Object id,title,sha256,ruleChannels);Reader=(Get-WelaChannelReader)} +} +function Get-WelaDnsClientProbeStateKey { + param($State) + $metadataErrors=if($State.Channel.MetadataErrors -is [Collections.IDictionary]){$State.Channel.MetadataErrors.Count}else{@($State.Channel.MetadataErrors.PSObject.Properties|Where-Object MemberType -eq NoteProperty).Count} + if($State.Service -cne 'Running' -or $State.Channel.State -cne 'Enabled' -or $State.Channel.Name -cne 'Microsoft-Windows-DNS-Client/Operational' -or -not $State.Channel.SecurityDescriptor -or $metadataErrors -or $State.Channel.Error -or $State.Channel.IsEnabled -ne $true -or $State.Channel.MaximumSizeInBytes -le 0 -or $State.Channel.LogMode -notin @('Circular','AutoBackup','Retain')){throw 'Enabled, fully observed DNS Client Operational channel is required.'} + if($State.Schema.State -cne 'Observed' -or $State.Schema.Provider -cne 'Microsoft-Windows-DNS-Client' -or $State.Schema.ChannelType -cne 'Operational' -or -not $State.Schema.ProviderGuid){throw 'Exact native DNS Client provider/channel manifest required.'} + $events=@($State.Schema.Events|Where-Object Id -eq 3008) + if(-not $events.Count){throw 'Native event3008 manifest is missing.'} + foreach($event in $events){ + if($event.Version -ne 0 -or $event.Channel -cne $State.Channel.Name){throw 'Unreviewed native DNS3008 version/channel.'} + foreach($name in @('QueryName','QueryType','QueryOptions','QueryStatus','QueryResults')){ + $field=@($event.Fields|Where-Object Name -ceq $name) + $types=switch($name){QueryName {@('win:UnicodeString')} QueryResults {@('win:UnicodeString')} QueryOptions {@('win:UInt64','win:HexInt64')} default {@('win:UInt32')}} + if($field.Count -ne 1 -or $field[0].InType -cnotin $types){throw "Native DNS3008 field/type is unreviewed: $name"} + } + } + Get-WelaChannelReadKey $State +} +function Get-WelaDnsClientProbeReaderKey { + param($Reader) + Get-WelaChannelReadKey ([pscustomobject][ordered]@{Computer=$Reader.Computer;UserSid=$Reader.UserSid;UserName=$Reader.UserName;AuthenticationId=$Reader.AuthenticationId;GroupSids=@($Reader.GroupSids);GroupCount=$Reader.GroupCount;PrivilegeCount=$Reader.PrivilegeCount;ElevatedAdministrator=$Reader.ElevatedAdministrator;TokenType=$Reader.TokenType;Impersonation=$Reader.Impersonation}) +} +function Get-WelaDnsClientProbeWatermark { + $reader=$null;$record=$null + try{$query=[Diagnostics.Eventing.Reader.EventLogQuery]::new('Microsoft-Windows-DNS-Client/Operational',[Diagnostics.Eventing.Reader.PathType]::LogName,'*');$query.ReverseDirection=$true;$reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($query);$reader.BatchSize=1;$record=$reader.ReadEvent([TimeSpan]::FromSeconds(5));Assert-WelaChannelQueryStatus -Channel 'Microsoft-Windows-DNS-Client/Operational' -LogStatus @($reader.LogStatus);if($record){if($record.RecordId -le 0){throw 'Invalid native record boundary.'};return [long]$record.RecordId};return [long]0}finally{if($record){$record.Dispose()};if($reader){$reader.Dispose()}} +} +function Start-WelaDnsClientProbeQuery { + param($State,[string]$Resolver,[string]$QueryName) + $fresh=Get-WelaDnsClientProbeState + if((Get-WelaDnsClientProbeStateKey $fresh) -cne (Get-WelaDnsClientProbeStateKey $State)){throw 'DNS prerequisites changed before query.'} + $boundary=Get-WelaDnsClientProbeWatermark + $worker=Join-Path $PSScriptRoot 'DnsClientProbeWorker.ps1' + $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.'} + $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.'} + if($output.Result.Length -gt 262144 -or $errorText.Result.Length -gt 65536){throw 'DNS worker output exceeded evidence bounds.'} + if($process.ExitCode -ne 0 -or $errorText.Result){throw ('DNS worker failed: '+$errorText.Result)} + $operation=ConvertFrom-WelaRecoveryJson $output.Result + 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.'} + $begin=ConvertTo-WelaArrivalUtc $operation.StartedUtc;$end=ConvertTo-WelaArrivalUtc $operation.CompletedUtc + 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((Get-WelaChannelReadKey $operation.BeforeToken) -cne (Get-WelaChannelReadKey $operation.AfterToken) -or (Get-WelaDnsClientProbeReaderKey $operation.BeforeToken) -cne (Get-WelaDnsClientProbeReaderKey $State.Reader)){throw 'DNS worker token differs from observed caller or changed.'} + $operation|Add-Member NoteProperty RecordIdBefore $boundary + $operation + }finally{try{if($started -and -not $process.HasExited){$process.Kill();if(-not $process.WaitForExit(5000)){throw 'Owned DNS worker termination could not be verified.'}}}finally{$process.Dispose()}} +} +function Read-WelaDnsClientProbeEvents { + param($Operation) + $xpath="*[System[Provider[@Name='Microsoft-Windows-DNS-Client'] and EventID=3008 and EventRecordID>$($Operation.RecordIdBefore) and TimeCreated[@SystemTime>='$($Operation.StartedUtc)' and @SystemTime<='$($Operation.CompletedUtc)']]]" + $records=@();$xml=@() + try{try{$records=@(Get-WinEvent -LogName 'Microsoft-Windows-DNS-Client/Operational' -FilterXPath $xpath -MaxEvents 256 -ErrorAction Stop)}catch{if($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*'){throw}};foreach($record in $records){$text=$record.ToXml();if($text.Length -gt 131072){throw 'Native DNS XML exceeds bound.'};$xml+=$text};[pscustomobject]@{Xml=$xml;Capped=($records.Count -ge 256);Query=$xpath}}finally{foreach($record in $records){$record.Dispose()}} +} +function Test-WelaDnsClientProbeEvent { + param([string]$Xml,$Operation,$State) + $reader=$null + try{ + $settings=[Xml.XmlReaderSettings]::new();$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=131072 + $reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Xml),$settings);$doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.Load($reader) + $ns=[Xml.XmlNamespaceManager]::new($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event') + 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:EventData',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:UserData',$ns).Count){return $false} + $system=@{};foreach($name in @('Provider','EventID','Version','EventRecordID','Channel','Computer','TimeCreated','Execution')){$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 $State.Schema.Provider -or $system.Provider.GetAttribute('Guid').Trim('{}') -ine $State.Schema.ProviderGuid.Trim('{}') -or $system.EventID.InnerText -cne '3008' -or $system.Version.InnerText -cne '0' -or $system.Channel.InnerText -cne $State.Channel.Name -or $system.EventRecordID.InnerText -cnotmatch '^[1-9][0-9]*$' -or [long]$system.EventRecordID.InnerText -le $Operation.RecordIdBefore){return $false} + $computers=@($State.Computer);if($State.Host.DomainJoined){$computers+=$State.Computer+'.'+$State.Host.Domain};if($system.Computer.InnerText -notin $computers){return $false} + $time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime');if($time -lt (ConvertTo-WelaArrivalUtc $Operation.StartedUtc) -or $time -gt (ConvertTo-WelaArrivalUtc $Operation.CompletedUtc)){return $false} + # Capture the native emitter PID but do not equate service-broker PID with caller identity. + if($system.Execution.GetAttribute('ProcessID') -cnotmatch '^[1-9][0-9]*$'){return $false} + $data=@{};foreach($node in $doc.SelectSingleNode('/e:Event/e:EventData',$ns).ChildNodes){if($node.NodeType -eq 'Whitespace'){continue};if($node.NodeType -ne 'Element' -or $node.LocalName -cne 'Data' -or $node.NamespaceURI -cne $ns.LookupNamespace('e') -or @($node.ChildNodes|Where-Object NodeType -eq Element).Count){return $false};$name=$node.GetAttribute('Name');if(-not $name -or $data.ContainsKey($name)){return $false};$data[$name]=$node.InnerText} + if($data.Count -ne 5 -or $data.QueryName.TrimEnd('.') -cne $Operation.Query.QueryName.TrimEnd('.') -or $data.QueryType -cne '1' -or $data.QueryStatus -cne [string]$Operation.Query.Status -or -not $data.ContainsKey('QueryResults')){return $false} + $options=if($data.QueryOptions -match '^0x[0-9a-fA-F]+$'){[Convert]::ToUInt64($data.QueryOptions.Substring(2),16)}elseif($data.QueryOptions -match '^[0-9]+$'){[uint64]$data.QueryOptions}else{return $false} + if(($options -band [uint64]$Operation.Query.Options) -ne [uint64]$Operation.Query.Options){return $false} + return $true + }catch{return $false}finally{if($reader){$reader.Dispose()}} +} +function Invoke-WelaDnsClientProbe { + param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$Resolver,[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15) + $ErrorActionPreference='Stop';Assert-WelaDnsClientResolver $Resolver + if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new output directory; Plan writes no files.'} + $report=[pscustomobject][ordered]@{Kind='WelaNativeDnsClientProbe';SchemaVersion=1;Action=$Action;Status='Unverified';ExitCode=1;Resolver=$Resolver;QueryPattern='wela-.wela.invalid.';QueryType='A';Transport='DNS TCP port53; recursion disabled';Before=$null;After=$null;Operation=$null;Query=$null;Candidates=0;Matches=0;Artifacts=@();OutputPath=$null;Diagnostic='';ReadyRuleCredit=0;ConfigurationChanges=0;RuleChannelMismatch='Pinned DNS rules use Microsoft-Windows-DNS Client Events/Operational; actual source is Microsoft-Windows-DNS-Client/Operational. No alias rewrite or rule credit.';Correlation='Random query name, native outcome, source/host, record boundary and operation time. Emitter PID is retained but may be a service broker. Event3008 does not independently prove resolver wire identity or exclusive request attribution.';Scope='One fixed native DNS Client lookup completion; no DNS configuration, cache flush, policy/channel/service changes, forwarding or backend execution. Sysmon excluded.'} + if($Action -eq 'Run'){$report.OutputPath=New-WelaArrivalOutput $OutputPath $PSScriptRoot} + try{ + $before=Get-WelaDnsClientProbeState;$report.Before=$before;$key=Get-WelaDnsClientProbeStateKey $before + if($Action -eq 'Plan'){$report.After=$before;$report.Status='PrerequisitesObserved';$report.ExitCode=0;return $report} + $report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'before.json' ($before|ConvertTo-Json -Depth 24) + $queryName='wela-'+[guid]::NewGuid().ToString('N')+'.wela.invalid.' + $operation=Start-WelaDnsClientProbeQuery $before $Resolver $queryName;$report.Operation=$operation + $report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'operation.json' ($operation|ConvertTo-Json -Depth 15) + $timer=[Diagnostics.Stopwatch]::StartNew();$matches=@() + do{$batch=Read-WelaDnsClientProbeEvents $operation;$report.Query=$batch.Query;$report.Candidates=@($batch.Xml).Count;if($batch.Capped -isnot [bool] -or $batch.Capped){throw 'DNS event query cap reached or completeness unknown.'};$matches=@($batch.Xml|Where-Object {Test-WelaDnsClientProbeEvent $_ $operation $before});if($matches.Count){break};Start-Sleep -Milliseconds 250}while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds) + $report.Matches=$matches.Count;if($matches.Count -gt 16){throw 'DNS matching event set exceeds sixteen records.'} + $i=0;foreach($xml in $matches){$i++;$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath ('event-'+$i+'.xml') $xml} + if(-not $matches.Count){$i=0;foreach($xml in @($batch.Xml|Select-Object -First 4)){$i++;$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath ('candidate-'+$i+'.xml') $xml};throw 'No matching native DNS3008 completion event was observed.'} + if((Get-WelaDnsClientProbeWatermark) -lt $operation.RecordIdBefore){throw 'DNS log record boundary moved backwards; continuity unverified.'} + $after=Get-WelaDnsClientProbeState;$report.After=$after;if((Get-WelaDnsClientProbeStateKey $after) -cne $key){throw 'DNS host, token, schema, channel or source changed during collection.'} + $report.Status='NativeDnsLookupObserved';$report.ExitCode=0 + }catch{$report.Diagnostic=$_.Exception.Message} + finally{if($report.Before -and -not $report.After){try{$report.After=Get-WelaDnsClientProbeState}catch{$report.Diagnostic+=' Final observation failed: '+$_.Exception.Message}};if($report.OutputPath -and $report.After){$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'after.json' ($report.After|ConvertTo-Json -Depth 24)}} + if($report.OutputPath){$null=Write-WelaArrivalArtifact $report.OutputPath 'manifest.json' ($report|ConvertTo-Json -Depth 28)} + $report +} diff --git a/scripts/DnsClientProbeNative.cs b/scripts/DnsClientProbeNative.cs new file mode 100644 index 00000000..cd6d4f54 --- /dev/null +++ b/scripts/DnsClientProbeNative.cs @@ -0,0 +1,62 @@ +// One fixed DNS query, with an explicit IPv4 resolver and no configuration writes. +using System; +using System.Collections.Generic; +using System.Net; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +namespace Wela.DnsClientProbe { + public sealed class Answer { public string Name, Address; public ushort Type; public uint Flags; } + public sealed class Result { public uint Status, ResultStatus; public ulong Options; public string QueryName, Resolver; public Answer[] Answers; } + public static class Native { + public static string SourceSha256; + // TCP, no recursion; bypass cache/local-name/hosts/NetBT/multicast/suffixes/IDN. + public const ulong Options=0x002019ee; + [StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)] struct Request { + public uint Version; [MarshalAs(UnmanagedType.LPWStr)] public string Name; public ushort Type; + public ulong Options; public IntPtr Servers; public uint Interface; public IntPtr Callback,Context; + } + [StructLayout(LayoutKind.Sequential)] struct QueryResult { public uint Version,Status; public ulong Options; public IntPtr Records,Reserved; } + [StructLayout(LayoutKind.Sequential)] struct Record { public IntPtr Next,Name; public ushort Type,Length; public uint Flags,Ttl,Reserved; } + [DllImport("dnsapi.dll",CharSet=CharSet.Unicode)] static extern uint DnsQueryEx(ref Request request,ref QueryResult result,IntPtr cancel); + [DllImport("dnsapi.dll")] static extern void DnsRecordListFree(IntPtr records,int freeType); + 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\.invalid\.$"))throw new ArgumentException("Only the fixed random probe name is accepted."); + ValidateResolver(resolver); + // SDK DNS_ADDR_ARRAY header32 + one DNS_ADDR64; sockaddr_in in its first16 bytes. + 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,12);BitConverter.GetBytes((ushort)2).CopyTo(server,32); + server[34]=0;server[35]=53;IPAddress.Parse(resolver).GetAddressBytes().CopyTo(server,36); + IntPtr servers=Marshal.AllocHGlobal(server.Length);QueryResult result=new QueryResult {Version=1}; + try { + Marshal.Copy(server,0,servers,server.Length); + Request request=new Request {Version=1,Name=name,Type=1,Options=Options,Servers=servers}; + uint status=DnsQueryEx(ref request,ref result,IntPtr.Zero); + if(status==9506)throw new InvalidOperationException("Unexpected asynchronous query response."); + List answers=new List();HashSet seen=new HashSet();IntPtr current=result.Records; + while(current!=IntPtr.Zero) { + if(!seen.Add(current)||seen.Count>64)throw new InvalidOperationException("DNS result record bound exceeded."); + Record record=(Record)Marshal.PtrToStructure(current,typeof(Record)); + string recordName=Marshal.PtrToStringUni(record.Name);if(recordName==null||recordName.Length>255)throw new InvalidOperationException("Invalid DNS result name."); + // Only A data is interpreted. Unexpected answer aliases/types cannot establish a fixed A result. + if((record.Flags&3)==1) { + if(record.Type!=1||!String.Equals(recordName.TrimEnd('.'),name.TrimEnd('.'),StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Unexpected DNS answer name/type; no follow-up application connection is made."); + if(record.Length<4)throw new InvalidOperationException("Truncated DNS A result."); + byte[] address=new byte[4];Marshal.Copy(IntPtr.Add(current,Marshal.SizeOf(typeof(Record))),address,0,4); + answers.Add(new Answer {Name=recordName,Type=record.Type,Flags=record.Flags,Address=new IPAddress(address).ToString()}); + if(answers.Count>16)throw new InvalidOperationException("DNS A answer bound exceeded."); + } + current=record.Next; + } + if((status==0 && answers.Count==0) || (status!=0 && answers.Count!=0))throw new InvalidOperationException("DNS status and A answers disagree."); + return new Result {Status=status,ResultStatus=result.Status,Options=request.Options,QueryName=name,Resolver=resolver,Answers=answers.ToArray()}; + }finally{if(result.Records!=IntPtr.Zero)DnsRecordListFree(result.Records,1);Marshal.FreeHGlobal(servers);} + } + } +} diff --git a/scripts/DnsClientProbeWorker.ps1 b/scripts/DnsClientProbeWorker.ps1 new file mode 100644 index 00000000..46731ca0 --- /dev/null +++ b/scripts/DnsClientProbeWorker.ps1 @@ -0,0 +1,15 @@ +param([Parameter(Mandatory)][string]$Resolver,[Parameter(Mandatory)][string]$QueryName) +$ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false) +. (Join-Path $PSScriptRoot 'WefArrival.ps1') +. (Join-Path $PSScriptRoot 'ChannelRead.ps1') +. (Join-Path $PSScriptRoot 'DnsClientProbe.ps1') +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') +$query=[Wela.DnsClientProbe.Native]::Query($QueryName,$Resolver) +$end=[DateTime]::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 diff --git a/tests/DnsClientProbe.Cli.Tests.ps1 b/tests/DnsClientProbe.Cli.Tests.ps1 new file mode 100644 index 00000000..af7c1c78 --- /dev/null +++ b/tests/DnsClientProbe.Cli.Tests.ps1 @@ -0,0 +1,14 @@ +$ErrorActionPreference='Stop';$root=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0 +$cases=@( + @{Args=@('dns-client-probe','-Help');Code=0;Pattern='Fixed benign A lookup'}, + @{Args=@('configure','-DnsClientProbeAction','Run','-Auto');Code=1;Pattern='require dns-client-probe'}, + @{Args=@('dns-analytical','-DnsClientProbeResolver','127.0.0.1');Code=1;Pattern='only dedicated'}, + @{Args=@('dns-client-probe','-Help','-Auto');Code=1;Pattern='only dedicated'}, + @{Args=@('dns-client-probe','-Help','-DryRun');Code=1;Pattern='only dedicated'}, + @{Args=@('dns-client-probe','-Help','-WmiProbeAction','Run');Code=1;Pattern='only dedicated'}, + @{Args=@('dns-client-probe','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only dedicated'}, + @{Args=@('dns-client-probe','-DnsClientProbeResolver','example.com');Code=1;Pattern='canonical unicast IPv4'}, + @{Args=@('dns-client-probe','-DnsClientProbeResolver','127.0.0.1','-DnsClientProbeAction','Run');Code=1;Pattern='Run requires a new output'}, + @{Args=@('dns-client-probe','-DnsClientProbeResolver','127.0.0.1','-DnsClientProbeOutputPath','unused');Code=1;Pattern='Plan writes no files'}) +foreach($case in $cases){$ErrorActionPreference='Continue';$out=& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $root 'WELA.ps1') @($case.Args) 2>&1|Out-String;$code=$LASTEXITCODE;$ErrorActionPreference='Stop';if($code -ne $case.Code -or $out -notmatch $case.Pattern){throw "Public CLI failed: $($case.Args -join ' ') [$code] $out"};$count++} +Write-Host "PASS: $count DNS Client public CLI checks.";$global:LASTEXITCODE=0 diff --git a/tests/DnsClientProbe.Tests.ps1 b/tests/DnsClientProbe.Tests.ps1 new file mode 100644 index 00000000..befd8814 --- /dev/null +++ b/tests/DnsClientProbe.Tests.ps1 @@ -0,0 +1,29 @@ +$ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $ScriptRoot 'modules/AuditProfiles.psm1') -Force +foreach($name in @('WefArrival','AuditRecovery','ChannelRead','DnsClientProbe')){. (Join-Path $ScriptRoot ('scripts/'+$name+'.ps1'))} +$script:count=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Throws($Code,$Pattern){$message='';try{& $Code|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern, got $message"} +Add-Type -Path (Join-Path $ScriptRoot 'scripts/DnsClientProbeNative.cs') +foreach($resolver in @('127.0.0.1','192.0.2.53','10.0.0.53')){Assert-WelaDnsClientResolver $resolver;Assert ([Wela.DnsClientProbe.Native]::ValidateResolver($resolver) -ceq $resolver) 'Explicit canonical IPv4 accepted.'} +foreach($resolver in @('','localhost','127.1','127.0.0.01','127.0.0.1:53','127.0.0.1"','0.0.0.0','224.0.0.1','255.255.255.255','192.0.2.999','::1')){Throws {Assert-WelaDnsClientResolver $resolver} 'IPv4';Throws {[Wela.DnsClientProbe.Native]::ValidateResolver($resolver)} 'resolver|IPv4'} +Throws {[Wela.DnsClientProbe.Native]::Query('arbitrary.example.','127.0.0.1')} 'fixed random' +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})}} +Assert ((Get-WelaDnsClientProbeStateKey $state).Length -gt 0) 'Exact schema prerequisite accepted.' +$state.Channel.MetadataErrors['LogMode']='denied';Throws {Get-WelaDnsClientProbeStateKey $state} 'fully observed';$state.Channel.MetadataErrors=@{} +$state.Schema.Events[0].Fields[0].InType='win:UInt32';Throws {Get-WelaDnsClientProbeStateKey $state} 'field/type';$state.Schema.Events[0].Fields[0].InType='win:UnicodeString' +$state.Schema.Events[0].Version=1;Throws {Get-WelaDnsClientProbeStateKey $state} 'version/channel';$state.Schema.Events[0].Version=0 +$operation=[pscustomobject]@{Query=[pscustomobject]@{QueryName='wela-0123456789abcdef0123456789abcdef.wela.invalid.';Status=0;Options=2103790};StartedUtc='2026-01-01T00:00:00.0000000Z';CompletedUtc='2026-01-01T00:00:01.0000000Z';RecordIdBefore=9} +$xml=@' +3008010Microsoft-Windows-DNS-Client/Operationalhostwela-0123456789abcdef0123456789abcdef.wela.invalid.10x2019ee0192.0.2.1; +'@ +Assert (Test-WelaDnsClientProbeEvent $xml $operation $state) 'Exact synthetic native3008 shape matches.' +$mutations=@( + @('>3008<','>3006<'),@('0','1'),@('>10<','>9<'),@('>host<','>other<'),@('DNS-Client/Operational','DNS Client Events/Operational'),@('1c95126e','2c95126e'),@('Microsoft-Windows-DNS-Client"','Other-Provider"'),@('00:00:00.5000000Z','00:00:01.5000000Z'),@('ProcessID="123"','ProcessID="0"'),@('Name="QueryType">1','Name="QueryType">28'),@('Name="QueryStatus">0','Name="QueryStatus">9003'),@('0x2019ee','0x2019ec'),@('0123456789abcdef0123456789abcdef','ffffffffffffffffffffffffffffffff'),@('','duplicate'),@('','extra'),@('192.0.2.1;',''),@(']>0','Name="QueryStatus">9003')) $operation $state) 'Typed NXDOMAIN completion differs from successful resolution.' +$operation.Query.Status=0 +Write-Host "PASS: $script:count DNS Client validators and refusal assertions; synthetic XML is not native evidence." diff --git a/tests/DnsClientProbe.Windows.Tests.ps1 b/tests/DnsClientProbe.Windows.Tests.ps1 new file mode 100644 index 00000000..8ae73242 --- /dev/null +++ b/tests/DnsClientProbe.Windows.Tests.ps1 @@ -0,0 +1,67 @@ +param([switch]$AllowDisposableDns,[ValidateSet('powershell','pwsh')][string]$TestEngine='powershell') +$ErrorActionPreference='Stop' +if(-not $AllowDisposableDns -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:OS -ne 'Windows_NT'){throw 'Explicit DNS mutation opt-in on a disposable GitHub-hosted Windows runner is required.'} +$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $ScriptRoot 'modules/AuditProfiles.psm1') -Force +Import-Module (Join-Path $ScriptRoot 'modules/NativeProviders.psm1') -Force +foreach($name in @('Configuration','ControlApplicability','NativeProviderPacks','AuditRecovery','WefArrival','ChannelRead','DnsClientProbe')){. (Join-Path $ScriptRoot ('scripts/'+$name+'.ps1'))} +$script:count=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +$os=Get-CimInstance Win32_OperatingSystem;$computer=Get-CimInstance Win32_ComputerSystem +if($os.ProductType -ne 3 -or [int]$os.BuildNumber -notin @(20348,26100) -or $computer.PartOfDomain -or $computer.DomainRole -ne 2 -or (Get-WindowsFeature DNS).Installed){throw 'This fixture requires an unjoined Server2022/2025 with no existing DNS role.'} +$engine=(Get-Command $TestEngine -ErrorAction Stop).Source +$private=New-WelaArrivalOutput (Join-Path $env:TEMP ('wela-dns-client-native-'+[guid]::NewGuid().ToString('N'))) $PSScriptRoot +$channel='Microsoft-Windows-DNS-Client/Operational';$zone='wela.invalid';$zoneFile='wela.invalid.dns' +$beforeFeatures=@(Get-WindowsFeature|Where-Object Installed|ForEach-Object Name);$policies=Get-WelaEffectiveAuditPolicy;$original=Get-WelaNativeChannel $channel +if($original.State -notin @('Enabled','Disabled') -or $original.MetadataErrors.Count -or $original.Error){throw 'Complete original DNS Client channel state is required before fixture mutation.'} +$null=Write-WelaArrivalArtifact $private 'original-channel.json' ($original|ConvertTo-Json -Depth 10) +$installed=$false;$zoneCreated=$false;$channelChanged=$false;$passed=$false +function Invoke-Cli { + param([string[]]$Arguments,[int]$Expected=0) + $ErrorActionPreference='Continue' + try{$text=@(& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $ScriptRoot 'WELA.ps1') @Arguments 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference='Stop'} + $text|ForEach-Object{Write-Host $_};$global:LASTEXITCODE=0 + Assert ($code -eq $Expected) "Public DNS Client CLI exit $code, expected $Expected." +} +function Set-ChannelEnabled([bool]$Enabled){$out=& "$env:SystemRoot\System32\wevtutil.exe" sl $channel ('/e:'+([string]$Enabled).ToLowerInvariant()) 2>&1;if($LASTEXITCODE -ne 0){throw "Fixture channel update failed: $out"};$global:LASTEXITCODE=0} +try { + $installed=$true;$feature=Install-WindowsFeature DNS -IncludeManagementTools -ErrorAction Stop + if(-not $feature.Success -or [string]$feature.RestartNeeded -ne 'No'){throw 'DNS role install failed or requires restart; no native acceptance claim.'} + Start-Service DNS -ErrorAction Stop + if(Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue){throw 'Fixture zone already exists; no replacement is permitted.'} + if(Test-Path -LiteralPath (Join-Path $env:SystemRoot ('System32\dns\'+$zoneFile))){throw 'Fixture zone file already exists.'} + $zoneCreated=$true;Add-DnsServerPrimaryZone -Name $zone -ZoneFile $zoneFile -DynamicUpdate None -ErrorAction Stop + Add-DnsServerResourceRecordA -ZoneName $zone -Name '*' -IPv4Address '192.0.2.1' -TimeToLive ([TimeSpan]::Zero) -ErrorAction Stop|Out-Null + # The zone is authoritative and the native request has recursion disabled. No external resolver or answer connection is used. + if(-not $original.IsEnabled){$channelChanged=$true;Set-ChannelEnabled $true} + $configured=Get-WelaNativeChannel $channel + $catalog=Get-WelaProviderPackCatalog;$pack=@($catalog.packs|Where-Object id -ceq 'dns-client')[0] + Write-Host ((Get-WelaProviderPackSchema $pack)|ConvertTo-Json -Depth 12) + Invoke-Cli @('dns-client-probe','-DnsClientProbeResolver','127.0.0.1') + $output=Join-Path $private 'evidence' + Invoke-Cli @('dns-client-probe','-DnsClientProbeAction','Run','-DnsClientProbeResolver','127.0.0.1','-DnsClientProbeOutputPath',$output) + $report=ConvertFrom-WelaRecoveryJson ([IO.File]::ReadAllText((Join-Path $output 'manifest.json'))) + Assert ($report.Status -ceq 'NativeDnsLookupObserved' -and $report.ExitCode -eq 0 -and $report.Matches -ge 1 -and $report.ReadyRuleCredit -eq 0 -and $report.ConfigurationChanges -eq 0) 'Actual native3008 correlation is observed without configuration/Sigma credit.' + Assert ($report.Operation.Query.QueryName -cmatch '^wela-[a-f0-9]{32}\.wela\.invalid\.$' -and $report.Operation.Query.Status -eq 0 -and $report.Operation.Query.ResultStatus -eq 0 -and @($report.Operation.Query.Answers).Count -eq 1 -and $report.Operation.Query.Answers[0].Address -ceq '192.0.2.1') 'Owned authoritative loopback resolver returns the exact fixed A answer.' + foreach($artifact in $report.Artifacts){Assert ((Get-FileHash -LiteralPath (Join-Path $output $artifact.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Evidence bytes match recorded SHA256.'} + foreach($file in Get-ChildItem -LiteralPath $output -Filter 'event-*.xml'){$xml=[IO.File]::ReadAllText($file.FullName);Assert (Test-WelaDnsClientProbeEvent $xml $report.Operation $report.Before) 'Actual persisted3008 XML matches the production validator.';Write-Host $xml} + Assert ((Get-WelaChannelReadKey (Get-WelaNativeChannel $channel)) -ceq (Get-WelaChannelReadKey $configured)) 'Product preserves the exact configured channel metadata.' + Assert ($report.RuleChannelMismatch -match 'DNS Client Events/Operational') 'Original rule-channel mismatch remains explicit.' + $passed=$true;Write-Host "PASS: $script:count native DNS Client checks through $TestEngine." +}catch{ + Write-Host ('Native DNS Client failure: '+($_|Out-String));Write-Host $_.ScriptStackTrace + # Small owned diagnostics only; avoid dumping unrelated channel payloads. + if(Test-Path (Join-Path $private 'evidence')){Get-ChildItem (Join-Path $private 'evidence') -File|Where-Object {$_.Name -in @('manifest.json','operation.json') -or $_.Name -like 'candidate-*.xml'}|ForEach-Object{Write-Host $_.Name;Write-Host ([IO.File]::ReadAllText($_.FullName))}} + throw +}finally{ + $errors=@() + try{if($channelChanged){Set-ChannelEnabled ([bool]$original.IsEnabled)};if((Get-WelaChannelReadKey (Get-WelaNativeChannel $channel)) -cne (Get-WelaChannelReadKey $original)){throw 'DNS Client channel configuration restoration differs.'}}catch{$errors+=$_.Exception.Message} + if($zoneCreated){try{$owned=Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue;if($owned){if($owned.IsDsIntegrated -or $owned.ZoneType -ne 'Primary'){throw 'Owned DNS zone identity changed; cleanup refused.'};Remove-DnsServerZone -Name $zone -Force -ErrorAction Stop};if(Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue){throw 'Owned zone remains.'};$file=Join-Path $env:SystemRoot ('System32\dns\'+$zoneFile);if(Test-Path -LiteralPath $file){Remove-Item -LiteralPath $file -ErrorAction Stop}}catch{$errors+=$_.Exception.Message}} + try{$afterPolicies=Get-WelaEffectiveAuditPolicy;foreach($guid in $policies.Keys){if($afterPolicies[$guid] -ne $policies[$guid]){throw 'Native audit policy changed.'}}}catch{$errors+=$_.Exception.Message} + $removal=[pscustomobject]@{ChannelAndZoneRestored=($errors.Count -eq 0);Attempted=$false;Features=@();Success=$null;RestartNeeded=$null;Boundary='Owned feature removal can require disposal of this GitHub-hosted VM; no restart or complete live feature-restoration claim.'} + if($installed -and -not $errors.Count){try{$added=@(Get-WindowsFeature|Where-Object {$_.Installed -and $_.Name -notin $beforeFeatures -and $_.Name -in @('DNS','RSAT-DNS-Server')}|ForEach-Object Name);if($added.Count){$removal.Attempted=$true;$removal.Features=$added;$removed=Uninstall-WindowsFeature -Name $added -ErrorAction Stop;$removal.Success=[bool]$removed.Success;$removal.RestartNeeded=[string]$removed.RestartNeeded;if(-not $removed.Success -or $removal.RestartNeeded -notin @('No','Yes')){throw 'DNS feature removal failed or restart state is unknown.'}}}catch{$errors+=$_.Exception.Message}} + $null=Write-WelaArrivalArtifact $private 'cleanup.json' ($removal|ConvertTo-Json -Depth 6);$removal|ConvertTo-Json -Depth 6|Write-Host + if($errors.Count){throw "Disposable DNS cleanup failed; evidence retained at $private : $($errors -join '; ')"} + if($passed){Remove-Item -LiteralPath $private -Recurse -Force} +} +$global:LASTEXITCODE=0 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index b91d1c7d..a306b6d1 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- 明示した IPv4 リゾルバーに固定の無害な A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (@Shirofune-Security) + - 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security) - 専用 CA 監査設定の再起動待ちを確認し、明示的に再開する `adcs-resume` を追加。元の記録・結果、出典、現在の CA と実行者、永続的な意図記録、変更直前・最終確認により古い計画の再実行を拒否し、観測した設定を保持します。ドライランと失敗を区別し、イベントや Sigma の証明は加算しません。使い捨て CA テストでは最初の再起動拒否のみを注入し、公開 CLI による実際の再起動と要求イベントを検証します。既存の専用 CA Configure のドライラン引数制限も修正しました。 (#431) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index ccaa1794..a9806f76 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Added opt-in `dns-client-probe` for one fixed benign A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (@Shirofune-Security) + - Added read-only `channel-read` to test actual current-token access to selected built-in local logs. Native query outcomes distinguish denied, missing, empty and observed-event reads independently of metadata/ACL inspection; token/context checks and bounded private reports preserve uncertainty without changing Windows settings or granting Sigma credit. Disposable standard-user denial/read tests cover Server 2022/2025 and PowerShell 5.1/7. (#432) (@Shirofune-Security) - Added `adcs-resume` to review and explicitly resume a dedicated pending CA auditing restart. Original journal/results, source and current CA/operator fingerprints, durable intent receipts and fresh/final checks prevent stale-plan replay and preserve observed settings. Dry-run and failed attempts remain explicit, with no event/Sigma credit. Disposable CA tests inject the initial refusal then verify an actual public-CLI restart and request events. Also fixed the existing dedicated CA Configure dry-run CLI guard. (#431) (@Shirofune-Security) From 7f9c53329d441df05bbf470a906f5b7202f1bc6c Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:39:02 +0900 Subject: [PATCH 02/10] Bind native DNS evidence to bounded query status and token intervals --- CHANGELOG-Japanese.md | 2 +- CHANGELOG.md | 2 +- docs/dns-client-probe.md | 2 +- scripts/DnsClientProbe.ps1 | 43 +++++++++++++++++++------- scripts/DnsClientProbeNative.cs | 2 +- tests/DnsClientProbe.Tests.ps1 | 30 +++++++++++++++++- website/docs/resources/changelog.ja.md | 2 +- website/docs/resources/changelog.md | 2 +- 8 files changed, 67 insertions(+), 18 deletions(-) diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index b40f1d81..57ae6659 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,7 +4,7 @@ **改善:** -- 明示した IPv4 リゾルバーに固定の無害な A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (@Shirofune-Security) +- 明示した IPv4 リゾルバーに固定の無害な A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (#434) (@Shirofune-Security) - 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 880f0ca6..54a4f532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ **Improvements:** -- Added opt-in `dns-client-probe` for one fixed benign A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (@Shirofune-Security) +- Added opt-in `dns-client-probe` for one fixed benign A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (#434) (@Shirofune-Security) - Added read-only `channel-read` to test actual current-token access to selected built-in local logs. Native query outcomes distinguish denied, missing, empty and observed-event reads independently of metadata/ACL inspection; token/context checks and bounded private reports preserve uncertainty without changing Windows settings or granting Sigma credit. Disposable standard-user denial/read tests cover Server 2022/2025 and PowerShell 5.1/7. (#432) (@Shirofune-Security) diff --git a/docs/dns-client-probe.md b/docs/dns-client-probe.md index 1173c21f..6479eecd 100644 --- a/docs/dns-client-probe.md +++ b/docs/dns-client-probe.md @@ -16,7 +16,7 @@ The example address is documentation-only: replace it with an approved resolver. The bounded worker has twenty seconds to finish. 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, 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. 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, 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. `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. diff --git a/scripts/DnsClientProbe.ps1 b/scripts/DnsClientProbe.ps1 index b247a990..027b6733 100644 --- a/scripts/DnsClientProbe.ps1 +++ b/scripts/DnsClientProbe.ps1 @@ -2,7 +2,7 @@ function Initialize-WelaDnsClientProbeNative { if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'DNS Client probe requires native 64-bit Windows.'} $bytes=[IO.File]::ReadAllBytes((Join-Path $PSScriptRoot 'DnsClientProbeNative.cs'));$hash=Get-WelaArrivalHash $bytes - if(-not ('Wela.DnsClientProbe.Native' -as [type])){Add-Type -TypeDefinition ([Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff)) -ErrorAction Stop;[Wela.DnsClientProbe.Native]::SourceSha256=$hash} + if(-not ('Wela.DnsClientProbe.Native' -as [type])){$source=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff);Add-Type -TypeDefinition $source.Replace('__WELA_DNS_CLIENT_SOURCE_SHA256__',$hash) -ErrorAction Stop} if([Wela.DnsClientProbe.Native]::SourceSha256 -cne $hash){throw 'Loaded DNS helper differs from source; start a fresh PowerShell process.'} } function Assert-WelaDnsClientResolver { @@ -34,16 +34,20 @@ function Get-WelaDnsClientProbeStateKey { if($State.Service -cne 'Running' -or $State.Channel.State -cne 'Enabled' -or $State.Channel.Name -cne 'Microsoft-Windows-DNS-Client/Operational' -or -not $State.Channel.SecurityDescriptor -or $metadataErrors -or $State.Channel.Error -or $State.Channel.IsEnabled -ne $true -or $State.Channel.MaximumSizeInBytes -le 0 -or $State.Channel.LogMode -notin @('Circular','AutoBackup','Retain')){throw 'Enabled, fully observed DNS Client Operational channel is required.'} if($State.Schema.State -cne 'Observed' -or $State.Schema.Provider -cne 'Microsoft-Windows-DNS-Client' -or $State.Schema.ChannelType -cne 'Operational' -or -not $State.Schema.ProviderGuid){throw 'Exact native DNS Client provider/channel manifest required.'} $events=@($State.Schema.Events|Where-Object Id -eq 3008) - if(-not $events.Count){throw 'Native event3008 manifest is missing.'} + if($events.Count -ne 1){throw 'Exactly one reviewed native event3008 template is required.'} foreach($event in $events){ - if($event.Version -ne 0 -or $event.Channel -cne $State.Channel.Name){throw 'Unreviewed native DNS3008 version/channel.'} + if($event.Version -ne 0 -or $event.Channel -cne $State.Channel.Name -or @($event.Fields).Count -ne 5){throw 'Unreviewed native DNS3008 version/channel.'} foreach($name in @('QueryName','QueryType','QueryOptions','QueryStatus','QueryResults')){ $field=@($event.Fields|Where-Object Name -ceq $name) $types=switch($name){QueryName {@('win:UnicodeString')} QueryResults {@('win:UnicodeString')} QueryOptions {@('win:UInt64','win:HexInt64')} default {@('win:UInt32')}} if($field.Count -ne 1 -or $field[0].InType -cnotin $types){throw "Native DNS3008 field/type is unreviewed: $name"} } } - Get-WelaChannelReadKey $State + # Metadata inventories may adjust and restore token privileges. Full token stability + # is verified around query/event I/O, outside those inventories. + $key=[ordered]@{};foreach($property in $State.PSObject.Properties){if($property.Name -cne 'Reader'){$key[$property.Name]=$property.Value}} + $key.ReaderContext=Get-WelaDnsClientProbeReaderKey $State.Reader + Get-WelaChannelReadKey ([pscustomobject]$key) } function Get-WelaDnsClientProbeReaderKey { param($Reader) @@ -58,6 +62,7 @@ function Start-WelaDnsClientProbeQuery { $fresh=Get-WelaDnsClientProbeState if((Get-WelaDnsClientProbeStateKey $fresh) -cne (Get-WelaDnsClientProbeStateKey $State)){throw 'DNS prerequisites changed before query.'} $boundary=Get-WelaDnsClientProbeWatermark + $callerBefore=Get-WelaChannelReader $worker=Join-Path $PSScriptRoot 'DnsClientProbeWorker.ps1' $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 @@ -71,17 +76,32 @@ function Start-WelaDnsClientProbeQuery { $operation=ConvertFrom-WelaRecoveryJson $output.Result 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.'} $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((Get-WelaChannelReadKey $operation.BeforeToken) -cne (Get-WelaChannelReadKey $operation.AfterToken) -or (Get-WelaDnsClientProbeReaderKey $operation.BeforeToken) -cne (Get-WelaDnsClientProbeReaderKey $State.Reader)){throw 'DNS worker token differs from observed caller or changed.'} + 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 $operation }finally{try{if($started -and -not $process.HasExited){$process.Kill();if(-not $process.WaitForExit(5000)){throw 'Owned DNS worker termination could not be verified.'}}}finally{$process.Dispose()}} } 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)']]]" - $records=@();$xml=@() - try{try{$records=@(Get-WinEvent -LogName 'Microsoft-Windows-DNS-Client/Operational' -FilterXPath $xpath -MaxEvents 256 -ErrorAction Stop)}catch{if($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*'){throw}};foreach($record in $records){$text=$record.ToXml();if($text.Length -gt 131072){throw 'Native DNS XML exceeds bound.'};$xml+=$text};[pscustomobject]@{Xml=$xml;Capped=($records.Count -ge 256);Query=$xpath}}finally{foreach($record in $records){$record.Dispose()}} + $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 + $reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($query);$reader.BatchSize=16 + while($xml.Count -lt 256){ + if($timer.Elapsed.TotalSeconds -ge 5){throw 'DNS event read exceeded five-second bound.'} + $record=$reader.ReadEvent([TimeSpan]::FromSeconds(5-$timer.Elapsed.TotalSeconds)) + if($null -eq $record){break} + try{$text=$record.ToXml();if($text.Length -gt 131072){throw 'Native DNS XML exceeds bound.'};$xml+=$text}finally{$record.Dispose();$record=$null} + } + $status=@($reader.LogStatus|ForEach-Object{[pscustomobject]@{LogName=$_.LogName;StatusCode=$_.StatusCode}}) + Assert-WelaChannelQueryStatus -Channel $channel -LogStatus $status + [pscustomobject]@{Xml=$xml;Capped=($xml.Count -ge 256);Query=$xpath;LogStatus=$status} + }finally{if($record){$record.Dispose()};if($reader){$reader.Dispose()}} } function Test-WelaDnsClientProbeEvent { param([string]$Xml,$Operation,$State) @@ -96,7 +116,7 @@ function Test-WelaDnsClientProbeEvent { $computers=@($State.Computer);if($State.Host.DomainJoined){$computers+=$State.Computer+'.'+$State.Host.Domain};if($system.Computer.InnerText -notin $computers){return $false} $time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime');if($time -lt (ConvertTo-WelaArrivalUtc $Operation.StartedUtc) -or $time -gt (ConvertTo-WelaArrivalUtc $Operation.CompletedUtc)){return $false} # Capture the native emitter PID but do not equate service-broker PID with caller identity. - if($system.Execution.GetAttribute('ProcessID') -cnotmatch '^[1-9][0-9]*$'){return $false} + if($system.Execution.GetAttribute('ProcessID') -cnotmatch '^[1-9][0-9]*$' -or [uint32]$system.Execution.GetAttribute('ProcessID') -eq 0){return $false} $data=@{};foreach($node in $doc.SelectSingleNode('/e:Event/e:EventData',$ns).ChildNodes){if($node.NodeType -eq 'Whitespace'){continue};if($node.NodeType -ne 'Element' -or $node.LocalName -cne 'Data' -or $node.NamespaceURI -cne $ns.LookupNamespace('e') -or @($node.ChildNodes|Where-Object NodeType -eq Element).Count){return $false};$name=$node.GetAttribute('Name');if(-not $name -or $data.ContainsKey($name)){return $false};$data[$name]=$node.InnerText} if($data.Count -ne 5 -or $data.QueryName.TrimEnd('.') -cne $Operation.Query.QueryName.TrimEnd('.') -or $data.QueryType -cne '1' -or $data.QueryStatus -cne [string]$Operation.Query.Status -or -not $data.ContainsKey('QueryResults')){return $false} $options=if($data.QueryOptions -match '^0x[0-9a-fA-F]+$'){[Convert]::ToUInt64($data.QueryOptions.Substring(2),16)}elseif($data.QueryOptions -match '^[0-9]+$'){[uint64]$data.QueryOptions}else{return $false} @@ -108,21 +128,22 @@ function Invoke-WelaDnsClientProbe { param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$Resolver,[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15) $ErrorActionPreference='Stop';Assert-WelaDnsClientResolver $Resolver if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new output directory; Plan writes no files.'} - $report=[pscustomobject][ordered]@{Kind='WelaNativeDnsClientProbe';SchemaVersion=1;Action=$Action;Status='Unverified';ExitCode=1;Resolver=$Resolver;QueryPattern='wela-.wela.invalid.';QueryType='A';Transport='DNS TCP port53; recursion disabled';Before=$null;After=$null;Operation=$null;Query=$null;Candidates=0;Matches=0;Artifacts=@();OutputPath=$null;Diagnostic='';ReadyRuleCredit=0;ConfigurationChanges=0;RuleChannelMismatch='Pinned DNS rules use Microsoft-Windows-DNS Client Events/Operational; actual source is Microsoft-Windows-DNS-Client/Operational. No alias rewrite or rule credit.';Correlation='Random query name, native outcome, source/host, record boundary and operation time. Emitter PID is retained but may be a service broker. Event3008 does not independently prove resolver wire identity or exclusive request attribution.';Scope='One fixed native DNS Client lookup completion; no DNS configuration, cache flush, policy/channel/service changes, forwarding or backend execution. Sysmon excluded.'} + $report=[pscustomobject][ordered]@{Kind='WelaNativeDnsClientProbe';SchemaVersion=1;Action=$Action;Status='Unverified';ExitCode=1;Resolver=$Resolver;QueryPattern='wela-.wela.invalid.';QueryType='A';Transport='DNS TCP port53; recursion disabled';Before=$null;After=$null;Operation=$null;Query=$null;QueryLogStatus=@();ReaderBefore=$null;ReaderAfter=$null;ReaderInterval='After all initial metadata/output preparation and record boundary, through worker/event I/O and continuity read; before final metadata inventory.';Candidates=0;Matches=0;Artifacts=@();OutputPath=$null;Diagnostic='';ReadyRuleCredit=0;ConfigurationChanges=0;RuleChannelMismatch='Pinned DNS rules use Microsoft-Windows-DNS Client Events/Operational; actual source is Microsoft-Windows-DNS-Client/Operational. No alias rewrite or rule credit.';Correlation='Random query name, native outcome, source/host, record boundary and operation time. Emitter PID is retained but may be a service broker. Event3008 does not independently prove resolver wire identity or exclusive request attribution.';Scope='One fixed native DNS Client lookup completion; no DNS configuration, cache flush, policy/channel/service changes, forwarding or backend execution. Sysmon excluded.'} if($Action -eq 'Run'){$report.OutputPath=New-WelaArrivalOutput $OutputPath $PSScriptRoot} try{ $before=Get-WelaDnsClientProbeState;$report.Before=$before;$key=Get-WelaDnsClientProbeStateKey $before if($Action -eq 'Plan'){$report.After=$before;$report.Status='PrerequisitesObserved';$report.ExitCode=0;return $report} $report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'before.json' ($before|ConvertTo-Json -Depth 24) $queryName='wela-'+[guid]::NewGuid().ToString('N')+'.wela.invalid.' - $operation=Start-WelaDnsClientProbeQuery $before $Resolver $queryName;$report.Operation=$operation + $operation=Start-WelaDnsClientProbeQuery $before $Resolver $queryName;$report.Operation=$operation;$report.ReaderBefore=$operation.CallerBefore $report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'operation.json' ($operation|ConvertTo-Json -Depth 15) $timer=[Diagnostics.Stopwatch]::StartNew();$matches=@() - do{$batch=Read-WelaDnsClientProbeEvents $operation;$report.Query=$batch.Query;$report.Candidates=@($batch.Xml).Count;if($batch.Capped -isnot [bool] -or $batch.Capped){throw 'DNS event query cap reached or completeness unknown.'};$matches=@($batch.Xml|Where-Object {Test-WelaDnsClientProbeEvent $_ $operation $before});if($matches.Count){break};Start-Sleep -Milliseconds 250}while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds) + do{$batch=Read-WelaDnsClientProbeEvents $operation;$report.Query=$batch.Query;$report.QueryLogStatus=@($batch.LogStatus);Assert-WelaChannelQueryStatus -Channel 'Microsoft-Windows-DNS-Client/Operational' -LogStatus $report.QueryLogStatus;$report.Candidates=@($batch.Xml).Count;if($batch.Capped -isnot [bool] -or $batch.Capped){throw 'DNS event query cap reached or completeness unknown.'};$matches=@($batch.Xml|Where-Object {Test-WelaDnsClientProbeEvent $_ $operation $before});if($matches.Count){break};Start-Sleep -Milliseconds 250}while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds) $report.Matches=$matches.Count;if($matches.Count -gt 16){throw 'DNS matching event set exceeds sixteen records.'} $i=0;foreach($xml in $matches){$i++;$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath ('event-'+$i+'.xml') $xml} if(-not $matches.Count){$i=0;foreach($xml in @($batch.Xml|Select-Object -First 4)){$i++;$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath ('candidate-'+$i+'.xml') $xml};throw 'No matching native DNS3008 completion event was observed.'} if((Get-WelaDnsClientProbeWatermark) -lt $operation.RecordIdBefore){throw 'DNS log record boundary moved backwards; continuity unverified.'} + $report.ReaderAfter=Get-WelaChannelReader;if((Get-WelaChannelReadKey $report.ReaderAfter) -cne (Get-WelaChannelReadKey $report.ReaderBefore)){throw 'Reader primary token changed during query/event collection.'} $after=Get-WelaDnsClientProbeState;$report.After=$after;if((Get-WelaDnsClientProbeStateKey $after) -cne $key){throw 'DNS host, token, schema, channel or source changed during collection.'} $report.Status='NativeDnsLookupObserved';$report.ExitCode=0 }catch{$report.Diagnostic=$_.Exception.Message} diff --git a/scripts/DnsClientProbeNative.cs b/scripts/DnsClientProbeNative.cs index cd6d4f54..d0041093 100644 --- a/scripts/DnsClientProbeNative.cs +++ b/scripts/DnsClientProbeNative.cs @@ -8,7 +8,7 @@ namespace Wela.DnsClientProbe { public sealed class Answer { public string Name, Address; public ushort Type; public uint Flags; } public sealed class Result { public uint Status, ResultStatus; public ulong Options; public string QueryName, Resolver; public Answer[] Answers; } public static class Native { - public static string SourceSha256; + public const string SourceSha256="__WELA_DNS_CLIENT_SOURCE_SHA256__"; // TCP, no recursion; bypass cache/local-name/hosts/NetBT/multicast/suffixes/IDN. public const ulong Options=0x002019ee; [StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)] struct Request { diff --git a/tests/DnsClientProbe.Tests.ps1 b/tests/DnsClientProbe.Tests.ps1 index befd8814..a3ab2503 100644 --- a/tests/DnsClientProbe.Tests.ps1 +++ b/tests/DnsClientProbe.Tests.ps1 @@ -8,6 +8,7 @@ Add-Type -Path (Join-Path $ScriptRoot 'scripts/DnsClientProbeNative.cs') foreach($resolver in @('127.0.0.1','192.0.2.53','10.0.0.53')){Assert-WelaDnsClientResolver $resolver;Assert ([Wela.DnsClientProbe.Native]::ValidateResolver($resolver) -ceq $resolver) 'Explicit canonical IPv4 accepted.'} foreach($resolver in @('','localhost','127.1','127.0.0.01','127.0.0.1:53','127.0.0.1"','0.0.0.0','224.0.0.1','255.255.255.255','192.0.2.999','::1')){Throws {Assert-WelaDnsClientResolver $resolver} 'IPv4';Throws {[Wela.DnsClientProbe.Native]::ValidateResolver($resolver)} 'resolver|IPv4'} Throws {[Wela.DnsClientProbe.Native]::Query('arbitrary.example.','127.0.0.1')} 'fixed random' +Assert ([Wela.DnsClientProbe.Native].GetField('SourceSha256').IsLiteral) 'Compiled source fingerprint cannot be reassigned.' 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})}} @@ -26,4 +27,31 @@ foreach($mutation in $mutations){Assert (-not(Test-WelaDnsClientProbeEvent ($xml Assert (Test-WelaDnsClientProbeEvent ($xml.Replace('ProcessID="123"','ProcessID="456"')) $operation $state) 'Emitter broker PID remains recorded without invented caller attribution.' $operation.Query.Status=9003;Assert (Test-WelaDnsClientProbeEvent ($xml.Replace('Name="QueryStatus">0','Name="QueryStatus">9003')) $operation $state) 'Typed NXDOMAIN completion differs from successful resolution.' $operation.Query.Status=0 -Write-Host "PASS: $script:count DNS Client validators and refusal assertions; synthetic XML is not native evidence." +# Exercise report/cap/drift behavior; only native boundaries are mocked. +function Clone($Value){ConvertFrom-WelaRecoveryJson ($Value|ConvertTo-Json -Depth 20 -Compress)} +$token=[pscustomobject]@{Computer='host';ProcessId=123;UserSid='S-1-5-21-1-2-3-1001';UserName='HOST\Reader';TokenId='100';AuthenticationId='99';ModifiedId='200';GroupSids=@('S-1-1-0');GroupCount=1;PrivilegeCount=1;ElevatedAdministrator=$false;TokenType='Primary';Impersonation='Absent'} +$state|Add-Member NoteProperty Reader (Clone $token);$operation|Add-Member NoteProperty CallerBefore (Clone $token) +$script:mode='Success';$script:reads=0;$script:workerCalls=0 +function Get-WelaDnsClientProbeState {$script:reads++;$copy=Clone $state;$copy.Reader.ModifiedId=[string](200+$script:reads);if($script:mode -eq 'Drift' -and $script:reads -gt 1){$copy.Computer='changed'};if($script:mode -eq 'Blocked'){$copy.Service='Stopped'};$copy} +function Start-WelaDnsClientProbeQuery {param($State,$Resolver,$QueryName);$script:workerCalls++;$operation} +function Read-WelaDnsClientProbeEvents {param($Operation);if($script:mode -eq 'ReadError'){throw 'native query denied'};[pscustomobject]@{Xml=$(if($script:mode -eq 'Missing'){@()}else{@($xml)});Capped=($script:mode -eq 'Cap');Query='synthetic bounded query';LogStatus=@([pscustomobject]@{LogName='Microsoft-Windows-DNS-Client/Operational';StatusCode=$(if($script:mode -eq 'DeniedStatus'){[int]5}else{[int]0})})}} +function Get-WelaChannelReader {$copy=Clone $token;if($script:mode -eq 'TokenDrift'){$copy.ModifiedId='changed'};$copy} +function Get-WelaDnsClientProbeWatermark {if($script:mode -eq 'Clear'){8}else{10}} +$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-dns-fixtures-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $temp +try{ + $plan=Invoke-WelaDnsClientProbe -Resolver '127.0.0.1' + Assert ($plan.Status -ceq 'PrerequisitesObserved' -and $script:workerCalls -eq 0 -and -not $plan.OutputPath) 'Default Plan never runs a DNS query or writes evidence.' + foreach($mode in @('Success','Blocked','Cap','ReadError','Drift','Clear','Missing','DeniedStatus','TokenDrift')){ + $script:mode=$mode;$script:reads=0;$script:workerCalls=0;$directory=Join-Path $temp $mode + $result=Invoke-WelaDnsClientProbe -Action Run -Resolver '127.0.0.1' -OutputPath $directory -TimeoutSeconds 1 + $manifest=ConvertFrom-WelaRecoveryJson ([IO.File]::ReadAllText((Join-Path $directory 'manifest.json'))) + Assert ($manifest.ReadyRuleCredit -eq 0 -and $manifest.ConfigurationChanges -eq 0 -and $manifest.RuleChannelMismatch -match 'DNS Client Events/Operational') 'Success and failure retain original channel mismatch and zero configuration/Sigma credit.' + Assert ($null -ne $manifest.After) 'Final observations survive failures.' + foreach($artifact in $manifest.Artifacts){Assert ($artifact.Sha256 -ceq (Get-FileHash -LiteralPath (Join-Path $directory $artifact.Name)).Hash.ToLowerInvariant()) 'Manifest hashes match written bytes.'} + if($mode -eq 'Success'){Assert ($result.Status -ceq 'NativeDnsLookupObserved' -and $result.Matches -eq 1 -and $result.ExitCode -eq 0) 'Exact synthetic event yields the bounded observation.';Assert ([IO.File]::ReadAllText((Join-Path $directory 'event-1.xml')) -ceq $xml) 'Original XML retained unchanged.'} + else{Assert ($result.Status -ceq 'Unverified' -and $result.ExitCode -eq 1 -and $result.Diagnostic) "Failure $mode remains unverified."} + if($mode -eq 'Blocked'){Assert ($script:workerCalls -eq 0) 'Missing prerequisites prevent the native operation.'} + } + Throws {Invoke-WelaDnsClientProbe -Action Run -Resolver '127.0.0.1' -OutputPath (Join-Path $temp 'Success')} 'new directory' +}finally{Remove-Item -LiteralPath $temp -Recurse -Force} +Write-Host "PASS: $script:count DNS Client validator/report/refusal assertions; native boundaries were mocked." diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index a306b6d1..c1b5070a 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,7 +7,7 @@ **改善:** -- 明示した IPv4 リゾルバーに固定の無害な A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (@Shirofune-Security) +- 明示した IPv4 リゾルバーに固定の無害な A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (#434) (@Shirofune-Security) - 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index a9806f76..d9de819b 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,7 +7,7 @@ **Improvements:** -- Added opt-in `dns-client-probe` for one fixed benign A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (@Shirofune-Security) +- Added opt-in `dns-client-probe` for one fixed benign A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (#434) (@Shirofune-Security) - Added read-only `channel-read` to test actual current-token access to selected built-in local logs. Native query outcomes distinguish denied, missing, empty and observed-event reads independently of metadata/ACL inspection; token/context checks and bounded private reports preserve uncertainty without changing Windows settings or granting Sigma credit. Disposable standard-user denial/read tests cover Server 2022/2025 and PowerShell 5.1/7. (#432) (@Shirofune-Security) From 1df3e401ff88677cae7ff9c4c2df014c4b26fcaa Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:52:31 +0900 Subject: [PATCH 03/10] Prepare explicit owned DNS zone data for native acceptance --- .github/workflows/release.yml | 2 +- scripts/DnsClientProbeNative.cs | 2 +- tests/DnsClientProbe.Windows.Tests.ps1 | 26 +++++++++++++++++++++----- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e912d6c..edefa9f6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: Copy-Item -Recurse -Path ./scripts -Destination release-binaries/ Copy-Item -Recurse -Path ./modules -Destination release-binaries/ New-Item -ItemType Directory -Path release-binaries/docs -Force | Out-Null - Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md -Destination release-binaries/docs/ + Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/dns-client-probe.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/scripts/DnsClientProbeNative.cs b/scripts/DnsClientProbeNative.cs index d0041093..b629e2b4 100644 --- a/scripts/DnsClientProbeNative.cs +++ b/scripts/DnsClientProbeNative.cs @@ -27,7 +27,7 @@ namespace Wela.DnsClientProbe { } 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\.invalid\.$"))throw new ArgumentException("Only the fixed random probe name is accepted."); + if(name==null||!Regex.IsMatch(name,@"^wela-[a-f0-9]{32}\.wela\.invalid\.\z"))throw new ArgumentException("Only the fixed random probe name is accepted."); ValidateResolver(resolver); // SDK DNS_ADDR_ARRAY header32 + one DNS_ADDR64; sockaddr_in in its first16 bytes. byte[] server=new byte[96];BitConverter.GetBytes((uint)1).CopyTo(server,0);BitConverter.GetBytes((uint)1).CopyTo(server,4); diff --git a/tests/DnsClientProbe.Windows.Tests.ps1 b/tests/DnsClientProbe.Windows.Tests.ps1 index 8ae73242..8334a1f1 100644 --- a/tests/DnsClientProbe.Windows.Tests.ps1 +++ b/tests/DnsClientProbe.Windows.Tests.ps1 @@ -11,7 +11,7 @@ $os=Get-CimInstance Win32_OperatingSystem;$computer=Get-CimInstance Win32_Comput if($os.ProductType -ne 3 -or [int]$os.BuildNumber -notin @(20348,26100) -or $computer.PartOfDomain -or $computer.DomainRole -ne 2 -or (Get-WindowsFeature DNS).Installed){throw 'This fixture requires an unjoined Server2022/2025 with no existing DNS role.'} $engine=(Get-Command $TestEngine -ErrorAction Stop).Source $private=New-WelaArrivalOutput (Join-Path $env:TEMP ('wela-dns-client-native-'+[guid]::NewGuid().ToString('N'))) $PSScriptRoot -$channel='Microsoft-Windows-DNS-Client/Operational';$zone='wela.invalid';$zoneFile='wela.invalid.dns' +$channel='Microsoft-Windows-DNS-Client/Operational';$zone='wela.invalid';$zoneFile='wela-native-'+[guid]::NewGuid().ToString('N')+'.dns';$zoneFilePath=$null;$zoneFileCreated=$false $beforeFeatures=@(Get-WindowsFeature|Where-Object Installed|ForEach-Object Name);$policies=Get-WelaEffectiveAuditPolicy;$original=Get-WelaNativeChannel $channel if($original.State -notin @('Enabled','Disabled') -or $original.MetadataErrors.Count -or $original.Error){throw 'Complete original DNS Client channel state is required before fixture mutation.'} $null=Write-WelaArrivalArtifact $private 'original-channel.json' ($original|ConvertTo-Json -Depth 10) @@ -28,10 +28,25 @@ try { $installed=$true;$feature=Install-WindowsFeature DNS -IncludeManagementTools -ErrorAction Stop if(-not $feature.Success -or [string]$feature.RestartNeeded -ne 'No'){throw 'DNS role install failed or requires restart; no native acceptance claim.'} Start-Service DNS -ErrorAction Stop + $ready=[Diagnostics.Stopwatch]::StartNew();do{try{$null=Get-DnsServerZone -ErrorAction Stop;break}catch{if($ready.Elapsed.TotalSeconds -gt 30){throw};Start-Sleep -Milliseconds 500}}while($true) if(Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue){throw 'Fixture zone already exists; no replacement is permitted.'} - if(Test-Path -LiteralPath (Join-Path $env:SystemRoot ('System32\dns\'+$zoneFile))){throw 'Fixture zone file already exists.'} - $zoneCreated=$true;Add-DnsServerPrimaryZone -Name $zone -ZoneFile $zoneFile -DynamicUpdate None -ErrorAction Stop - Add-DnsServerResourceRecordA -ZoneName $zone -Name '*' -IPv4Address '192.0.2.1' -TimeToLive ([TimeSpan]::Zero) -ErrorAction Stop|Out-Null + $zoneFilePath=Join-Path $env:SystemRoot ('System32\dns\'+$zoneFile) + if(Test-Path -LiteralPath $zoneFilePath){throw 'Fixture zone file already exists.'} + # Avoid relying on generated SOA/NS names on an unjoined, suffix-free runner. + $zoneText=@' +$ORIGIN wela.invalid. +$TTL 0 +@ IN SOA ns.wela.invalid. hostmaster.wela.invalid. ( 1 3600 600 86400 0 ) +@ IN NS ns.wela.invalid. +ns IN A 127.0.0.1 +* IN A 192.0.2.1 +'@ + $stream=[IO.File]::Open($zoneFilePath,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$zoneFileCreated=$true;$bytes=[Text.Encoding]::ASCII.GetBytes($zoneText.Replace("`n","`r`n")+"`r`n");$stream.Write($bytes,0,$bytes.Length);$stream.Flush()}finally{$stream.Dispose()} + Write-Host "Creating owned authoritative zone $zone from new file $zoneFile" + Add-DnsServerPrimaryZone -Name $zone -ZoneFile $zoneFile -DynamicUpdate None -LoadExisting -ErrorAction Stop;$zoneCreated=$true + $record=Get-DnsServerResourceRecord -ZoneName $zone -Name '*' -RRType A -ErrorAction Stop + Assert (@($record).Count -eq 1 -and $record.RecordData.IPv4Address.IPAddressToString -ceq '192.0.2.1') 'Loaded owned wildcard A record is exact.' # The zone is authoritative and the native request has recursion disabled. No external resolver or answer connection is used. if(-not $original.IsEnabled){$channelChanged=$true;Set-ChannelEnabled $true} $configured=Get-WelaNativeChannel $channel @@ -56,7 +71,8 @@ try { }finally{ $errors=@() try{if($channelChanged){Set-ChannelEnabled ([bool]$original.IsEnabled)};if((Get-WelaChannelReadKey (Get-WelaNativeChannel $channel)) -cne (Get-WelaChannelReadKey $original)){throw 'DNS Client channel configuration restoration differs.'}}catch{$errors+=$_.Exception.Message} - if($zoneCreated){try{$owned=Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue;if($owned){if($owned.IsDsIntegrated -or $owned.ZoneType -ne 'Primary'){throw 'Owned DNS zone identity changed; cleanup refused.'};Remove-DnsServerZone -Name $zone -Force -ErrorAction Stop};if(Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue){throw 'Owned zone remains.'};$file=Join-Path $env:SystemRoot ('System32\dns\'+$zoneFile);if(Test-Path -LiteralPath $file){Remove-Item -LiteralPath $file -ErrorAction Stop}}catch{$errors+=$_.Exception.Message}} + if($zoneCreated){try{$owned=Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue;if($owned){if($owned.IsDsIntegrated -or $owned.ZoneType -ne 'Primary'){throw 'Owned DNS zone identity changed; cleanup refused.'};Remove-DnsServerZone -Name $zone -Force -ErrorAction Stop};if(Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue){throw 'Owned zone remains.'}}catch{$errors+=$_.Exception.Message}} + if($zoneFileCreated){try{if(Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue){throw 'Refuse deleting a zone file still loaded by DNS.'};if(Test-Path -LiteralPath $zoneFilePath){Remove-Item -LiteralPath $zoneFilePath -ErrorAction Stop}}catch{$errors+=$_.Exception.Message}} try{$afterPolicies=Get-WelaEffectiveAuditPolicy;foreach($guid in $policies.Keys){if($afterPolicies[$guid] -ne $policies[$guid]){throw 'Native audit policy changed.'}}}catch{$errors+=$_.Exception.Message} $removal=[pscustomobject]@{ChannelAndZoneRestored=($errors.Count -eq 0);Attempted=$false;Features=@();Success=$null;RestartNeeded=$null;Boundary='Owned feature removal can require disposal of this GitHub-hosted VM; no restart or complete live feature-restoration claim.'} if($installed -and -not $errors.Count){try{$added=@(Get-WindowsFeature|Where-Object {$_.Installed -and $_.Name -notin $beforeFeatures -and $_.Name -in @('DNS','RSAT-DNS-Server')}|ForEach-Object Name);if($added.Count){$removal.Attempted=$true;$removal.Features=$added;$removed=Uninstall-WindowsFeature -Name $added -ErrorAction Stop;$removal.Success=[bool]$removed.Success;$removal.RestartNeeded=[string]$removed.RestartNeeded;if(-not $removed.Success -or $removal.RestartNeeded -notin @('No','Yes')){throw 'DNS feature removal failed or restart state is unknown.'}}}catch{$errors+=$_.Exception.Message}} From 1759f5a19621095efab44af479cf06f594ef0362 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:56:40 +0900 Subject: [PATCH 04/10] Use reserved test namespace for native DNS completion probe --- CHANGELOG-Japanese.md | 2 +- CHANGELOG.md | 2 +- WELA.ps1 | 2 +- docs/dns-client-probe.md | 4 ++-- scripts/DnsClientProbe.ps1 | 4 ++-- scripts/DnsClientProbeNative.cs | 2 +- tests/DnsClientProbe.Tests.ps1 | 4 ++-- tests/DnsClientProbe.Windows.Tests.ps1 | 14 +++++++------- website/docs/resources/changelog.ja.md | 2 +- website/docs/resources/changelog.md | 2 +- 10 files changed, 19 insertions(+), 19 deletions(-) diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 57ae6659..f7f8cca4 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,7 +4,7 @@ **改善:** -- 明示した IPv4 リゾルバーに固定の無害な A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (#434) (@Shirofune-Security) +- 明示した IPv4 リゾルバーに固定の無害な `wela-.wela.test.` A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (#434) (@Shirofune-Security) - 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54a4f532..7bf7e327 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ **Improvements:** -- Added opt-in `dns-client-probe` for one fixed benign A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (#434) (@Shirofune-Security) +- Added opt-in `dns-client-probe` for one fixed benign `wela-.wela.test.` A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (#434) (@Shirofune-Security) - Added read-only `channel-read` to test actual current-token access to selected built-in local logs. Native query outcomes distinguish denied, missing, empty and observed-event reads independently of metadata/ACL inspection; token/context checks and bounded private reports preserve uncertainty without changing Windows settings or granting Sigma credit. Disposable standard-user denial/read tests cover Server 2022/2025 and PowerShell 5.1/7. (#432) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index b5a203d3..0baec2ce 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -2255,7 +2255,7 @@ switch ($Cmd.ToLower()) { if($report.ExitCode){exit $report.ExitCode} } 'dns-client-probe' { - if ($Help) {Write-Host 'Usage: dns-client-probe [-DnsClientProbeAction Plan|Run] -DnsClientProbeResolver approved-IPv4 [-DnsClientProbeOutputPath new-private-directory] [-DnsClientProbeTimeoutSeconds 1..30]. Fixed benign A lookup to wela-.wela.invalid. via explicit DNS TCP53 resolver; no configuration changes or Sigma credit. Plan observes prerequisites only. See docs/dns-client-probe.md.';return} + if ($Help) {Write-Host 'Usage: dns-client-probe [-DnsClientProbeAction Plan|Run] -DnsClientProbeResolver approved-IPv4 [-DnsClientProbeOutputPath new-private-directory] [-DnsClientProbeTimeoutSeconds 1..30]. Fixed benign A lookup to wela-.wela.test. via explicit DNS TCP53 resolver; no configuration changes or Sigma credit. Plan observes prerequisites only. See docs/dns-client-probe.md.';return} $report=Invoke-WelaDnsClientProbe -Action $DnsClientProbeAction -Resolver $DnsClientProbeResolver -OutputPath $DnsClientProbeOutputPath -TimeoutSeconds $DnsClientProbeTimeoutSeconds $report if($report.ExitCode){exit $report.ExitCode} diff --git a/docs/dns-client-probe.md b/docs/dns-client-probe.md index 6479eecd..e52e6002 100644 --- a/docs/dns-client-probe.md +++ b/docs/dns-client-probe.md @@ -12,7 +12,7 @@ -DnsClientProbeOutputPath C:\WelaEvidence\dns-client-01 ``` -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.invalid.` type A. 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 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. 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. @@ -20,6 +20,6 @@ Evidence includes observed build/patch/role, token and same-engine context, exac `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.invalid` 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. +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. 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 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 027b6733..c7bc4f2c 100644 --- a/scripts/DnsClientProbe.ps1 +++ b/scripts/DnsClientProbe.ps1 @@ -128,13 +128,13 @@ function Invoke-WelaDnsClientProbe { param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$Resolver,[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15) $ErrorActionPreference='Stop';Assert-WelaDnsClientResolver $Resolver if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new output directory; Plan writes no files.'} - $report=[pscustomobject][ordered]@{Kind='WelaNativeDnsClientProbe';SchemaVersion=1;Action=$Action;Status='Unverified';ExitCode=1;Resolver=$Resolver;QueryPattern='wela-.wela.invalid.';QueryType='A';Transport='DNS TCP port53; recursion disabled';Before=$null;After=$null;Operation=$null;Query=$null;QueryLogStatus=@();ReaderBefore=$null;ReaderAfter=$null;ReaderInterval='After all initial metadata/output preparation and record boundary, through worker/event I/O and continuity read; before final metadata inventory.';Candidates=0;Matches=0;Artifacts=@();OutputPath=$null;Diagnostic='';ReadyRuleCredit=0;ConfigurationChanges=0;RuleChannelMismatch='Pinned DNS rules use Microsoft-Windows-DNS Client Events/Operational; actual source is Microsoft-Windows-DNS-Client/Operational. No alias rewrite or rule credit.';Correlation='Random query name, native outcome, source/host, record boundary and operation time. Emitter PID is retained but may be a service broker. Event3008 does not independently prove resolver wire identity or exclusive request attribution.';Scope='One fixed native DNS Client lookup completion; no DNS configuration, cache flush, policy/channel/service changes, forwarding or backend execution. Sysmon excluded.'} + $report=[pscustomobject][ordered]@{Kind='WelaNativeDnsClientProbe';SchemaVersion=1;Action=$Action;Status='Unverified';ExitCode=1;Resolver=$Resolver;QueryPattern='wela-.wela.test.';QueryType='A';Transport='DNS TCP port53; recursion disabled';Before=$null;After=$null;Operation=$null;Query=$null;QueryLogStatus=@();ReaderBefore=$null;ReaderAfter=$null;ReaderInterval='After all initial metadata/output preparation and record boundary, through worker/event I/O and continuity read; before final metadata inventory.';Candidates=0;Matches=0;Artifacts=@();OutputPath=$null;Diagnostic='';ReadyRuleCredit=0;ConfigurationChanges=0;RuleChannelMismatch='Pinned DNS rules use Microsoft-Windows-DNS Client Events/Operational; actual source is Microsoft-Windows-DNS-Client/Operational. No alias rewrite or rule credit.';Correlation='Random query name, native outcome, source/host, record boundary and operation time. Emitter PID is retained but may be a service broker. Event3008 does not independently prove resolver wire identity or exclusive request attribution.';Scope='One fixed native DNS Client lookup completion; no DNS configuration, cache flush, policy/channel/service changes, forwarding or backend execution. Sysmon excluded.'} if($Action -eq 'Run'){$report.OutputPath=New-WelaArrivalOutput $OutputPath $PSScriptRoot} try{ $before=Get-WelaDnsClientProbeState;$report.Before=$before;$key=Get-WelaDnsClientProbeStateKey $before if($Action -eq 'Plan'){$report.After=$before;$report.Status='PrerequisitesObserved';$report.ExitCode=0;return $report} $report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'before.json' ($before|ConvertTo-Json -Depth 24) - $queryName='wela-'+[guid]::NewGuid().ToString('N')+'.wela.invalid.' + $queryName='wela-'+[guid]::NewGuid().ToString('N')+'.wela.test.' $operation=Start-WelaDnsClientProbeQuery $before $Resolver $queryName;$report.Operation=$operation;$report.ReaderBefore=$operation.CallerBefore $report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'operation.json' ($operation|ConvertTo-Json -Depth 15) $timer=[Diagnostics.Stopwatch]::StartNew();$matches=@() diff --git a/scripts/DnsClientProbeNative.cs b/scripts/DnsClientProbeNative.cs index b629e2b4..8712158a 100644 --- a/scripts/DnsClientProbeNative.cs +++ b/scripts/DnsClientProbeNative.cs @@ -27,7 +27,7 @@ namespace Wela.DnsClientProbe { } 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\.invalid\.\z"))throw new ArgumentException("Only the fixed random probe name is accepted."); + 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."); ValidateResolver(resolver); // SDK DNS_ADDR_ARRAY header32 + one DNS_ADDR64; sockaddr_in in its first16 bytes. byte[] server=new byte[96];BitConverter.GetBytes((uint)1).CopyTo(server,0);BitConverter.GetBytes((uint)1).CopyTo(server,4); diff --git a/tests/DnsClientProbe.Tests.ps1 b/tests/DnsClientProbe.Tests.ps1 index a3ab2503..71aef0db 100644 --- a/tests/DnsClientProbe.Tests.ps1 +++ b/tests/DnsClientProbe.Tests.ps1 @@ -16,9 +16,9 @@ Assert ((Get-WelaDnsClientProbeStateKey $state).Length -gt 0) 'Exact schema prer $state.Channel.MetadataErrors['LogMode']='denied';Throws {Get-WelaDnsClientProbeStateKey $state} 'fully observed';$state.Channel.MetadataErrors=@{} $state.Schema.Events[0].Fields[0].InType='win:UInt32';Throws {Get-WelaDnsClientProbeStateKey $state} 'field/type';$state.Schema.Events[0].Fields[0].InType='win:UnicodeString' $state.Schema.Events[0].Version=1;Throws {Get-WelaDnsClientProbeStateKey $state} 'version/channel';$state.Schema.Events[0].Version=0 -$operation=[pscustomobject]@{Query=[pscustomobject]@{QueryName='wela-0123456789abcdef0123456789abcdef.wela.invalid.';Status=0;Options=2103790};StartedUtc='2026-01-01T00:00:00.0000000Z';CompletedUtc='2026-01-01T00:00:01.0000000Z';RecordIdBefore=9} +$operation=[pscustomobject]@{Query=[pscustomobject]@{QueryName='wela-0123456789abcdef0123456789abcdef.wela.test.';Status=0;Options=2103790};StartedUtc='2026-01-01T00:00:00.0000000Z';CompletedUtc='2026-01-01T00:00:01.0000000Z';RecordIdBefore=9} $xml=@' -3008010Microsoft-Windows-DNS-Client/Operationalhostwela-0123456789abcdef0123456789abcdef.wela.invalid.10x2019ee0192.0.2.1; +3008010Microsoft-Windows-DNS-Client/Operationalhostwela-0123456789abcdef0123456789abcdef.wela.test.10x2019ee0192.0.2.1; '@ Assert (Test-WelaDnsClientProbeEvent $xml $operation $state) 'Exact synthetic native3008 shape matches.' $mutations=@( diff --git a/tests/DnsClientProbe.Windows.Tests.ps1 b/tests/DnsClientProbe.Windows.Tests.ps1 index 8334a1f1..4353e8c8 100644 --- a/tests/DnsClientProbe.Windows.Tests.ps1 +++ b/tests/DnsClientProbe.Windows.Tests.ps1 @@ -11,7 +11,7 @@ $os=Get-CimInstance Win32_OperatingSystem;$computer=Get-CimInstance Win32_Comput if($os.ProductType -ne 3 -or [int]$os.BuildNumber -notin @(20348,26100) -or $computer.PartOfDomain -or $computer.DomainRole -ne 2 -or (Get-WindowsFeature DNS).Installed){throw 'This fixture requires an unjoined Server2022/2025 with no existing DNS role.'} $engine=(Get-Command $TestEngine -ErrorAction Stop).Source $private=New-WelaArrivalOutput (Join-Path $env:TEMP ('wela-dns-client-native-'+[guid]::NewGuid().ToString('N'))) $PSScriptRoot -$channel='Microsoft-Windows-DNS-Client/Operational';$zone='wela.invalid';$zoneFile='wela-native-'+[guid]::NewGuid().ToString('N')+'.dns';$zoneFilePath=$null;$zoneFileCreated=$false +$channel='Microsoft-Windows-DNS-Client/Operational';$zone='wela.test';$zoneFile='wela-native-'+[guid]::NewGuid().ToString('N')+'.dns';$zoneFilePath=$null;$zoneFileCreated=$false $beforeFeatures=@(Get-WindowsFeature|Where-Object Installed|ForEach-Object Name);$policies=Get-WelaEffectiveAuditPolicy;$original=Get-WelaNativeChannel $channel if($original.State -notin @('Enabled','Disabled') -or $original.MetadataErrors.Count -or $original.Error){throw 'Complete original DNS Client channel state is required before fixture mutation.'} $null=Write-WelaArrivalArtifact $private 'original-channel.json' ($original|ConvertTo-Json -Depth 10) @@ -34,18 +34,18 @@ try { if(Test-Path -LiteralPath $zoneFilePath){throw 'Fixture zone file already exists.'} # Avoid relying on generated SOA/NS names on an unjoined, suffix-free runner. $zoneText=@' -$ORIGIN wela.invalid. +$ORIGIN wela.test. $TTL 0 -@ IN SOA ns.wela.invalid. hostmaster.wela.invalid. ( 1 3600 600 86400 0 ) -@ IN NS ns.wela.invalid. +@ IN SOA ns.wela.test. hostmaster.wela.test. ( 1 3600 600 86400 0 ) +@ IN NS ns.wela.test. ns IN A 127.0.0.1 * IN A 192.0.2.1 '@ $stream=[IO.File]::Open($zoneFilePath,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) - try{$zoneFileCreated=$true;$bytes=[Text.Encoding]::ASCII.GetBytes($zoneText.Replace("`n","`r`n")+"`r`n");$stream.Write($bytes,0,$bytes.Length);$stream.Flush()}finally{$stream.Dispose()} + try{$zoneFileCreated=$true;$bytes=[Text.Encoding]::ASCII.GetBytes(($zoneText -replace "\r?\n","`r`n")+"`r`n");$stream.Write($bytes,0,$bytes.Length);$stream.Flush()}finally{$stream.Dispose()} Write-Host "Creating owned authoritative zone $zone from new file $zoneFile" Add-DnsServerPrimaryZone -Name $zone -ZoneFile $zoneFile -DynamicUpdate None -LoadExisting -ErrorAction Stop;$zoneCreated=$true - $record=Get-DnsServerResourceRecord -ZoneName $zone -Name '*' -RRType A -ErrorAction Stop + $record=@(Get-DnsServerResourceRecord -ZoneName $zone -RRType A -ErrorAction Stop|Where-Object HostName -ceq '*') Assert (@($record).Count -eq 1 -and $record.RecordData.IPv4Address.IPAddressToString -ceq '192.0.2.1') 'Loaded owned wildcard A record is exact.' # The zone is authoritative and the native request has recursion disabled. No external resolver or answer connection is used. if(-not $original.IsEnabled){$channelChanged=$true;Set-ChannelEnabled $true} @@ -57,7 +57,7 @@ ns IN A 127.0.0.1 Invoke-Cli @('dns-client-probe','-DnsClientProbeAction','Run','-DnsClientProbeResolver','127.0.0.1','-DnsClientProbeOutputPath',$output) $report=ConvertFrom-WelaRecoveryJson ([IO.File]::ReadAllText((Join-Path $output 'manifest.json'))) Assert ($report.Status -ceq 'NativeDnsLookupObserved' -and $report.ExitCode -eq 0 -and $report.Matches -ge 1 -and $report.ReadyRuleCredit -eq 0 -and $report.ConfigurationChanges -eq 0) 'Actual native3008 correlation is observed without configuration/Sigma credit.' - Assert ($report.Operation.Query.QueryName -cmatch '^wela-[a-f0-9]{32}\.wela\.invalid\.$' -and $report.Operation.Query.Status -eq 0 -and $report.Operation.Query.ResultStatus -eq 0 -and @($report.Operation.Query.Answers).Count -eq 1 -and $report.Operation.Query.Answers[0].Address -ceq '192.0.2.1') 'Owned authoritative loopback resolver returns the exact fixed A answer.' + Assert ($report.Operation.Query.QueryName -cmatch '^wela-[a-f0-9]{32}\.wela\.test\.$' -and $report.Operation.Query.Status -eq 0 -and $report.Operation.Query.ResultStatus -eq 0 -and @($report.Operation.Query.Answers).Count -eq 1 -and $report.Operation.Query.Answers[0].Address -ceq '192.0.2.1') 'Owned authoritative loopback resolver returns the exact fixed A answer.' foreach($artifact in $report.Artifacts){Assert ((Get-FileHash -LiteralPath (Join-Path $output $artifact.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Evidence bytes match recorded SHA256.'} foreach($file in Get-ChildItem -LiteralPath $output -Filter 'event-*.xml'){$xml=[IO.File]::ReadAllText($file.FullName);Assert (Test-WelaDnsClientProbeEvent $xml $report.Operation $report.Before) 'Actual persisted3008 XML matches the production validator.';Write-Host $xml} Assert ((Get-WelaChannelReadKey (Get-WelaNativeChannel $channel)) -ceq (Get-WelaChannelReadKey $configured)) 'Product preserves the exact configured channel metadata.' diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index c1b5070a..3c9299b7 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,7 +7,7 @@ **改善:** -- 明示した IPv4 リゾルバーに固定の無害な A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (#434) (@Shirofune-Security) +- 明示した IPv4 リゾルバーに固定の無害な `wela-.wela.test.` A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (#434) (@Shirofune-Security) - 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index d9de819b..a1f92c6e 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,7 +7,7 @@ **Improvements:** -- Added opt-in `dns-client-probe` for one fixed benign A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (#434) (@Shirofune-Security) +- Added opt-in `dns-client-probe` for one fixed benign `wela-.wela.test.` A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (#434) (@Shirofune-Security) - Added read-only `channel-read` to test actual current-token access to selected built-in local logs. Native query outcomes distinguish denied, missing, empty and observed-event reads independently of metadata/ACL inspection; token/context checks and bounded private reports preserve uncertainty without changing Windows settings or granting Sigma credit. Disposable standard-user denial/read tests cover Server 2022/2025 and PowerShell 5.1/7. (#432) (@Shirofune-Security) From d36203bbe4180be0727c2ccea28712a59023bd7d Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:03:37 +0900 Subject: [PATCH 05/10] Use native zone and wildcard creation for DNS test namespace --- tests/DnsClientProbe.Windows.Tests.ps1 | 30 ++++++++------------------ 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/tests/DnsClientProbe.Windows.Tests.ps1 b/tests/DnsClientProbe.Windows.Tests.ps1 index 4353e8c8..fd05e570 100644 --- a/tests/DnsClientProbe.Windows.Tests.ps1 +++ b/tests/DnsClientProbe.Windows.Tests.ps1 @@ -11,7 +11,7 @@ $os=Get-CimInstance Win32_OperatingSystem;$computer=Get-CimInstance Win32_Comput if($os.ProductType -ne 3 -or [int]$os.BuildNumber -notin @(20348,26100) -or $computer.PartOfDomain -or $computer.DomainRole -ne 2 -or (Get-WindowsFeature DNS).Installed){throw 'This fixture requires an unjoined Server2022/2025 with no existing DNS role.'} $engine=(Get-Command $TestEngine -ErrorAction Stop).Source $private=New-WelaArrivalOutput (Join-Path $env:TEMP ('wela-dns-client-native-'+[guid]::NewGuid().ToString('N'))) $PSScriptRoot -$channel='Microsoft-Windows-DNS-Client/Operational';$zone='wela.test';$zoneFile='wela-native-'+[guid]::NewGuid().ToString('N')+'.dns';$zoneFilePath=$null;$zoneFileCreated=$false +$channel='Microsoft-Windows-DNS-Client/Operational';$zone='wela.test';$zoneFile='wela-native-'+[guid]::NewGuid().ToString('N')+'.dns' $beforeFeatures=@(Get-WindowsFeature|Where-Object Installed|ForEach-Object Name);$policies=Get-WelaEffectiveAuditPolicy;$original=Get-WelaNativeChannel $channel if($original.State -notin @('Enabled','Disabled') -or $original.MetadataErrors.Count -or $original.Error){throw 'Complete original DNS Client channel state is required before fixture mutation.'} $null=Write-WelaArrivalArtifact $private 'original-channel.json' ($original|ConvertTo-Json -Depth 10) @@ -24,34 +24,22 @@ function Invoke-Cli { Assert ($code -eq $Expected) "Public DNS Client CLI exit $code, expected $Expected." } function Set-ChannelEnabled([bool]$Enabled){$out=& "$env:SystemRoot\System32\wevtutil.exe" sl $channel ('/e:'+([string]$Enabled).ToLowerInvariant()) 2>&1;if($LASTEXITCODE -ne 0){throw "Fixture channel update failed: $out"};$global:LASTEXITCODE=0} +$catalog=Get-WelaProviderPackCatalog;$pack=@($catalog.packs|Where-Object id -ceq 'dns-client')[0] +Write-Host ((Get-WelaProviderPackSchema $pack)|ConvertTo-Json -Depth 12) try { $installed=$true;$feature=Install-WindowsFeature DNS -IncludeManagementTools -ErrorAction Stop if(-not $feature.Success -or [string]$feature.RestartNeeded -ne 'No'){throw 'DNS role install failed or requires restart; no native acceptance claim.'} Start-Service DNS -ErrorAction Stop $ready=[Diagnostics.Stopwatch]::StartNew();do{try{$null=Get-DnsServerZone -ErrorAction Stop;break}catch{if($ready.Elapsed.TotalSeconds -gt 30){throw};Start-Sleep -Milliseconds 500}}while($true) if(Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue){throw 'Fixture zone already exists; no replacement is permitted.'} - $zoneFilePath=Join-Path $env:SystemRoot ('System32\dns\'+$zoneFile) - if(Test-Path -LiteralPath $zoneFilePath){throw 'Fixture zone file already exists.'} - # Avoid relying on generated SOA/NS names on an unjoined, suffix-free runner. - $zoneText=@' -$ORIGIN wela.test. -$TTL 0 -@ IN SOA ns.wela.test. hostmaster.wela.test. ( 1 3600 600 86400 0 ) -@ IN NS ns.wela.test. -ns IN A 127.0.0.1 -* IN A 192.0.2.1 -'@ - $stream=[IO.File]::Open($zoneFilePath,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) - try{$zoneFileCreated=$true;$bytes=[Text.Encoding]::ASCII.GetBytes(($zoneText -replace "\r?\n","`r`n")+"`r`n");$stream.Write($bytes,0,$bytes.Length);$stream.Flush()}finally{$stream.Dispose()} - Write-Host "Creating owned authoritative zone $zone from new file $zoneFile" - Add-DnsServerPrimaryZone -Name $zone -ZoneFile $zoneFile -DynamicUpdate None -LoadExisting -ErrorAction Stop;$zoneCreated=$true + if(Test-Path -LiteralPath (Join-Path $env:SystemRoot ('System32\dns\'+$zoneFile))){throw 'Fixture zone file already exists.'} + Add-DnsServerPrimaryZone -Name $zone -ZoneFile $zoneFile -DynamicUpdate None -ErrorAction Stop;$zoneCreated=$true + Add-DnsServerResourceRecordA -ZoneName $zone -Name '*' -IPv4Address '192.0.2.1' -TimeToLive ([TimeSpan]::FromSeconds(1)) -ErrorAction Stop|Out-Null $record=@(Get-DnsServerResourceRecord -ZoneName $zone -RRType A -ErrorAction Stop|Where-Object HostName -ceq '*') - Assert (@($record).Count -eq 1 -and $record.RecordData.IPv4Address.IPAddressToString -ceq '192.0.2.1') 'Loaded owned wildcard A record is exact.' + Assert ($record.Count -eq 1 -and $record[0].RecordData.IPv4Address.IPAddressToString -ceq '192.0.2.1') 'Owned wildcard A record is exact.' # The zone is authoritative and the native request has recursion disabled. No external resolver or answer connection is used. if(-not $original.IsEnabled){$channelChanged=$true;Set-ChannelEnabled $true} $configured=Get-WelaNativeChannel $channel - $catalog=Get-WelaProviderPackCatalog;$pack=@($catalog.packs|Where-Object id -ceq 'dns-client')[0] - Write-Host ((Get-WelaProviderPackSchema $pack)|ConvertTo-Json -Depth 12) Invoke-Cli @('dns-client-probe','-DnsClientProbeResolver','127.0.0.1') $output=Join-Path $private 'evidence' Invoke-Cli @('dns-client-probe','-DnsClientProbeAction','Run','-DnsClientProbeResolver','127.0.0.1','-DnsClientProbeOutputPath',$output) @@ -71,8 +59,8 @@ ns IN A 127.0.0.1 }finally{ $errors=@() try{if($channelChanged){Set-ChannelEnabled ([bool]$original.IsEnabled)};if((Get-WelaChannelReadKey (Get-WelaNativeChannel $channel)) -cne (Get-WelaChannelReadKey $original)){throw 'DNS Client channel configuration restoration differs.'}}catch{$errors+=$_.Exception.Message} - if($zoneCreated){try{$owned=Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue;if($owned){if($owned.IsDsIntegrated -or $owned.ZoneType -ne 'Primary'){throw 'Owned DNS zone identity changed; cleanup refused.'};Remove-DnsServerZone -Name $zone -Force -ErrorAction Stop};if(Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue){throw 'Owned zone remains.'}}catch{$errors+=$_.Exception.Message}} - if($zoneFileCreated){try{if(Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue){throw 'Refuse deleting a zone file still loaded by DNS.'};if(Test-Path -LiteralPath $zoneFilePath){Remove-Item -LiteralPath $zoneFilePath -ErrorAction Stop}}catch{$errors+=$_.Exception.Message}} + if($zoneCreated){try{$owned=Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue;if($owned){if($owned.IsDsIntegrated -or $owned.ZoneType -ne 'Primary'){throw 'Owned DNS zone identity changed; cleanup refused.'};Remove-DnsServerZone -Name $zone -Force -ErrorAction Stop};if(Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue){throw 'Owned zone remains.'};$file=Join-Path $env:SystemRoot ('System32\dns\'+$zoneFile);if(Test-Path -LiteralPath $file){Remove-Item -LiteralPath $file -ErrorAction Stop}}catch{$errors+=$_.Exception.Message}} + try{$afterPolicies=Get-WelaEffectiveAuditPolicy;foreach($guid in $policies.Keys){if($afterPolicies[$guid] -ne $policies[$guid]){throw 'Native audit policy changed.'}}}catch{$errors+=$_.Exception.Message} $removal=[pscustomobject]@{ChannelAndZoneRestored=($errors.Count -eq 0);Attempted=$false;Features=@();Success=$null;RestartNeeded=$null;Boundary='Owned feature removal can require disposal of this GitHub-hosted VM; no restart or complete live feature-restoration claim.'} if($installed -and -not $errors.Count){try{$added=@(Get-WindowsFeature|Where-Object {$_.Installed -and $_.Name -notin $beforeFeatures -and $_.Name -in @('DNS','RSAT-DNS-Server')}|ForEach-Object Name);if($added.Count){$removal.Attempted=$true;$removal.Features=$added;$removed=Uninstall-WindowsFeature -Name $added -ErrorAction Stop;$removal.Success=[bool]$removed.Success;$removal.RestartNeeded=[string]$removed.RestartNeeded;if(-not $removed.Success -or $removal.RestartNeeded -notin @('No','Yes')){throw 'DNS feature removal failed or restart state is unknown.'}}}catch{$errors+=$_.Exception.Message}} From f75bb3019bdf9b9b76d676df58ca346cae201f65 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:07:57 +0900 Subject: [PATCH 06/10] Retain bounded DNS worker evidence when native validation fails --- docs/dns-client-probe.md | 4 ++-- scripts/DnsClientProbe.ps1 | 8 +++++--- tests/DnsClientProbe.Windows.Tests.ps1 | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/dns-client-probe.md b/docs/dns-client-probe.md index e52e6002..13941da5 100644 --- a/docs/dns-client-probe.md +++ b/docs/dns-client-probe.md @@ -14,9 +14,9 @@ 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. 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. 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. -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, 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. 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. diff --git a/scripts/DnsClientProbe.ps1 b/scripts/DnsClientProbe.ps1 index c7bc4f2c..4f64ca48 100644 --- a/scripts/DnsClientProbe.ps1 +++ b/scripts/DnsClientProbe.ps1 @@ -58,7 +58,7 @@ function Get-WelaDnsClientProbeWatermark { try{$query=[Diagnostics.Eventing.Reader.EventLogQuery]::new('Microsoft-Windows-DNS-Client/Operational',[Diagnostics.Eventing.Reader.PathType]::LogName,'*');$query.ReverseDirection=$true;$reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($query);$reader.BatchSize=1;$record=$reader.ReadEvent([TimeSpan]::FromSeconds(5));Assert-WelaChannelQueryStatus -Channel 'Microsoft-Windows-DNS-Client/Operational' -LogStatus @($reader.LogStatus);if($record){if($record.RecordId -le 0){throw 'Invalid native record boundary.'};return [long]$record.RecordId};return [long]0}finally{if($record){$record.Dispose()};if($reader){$reader.Dispose()}} } function Start-WelaDnsClientProbeQuery { - param($State,[string]$Resolver,[string]$QueryName) + param($State,[string]$Resolver,[string]$QueryName,$Report) $fresh=Get-WelaDnsClientProbeState if((Get-WelaDnsClientProbeStateKey $fresh) -cne (Get-WelaDnsClientProbeStateKey $State)){throw 'DNS prerequisites changed before query.'} $boundary=Get-WelaDnsClientProbeWatermark @@ -73,8 +73,10 @@ function Start-WelaDnsClientProbeQuery { if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($output,$errorText),5000)){throw 'DNS worker output did not finish.'} if($output.Result.Length -gt 262144 -or $errorText.Result.Length -gt 65536){throw 'DNS worker output exceeded evidence bounds.'} if($process.ExitCode -ne 0 -or $errorText.Result){throw ('DNS worker failed: '+$errorText.Result)} + # Retain bounded owned-worker output even when schema/status validation refuses it. + $Report.Artifacts+=Write-WelaArrivalArtifact $Report.OutputPath 'worker.json' $output.Result $operation=ConvertFrom-WelaRecoveryJson $output.Result - 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.'} + 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.'} @@ -135,7 +137,7 @@ function Invoke-WelaDnsClientProbe { if($Action -eq 'Plan'){$report.After=$before;$report.Status='PrerequisitesObserved';$report.ExitCode=0;return $report} $report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'before.json' ($before|ConvertTo-Json -Depth 24) $queryName='wela-'+[guid]::NewGuid().ToString('N')+'.wela.test.' - $operation=Start-WelaDnsClientProbeQuery $before $Resolver $queryName;$report.Operation=$operation;$report.ReaderBefore=$operation.CallerBefore + $operation=Start-WelaDnsClientProbeQuery $before $Resolver $queryName $report;$report.Operation=$operation;$report.ReaderBefore=$operation.CallerBefore $report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'operation.json' ($operation|ConvertTo-Json -Depth 15) $timer=[Diagnostics.Stopwatch]::StartNew();$matches=@() do{$batch=Read-WelaDnsClientProbeEvents $operation;$report.Query=$batch.Query;$report.QueryLogStatus=@($batch.LogStatus);Assert-WelaChannelQueryStatus -Channel 'Microsoft-Windows-DNS-Client/Operational' -LogStatus $report.QueryLogStatus;$report.Candidates=@($batch.Xml).Count;if($batch.Capped -isnot [bool] -or $batch.Capped){throw 'DNS event query cap reached or completeness unknown.'};$matches=@($batch.Xml|Where-Object {Test-WelaDnsClientProbeEvent $_ $operation $before});if($matches.Count){break};Start-Sleep -Milliseconds 250}while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds) diff --git a/tests/DnsClientProbe.Windows.Tests.ps1 b/tests/DnsClientProbe.Windows.Tests.ps1 index fd05e570..a3c8a6f2 100644 --- a/tests/DnsClientProbe.Windows.Tests.ps1 +++ b/tests/DnsClientProbe.Windows.Tests.ps1 @@ -54,7 +54,7 @@ try { }catch{ Write-Host ('Native DNS Client failure: '+($_|Out-String));Write-Host $_.ScriptStackTrace # Small owned diagnostics only; avoid dumping unrelated channel payloads. - if(Test-Path (Join-Path $private 'evidence')){Get-ChildItem (Join-Path $private 'evidence') -File|Where-Object {$_.Name -in @('manifest.json','operation.json') -or $_.Name -like 'candidate-*.xml'}|ForEach-Object{Write-Host $_.Name;Write-Host ([IO.File]::ReadAllText($_.FullName))}} + if(Test-Path (Join-Path $private 'evidence')){Get-ChildItem (Join-Path $private 'evidence') -File|Where-Object {$_.Name -in @('manifest.json','operation.json','worker.json') -or $_.Name -like 'candidate-*.xml'}|ForEach-Object{Write-Host $_.Name;Write-Host ([IO.File]::ReadAllText($_.FullName))}} throw }finally{ $errors=@() From d43352cbd6ec85a63aeb21005ebe0452b603638a Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:26:31 +0900 Subject: [PATCH 07/10] Set documented DNS server-array byte size for native requests --- docs/dns-client-probe.md | 2 +- scripts/DnsClientProbeNative.cs | 3 ++- tests/DnsClientProbe.Tests.ps1 | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/dns-client-probe.md b/docs/dns-client-probe.md index 13941da5..d45abb43 100644 --- a/docs/dns-client-probe.md +++ b/docs/dns-client-probe.md @@ -22,4 +22,4 @@ Evidence includes observed build/patch/role, token and same-engine context, exac 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. -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 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). +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/DnsClientProbeNative.cs b/scripts/DnsClientProbeNative.cs index 8712158a..6323d0c9 100644 --- a/scripts/DnsClientProbeNative.cs +++ b/scripts/DnsClientProbeNative.cs @@ -30,7 +30,8 @@ namespace Wela.DnsClientProbe { 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."); ValidateResolver(resolver); // SDK DNS_ADDR_ARRAY header32 + one DNS_ADDR64; sockaddr_in in its first16 bytes. - byte[] server=new byte[96];BitConverter.GetBytes((uint)1).CopyTo(server,0);BitConverter.GetBytes((uint)1).CopyTo(server,4); + // DNS_ADDR_ARRAY.MaxCount is the structure size in bytes; AddrCount is the element count. + byte[] server=new byte[96];BitConverter.GetBytes((uint)server.Length).CopyTo(server,0);BitConverter.GetBytes((uint)1).CopyTo(server,4); BitConverter.GetBytes((ushort)2).CopyTo(server,12);BitConverter.GetBytes((ushort)2).CopyTo(server,32); server[34]=0;server[35]=53;IPAddress.Parse(resolver).GetAddressBytes().CopyTo(server,36); IntPtr servers=Marshal.AllocHGlobal(server.Length);QueryResult result=new QueryResult {Version=1}; diff --git a/tests/DnsClientProbe.Tests.ps1 b/tests/DnsClientProbe.Tests.ps1 index 71aef0db..12b45614 100644 --- a/tests/DnsClientProbe.Tests.ps1 +++ b/tests/DnsClientProbe.Tests.ps1 @@ -8,6 +8,7 @@ Add-Type -Path (Join-Path $ScriptRoot 'scripts/DnsClientProbeNative.cs') foreach($resolver in @('127.0.0.1','192.0.2.53','10.0.0.53')){Assert-WelaDnsClientResolver $resolver;Assert ([Wela.DnsClientProbe.Native]::ValidateResolver($resolver) -ceq $resolver) 'Explicit canonical IPv4 accepted.'} foreach($resolver in @('','localhost','127.1','127.0.0.01','127.0.0.1:53','127.0.0.1"','0.0.0.0','224.0.0.1','255.255.255.255','192.0.2.999','::1')){Throws {Assert-WelaDnsClientResolver $resolver} 'IPv4';Throws {[Wela.DnsClientProbe.Native]::ValidateResolver($resolver)} 'resolver|IPv4'} Throws {[Wela.DnsClientProbe.Native]::Query('arbitrary.example.','127.0.0.1')} 'fixed random' +foreach($entry in @(@('Request',64),@('QueryResult',32),@('Record',32))){$nativeType=[Wela.DnsClientProbe.Native].GetNestedType($entry[0],[Reflection.BindingFlags]::NonPublic);Assert ([Runtime.InteropServices.Marshal]::SizeOf($nativeType) -eq $entry[1]) ('Native64 SDK layout: '+$entry[0])} Assert ([Wela.DnsClientProbe.Native].GetField('SourceSha256').IsLiteral) 'Compiled source fingerprint cannot be reassigned.' 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'})}}) From 58231e61f0978ec74b26e59c91132029332d60e4 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:27:12 +0900 Subject: [PATCH 08/10] Select the native-layout type overload in ABI fixtures --- tests/DnsClientProbe.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/DnsClientProbe.Tests.ps1 b/tests/DnsClientProbe.Tests.ps1 index 12b45614..3bb3c4bd 100644 --- a/tests/DnsClientProbe.Tests.ps1 +++ b/tests/DnsClientProbe.Tests.ps1 @@ -8,7 +8,7 @@ Add-Type -Path (Join-Path $ScriptRoot 'scripts/DnsClientProbeNative.cs') foreach($resolver in @('127.0.0.1','192.0.2.53','10.0.0.53')){Assert-WelaDnsClientResolver $resolver;Assert ([Wela.DnsClientProbe.Native]::ValidateResolver($resolver) -ceq $resolver) 'Explicit canonical IPv4 accepted.'} foreach($resolver in @('','localhost','127.1','127.0.0.01','127.0.0.1:53','127.0.0.1"','0.0.0.0','224.0.0.1','255.255.255.255','192.0.2.999','::1')){Throws {Assert-WelaDnsClientResolver $resolver} 'IPv4';Throws {[Wela.DnsClientProbe.Native]::ValidateResolver($resolver)} 'resolver|IPv4'} Throws {[Wela.DnsClientProbe.Native]::Query('arbitrary.example.','127.0.0.1')} 'fixed random' -foreach($entry in @(@('Request',64),@('QueryResult',32),@('Record',32))){$nativeType=[Wela.DnsClientProbe.Native].GetNestedType($entry[0],[Reflection.BindingFlags]::NonPublic);Assert ([Runtime.InteropServices.Marshal]::SizeOf($nativeType) -eq $entry[1]) ('Native64 SDK layout: '+$entry[0])} +foreach($entry in @(@('Request',64),@('QueryResult',32),@('Record',32))){$nativeType=[Wela.DnsClientProbe.Native].GetNestedType($entry[0],[Reflection.BindingFlags]::NonPublic);Assert ([Runtime.InteropServices.Marshal].GetMethod('SizeOf',[type[]]@([type])).Invoke($null,@($nativeType)) -eq $entry[1]) ('Native64 SDK layout: '+$entry[0])} Assert ([Wela.DnsClientProbe.Native].GetField('SourceSha256').IsLiteral) 'Compiled source fingerprint cannot be reassigned.' 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'})}}) From cddafd04e3bd7692c9773d2f4ef3630e0c81e84d Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:41:30 +0900 Subject: [PATCH 09/10] Bind documented DNS query export exactly and match Microsoft server buffer --- scripts/DnsClientProbeNative.cs | 12 +++++++----- tests/DnsClientProbe.Diagnostics.ps1 | 11 +++++++++++ tests/DnsClientProbe.Tests.ps1 | 2 ++ tests/DnsClientProbe.Windows.Tests.ps1 | 4 ++++ 4 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 tests/DnsClientProbe.Diagnostics.ps1 diff --git a/scripts/DnsClientProbeNative.cs b/scripts/DnsClientProbeNative.cs index 6323d0c9..369da352 100644 --- a/scripts/DnsClientProbeNative.cs +++ b/scripts/DnsClientProbeNative.cs @@ -17,7 +17,8 @@ namespace Wela.DnsClientProbe { } [StructLayout(LayoutKind.Sequential)] struct QueryResult { public uint Version,Status; public ulong Options; public IntPtr Records,Reserved; } [StructLayout(LayoutKind.Sequential)] struct Record { public IntPtr Next,Name; public ushort Type,Length; public uint Flags,Ttl,Reserved; } - [DllImport("dnsapi.dll",CharSet=CharSet.Unicode)] static extern uint DnsQueryEx(ref Request request,ref QueryResult result,IntPtr cancel); + // 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); 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."); @@ -30,10 +31,11 @@ namespace Wela.DnsClientProbe { 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."); ValidateResolver(resolver); // SDK DNS_ADDR_ARRAY header32 + one DNS_ADDR64; sockaddr_in in its first16 bytes. - // DNS_ADDR_ARRAY.MaxCount is the structure size in bytes; AddrCount is the element count. - byte[] server=new byte[96];BitConverter.GetBytes((uint)server.Length).CopyTo(server,0);BitConverter.GetBytes((uint)1).CopyTo(server,4); - BitConverter.GetBytes((ushort)2).CopyTo(server,12);BitConverter.GetBytes((ushort)2).CopyTo(server,32); - server[34]=0;server[35]=53;IPAddress.Parse(resolver).GetAddressBytes().CopyTo(server,36); + // Match Microsoft Windows-classic-samples/DNSAsyncQuery CreateDnsServerList: + // one address, unspecified aggregate family, sockaddr IPv4 with default DNS port. + 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); IntPtr servers=Marshal.AllocHGlobal(server.Length);QueryResult result=new QueryResult {Version=1}; try { Marshal.Copy(server,0,servers,server.Length); diff --git a/tests/DnsClientProbe.Diagnostics.ps1 b/tests/DnsClientProbe.Diagnostics.ps1 new file mode 100644 index 00000000..8fc49ebb --- /dev/null +++ b/tests/DnsClientProbe.Diagnostics.ps1 @@ -0,0 +1,11 @@ +# Temporary native ABI diagnostic: called only inside the explicitly gated owned DNS fixture. +param([ValidateRange(0,4)][int]$Variant) +$ErrorActionPreference='Stop' +if($env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:OS -ne 'Windows_NT'){throw 'Disposable native fixture only.'} +$source=[IO.File]::ReadAllText((Join-Path $PSScriptRoot '../scripts/DnsClientProbeNative.cs')) +# Keep the fixed product query name/options and explicit loopback resolver. Vary only server buffer ABI. +$variants=@(@(1,0,0),@(96,0,0),@(1,2,0),@(1,0,53),@(96,2,53)) +$v=$variants[$Variant] +$source=$source.Replace('BitConverter.GetBytes((uint)1).CopyTo(server,0);',('BitConverter.GetBytes((uint)'+$v[0]+').CopyTo(server,0);BitConverter.GetBytes((ushort)'+$v[1]+').CopyTo(server,12);server[35]='+$v[2]+';')) +Add-Type -TypeDefinition $source +[pscustomobject]@{Variant=$Variant;MaxCount=$v[0];Family=$v[1];Port=$v[2];Result=[Wela.DnsClientProbe.Native]::Query(('wela-'+[guid]::NewGuid().ToString('N')+'.wela.test.'),'127.0.0.1')}|ConvertTo-Json -Depth 8 -Compress diff --git a/tests/DnsClientProbe.Tests.ps1 b/tests/DnsClientProbe.Tests.ps1 index 3bb3c4bd..ed8737d8 100644 --- a/tests/DnsClientProbe.Tests.ps1 +++ b/tests/DnsClientProbe.Tests.ps1 @@ -10,6 +10,8 @@ foreach($resolver in @('','localhost','127.1','127.0.0.01','127.0.0.1:53','127.0 Throws {[Wela.DnsClientProbe.Native]::Query('arbitrary.example.','127.0.0.1')} 'fixed random' foreach($entry in @(@('Request',64),@('QueryResult',32),@('Record',32))){$nativeType=[Wela.DnsClientProbe.Native].GetNestedType($entry[0],[Reflection.BindingFlags]::NonPublic);Assert ([Runtime.InteropServices.Marshal].GetMethod('SizeOf',[type[]]@([type])).Invoke($null,@($nativeType)) -eq $entry[1]) ('Native64 SDK layout: '+$entry[0])} 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.' 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})}} diff --git a/tests/DnsClientProbe.Windows.Tests.ps1 b/tests/DnsClientProbe.Windows.Tests.ps1 index a3c8a6f2..4102c78c 100644 --- a/tests/DnsClientProbe.Windows.Tests.ps1 +++ b/tests/DnsClientProbe.Windows.Tests.ps1 @@ -53,6 +53,10 @@ try { $passed=$true;Write-Host "PASS: $script:count native DNS Client checks through $TestEngine." }catch{ Write-Host ('Native DNS Client failure: '+($_|Out-String));Write-Host $_.ScriptStackTrace + if($zoneCreated){foreach($variant in 0..4){ + $diagnostic=[Diagnostics.Process]::new();$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$engine;$info.Arguments='-NoLogo -NoProfile -NonInteractive -File "'+(Join-Path $PSScriptRoot 'DnsClientProbe.Diagnostics.ps1')+'" -Variant '+$variant;$info.UseShellExecute=$false;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true;$diagnostic.StartInfo=$info + try{$null=$diagnostic.Start();$stdout=$diagnostic.StandardOutput.ReadToEndAsync();$stderr=$diagnostic.StandardError.ReadToEndAsync();if(-not $diagnostic.WaitForExit(20000)){throw 'Owned diagnostic worker timeout.'};if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Diagnostic output timeout.'};Write-Host ('Owned ABI variant '+$variant+' exit '+$diagnostic.ExitCode);Write-Host $stdout.Result;Write-Host $stderr.Result}catch{Write-Host $_}finally{if(-not $diagnostic.HasExited){$diagnostic.Kill();$null=$diagnostic.WaitForExit(5000)};$diagnostic.Dispose()} + }} # Small owned diagnostics only; avoid dumping unrelated channel payloads. if(Test-Path (Join-Path $private 'evidence')){Get-ChildItem (Join-Path $private 'evidence') -File|Where-Object {$_.Name -in @('manifest.json','operation.json','worker.json') -or $_.Name -like 'candidate-*.xml'}|ForEach-Object{Write-Host $_.Name;Write-Host ([IO.File]::ReadAllText($_.FullName))}} throw 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 10/10] 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})}}