diff --git a/.gitattributes b/.gitattributes index 40c44920..72ba4943 100644 --- a/.gitattributes +++ b/.gitattributes @@ -110,3 +110,6 @@ tests/NativeProviderConfigure.Windows.Tests.ps1 text eol=lf # Public filesystem-SACL disposable lifecycle evidence. tests/FileSaclProfileFixture.cs text eol=lf tests/FileSaclLifecycle.Windows.Tests.ps1 text eol=lf + +/scripts/RegistryValueProbe* text eol=lf +/tests/RegistryValueProbe* text eol=lf diff --git a/.github/workflows/registry-value-probe.yml b/.github/workflows/registry-value-probe.yml new file mode 100644 index 00000000..e839ac8f --- /dev/null +++ b/.github/workflows/registry-value-probe.yml @@ -0,0 +1,48 @@ +name: Native fixed registry value probe +on: + push: + branches: ['**'] + paths: + - 'WELA.ps1' + - 'scripts/RegistryValueProbe*' + - 'scripts/FileAccessProbe.ps1' + - 'scripts/ChannelRead*' + - 'scripts/Configuration.ps1' + - 'tests/RegistryValueProbe*' + - '.github/workflows/registry-value-probe.yml' + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + registry-value-probe: + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Scoped audit tests in Windows PowerShell + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/RegistryValueProbe.Tests.ps1 + ./tests/RegistryValueProbe.Cli.Tests.ps1 + ./tests/RegistryValueProbe.Windows.Tests.ps1 -AllowDisposableRegistryProbe + - name: Scoped audit tests in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/RegistryValueProbe.Tests.ps1 + ./tests/RegistryValueProbe.Cli.Tests.ps1 + ./tests/RegistryValueProbe.Windows.Tests.ps1 -AllowDisposableRegistryProbe + - name: Retain typed originals, results and cleanup + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: registry-value-probe-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-registry-value-probe-*/ + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 544eba1a..e720cc52 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, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md, ./docs/audit-catalog-mappings.md, ./docs/native-token-right-attribution.md, ./docs/audit-notifications.md, ./docs/native-onesettings-acceptance.md, ./docs/ntlm-auditing.md, ./docs/wef-query.md, ./docs/native-filesystem-sacl-validation.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/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md, ./docs/audit-catalog-mappings.md, ./docs/native-token-right-attribution.md, ./docs/audit-notifications.md, ./docs/native-onesettings-acceptance.md, ./docs/ntlm-auditing.md, ./docs/wef-query.md, ./docs/native-filesystem-sacl-validation.md, ./docs/registry-value-probe.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 764fd355..298feafb 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,7 @@ **改善:** +- 現在のユーザーの既存 WELA 診断キー内で一時値を検証する `registry-probe` を追加しました。既存の監査と SACL を前提に、Security 4657 の厳密な対応付け、所有する一時値の削除、ポリシーとセキュリティ状態の保持を確認します。#373、#387 に関連します。 - Server2022/2025 と PowerShell5.1/7 で、Token Right Adjusted の正規GUIDに対する Security4703 の実機検証を追加しました。所有する子プロセスの既存権限を固定手順で無効化・復元し、候補となる2つの監査マスクを比較して、実イベント・実行条件・ハッシュとポリシー/トークンの復元を記録します。過去の候補は条件付きのまま維持し、製品用プローブ・全OS共通の対応関係・Sigma加点は追加しません。(関連 #380) (@Shirofune-Security) - OneSettingsポリシーと明示的なPrivacyチャネル設定の公開CLIを実機検証します。Server 2022で実際の適用、型付き復元記録と再読取、冪等性、不正値の拒否を確認し、Server 2025の既存の拒否を維持します。両PowerShellで厳密な後処理を検証し、イベント生成やSigma対応は主張しません。通知コントロール省略時の引数渡しを修正し、既定のAuditは両項目を読み取り、項目未選択のConfigureは非ゼロで失敗します。(関連 #378) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 195d6774..24ec1f4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ **Improvements:** +- Added an opt-in `registry-probe` for one temporary value in an existing current-user WELA diagnostic key, with existing audit/SACL prerequisites, exact native Security 4657 attribution, owned-value cleanup and preserved policy/security state. Related to #373 and #387. - Added disposable native Security4703 attribution for the canonical Token Right Adjusted GUID on Server2022/2025 and PowerShell5.1/7. A fixed owned-child privilege disable/restore compares the two historical audit-mask candidates, retains exact event/context/hash evidence and verifies policy/token cleanup. Historical candidates remain conditional; no production probe, universal mapping or Sigma credit. (Related #380) (@Shirofune-Security) - Add native public OneSettings policy and explicit Privacy-channel configuration acceptance: actual apply, typed journals/readback, idempotence and invalid-value refusals on Server 2022; preserve the existing Server 2025 refusal. Both PowerShell engines verify exact fixture cleanup without event or Sigma claims. Fix omitted notification-control dispatch so default Audit reads both controls and Configure without a selection fails with a nonzero exit. (Related #378) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 7ece9a25..a0434e95 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -228,7 +228,10 @@ [ValidateRange(1,1024)][int]$MeasurementMaximumEvents = 256, [string]$MeasurementOutputPath, [switch]$MeasurementExportEvtx, - [switch]$Help + [switch]$Help, + [ValidateSet("Plan","Run")][string]$RegistryProbeAction = "Plan", + [string]$RegistryProbeOutputPath, + [ValidateRange(1,30)][int]$RegistryProbeTimeoutSeconds = 15 ) $WELAVersion = "2.2.0" @@ -263,6 +266,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1") . (Join-Path $ScriptRoot "scripts/WmiProbe.ps1") . (Join-Path $ScriptRoot "scripts/FileAccessProbe.ps1") +. (Join-Path $ScriptRoot "scripts/RegistryValueProbe.ps1") . (Join-Path $ScriptRoot "scripts/Capi2Probe.ps1") . (Join-Path $ScriptRoot "scripts/FailedLogonProbe.ps1") . (Join-Path $ScriptRoot "scripts/PowerShellTranscription.ps1") @@ -2167,6 +2171,8 @@ if ($Cmd -eq 'intune-export' -and @($PSBoundParameters.Keys | Where-Object { $_ throw 'intune-export accepts only Intune target/export options, IncludeOptional and Help. No command was run.' } +if ($Cmd -ne 'registry-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'RegistryProbe*'}).Count) {throw 'RegistryProbe options require registry-probe.'} +if ($Cmd -eq 'registry-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','RegistryProbeAction','RegistryProbeOutputPath','RegistryProbeTimeoutSeconds','Help')}).Count) {throw 'registry-probe accepts only its dedicated options.'} if ($Cmd -ne 'file-access-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'FileProbe*'}).Count) {throw 'FileProbe options require file-access-probe.'} if ($Cmd -eq 'file-access-probe' -and ($args.Count -gt 0 -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','FileProbeAction','FileProbePath','FileProbeOutputPath','FileProbeTimeoutSeconds','Help')}).Count)) {throw 'file-access-probe accepts only its dedicated options.'} if ($Cmd -ne 'evtx-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Evtx*'}).Count) {throw 'EVTX options require evtx-recovery. No command was run.'} @@ -2426,6 +2432,12 @@ switch ($Cmd.ToLower()) { $report if ($report.ExitCode) {exit $report.ExitCode} } + 'registry-probe' { + if ($Help) {Write-Host 'Usage: registry-probe [-RegistryProbeAction Plan|Run] [-RegistryProbeOutputPath new-directory] [-RegistryProbeTimeoutSeconds 15]. Uses only the existing current-user Software\WELA\AuditProbe key with existing SetValue SACL and Registry success policy. Run creates, modifies and deletes one owned nonce value; never configures auditing. See docs/registry-value-probe.md.';return} + $report=Invoke-WelaRegistryValueProbe -Action $RegistryProbeAction -OutputPath $RegistryProbeOutputPath -TimeoutSeconds $RegistryProbeTimeoutSeconds + $report|ConvertTo-Json -Depth 28 + exit $report.ExitCode + } 'file-access-probe' { if ($Help) {Write-Host 'Usage: file-access-probe [-FileProbeAction Plan] -FileProbePath C:\Audit\existing-file.txt; Run additionally requires -FileProbeOutputPath C:\Evidence\new-probe [-FileProbeTimeoutSeconds 15]. Reads one byte and discards it; event matching uses the measured read plus held-handle identity/security readback phase, with the ReadFile return recorded separately. Source-tree/active-engine targets and aliases are refused before hashing. Existing File System success policy, precedence and matching ReadData SACL are required; no policy, ACL or file-data writes. Local4663 success only, no failure/forwarding/Sigma credit. See docs/file-access-probe.md.';return} $report=Invoke-WelaFileAccessProbe -Action $FileProbeAction -FilePath $FileProbePath -OutputPath $FileProbeOutputPath -TimeoutSeconds $FileProbeTimeoutSeconds diff --git a/docs/registry-value-probe.md b/docs/registry-value-probe.md new file mode 100644 index 00000000..de720ccc --- /dev/null +++ b/docs/registry-value-probe.md @@ -0,0 +1,24 @@ +# Fixed current-user registry value probe + +`registry-probe` validates a narrow native Security 4657 component using an existing diagnostic key, `HKEY_USERS\\Software\WELA\AuditProbe`. It accepts no alternate key or value input. The default Plan action reads prerequisites; explicit Run creates one unpredictable `WELA_Probe_` REG_SZ value, modifies its fixed marker text, verifies readback, then deletes only that owned value. The key's native last-write metadata changes. This command never creates keys, installs SACLs, changes audit policy, alters autostart entries, starts services or accesses remote computers. + +```powershell +./WELA.ps1 registry-probe +./WELA.ps1 registry-probe -RegistryProbeAction Run -RegistryProbeOutputPath C:\WelaEvidence\registry-001 +``` + +Prepare the diagnostic key and its auditing through a separately reviewed administrative process. Run requires an ordinary elevated current primary token with its existing SeSecurityPrivilege assigned, native 64-bit Windows PowerShell 5.1 or PowerShell7, an enabled/readable Security channel, Registry success auditing, audit precedence DWORD1, and an ordinary success SetValue SACL ACE matching the actual user or enabled group. No target prerequisites are silently repaired. The fixed key must have no children. Component-by-component native opens reject registry symbolic links and verify the held NT path. Full SDK-defined security-descriptor sections and a bounded inventory of raw typed values are retained; excessive or unreadable state fails closed. + +Microsoft describes [4657 and the Set Value SACL prerequisite](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/auditing/event-4657). The probe uses the documented [RegSetValueExW](https://learn.microsoft.com/en-us/windows/win32/api/winreg/nf-winreg-regsetvalueexw) and [RegDeleteValueW](https://learn.microsoft.com/en-us/windows/win32/api/winreg/nf-winreg-regdeletevaluew) APIs through the same held key. Existing values are never selected for modification. A collision refuses before writing; a changed owned value is preserved with failed cleanup rather than blindly deleted. Operations are not transactions with other administrators. + +A durable intent identifies the exact nonce/key/value before mutation. The operation receipt retains precise native write/readback timestamps, handle, original and final descriptor/value snapshots, and cleanup result. The parser requires exact provider/GUID, event version/task/success keyword/channel/host, record boundary, current SID/logon LUID, process ID/executable, held handle, key, value name, operation type and old/new REG_SZ marker data inside the measured modification interval. Creation/deletion records cannot substitute for the modification. Queries are bounded and completeness failures, duplicate matches, missing records, token changes, cleanup failures or later policy/security/source drift prevent success. + +The descriptor reader temporarily enables the caller's assigned SeSecurityPrivilege and restores its original attributes. Full token identity, groups and privilege attributes are compared before and after. It grants no rights. Audit masks, precedence, services, channel settings, descriptors and unrelated values must remain unchanged. Local reads and the temporary value lifecycle can themselves generate audit events; the Security log is never cleared. + +If interrupted, the private output directory retains `intent.json` and any completed receipts. Inspect the exact fixed key and nonce value. Delete a leftover marker only after establishing it is still the value owned by this run and contains the recorded marker data; preserve unexpected replacement data. There is no blind cleanup of keys or other values. Protect evidence files, which include local policy/token metadata and selected event XML. + +## Validation and limits + +Focused tests cover strict prerequisites, typed receipts, cleanup refusal, XML attribution, source drift and durable intent. The disposable native fixture creates the fixed WELA parent only if absent, marks ownership, prepares one SetValue SACL and independent audit prerequisites, then invokes public Run twice. It requires two exact4657 records, no retained temporary values, preserved unrelated typed values/security/token, and restoration of all59 audit masks/precedence plus removal of its owned keys. CI retains artifacts, source fingerprints, host/engine context and cleanup receipts on Server2022/2025 with Windows PowerShell5.1/PowerShell7. + +This is component evidence for the fixed diagnostic key and current user only. It does not validate production registry paths, inherited SACL coverage, access failures, other users, Windows11/DC/ADCS behavior, forwarding, backend normalization or complete Sigma rules. `SigmaEvtxCredit=0`. Built-in Windows only; Sysmon is excluded. diff --git a/scripts/RegistryValueProbe.ps1 b/scripts/RegistryValueProbe.ps1 new file mode 100644 index 00000000..27bc7a86 --- /dev/null +++ b/scripts/RegistryValueProbe.ps1 @@ -0,0 +1,144 @@ +# Fixed current-user diagnostic key only. Existing auditing is required, never installed. +function Initialize-WelaRegistryValueProbe { + Initialize-WelaWmiProbeNative + $source=Join-Path $PSScriptRoot 'RegistryValueProbeNative.cs';$bytes=[IO.File]::ReadAllBytes($source);$hash=Get-WelaArrivalHash $bytes + if(-not ('Wela.RegistryValueProbe.Target' -as [type])){Add-Type -TypeDefinition ([Text.UTF8Encoding]::new($false,$true).GetString($bytes).Replace('__WELA_REGISTRY_VALUE_PROBE_SOURCE_SHA256__',$hash)) -ErrorAction Stop} + if([Wela.RegistryValueProbe.Descriptor]::SourceSha256 -cne $hash){throw 'Loaded registry probe helper differs from source; start a fresh session.'} +} +function Get-WelaRegistryValueProbeSources { + $sources=[ordered]@{} + foreach($name in @('WELA.ps1','scripts/RegistryValueProbe.ps1','scripts/RegistryValueProbeNative.cs','scripts/FileAccessProbe.ps1','scripts/WmiProbe.ps1','scripts/WmiProbeNative.cs','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1','scripts/Configuration.ps1','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1','scripts/IpsecPrerequisites.ps1','config/audit_profiles.json')){$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot ('../'+$name)) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()} + [pscustomobject]$sources +} +function Get-WelaRegistryValueProbeState { + if($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitProcess){throw 'Native 64-bit Windows is required.'} + Initialize-WelaRegistryValueProbe + $tokenBefore=[Wela.RegistryValueProbe.TokenReader]::Snapshot() + $services=@(Get-Service EventLog,Winmgmt,RpcSs -ErrorAction Stop|Sort-Object Name|ForEach-Object {[pscustomobject]@{Name=$_.Name;Status=[string]$_.Status}}) + if($services.Count -ne 3 -or @($services|Where-Object Status -ne Running).Count){throw 'EventLog, Winmgmt and RpcSs must already be running.'} + $reader=Get-WelaChannelReader;$null=Get-WelaFileProbeReaderKey $reader + $hostState=Get-WelaChannelReadHost + $target=[Wela.RegistryValueProbe.Target]::new($false) + try{$registry=$target.Read()}finally{$target.Dispose()} + $channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Security') + try{$log=[pscustomobject]@{Name=$channel.LogName;Enabled=$channel.IsEnabled;SecurityDescriptor=$channel.SecurityDescriptor;MaximumSize=$channel.MaximumSizeInBytes;Mode=[string]$channel.LogMode}}finally{$channel.Dispose()} + $policy=Get-WelaEffectiveAuditPolicy;$masks=[ordered]@{};foreach($guid in @($policy.Keys|Sort-Object)){$masks[$guid]=$policy[$guid]} + $engine=(Get-Process -Id $PID -ErrorAction Stop).Path;$token=[Wela.RegistryValueProbe.TokenReader]::Snapshot() + if((Get-WelaFileProbeTokenKey $tokenBefore) -cne (Get-WelaFileProbeTokenKey $token)){throw 'Registry prerequisite observation changed the full process token.'} + [pscustomobject][ordered]@{Computer=[Environment]::MachineName;Host=$hostState;Services=$services;Reader=($reader|Select-Object UserSid,UserName,AuthenticationId,GroupSids,GroupCount,PrivilegeCount,ElevatedAdministrator,TokenType,Impersonation);Token=$token;Registry=$registry;AuditPolicies=[pscustomobject]$masks;Precedence=Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy;Channel=$log;Engine=$engine;EngineHash=(Get-FileHash -LiteralPath $engine).Hash.ToLowerInvariant();Sources=Get-WelaRegistryValueProbeSources} +} +function Get-WelaRegistryValueProbeStateKey { + param($State) + $null=Get-WelaFileProbeTokenKey $State.Token + if($State.Computer -isnot [string] -or -not $State.Computer -or -not(Test-WelaFileProbeInteger $State.Host.ProductType) -or $State.Host.ProductType -notin 1,2,3 -or -not(Test-WelaFileProbeInteger $State.Host.Build) -or $State.Host.Build -notin 22000,22621,22631,20348,26100,26200 -or $State.Host.DomainJoined -isnot [bool]){throw 'Complete supported native host context is required.'} + if($State.Services -isnot [array] -or ($State.Services.Name -join ',') -cne 'EventLog,RpcSs,Winmgmt' -or @($State.Services|Where-Object Status -ne Running).Count){throw 'Required services must already be running.'} + if($State.Reader.ElevatedAdministrator -isnot [bool] -or -not $State.Reader.ElevatedAdministrator -or $State.Reader.TokenType -cne 'Primary' -or $State.Reader.Impersonation -cne 'Absent' -or $State.Reader.UserSid -cne $State.Token.Sid){throw 'Actual elevated non-impersonating primary token required.'} + $expected='HKEY_USERS\'+$State.Token.Sid+'\Software\WELA\AuditProbe' + $r=$State.Registry + if($r.Path -ine $expected -or $r.Kind -cne 'Registry' -or $r.IsDirectory -isnot [bool] -or $r.IsDirectory -or -not(Test-WelaFileProbeInteger $r.SecurityInformation) -or $r.SecurityInformation -ne 511 -or $r.DescriptorBase64 -isnot [string] -or -not $r.DescriptorBase64 -or $r.Identity -isnot [string] -or -not $r.Identity -or $r.Values -isnot [array] -or $r.Values.Count -gt 128 -or $r.Aces -isnot [array] -or $r.Aces.Count -gt 128){throw 'Complete fixed registry-key observation is required.'} + foreach($ace in $r.Aces){if($ace.Ordinary -isnot [bool] -or -not(Test-WelaFileProbeInteger $ace.Type) -or -not(Test-WelaFileProbeInteger $ace.Flags) -or -not(Test-WelaFileProbeInteger $ace.Mask)){throw 'Typed audit ACE evidence is required.'}} + $mask=$State.AuditPolicies.'0CCE921E-69AE-11D9-BED3-505054503030' + if(-not(Test-WelaFileProbeInteger $mask) -or $mask -notin 1,3 -or $State.Precedence.ValueExists -isnot [bool] -or -not $State.Precedence.ValueExists -or $State.Precedence.Type -cne 'DWord' -or -not(Test-WelaFileProbeInteger $State.Precedence.Value) -or $State.Precedence.Value -ne 1){throw 'Registry success auditing and typed precedence DWORD1 must already be configured.'} + if($State.Channel.Name -cne 'Security' -or $State.Channel.Enabled -isnot [bool] -or -not $State.Channel.Enabled -or -not $State.Channel.SecurityDescriptor){throw 'Security channel must already be enabled and readable.'} + $sids=@($State.Token.Sid)+@($State.Token.Groups|Where-Object {($_.Attributes -band 4) -and -not($_.Attributes -band 16)}|ForEach-Object Sid) + $aces=@($r.Aces|Where-Object {$_.Ordinary -and $_.Type -eq 2 -and ($_.Flags -band 64) -and -not($_.Flags -band 8) -and ($_.Mask -band 2) -and $_.Sid -in $sids}) + if(-not $aces.Count){throw 'An existing matching success SetValue audit ACE is required; no SACL is added.'} + if($State.Engine -isnot [string] -or -not $State.Engine -or $State.EngineHash -cnotmatch '^[a-f0-9]{64}$'){throw 'Native engine identity is incomplete.'} + if(-not @($State.Sources.PSObject.Properties).Count){throw 'Implementation fingerprints are missing.'} + foreach($source in $State.Sources.PSObject.Properties){if($source.Value -isnot [string] -or $source.Value -cnotmatch '^[a-f0-9]{64}$'){throw 'Invalid implementation fingerprint.'}} + # Registry last-write time changes after the owned temporary value lifecycle; preserve it in raw receipts. + $stable=$State|Select-Object * -ExcludeProperty Registry + $stable|Add-Member NoteProperty Registry ($r|Select-Object * -ExcludeProperty Identity) + Get-WelaFileProbeKey $stable +} +function Invoke-WelaRegistryValueProbeOperation { + param($State,[string]$Nonce) + $fresh=Get-WelaRegistryValueProbeState + if((Get-WelaRegistryValueProbeStateKey $fresh) -cne (Get-WelaRegistryValueProbeStateKey $State) -or $fresh.Registry.Identity -cne $State.Registry.Identity){throw 'Registry prerequisites changed before the owned value operation.'} + $watermark=Get-WelaFileProbeWatermark;$before=[Wela.RegistryValueProbe.TokenReader]::Snapshot();$target=$null;$native=$null + $launch=[Wela.RegistryValueProbe.Target]::UtcNow() + try{$target=[Wela.RegistryValueProbe.Target]::new($true);$native=$target.Run($Nonce,$State.Registry.Identity,$State.Registry.DescriptorBase64)}finally{if($target){$target.Dispose()}} + $after=[Wela.RegistryValueProbe.TokenReader]::Snapshot() + $operation=[pscustomobject]@{Kind='WelaOwnedRegistryValueModification';Nonce=$Nonce;ProcessId=$PID;Executable=$State.Engine;RecordIdBefore=$watermark;BeforeToken=$before;AfterToken=$after;LaunchedUtc=$launch.ToString('o');ObservedUtc=[Wela.RegistryValueProbe.Target]::UtcNow().ToString('o');Native=$native} + return $operation +} +function Assert-WelaRegistryValueProbeOperation { + param($Operation,$State) + $r=$Operation.Native + if($Operation.Kind -cne 'WelaOwnedRegistryValueModification' -or $Operation.Nonce -cnotmatch '^[a-f0-9]{32}$' -or -not(Test-WelaFileProbeInteger $Operation.ProcessId) -or $Operation.ProcessId -le 0 -or $Operation.Executable -ine $State.Engine -or -not(Test-WelaFileProbeInteger $Operation.RecordIdBefore) -or $Operation.RecordIdBefore -le 0){throw 'Incomplete registry operation authority.'} + if($r.Succeeded -isnot [bool] -or -not $r.Succeeded -or $r.CleanupComplete -isnot [bool] -or -not $r.CleanupComplete -or $r.Nonce -cne $Operation.Nonce -or $r.Name -cne ('WELA_Probe_'+$Operation.Nonce) -or $r.BeforeValue -cne ('WELA_BEFORE_'+$Operation.Nonce) -or $r.AfterValue -cne ('WELA_AFTER_'+$Operation.Nonce) -or $r.HandleId -cnotmatch '^0x[1-9a-f][0-9a-f]*$'){throw ('Owned value operation or cleanup failed: '+$r.Diagnostic)} + if($r.Before.Path -ine $State.Registry.Path -or $r.After.Path -ine $State.Registry.Path -or (Get-WelaFileProbeKey $r.Before.Values) -cne (Get-WelaFileProbeKey $State.Registry.Values) -or $r.Before.Identity -cne $State.Registry.Identity -or $r.Before.DescriptorBase64 -cne $State.Registry.DescriptorBase64 -or $r.After.DescriptorBase64 -cne $State.Registry.DescriptorBase64 -or (Get-WelaFileProbeKey $r.Before.Values) -cne (Get-WelaFileProbeKey $r.After.Values)){throw 'Held registry descriptor or unrelated values changed.'} + $start=ConvertTo-WelaArrivalUtc $r.StartedUtc;$returned=ConvertTo-WelaArrivalUtc $r.WriteReturnedUtc;$end=ConvertTo-WelaArrivalUtc $r.CompletedUtc + if($start -lt (ConvertTo-WelaArrivalUtc $Operation.LaunchedUtc) -or $returned -lt $start -or $end -lt $returned -or $end -gt (ConvertTo-WelaArrivalUtc $Operation.ObservedUtc) -or ($end-$start).TotalSeconds -gt 10){throw 'Invalid precise native value-modification interval.'} + if((Get-WelaFileProbeTokenKey $Operation.BeforeToken) -cne (Get-WelaFileProbeTokenKey $Operation.AfterToken) -or (Get-WelaFileProbeTokenKey $Operation.BeforeToken) -cne (Get-WelaFileProbeTokenKey $State.Token)){throw 'Registry operation changed the full process token.'} +} +function Read-WelaRegistryValueProbeEvents { + param($Operation) + # Keep out-of-interval candidates for diagnosis; the matcher never credits them. + $query="*[System[Provider[@Name='Microsoft-Windows-Security-Auditing'] and EventID=4657 and EventRecordID>$($Operation.RecordIdBefore)]]" + $reader=$null;$records=New-Object 'System.Collections.Generic.List[string]' + try { + $q=[Diagnostics.Eventing.Reader.EventLogQuery]::new('Security',[Diagnostics.Eventing.Reader.PathType]::LogName,$query);$q.TolerateQueryErrors=$false + $reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($q);$reader.BatchSize=16 + while($records.Count -lt 256){$event=$reader.ReadEvent([TimeSpan]::FromSeconds(1));if($null -eq $event){break};try{$xml=$event.ToXml();if($xml.Length -gt 131072){throw 'Security event exceeds the XML bound.'};$records.Add($xml)}finally{$event.Dispose()}} + $status=@($reader.LogStatus|ForEach-Object {[pscustomobject]@{LogName=$_.LogName;StatusCode=$_.StatusCode}});Assert-WelaChannelQueryStatus Security $status + [pscustomobject]@{Xml=@($records.ToArray());Capped=($records.Count -ge 256);Query=$query;MaximumEvents=256;LogStatus=$status} + }finally{if($reader){$reader.Dispose()}} +} +function Test-WelaRegistryValueProbeEvent { + param([string]$Xml,$Operation,$State) + $reader=$null + try { + if($Xml.Length -gt 131072){return $false} + $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','Keywords','EventRecordID','Channel','Computer','TimeCreated','Level','Task','Opcode')){$nodes=$doc.SelectNodes("/e:Event/e:System/e:$name",$ns);if($nodes.Count -ne 1){return $false};$system[$name]=$nodes[0]} + if($system.Provider.GetAttribute('Name') -cne 'Microsoft-Windows-Security-Auditing' -or $system.Provider.GetAttribute('Guid').Trim('{}') -ine '54849625-5478-4994-a5ba-3e3b0328c30d' -or $system.EventID.InnerText -cne '4657' -or $system.Version.InnerText -cne '0' -or $system.Keywords.InnerText -ine '0x8020000000000000' -or $system.Channel.InnerText -cne 'Security' -or $system.Level.InnerText -cne '0' -or $system.Task.InnerText -cne '12801' -or $system.Opcode.InnerText -cne '0' -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.Native.StartedUtc) -or $time -gt (ConvertTo-WelaArrivalUtc $Operation.Native.CompletedUtc)){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')){return $false};$name=$node.GetAttribute('Name');if(-not $name -or $data.ContainsKey($name) -or @($node.ChildNodes|Where-Object NodeType -eq Element).Count){return $false};$data[$name]=$node.InnerText} + foreach($name in @('SubjectUserSid','SubjectUserName','SubjectDomainName','SubjectLogonId','ObjectName','ObjectValueName','HandleId','OperationType','OldValueType','OldValue','NewValueType','NewValue','ProcessId','ProcessName')){if(-not $data.ContainsKey($name)){return $false}} + $nativePath='\REGISTRY\USER\'+$State.Token.Sid+'\Software\WELA\AuditProbe' + if($data.Count -ne 14 -or $data.ObjectName -ine $nativePath -or $data.ObjectValueName -cne $Operation.Native.Name -or $data.ProcessName -ine $State.Engine -or $data.SubjectUserSid -cne $Operation.BeforeToken.Sid -or $data.OperationType -cne '%%1905' -or $data.OldValueType -cne '%%1873' -or $data.NewValueType -cne '%%1873' -or $data.OldValue -cne $Operation.Native.BeforeValue -or $data.NewValue -cne $Operation.Native.AfterValue){return $false} + foreach($name in @('SubjectLogonId','ProcessId','HandleId')){if($data[$name] -cnotmatch '^0x[0-9a-fA-F]+$'){return $false}} + if([Convert]::ToUInt64($data.SubjectLogonId.Substring(2),16) -ne [Convert]::ToUInt64($Operation.BeforeToken.AuthenticationId.Substring(2),16) -or [Convert]::ToUInt64($data.ProcessId.Substring(2),16) -ne $Operation.ProcessId -or [Convert]::ToUInt64($data.HandleId.Substring(2),16) -ne [Convert]::ToUInt64($Operation.Native.HandleId.Substring(2),16)){return $false} + $true + }catch{$false}finally{if($reader){$reader.Dispose()}} +} +function Invoke-WelaRegistryValueProbe { + param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15) + if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new RegistryProbeOutputPath; Plan creates no output.'} + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaRegistryValueProbe';Action=$Action;Status='Unverified';ExitCode=1;RecordedUtc=[datetime]::UtcNow.ToString('o');Before=$null;After=$null;Operation=$null;Candidates=0;Matches=0;Query=$null;Artifacts=@();Diagnostic='';OutputPath=$null;ConfigurationChanges=0;TemporaryRegistryValueWrites='Only Run: create/modify/delete one owned nonce value in fixed current-user probe key.';SigmaEvtxCredit=0;Scope='One owned current-user registry REG_SZ modification only; other keys/users, inherited coverage, forwarding and Sigma are unverified. Native key last-write metadata changes.'} + $outputKey=$null;$beforeKey=$null + try { + if($Action -eq 'Run'){$report.OutputPath=New-WelaArrivalOutput $OutputPath $script:ScriptRoot;$outputKey=Get-WelaFileProbeOutputKey $report.OutputPath} + $before=Get-WelaRegistryValueProbeState;$beforeKey=Get-WelaRegistryValueProbeStateKey $before;$report.Before=$before + if($Action -eq 'Plan'){$report.After=$before;$report.Status='PrerequisitesObserved';$report.ExitCode=0;return $report} + $report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'before.json' ($before|ConvertTo-Json -Depth 24) + $nonce=[guid]::NewGuid().ToString('N');$intent=[pscustomobject]@{Kind='WelaOwnedRegistryValueIntent';Nonce=$nonce;Path=$before.Registry.Path;Name=('WELA_Probe_'+$nonce);Original='Absent';Outcome='Pending; interruption may leave the owned marker value. Inspect receipts before manual cleanup.'} + $report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'intent.json' ($intent|ConvertTo-Json -Depth 8) + $operation=Invoke-WelaRegistryValueProbeOperation $before $nonce;$report.Operation=$operation + $report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'operation.json' ($operation|ConvertTo-Json -Depth 24) + Assert-WelaRegistryValueProbeOperation $operation $before + $timer=[Diagnostics.Stopwatch]::StartNew();$matches=@() + do{$batch=Read-WelaRegistryValueProbeEvents $operation;$report.Candidates=@($batch.Xml).Count;$report.Query=$batch.Query + if($batch.Capped -isnot [bool] -or $batch.Capped){throw 'Security query reached its 256-event cap or completeness is unknown.'} + $matches=@($batch.Xml|Where-Object {Test-WelaRegistryValueProbeEvent $_ $operation $before});if($matches.Count){break};Start-Sleep -Milliseconds 250 + }while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds) + $report.Matches=$matches.Count + if($matches.Count -ne 1){$i=0;foreach($xml in @($batch.Xml|Select-Object -First 4)){$i++;$report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey ('candidate-'+$i+'.xml') $xml};throw 'Exactly one attributable native4657 was not observed in the measured owned-value modification phase.'} + $report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'event.xml' $matches[0] + if((Get-WelaFileProbeWatermark) -lt $operation.RecordIdBefore){throw 'Security record boundary moved backwards.'} + $after=Get-WelaRegistryValueProbeState;$report.After=$after + if((Get-WelaRegistryValueProbeStateKey $after) -cne $beforeKey){throw 'Registry values/security, policy, channel, host, token or implementation changed during the probe.'} + $report.Status='RegistryValueModificationObserved';$report.ExitCode=0 + }catch{$report.Diagnostic=$_.Exception.Message} + finally{if($report.Before -and -not $report.After){try{$report.After=Get-WelaRegistryValueProbeState}catch{$report.Diagnostic+=' Final observation failed: '+$_.Exception.Message}}} + if($report.OutputPath -and $outputKey){ + if($report.After){$report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'after.json' ($report.After|ConvertTo-Json -Depth 24)} + $null=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'manifest.json' ($report|ConvertTo-Json -Depth 28) + } + $report +} diff --git a/scripts/RegistryValueProbeNative.cs b/scripts/RegistryValueProbeNative.cs new file mode 100644 index 00000000..828f7144 --- /dev/null +++ b/scripts/RegistryValueProbeNative.cs @@ -0,0 +1,202 @@ +// Fixed existing current-user WELA probe key: one owned temporary value lifecycle. +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +namespace Wela.RegistryValueProbe { + public sealed class Ace { public string Binary; public int Type,Flags,Mask; public string Sid; public bool Ordinary; } + public sealed class Value {public string Name,DataBase64; public uint Type;} + public sealed class Operation {public bool Succeeded,CleanupComplete;public string Nonce,Name,BeforeValue,AfterValue,HandleId,StartedUtc,WriteReturnedUtc,CompletedUtc,Diagnostic;public Snapshot Before,After;} + public sealed class Snapshot { + public string Path,Kind,Identity; public bool IsDirectory; + public string DescriptorBase64,Owner,Group,DaclBase64; public int ControlFlags,SecurityInformation; + public string DescriptorScope; public Ace[] Aces; public Value[] Values; + } + public static class Descriptor { + public const string SourceSha256="__WELA_REGISTRY_VALUE_PROBE_SOURCE_SHA256__"; + public static string Bytes(GenericAcl value) { if(value==null)return null;byte[] b=new byte[value.BinaryLength];value.GetBinaryForm(b,0);return Convert.ToBase64String(b); } + public static string Bytes(GenericAce value) { byte[] b=new byte[value.BinaryLength];value.GetBinaryForm(b,0);return Convert.ToBase64String(b); } + static string Sid(SecurityIdentifier value) {return value==null?null:value.Value;} + public static RawSecurityDescriptor Parse(string value) { + byte[] b=Convert.FromBase64String(value); + if(b.Length<20||b.Length>1048576||Convert.ToBase64String(b)!=value)throw new InvalidOperationException("Invalid or noncanonical descriptor bytes."); + RawSecurityDescriptor sd=new RawSecurityDescriptor(b,0); + return sd; + } + public static Snapshot Observe(string path,string identity,byte[] bytes) { + string encoded=Convert.ToBase64String(bytes);RawSecurityDescriptor sd=Parse(encoded);List entries=new List(); + if(sd.SystemAcl!=null)foreach(GenericAce ace in sd.SystemAcl){CommonAce common=ace as CommonAce;bool ordinary=common!=null&&!common.IsCallback&&common.AceType==AceType.SystemAudit;entries.Add(new Ace {Binary=Bytes(ace),Type=(int)ace.AceType,Flags=(int)ace.AceFlags,Mask=ordinary?common.AccessMask:0,Sid=ordinary?common.SecurityIdentifier.Value:null,Ordinary=ordinary});} + return new Snapshot {Path=path,Kind="Registry",Identity=identity,IsDirectory=false,DescriptorBase64=encoded,Owner=Sid(sd.Owner),Group=Sid(sd.Group),DaclBase64=Bytes(sd.DiscretionaryAcl),ControlFlags=(int)sd.ControlFlags,SecurityInformation=511,DescriptorScope="WinSDK-defined sections 0x1ff; future sections unobserved",Aces=entries.ToArray()}; + } + } + sealed class Privilege : IDisposable { + [StructLayout(LayoutKind.Sequential)] struct Luid {public uint Low;public int High;} + [StructLayout(LayoutKind.Sequential)] struct TokenPrivileges {public uint Count;public Luid Luid;public uint Attributes;} + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess(); + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentThread(); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr value); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr process,uint access,out IntPtr token); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenThreadToken(IntPtr thread,uint access,bool self,out IntPtr token); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool LookupPrivilegeValue(string system,string name,out Luid luid); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool AdjustTokenPrivileges(IntPtr token,bool disable,ref TokenPrivileges value,uint size,out TokenPrivileges previous,out uint required); + IntPtr token;TokenPrivileges previous; + public Privilege(){IntPtr thread; + if(OpenThreadToken(GetCurrentThread(),8,true,out thread)){CloseHandle(thread);throw new InvalidOperationException("Impersonated recovery is unsupported.");} + int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error); + if(!OpenProcessToken(GetCurrentProcess(),0x28,out token))throw new Win32Exception(Marshal.GetLastWin32Error()); + try{Luid luid;if(!LookupPrivilegeValue(null,"SeSecurityPrivilege",out luid))throw new Win32Exception(Marshal.GetLastWin32Error());TokenPrivileges request=new TokenPrivileges {Count=1,Luid=luid,Attributes=2};uint required;bool ok=AdjustTokenPrivileges(token,false,ref request,(uint)Marshal.SizeOf(typeof(TokenPrivileges)),out previous,out required);error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"SeSecurityPrivilege is unavailable.");} + catch{CloseHandle(token);token=IntPtr.Zero;throw;} + } + public void Dispose(){if(token==IntPtr.Zero)return;try{TokenPrivileges ignored;uint required;bool ok=AdjustTokenPrivileges(token,false,ref previous,(uint)Marshal.SizeOf(typeof(TokenPrivileges)),out ignored,out required);int error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"SeSecurityPrivilege restoration failed.");}finally{CloseHandle(token);token=IntPtr.Zero;}} + } + public sealed class Target : IDisposable { + [DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr value); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegOpenKeyExW(IntPtr root,string name,uint options,uint access,out IntPtr key); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegQueryValueExW(IntPtr key,string name,IntPtr reserved,out uint type,IntPtr data,ref uint size); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegQueryInfoKeyW(IntPtr key,IntPtr cls,IntPtr clsSize,IntPtr reserved,IntPtr subKeys,IntPtr maxSubKey,IntPtr maxClass,IntPtr values,IntPtr maxValueName,IntPtr maxValue,IntPtr securitySize,out long written); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegEnumKeyExW(IntPtr key,uint index,StringBuilder name,ref uint length,IntPtr reserved,IntPtr cls,IntPtr clsLength,IntPtr written); + [DllImport("advapi32.dll")] static extern int RegCloseKey(IntPtr key); + [DllImport("ntdll.dll")] static extern int NtQueryKey(IntPtr key,int cls,IntPtr information,uint length,out uint resultLength); + [DllImport("advapi32.dll")] static extern uint GetSecurityInfo(IntPtr handle,uint kind,uint flags,out IntPtr owner,out IntPtr group,out IntPtr dacl,out IntPtr sacl,out IntPtr descriptor); + [DllImport("advapi32.dll")] static extern uint GetSecurityDescriptorLength(IntPtr descriptor); + + readonly string path,nativePath;readonly List keys=new List();IntPtr handle;Privilege privilege; + readonly bool writable; + public Target(bool write){ + writable=write; + string sid=TokenReader.Snapshot().Sid; + string path="HKEY_USERS\\"+sid+"\\Software\\WELA\\AuditProbe"; + this.path=path; + if(String.IsNullOrEmpty(path)||path.IndexOfAny(new char[]{'/','*','?','%','\0'})>=0)throw new InvalidOperationException("Exact local registry path required."); + string[] parts=path.Split('\\');IntPtr root; + if(parts[0]=="HKEY_LOCAL_MACHINE"){root=new IntPtr(unchecked((int)0x80000002));nativePath="\\REGISTRY\\MACHINE";} + else if(parts[0]=="HKEY_USERS"){root=new IntPtr(unchecked((int)0x80000003));nativePath="\\REGISTRY\\USER";} + else throw new InvalidOperationException("Only selected HKLM/HKU keys are supported."); + if(parts.Length<2)throw new InvalidOperationException("Hive roots cannot be recovered."); + for(int i=1;i65536)throw new InvalidOperationException("Native registry name query is unavailable."); + IntPtr buffer=Marshal.AllocHGlobal((int)required); + try{uint actual;status=NtQueryKey(handle,3,buffer,required,out actual);if(status!=0||actual>required)throw new InvalidOperationException("Native registry name query failed.");int size=Marshal.ReadInt32(buffer);if(size<2||size%2!=0||size>required-4)throw new InvalidOperationException("Native registry name is malformed.");string name=Marshal.PtrToStringUni(IntPtr.Add(buffer,4),size/2);if(!String.Equals(name,nativePath,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Held registry name differs from the selected path.");}finally{Marshal.FreeHGlobal(buffer);} + long written;int error=RegQueryInfoKeyW(handle,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,out written);if(error!=0)throw new Win32Exception(error,"Registry last-write observation failed.");return path+":"+written; + } + public void AssertEmpty(){if(handle==IntPtr.Zero)throw new ObjectDisposedException("Target");StringBuilder name=new StringBuilder(256);uint size=256;int error=RegEnumKeyExW(handle,0,name,ref size,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero);if(error==0||error==234)throw new InvalidOperationException("Recovery requires an observed empty registry descendant inventory.");if(error!=259)throw new Win32Exception(error,"Registry child enumeration is unknown.");} + public Snapshot Read(){ + string identity=Check();AssertEmpty();IntPtr owner,group,dacl,sacl,descriptor;uint error=GetSecurityInfo(handle,4,511,out owner,out group,out dacl,out sacl,out descriptor);if(error!=0)throw new Win32Exception((int)error,"Full SDK-defined registry descriptor read failed.");byte[] bytes; + try{uint size=GetSecurityDescriptorLength(descriptor);if(size<20||size>1048576)throw new InvalidOperationException("Invalid native descriptor size.");bytes=new byte[size];Marshal.Copy(descriptor,bytes,0,(int)size);}finally{LocalFree(descriptor);} + AssertEmpty();if(Check()!=identity)throw new InvalidOperationException("Registry last-write identity changed during observation.");Snapshot result=Descriptor.Observe(path,identity,bytes);result.Values=ReadValues();if(Check()!=identity)throw new InvalidOperationException("Registry changed during value observation.");return result; + } + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegEnumValueW(IntPtr key,uint index,StringBuilder name,ref uint nameLength,IntPtr reserved,out uint type,byte[] data,ref uint size); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegSetValueExW(IntPtr key,string name,uint reserved,uint type,byte[] data,uint size); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegDeleteValueW(IntPtr key,string name); + [DllImport("kernel32.dll",ExactSpelling=true)] static extern void GetSystemTimePreciseAsFileTime(out long value); + public static DateTime UtcNow(){long n;GetSystemTimePreciseAsFileTime(out n);return DateTime.FromFileTimeUtc(n);} + Value[] ReadValues(){ + List values=new List();long total=0; + for(uint i=0;i<=128;i++){ + StringBuilder name=new StringBuilder(16384);uint length=16384,type,size=65536;byte[] data=new byte[size]; + int error=RegEnumValueW(handle,i,name,ref length,IntPtr.Zero,out type,data,ref size); + if(error==259)break;if(error!=0)throw new Win32Exception(error,"Bounded registry value inventory failed."); + if(i==128||size>65536||length>=16384||total+size>1048576)throw new InvalidOperationException("Registry value inventory exceeds bound."); + total+=size;byte[] exact=new byte[size];Array.Copy(data,exact,size);values.Add(new Value{Name=name.ToString(),Type=type,DataBase64=Convert.ToBase64String(exact)}); + } + values.Sort((a,b)=>String.CompareOrdinal(a.Name,b.Name));return values.ToArray(); + } + static bool SameValues(Value[] a,Value[] b){if(a.Length!=b.Length)return false;for(int i=0;i='0'&&c<='9')||(c>='a'&&c<='f')))throw new InvalidOperationException("Invalid probe nonce."); + Operation r=new Operation{Nonce=nonce,Name="WELA_Probe_"+nonce,BeforeValue="WELA_BEFORE_"+nonce,AfterValue="WELA_AFTER_"+nonce,HandleId="0x"+handle.ToInt64().ToString("x"),CleanupComplete=false,Diagnostic=""}; + r.Before=Read();if(r.Before.Identity!=expectedIdentity||r.Before.DescriptorBase64!=expectedDescriptor)throw new InvalidOperationException("Probe key changed after planning."); + if(Find(r.Name)!=null)throw new InvalidOperationException("Owned nonce value already exists; refusing overwrite."); + bool attempted=false; + try{ + attempted=true;Put(r.Name,r.BeforeValue); + // Credit only the explicit existing REG_SZ modification, not creation/deletion. + r.StartedUtc=UtcNow().ToString("o");Put(r.Name,r.AfterValue);r.WriteReturnedUtc=UtcNow().ToString("o"); + Snapshot during=Read();if(during.DescriptorBase64!=r.Before.DescriptorBase64)throw new InvalidOperationException("Probe key security changed."); + r.CompletedUtc=UtcNow().ToString("o");r.Succeeded=true; + }catch(Exception e){r.Diagnostic=e.ToString();} + finally{ + try{ + Value owned=Find(r.Name); + if(owned!=null){if(!attempted||owned.Type!=1||(owned.DataBase64!=Encoded(r.BeforeValue)&&owned.DataBase64!=Encoded(r.AfterValue)))throw new InvalidOperationException("Owned probe value drifted; refusing deletion.");int error=RegDeleteValueW(handle,r.Name);if(error!=0)throw new Win32Exception(error,"Owned probe cleanup failed.");} + if(Find(r.Name)!=null)throw new InvalidOperationException("Owned probe value remains."); + r.After=Read();if(r.After.DescriptorBase64!=r.Before.DescriptorBase64||!SameValues(r.Before.Values,r.After.Values))throw new InvalidOperationException("Probe changed unrelated values or key security."); + r.CleanupComplete=true; + }catch(Exception e){r.Diagnostic+=" Cleanup: "+e.ToString();r.Succeeded=false;} + } + return r; + } + public void Dispose(){try{for(int i=keys.Count-1;i>=0;i--)RegCloseKey(keys[i]);keys.Clear();handle=IntPtr.Zero;}finally{if(privilege!=null){privilege.Dispose();privilege=null;}}} + } + public sealed class Group { public string Sid; public uint Attributes; } + public sealed class TokenPrivilege { public string Luid; public uint Attributes; } + public sealed class Token { + public string Sid, Name, AuthenticationId, AuthenticationType, ImpersonationLevel, TokenSource; + public Group[] Groups; public TokenPrivilege[] Privileges; + } + public static class TokenReader { + [DllImport("kernel32.dll",ExactSpelling=true)] static extern void GetSystemTimePreciseAsFileTime(out long value); + public static DateTime UtcNow() {long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value);} + [StructLayout(LayoutKind.Sequential)] struct Luid {public uint Low; public int High;} + [StructLayout(LayoutKind.Sequential)] struct Statistics {public Luid TokenId,AuthenticationId;public long Expiration;public int Type,Level;public uint Charged,Available,Groups,Privileges;public Luid Modified;} + [StructLayout(LayoutKind.Sequential)] struct SidAndAttributes {public IntPtr Sid;public uint Attributes;} + [StructLayout(LayoutKind.Sequential)] struct TokenGroups {public uint Count;public SidAndAttributes First;} + [StructLayout(LayoutKind.Sequential)] struct LuidAndAttributes {public Luid Luid;public uint Attributes;} + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess(); + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentThread(); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr h); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr p,uint access,out IntPtr t); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenThreadToken(IntPtr p,uint access,bool self,out IntPtr t); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool GetTokenInformation(IntPtr t,int cls,IntPtr data,int length,out int needed); + static string Hex(Luid id) {return "0x"+(((ulong)(uint)id.High<<32)|id.Low).ToString("x");} + static IntPtr Read(IntPtr token,int cls,out int length) { + GetTokenInformation(token,cls,IntPtr.Zero,0,out length); + if(Marshal.GetLastWin32Error()!=122||length<4||length>65536)throw new InvalidOperationException("Unknown or oversized token information."); + IntPtr data=Marshal.AllocHGlobal(length); + if(!GetTokenInformation(token,cls,data,length,out length)){int error=Marshal.GetLastWin32Error();Marshal.FreeHGlobal(data);throw new Win32Exception(error);} + return data; + } + static Token ReadToken(IntPtr token,string source) { + Token result=new Token();result.TokenSource=source;using(WindowsIdentity identity=new WindowsIdentity(token)){result.Sid=identity.User.Value;result.Name=identity.Name;result.AuthenticationType=identity.AuthenticationType;result.ImpersonationLevel=identity.ImpersonationLevel.ToString();} + int length;IntPtr p=Read(token,10,out length); + try {if(length4096||offset+(long)count*size>length)throw new InvalidOperationException("Invalid token groups.");List groups=new List();for(int i=0;iString.CompareOrdinal(a.Sid,b.Sid));result.Groups=groups.ToArray();}finally{Marshal.FreeHGlobal(p);} + p=Read(token,3,out length); + try {int count=Marshal.ReadInt32(p),size=Marshal.SizeOf(typeof(LuidAndAttributes));if(count<0||count>4096||4+(long)count*size>length)throw new InvalidOperationException("Invalid token privileges.");List privileges=new List();for(int i=0;iString.CompareOrdinal(a.Luid,b.Luid));result.Privileges=privileges.ToArray();}finally{Marshal.FreeHGlobal(p);} + return result; + } + [DllImport("advapi32.dll")] static extern bool IsTokenRestricted(IntPtr token); + public static Token Snapshot() { + IntPtr thread=IntPtr.Zero,process=IntPtr.Zero; + if(!OpenThreadToken(GetCurrentThread(),8,true,out thread)){int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error);} + try { + if(!OpenProcessToken(GetCurrentProcess(),8,out process))throw new Win32Exception(Marshal.GetLastWin32Error()); + if(IsTokenRestricted(process)||(thread!=IntPtr.Zero&&IsTokenRestricted(thread)))throw new InvalidOperationException("Restricted tokens are unsupported."); + Token primary=ReadToken(process,"Process"); + if(thread!=IntPtr.Zero)throw new InvalidOperationException("Impersonated recovery is unsupported."); + return primary; + } finally {if(process!=IntPtr.Zero)CloseHandle(process);if(thread!=IntPtr.Zero)CloseHandle(thread);} + } + } +} diff --git a/tests/RegistryValueProbe.Cli.Tests.ps1 b/tests/RegistryValueProbe.Cli.Tests.ps1 new file mode 100644 index 00000000..739e76b5 --- /dev/null +++ b/tests/RegistryValueProbe.Cli.Tests.ps1 @@ -0,0 +1,15 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0 +$cases=@( + @{Args=@('registry-probe','-Help');Pattern='existing current-user';Ok=$true}, + @{Args=@('configure','-RegistryProbeAction','Run');Pattern='RegistryProbe options require';Ok=$false}, + @{Args=@('registry-probe','-Auto');Pattern='dedicated options';Ok=$false}, + @{Args=@('registry-probe','-DryRun');Pattern='dedicated options';Ok=$false}, + @{Args=@('registry-probe','-Role','Client');Pattern='dedicated options';Ok=$false}, + @{Args=@('registry-probe','-Profile','wela-2.2.0');Pattern='dedicated options';Ok=$false}, + @{Args=@('registry-probe','-RegistryProbeAction','Run','-Typo');Pattern='Unsupported trailing';Ok=$false}, + @{Args=@('registry-probe','extra');Pattern='dedicated options';Ok=$false}, + @{Args=@('registry-probe','-RegistryProbeAction','Run');Pattern='Run requires';Ok=$false}, + @{Args=@('registry-probe','-RegistryProbeOutputPath','unused');Pattern='Run requires';Ok=$false} +) +foreach($case in $cases){$old=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$output=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old};if(($code -eq 0) -ne $case.Ok -or ($output -join ' ') -notmatch $case.Pattern){throw "CLI failed: $($case.Args) : $code $output"};$count++} +Write-Host "PASS: $count registry probe CLI guards." diff --git a/tests/RegistryValueProbe.Tests.ps1 b/tests/RegistryValueProbe.Tests.ps1 new file mode 100644 index 00000000..e06cafdf --- /dev/null +++ b/tests/RegistryValueProbe.Tests.ps1 @@ -0,0 +1,53 @@ +$ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -ErrorAction Stop +foreach($name in @('WefArrival','FileAccessProbe','RegistryValueProbe')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))} +$count=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Reject([scriptblock]$Action){$threw=$false;try{& $Action|Out-Null}catch{$threw=$true};Assert $threw 'Expected evidence refusal.'} +function Copy-Value($Value){ConvertFrom-WelaArrivalJson (Get-WelaFileProbeKey $Value)} +function Fixture { + $script:token=[pscustomobject]@{Sid='S-1-5-21-1-2-3-1001';Name='FIXTURE\Reader';AuthenticationId='0x1234';AuthenticationType='Negotiate';ImpersonationLevel='None';TokenSource='Process';Groups=@([pscustomobject]@{Sid='S-1-1-0';Attributes=7});Privileges=@([pscustomobject]@{Luid='0x8';Attributes=0})} + $script:state=[pscustomobject]@{Computer='FIXTURE';Host=[pscustomobject]@{ProductType=3;Build=20348;DomainJoined=$false;Domain='WORKGROUP'};Services=@([pscustomobject]@{Name='EventLog';Status='Running'},[pscustomobject]@{Name='RpcSs';Status='Running'},[pscustomobject]@{Name='Winmgmt';Status='Running'});Reader=[pscustomobject]@{UserSid=$script:token.Sid;ElevatedAdministrator=$true;TokenType='Primary';Impersonation='Absent'};Token=Copy-Value $script:token;Registry=[pscustomobject]@{Path=('HKEY_USERS\'+$script:token.Sid+'\Software\WELA\AuditProbe');Kind='Registry';IsDirectory=$false;Identity='fixed:123';DescriptorBase64='AA==';SecurityInformation=511;Values=@();Aces=@([pscustomobject]@{Ordinary=$true;Type=2;Flags=64;Mask=2;Sid='S-1-1-0';Binary='AA=='})};AuditPolicies=[pscustomobject]@{'0CCE921E-69AE-11D9-BED3-505054503030'=1};Precedence=[pscustomobject]@{ValueExists=$true;Type='DWord';Value=1};Channel=[pscustomobject]@{Name='Security';Enabled=$true;SecurityDescriptor='O:SYG:SYD:'};Engine='C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe';EngineHash=('b'*64);Sources=[pscustomobject]@{Source=('c'*64)}} + $n='d'*32;$script:operation=[pscustomobject]@{Kind='WelaOwnedRegistryValueModification';Nonce=$n;ProcessId=1234;Executable=$script:state.Engine;RecordIdBefore=10;BeforeToken=Copy-Value $script:token;AfterToken=Copy-Value $script:token;LaunchedUtc='2026-09-21T00:00:00.0000000Z';ObservedUtc='2026-09-21T00:00:01.0000000Z';Native=[pscustomobject]@{Succeeded=$true;CleanupComplete=$true;Nonce=$n;Name=('WELA_Probe_'+$n);BeforeValue=('WELA_BEFORE_'+$n);AfterValue=('WELA_AFTER_'+$n);HandleId='0x888';Before=Copy-Value $script:state.Registry;After=Copy-Value $script:state.Registry;StartedUtc='2026-09-21T00:00:00.0001000Z';WriteReturnedUtc='2026-09-21T00:00:00.0001600Z';CompletedUtc='2026-09-21T00:00:00.0002000Z';Diagnostic=''}} + $script:stateReads=0;$script:attempts=0;$script:batchMode='match';$script:failArtifact=$null;$script:afterDrift=$false +} +function Xml { + @" +4657001280100x802000000000000011SecurityFIXTURES-1-5-21-1-2-3-1001ReaderFIXTURE0x1234\REGISTRY\USER\S-1-5-21-1-2-3-1001\Software\WELA\AuditProbe$($script:operation.Native.Name)0x888%%1905%%1873$($script:operation.Native.BeforeValue)%%1873$($script:operation.Native.AfterValue)0x4d2C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe +"@ +} +Fixture;$null=Get-WelaRegistryValueProbeStateKey $script:state;Assert-WelaRegistryValueProbeOperation $script:operation $script:state;Assert $true 'Complete fixed-key operation with cleanup is accepted.' +foreach($bad in @('path','mask','precedence','channel','role','group','inherit-only','failure','callback','right','descriptor','sources','token')){ + Fixture + switch($bad){'path'{$script:state.Registry.Path='HKEY_LOCAL_MACHINE\Software\WELA\AuditProbe'};'mask'{$script:state.AuditPolicies.'0CCE921E-69AE-11D9-BED3-505054503030'=0};'precedence'{$script:state.Precedence.Value=$true};'channel'{$script:state.Channel.Enabled='true'};'role'{$script:state.Host.ProductType=$true};'group'{$script:state.Token.Groups[0].Attributes=16};'inherit-only'{$script:state.Registry.Aces[0].Flags=72};'failure'{$script:state.Registry.Aces[0].Flags=128};'callback'{$script:state.Registry.Aces[0].Ordinary=$false};'right'{$script:state.Registry.Aces[0].Mask=1};'descriptor'{$script:state.Registry.DescriptorBase64=$null};'sources'{$script:state.Sources.Source='bad'};'token'{$script:state.Token.TokenSource='Thread'}} + Reject {Get-WelaRegistryValueProbeStateKey $script:state} +} +foreach($bad in @('nonce','success','cleanup','key','values','before','after','reverse','handle','token')){ + Fixture + switch($bad){'nonce'{$script:operation.Nonce='invalid'};'success'{$script:operation.Native.Succeeded='true'};'cleanup'{$script:operation.Native.CleanupComplete=$false};'key'{$script:operation.Native.After.Path='wrong'};'values'{$script:operation.Native.After.Values=@('new')};'before'{$script:operation.Native.StartedUtc='2026-09-20T00:00:00Z'};'after'{$script:operation.Native.CompletedUtc='2026-09-22T00:00:00Z'};'reverse'{$script:operation.Native.WriteReturnedUtc='2026-09-21T00:00:00Z'};'handle'{$script:operation.Native.HandleId='0x0'};'token'{$script:operation.AfterToken.Privileges[0].Attributes=2}} + Reject {Assert-WelaRegistryValueProbeOperation $script:operation $script:state} +} +Fixture;$xml=Xml;Assert (Test-WelaRegistryValueProbeEvent $xml $script:operation $script:state) 'Exact native-schema4657 is attributed.' +foreach($change in @(@('4657','4663'),@('0','1'),@('12801','12800'),@('0x8020000000000000','0x8010000000000000'),@('>FIXTURE','>OTHER'),@('>0x888','>0x889'),@('>0x4d2','>0x4d3'),@('>0x1234','>0x1235'),@('AuditProbe','OtherKey'),@('%%1905','%%1904'),@('%%1873','%%1874'),@('WELA_BEFORE_','DIFFERENT_'),@('WELA_AFTER_','DIFFERENT_'),@('WELA_Probe_','DIFFERENT_'),@('0001500Z','0000999Z'),@('0001500Z','0002001Z'),@('11','10'),@('1001','1002'),@('v1.0\powershell.exe','v1.0\other.exe'))){Assert (-not(Test-WelaRegistryValueProbeEvent $xml.Replace($change[0],$change[1]) $script:operation $script:state)) 'Mismatched provider/event/key/value/process/token/time is refused.'} +foreach($badXml in @($xml.Replace('','duplicate'),(']>'+$xml),(''+$xml+''))){Assert (-not(Test-WelaRegistryValueProbeEvent $badXml $script:operation $script:state)) 'Ambiguous or unsafe XML is refused.'} +# Compile exact source without invoking native API. +function Initialize-WelaWmiProbeNative {} +Initialize-WelaRegistryValueProbe +Assert ([Wela.RegistryValueProbe.Descriptor]::SourceSha256 -ceq (Get-FileHash (Join-Path $script:ScriptRoot 'scripts/RegistryValueProbeNative.cs')).Hash.ToLowerInvariant()) 'Exact native source hash is bound.' +$writer=(Get-Command Write-WelaFileProbeArtifact).ScriptBlock +function Get-WelaFileProbeOutputKey {param($Path) 'private-fixture'} +function Write-WelaFileProbeArtifact {param($Root,$OutputKey,$Name,$Text) if($Name -eq $script:failArtifact){throw 'Injected artifact failure'};& $script:writer $Root $OutputKey $Name $Text} +function Get-WelaRegistryValueProbeState {$script:stateReads++;Copy-Value $script:state} +function Invoke-WelaRegistryValueProbeOperation {param($State,$Nonce) $script:attempts++;$script:operation.Nonce=$Nonce;$script:operation.Native.Nonce=$Nonce;$script:operation.Native.Name='WELA_Probe_'+$Nonce;$script:operation.Native.BeforeValue='WELA_BEFORE_'+$Nonce;$script:operation.Native.AfterValue='WELA_AFTER_'+$Nonce;if($script:afterDrift){$script:state.Sources.Source='f'*64};Copy-Value $script:operation} +function Read-WelaRegistryValueProbeEvents {param($Operation) $xml=Xml;$items=if($script:batchMode -eq 'empty'){@()}elseif($script:batchMode -eq 'duplicate'){@($xml,$xml)}else{@($xml)};[pscustomobject]@{Xml=$items;Capped=($script:batchMode -eq 'capped');Query='fixture'}} +function Get-WelaFileProbeWatermark {11} +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-reg-probe-test-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +try{ + Fixture;$r=Invoke-WelaRegistryValueProbe;Assert ($r.ExitCode -eq 0 -and $r.Status -ceq 'PrerequisitesObserved' -and $script:attempts -eq 0) 'Plan never attempts a value write.' + Fixture;$r=Invoke-WelaRegistryValueProbe -Action Run -OutputPath (Join-Path $root 'pass');Assert ($r.ExitCode -eq 0 -and $r.Status -ceq 'RegistryValueModificationObserved' -and $r.Matches -eq 1 -and $r.SigmaEvtxCredit -eq 0) 'Exact4657 with cleanup is component evidence only.' + foreach($mode in @('capped','duplicate','empty')){Fixture;$script:batchMode=$mode;$r=Invoke-WelaRegistryValueProbe -Action Run -OutputPath (Join-Path $root $mode) -TimeoutSeconds 1;Assert ($r.ExitCode -eq 1) 'Incomplete/duplicate/absent event evidence fails.'} + Fixture;$script:failArtifact='intent.json';$r=Invoke-WelaRegistryValueProbe -Action Run -OutputPath (Join-Path $root 'intent');Assert ($r.ExitCode -eq 1 -and $script:attempts -eq 0) 'Intent failure prevents value mutation.' + Fixture;$script:afterDrift=$true;$r=Invoke-WelaRegistryValueProbe -Action Run -OutputPath (Join-Path $root 'drift');Assert ($r.ExitCode -eq 1) 'Final drift prevents observed-event credit.' + Fixture;$script:operation.Native.CleanupComplete=$false;$r=Invoke-WelaRegistryValueProbe -Action Run -OutputPath (Join-Path $root 'cleanup');Assert ($r.ExitCode -eq 1 -and (Test-Path (Join-Path $root 'cleanup/operation.json'))) 'Failed cleanup receipt is retained and never credited.' +}finally{Remove-Item -LiteralPath $root -Recurse -Force} +Write-Host "PASS: $count registry value probe assertions." diff --git a/tests/RegistryValueProbe.Windows.Tests.ps1 b/tests/RegistryValueProbe.Windows.Tests.ps1 new file mode 100644 index 00000000..3c0d9d79 --- /dev/null +++ b/tests/RegistryValueProbe.Windows.Tests.ps1 @@ -0,0 +1,67 @@ +param([switch]$AllowDisposableRegistryProbe) +$ErrorActionPreference='Stop' +if(-not $AllowDisposableRegistryProbe -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:OS -ne 'Windows_NT'){throw 'Explicit disposable GitHub-hosted Windows fixture required.'} +$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -Force +foreach($name in @('Configuration','WefArrival','ChannelRead','WmiProbe','FileAccessProbe','RegistryValueProbe','SelectedSaclConfiguration')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))} +Initialize-WelaRegistryValueProbe;Initialize-WelaSelectedSaclNative +Add-Type -Path (Join-Path $PSScriptRoot 'RegistryValueProbeFixture.cs') +$root=New-WelaArrivalOutput (Join-Path $env:RUNNER_TEMP ('wela-registry-value-probe-'+[guid]::NewGuid().ToString('N'))) $script:ScriptRoot +$engine=(Get-Process -Id $PID).Path;$count=0;$failure=$null;$cleanupErrors=@();$owner=$null;$policyTouched=$false +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Key($Value){Get-WelaFileProbeKey $Value} +function Save($Name,$Value){[IO.File]::WriteAllText((Join-Path $root $Name),($Value|ConvertTo-Json -Depth 28),[Text.UTF8Encoding]::new($false))} +function Masks{$m=Get-WelaEffectiveAuditPolicy;@($m.Keys|Sort-Object|ForEach-Object{"$_=$($m[$_])"}) -join ';'} +function Channel{$c=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Security');try{[pscustomobject]@{Enabled=$c.IsEnabled;Size=$c.MaximumSizeInBytes;Mode=[string]$c.LogMode;Security=$c.SecurityDescriptor}}finally{$c.Dispose()}} +$token=[Wela.RegistryValueProbe.TokenReader]::Snapshot();$originalMasks=Get-WelaEffectiveAuditPolicy;$masks=Masks;$channel=Channel +$guid='0cce921e-69ae-11d9-bed3-505054503030';$p='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$precedence=Get-WelaRegistryState $p SCENoApplyLegacyAuditPolicy +$hostState=Get-WelaChannelReadHost +Assert ($hostState.ProductType -eq 3 -and -not $hostState.DomainJoined) 'Actual disposable standalone Server required.' +Assert (-not $precedence.ValueExists -or ($precedence.Type -ceq 'DWord' -and $precedence.Value -in 0,1)) 'Unknown precedence is preserved.' +$softwareKey=[Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Software') +try{$softwareBefore=@($softwareKey.GetSubKeyNames()|Sort-Object)}finally{$softwareKey.Dispose()} +Save 'original.json' @{Token=$token;Masks=$masks;Precedence=$precedence;Channel=$channel;Host=$hostState;Engine=$PSVersionTable.PSVersion.ToString();Commit=$env:GITHUB_SHA;Sources=Get-WelaRegistryValueProbeSources;SoftwareChildren=$softwareBefore} +try{ + $owner=[Wela.RegistryValueProbeFixture.Owner]::new([guid]::NewGuid().ToString('N'));$owner.Create() + $path='HKEY_USERS\'+$token.Sid+'\Software\WELA\AuditProbe' + $denied=Invoke-WelaRegistryValueProbe + Assert ($denied.ExitCode -eq 1) 'Missing policy/SACL prerequisites refuse before mutation.' + $privilege=[Wela.SelectedSacl.Privilege]::new();$target=$null + try{$target=[Wela.SelectedSacl.Target]::new('Registry',$path);$before=$target.Read();$after=$target.Add($before.Identity,$before.DescriptorBase64,'S-1-1-0',2,64)}finally{if($target){$target.Dispose()};$privilege.Dispose()} + Save 'prepared-key.json' $after + $policyTouched=$true + Set-ItemProperty -LiteralPath $p -Name SCENoApplyLegacyAuditPolicy -Value 1 -Type DWord + Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 1 -Mode minimum + $preparedMasks=Masks;$prepared=Get-WelaRegistryValueProbeState;Save 'prepared.json' $prepared + $plan=Invoke-WelaRegistryValueProbe + Assert ($plan.ExitCode -eq 0 -and $plan.Status -ceq 'PrerequisitesObserved' -and -not $plan.Operation) 'Plan reads existing prerequisites without value writes.' + for($case=1;$case -le 2;$case++){ + $outputPath=Join-Path $root ('public-'+$case) + $old=$ErrorActionPreference + try{$ErrorActionPreference='Continue';$output=@(& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $script:ScriptRoot 'WELA.ps1') registry-probe -RegistryProbeAction Run -RegistryProbeOutputPath $outputPath 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old;$global:LASTEXITCODE=0} + Save ('public-'+$case+'-output.json') @($output|ForEach-Object{[string]$_}) + Assert ($code -eq 0) ('Public registry probe failed: '+($output -join ' ')) + $report=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText((Join-Path $outputPath 'manifest.json'))) + Assert ($report.Status -ceq 'RegistryValueModificationObserved' -and $report.Matches -eq 1 -and $report.ConfigurationChanges -eq 0 -and $report.SigmaEvtxCredit -eq 0) 'Exactly one4657 is attributed without configuration/rule credit.' + Assert-WelaRegistryValueProbeOperation $report.Operation $report.Before + $xml=[IO.File]::ReadAllText((Join-Path $outputPath 'event.xml')) + Assert (Test-WelaRegistryValueProbeEvent $xml $report.Operation $report.Before) 'Independent matcher verifies exact native4657 key/value/old/new/type/handle/PID/token/time.' + foreach($artifact in $report.Artifacts){Assert ((Get-FileHash -LiteralPath (Join-Path $outputPath $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Saved product artifact hash matches.'} + $current=Get-WelaRegistryValueProbeState + Assert ((Get-WelaRegistryValueProbeStateKey $current) -ceq (Get-WelaRegistryValueProbeStateKey $prepared)) 'Full stable host/token/descriptor/value/mask/channel/source state is preserved.' + Assert ((Masks) -ceq $preparedMasks) 'All59 prepared masks unchanged.' + } + Save 'completed.json' @{Status='Passed';Assertions=$count;Exact4657=2;ProbeValuesRemoved=2;Scope='Only fixed owned current-user probe key on actual standalone Server; no arbitrary/production/remote/DC/CA or forwarding credit.'} +}catch{$failure=$_.ToString();Save 'failure.json' @{Error=$failure;Stack=$_.ScriptStackTrace}} +finally{ + if($policyTouched){try{Set-WelaEffectiveAuditPolicy -Guid $guid -Mask $originalMasks[$guid] -Mode exact}catch{$cleanupErrors+=$_.ToString()};try{if($precedence.ValueExists){Set-ItemProperty -LiteralPath $p -Name SCENoApplyLegacyAuditPolicy -Value $precedence.Value -Type $precedence.Type}else{Remove-ItemProperty -LiteralPath $p -Name SCENoApplyLegacyAuditPolicy -ErrorAction Stop}}catch{$cleanupErrors+=$_.ToString()}} + if($owner){try{$owner.Dispose()}catch{$cleanupErrors+=$_.ToString()}} + $checks=[ordered]@{} + foreach($pair in @(@('All59Masks',{(Masks) -ceq $masks}),@('Precedence',{(Key (Get-WelaRegistryState $p SCENoApplyLegacyAuditPolicy)) -ceq (Key $precedence)}),@('Token',{(Key ([Wela.RegistryValueProbe.TokenReader]::Snapshot())) -ceq (Key $token)}),@('Channel',{(Key (Channel)) -ceq (Key $channel)}),@('OwnedKeyRemoved',{-not(Test-Path 'HKCU:\Software\WELA')}))){try{$checks[$pair[0]]=& $pair[1]}catch{$checks[$pair[0]]=$false;$cleanupErrors+=$_.ToString()}} + $complete=$cleanupErrors.Count -eq 0 -and @($checks.Values|Where-Object {-not $_}).Count -eq 0 + Save 'cleanup.json' @{Complete=$complete;Checks=$checks;Errors=$cleanupErrors;Failure=$failure;Assertions=$count} + Save 'artifact-hashes.json' @(Get-ChildItem -LiteralPath $root -Recurse -File|Where-Object Name -ne 'artifact-hashes.json'|Sort-Object FullName|ForEach-Object{[pscustomobject]@{Name=$_.FullName.Substring($root.Length+1).Replace('\','/');Sha256=(Get-FileHash -LiteralPath $_.FullName).Hash.ToLowerInvariant()}}) + if(-not $complete){throw 'Registry probe native cleanup failed.'} +} +if($failure){throw $failure} +Write-Host "PASS: $count native public registry4657 assertions and exact cleanup." diff --git a/tests/RegistryValueProbeFixture.cs b/tests/RegistryValueProbeFixture.cs new file mode 100644 index 00000000..c1872332 --- /dev/null +++ b/tests/RegistryValueProbeFixture.cs @@ -0,0 +1,22 @@ +// Disposable CI only: creates the fixed diagnostic parent only if absent. +using System;using System.ComponentModel;using System.Runtime.InteropServices;using Microsoft.Win32; +namespace Wela.RegistryValueProbeFixture { + public sealed class Owner : IDisposable { + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegCreateKeyExW(IntPtr root,string path,uint reserved,string cls,uint options,uint access,IntPtr security,out IntPtr key,out uint disposition); + [DllImport("advapi32.dll")] static extern int RegCloseKey(IntPtr key); + readonly string nonce;bool owned; + public Owner(string nonce){this.nonce=nonce;} + public void Create(){ + IntPtr key;uint disposition;int error=RegCreateKeyExW(new IntPtr(unchecked((int)0x80000001)),"Software\\WELA",0,null,0,0xF003F,IntPtr.Zero,out key,out disposition); + if(error!=0)throw new Win32Exception(error);try{if(disposition!=1)throw new InvalidOperationException("Existing WELA key is not disposable fixture property.");owned=true;}finally{RegCloseKey(key);} + using(RegistryKey p=Registry.CurrentUser.OpenSubKey("Software\\WELA",true)){p.SetValue("FixtureOwner",nonce,RegistryValueKind.String);using(RegistryKey c=p.CreateSubKey("AuditProbe")){c.SetValue("KeepTypedDword",321,RegistryValueKind.DWord);}} + } + public void Dispose(){if(!owned)return; + using(RegistryKey p=Registry.CurrentUser.OpenSubKey("Software\\WELA")){ + if(p==null||p.ValueCount!=1||p.SubKeyCount!=1||p.GetValueKind("FixtureOwner")!=RegistryValueKind.String||(string)p.GetValue("FixtureOwner")!=nonce)throw new InvalidOperationException("Fixture parent drift; no deletion."); + using(RegistryKey c=p.OpenSubKey("AuditProbe")){if(c==null||c.SubKeyCount!=0||c.ValueCount!=1||c.GetValueKind("KeepTypedDword")!=RegistryValueKind.DWord||(int)c.GetValue("KeepTypedDword")!=321)throw new InvalidOperationException("Fixture child drift or probe residue; no deletion.");} + } + Registry.CurrentUser.DeleteSubKey("Software\\WELA\\AuditProbe",true);Registry.CurrentUser.DeleteSubKey("Software\\WELA",true);owned=false; + } + } +} diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 4634748f..f159f93f 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,7 @@ **改善:** +- 現在のユーザーの既存 WELA 診断キー内で一時値を検証する `registry-probe` を追加しました。既存の監査と SACL を前提に、Security 4657 の厳密な対応付け、所有する一時値の削除、ポリシーとセキュリティ状態の保持を確認します。#373、#387 に関連します。 - Server2022/2025 と PowerShell5.1/7 で、Token Right Adjusted の正規GUIDに対する Security4703 の実機検証を追加しました。所有する子プロセスの既存権限を固定手順で無効化・復元し、候補となる2つの監査マスクを比較して、実イベント・実行条件・ハッシュとポリシー/トークンの復元を記録します。過去の候補は条件付きのまま維持し、製品用プローブ・全OS共通の対応関係・Sigma加点は追加しません。(関連 #380) (@Shirofune-Security) - OneSettingsポリシーと明示的なPrivacyチャネル設定の公開CLIを実機検証します。Server 2022で実際の適用、型付き復元記録と再読取、冪等性、不正値の拒否を確認し、Server 2025の既存の拒否を維持します。両PowerShellで厳密な後処理を検証し、イベント生成やSigma対応は主張しません。通知コントロール省略時の引数渡しを修正し、既定のAuditは両項目を読み取り、項目未選択のConfigureは非ゼロで失敗します。(関連 #378) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index cb0153b1..7db2801a 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,7 @@ **Improvements:** +- Added an opt-in `registry-probe` for one temporary value in an existing current-user WELA diagnostic key, with existing audit/SACL prerequisites, exact native Security 4657 attribution, owned-value cleanup and preserved policy/security state. Related to #373 and #387. - Added disposable native Security4703 attribution for the canonical Token Right Adjusted GUID on Server2022/2025 and PowerShell5.1/7. A fixed owned-child privilege disable/restore compares the two historical audit-mask candidates, retains exact event/context/hash evidence and verifies policy/token cleanup. Historical candidates remain conditional; no production probe, universal mapping or Sigma credit. (Related #380) (@Shirofune-Security) - Add native public OneSettings policy and explicit Privacy-channel configuration acceptance: actual apply, typed journals/readback, idempotence and invalid-value refusals on Server 2022; preserve the existing Server 2025 refusal. Both PowerShell engines verify exact fixture cleanup without event or Sigma claims. Fix omitted notification-control dispatch so default Audit reads both controls and Configure without a selection fails with a nonzero exit. (Related #378) (@Shirofune-Security)