diff --git a/.gitattributes b/.gitattributes index 04e16552..40c44920 100644 --- a/.gitattributes +++ b/.gitattributes @@ -97,6 +97,8 @@ tests/NativeProviderConfigure.Windows.Tests.ps1 text eol=lf /tests/WecCollectorObservation* text eol=lf /tests/WecSubscriptionInventory* text eol=lf +/tests/TokenRightAttribution*.ps1 text eol=lf +/tests/TokenRightAttribution*.cs text eol=lf # Disposable public OneSettings fixture source identity. /tests/OneSettingsConfigure*.ps1 text eol=lf # Scoped NTLM source and native evidence retain stable bytes. diff --git a/.github/workflows/native-token-attribution.yml b/.github/workflows/native-token-attribution.yml new file mode 100644 index 00000000..b9c1dd98 --- /dev/null +++ b/.github/workflows/native-token-attribution.yml @@ -0,0 +1,47 @@ +name: Native Security4703 audit attribution +on: + push: + branches: ['**'] + paths: + - 'tests/TokenRightAttribution*' + - 'docs/native-token-right-attribution.md' + - 'config/eid_subcategory_mapping.csv' + - 'config/audit_profiles.json' + - '.github/workflows/native-token-attribution.yml' + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + token-right-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: Native audit attribution in Windows PowerShell + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/TokenRightAttribution.Tests.ps1 + ./tests/AuditCatalogMappings.Tests.ps1 + ./tests/TokenRightAttribution.Windows.Tests.ps1 -AllowDisposableAuditWrite + - name: Native audit attribution in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/TokenRightAttribution.Tests.ps1 + ./tests/AuditCatalogMappings.Tests.ps1 + ./tests/TokenRightAttribution.Windows.Tests.ps1 -AllowDisposableAuditWrite + - name: Retain native events and cleanup + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: token-right-probe-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-token-right-attribution-*/ + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8dd7c1eb..544eba1a 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-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 -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 f60ac050..764fd355 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- 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) - 受信・ドメイン監査を明示的に選択する `ntlm-auditing` Audit/Plan/Configure を追加しました。受信監査 DWORD2 と実際のDC上のドメイン監査 DWORD7 のみを設定し、旧値2の意味を推測せず識別します。不明な値・ホストや設定の変化を拒否し、型付き変更前記録・再読取・部分失敗を区別します。ネイティブテストで受信設定、非DCのスキップ、無関係な設定の保持と復元を検証します。(関連 #363) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 217f65f0..195d6774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- 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) - Add `ntlm-auditing` Audit/Plan/Configure with explicit Incoming/Domain/Both selection. Configure only incoming audit DWORD2 and actual-DC domain audit DWORD7, identify legacy domain2 without invented semantics, refuse unknown values and host/state drift, and retain separate typed journals/readback/partial outcomes. Native tests verify incoming changes, non-DC skips and exact preservation of unrelated settings. (Related #363) (@Shirofune-Security) diff --git a/docs/audit-catalog-mappings.md b/docs/audit-catalog-mappings.md index 1ee7d66d..8dbd19be 100644 --- a/docs/audit-catalog-mappings.md +++ b/docs/audit-catalog-mappings.md @@ -15,3 +15,5 @@ The export fingerprints the mapping file, lists candidates and reasons per Event The bundled CSV remains a historical candidate map, not a universally valid event-generation contract. For example, 4703 appears against both Token Right Adjusted and Authorization Policy Change. Microsoft's [Token Right Adjusted page](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/auditing/audit-token-right-adjusted) lists it, while the [4703 event page](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/auditing/event-4703) names Authorization Policy Change. WELA retains the conflict rather than inventing a build-independent resolution. Microsoft also states that Token Right Adjusted has no Failure events; setting a Failure mask is not proof of Failure records. Fixtures check malformed/duplicate identifiers, unknown events, ambiguous 4703/object mappings, and independent RPC/token state through all four legacy baseline renderers. Windows CI runs [`auditpol /list /subcategory:* /v`](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/auditpol-list) and native policy queries under Windows PowerShell 5.1/PowerShell 7. These checks validate identifiers/readback, not generated event XML. Build-specific client, member-server, DC and CA event/outcome validation remains separate acceptance work for issue #380. Native Windows functionality only; Sysmon is out of scope. + +A separate [native4703 attribution fixture](native-token-right-attribution.md) compares the two selected masks on disposable standalone Server2022/2025 hosts with actual fixed privilege-adjustment XML. It retains all other audit masks and records the build/UBR and provider schema. This bounded generation evidence leaves historical candidates conditional and does not grant detection readiness. diff --git a/docs/native-token-right-attribution.md b/docs/native-token-right-attribution.md new file mode 100644 index 00000000..df3a5093 --- /dev/null +++ b/docs/native-token-right-attribution.md @@ -0,0 +1,27 @@ +# Native Security 4703 audit attribution + +The disposable `Native Security 4703 audit attribution` workflow tests the two historical audit-subcategory candidates for event 4703 on standalone Windows Server 2022/2025 under Windows PowerShell 5.1 and PowerShell 7. It does not add a production probe, change WELA policy recommendations, remove historical mapping candidates or grant Sigma readiness. + +Microsoft's [Token Right Adjusted guidance](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/auditing/audit-token-right-adjusted) and [advanced audit-policy reference](https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/plan/security-best-practices/advanced-audit-policy-configuration) associate 4703 with token adjustment. The older [4703 event reference](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/auditing/event-4703) names Authorization Policy Change. [WELA's mapping review](audit-catalog-mappings.md) retains both historical candidates as conditional. Native evidence below is specific to the recorded Windows build/UBR, provider manifest, engine and fixed operation. + +The opted-in test changes exactly two audit masks and the advanced-audit precedence DWORD on an isolated GitHub-hosted runner. First it sets Token Right Adjusted Events (`0CCE924A-69AE-11D9-BED3-505054503030`) to Success and Authorization Policy Change (`0CCE9231-69AE-11D9-BED3-505054503030`) to None. Then it reverses those two masks. The other 57 audit masks remain at their observed original values. This comparison establishes the selected two-mask behavior under that retained context; it is not an experiment with all other audit sources disabled. + +The installed provider task definitions and independently read `wevtutil gp` XML must both name `SE_ADT_DETAILEDTRACKING_TOKENRIGHTADJ` with value 13317. The emitted header is checked against that reviewed runtime task; it is not inferred from a candidate event. The observed generic 4703 declaration reports task 0, and that declaration remains in the receipt alongside the runtime definition. The older Microsoft event example uses task 13570; this discrepancy is retained rather than presented as a universal mapping correction. + +Each phase starts a fresh owned child. A test-only native helper requires an already-enabled `SeDebugPrivilege` in that child's primary token, disables it, reads the complete privilege inventory, restores its original attributes and verifies the complete inventory again. It refuses impersonation, missing or disabled privileges. It never grants a new right, removes a privilege, opens another process, performs a debug operation or changes an account's assigned rights. The executable path is read through `QueryFullProcessImageName` before the operation so `Get-Process` cannot introduce an extra privilege adjustment inside the measured operation. + +Acceptance requires two distinct actual Security 4703 records in the TokenRight-only phase: exactly the fixed disable and restoration. The inverse phase must have no matching records during its bounded observation. A match requires the installed provider GUID/name, eventID/version/task, Security channel, success keyword, observed computer identity, fresh record boundary, owned PID/executable, subject and target SID/logon ID, and exact privilege direction/sentinel. The precise UTC envelope starts before the native adjustment and ends after the required native full-token after-snapshot. Individual syscall-return times and the inner privilege-inventory verification endpoint are also retained. Security logging can timestamp a record just after the adjustment call returns; the measured verification interval is part of the operation, with no artificial delay or padded interval accepted as evidence. A wider query only collects diagnostic candidates; the strict matcher determines attribution. + +The child has a 90-second limit, bounded asynchronous output, a bounded drain and confirmed termination before fixture cleanup. Native event reads have a timeout and require one successful Security-channel status. Query errors, schema differences, extra attributable records, caps, missing events, policy drift or failed cleanup fail the fixture; they are never reported as an empty successful observation. Native events and diagnostic XML remain in the short-lived CI artifacts. + +Retained evidence includes actual host/build/UBR, native audit name/GUID listing, all 59 original/prepared/restored masks, typed precedence state, full Security-channel configuration, service states, parent/child tokens and complete privilege arrays, precise timestamps, raw event XML, mapping review, source fingerprints and artifact hashes. Cleanup independently restores both selected masks and the original precedence value or absence, then checks all masks, full channel configuration, service states and parent token. It does not erase generated events or recreate a historical event-log contents snapshot; dispose of the runner. + +Run only on the explicitly supported disposable hosted fixture: + +```powershell +./tests/TokenRightAttribution.Tests.ps1 +./tests/AuditCatalogMappings.Tests.ps1 +./tests/TokenRightAttribution.Windows.Tests.ps1 -AllowDisposableAuditWrite +``` + +This is a build-specific regression for issue #380, not universal proof about Windows 11, domain controllers, AD CS, every privilege, failure auditing, remote forwarding, policy persistence or Sigma Boolean/field requirements. All functionality is built into Windows; Sysmon is excluded. diff --git a/tests/TokenRightAttribution.Tests.ps1 b/tests/TokenRightAttribution.Tests.ps1 new file mode 100644 index 00000000..b9038b9c --- /dev/null +++ b/tests/TokenRightAttribution.Tests.ps1 @@ -0,0 +1,50 @@ +$ErrorActionPreference='Stop' +. "$PSScriptRoot/TokenRightAttributionEvidence.ps1" +$count=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +$start=[DateTime]::Parse('2026-09-22T00:00:00Z').ToFileTimeUtc() +$context=[pscustomobject]@{Task=13570;Computers=@('HOST','HOST.example.test');Watermark=100;ProcessId=1234;ProcessName='C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe';Sid='S-1-5-21-1-2-3-500';AuthenticationId='0x123';DisableStartedFileTime=$start;DisableReturnedFileTime=$start+100000;RestoreStartedFileTime=$start+200000;RestoreReturnedFileTime=$start+300000;PrivilegeVerificationCompletedFileTime=$start+300000;OperationCompletedFileTime=$start+300000} +$xml=@' +4703001357000x8020000000000000101SecurityHOST.example.testS-1-5-21-1-2-3-5000x123S-1-5-21-1-2-3-5000x123C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe0x4d2-SeDebugPrivilege +'@ +$result=Get-WelaTokenAttributionMatch $xml $context +Assert ($result.Direction -ceq 'Disable' -and $result.RecordId -eq 101) 'Exact fixed disable is attributable.' +$restore=$xml.Replace('>101<','>102<').Replace('00.0050000Z','00.0250000Z').Replace('Name="EnabledPrivilegeList">-','Name="EnabledPrivilegeList">SeDebugPrivilege').Replace('Name="DisabledPrivilegeList">SeDebugPrivilege','Name="DisabledPrivilegeList">-') +Assert ((Get-WelaTokenAttributionMatch $restore $context).Direction -ceq 'Restore') 'Exact fixed restoration is independently attributable.' +foreach($case in @( + @('Microsoft-Windows-Security-Auditing','Other-Provider'),@('54849625','54849626'),@('4703','4704'),@('0','1'),@('0','1'),@('13570','13571'),@('0','1'),@('0x8020000000000000','0x8010000000000000'),@('Security','ForwardedEvents'),@('HOST.example.test','HOST.attacker.test'),@('>101<','>100<'),@('>101<','>0<'),@('>101<','>true<'),@('>0x4d2<','>0x4d3<'),@('>0x4d2<','>1234<'),@('powershell.exe','pwsh.exe'),@('S-1-5-21-1-2-3-500','S-1-5-21-1-2-3-501'),@('>0x123<','>0x124<'),@('>SeDebugPrivilege<','>SeDebugPrivilege SeBackupPrivilege<'),@('>SeDebugPrivilege<','>SeChangeNotifyPrivilege<'),@('>SeDebugPrivilege<','>sedebugprivilege<'),@('00.0050000Z','00.0350000Z'),@('00.0050000Z','00.0050000+00:00'),@('http://schemas.microsoft.com/win/2004/08/events/event','urn:wrong') +)){Assert ($null -eq (Get-WelaTokenAttributionMatch ($xml.Replace($case[0],$case[1])) $context)) ('Mismatched event rejected: '+$case[0])} +foreach($field in @('SubjectUserSid','SubjectLogonId','TargetUserSid','TargetLogonId')){ + $doc=[xml]$xml;$node=@($doc.Event.EventData.Data|Where-Object{$_.Name -ceq $field})[0];$node.InnerText+='9' + Assert ($null -eq (Get-WelaTokenAttributionMatch $doc.OuterXml $context)) ('Independent mismatched identity rejected: '+$field) +} +$doc=[xml]$xml;$node=$doc.Event.EventData.Data[0];$null=$doc.Event.EventData.AppendChild($node.CloneNode($true));Assert ($null -eq (Get-WelaTokenAttributionMatch $doc.OuterXml $context)) 'Duplicate data field refused.' +$doc=[xml]$xml;$node=$doc.Event.System.EventID;$null=$doc.Event.System.AppendChild($doc.Event.System.SelectSingleNode('*[local-name()="EventID"]').CloneNode($true));Assert ($null -eq (Get-WelaTokenAttributionMatch $doc.OuterXml $context)) 'Duplicate header field refused.' +$doc=[xml]$xml;$null=$doc.Event.System.RemoveChild($doc.Event.System.SelectSingleNode('*[local-name()="Task"]'));Assert ($null -eq (Get-WelaTokenAttributionMatch $doc.OuterXml $context)) 'Missing native task refused.' +foreach($text in @((' '+$xml).PadRight(65537),(']>'+$xml))){$rejected=$false;try{$null=Get-WelaTokenAttributionMatch $text $context}catch{$rejected=$true};Assert $rejected 'Oversized XML and DTD refuse explicitly.'} +# Regex operations must not corrupt the event accumulator (PowerShell owns $Matches). +$attributedEvents=@();foreach($text in @($xml,$restore)){$m=Get-WelaTokenAttributionMatch $text $context;if($m){$attributedEvents+=@($m)}} +Assert ($attributedEvents.Count -eq 2 -and @($attributedEvents|Select-Object -ExpandProperty RecordId -Unique).Count -eq 2) 'Two directions survive regex correlation as distinct records.' +Import-Module "$PSScriptRoot/../modules/AuditProfiles.psm1" -Force +Import-Module "$PSScriptRoot/../modules/AuditCatalog.psm1" -Force +$catalog=(Import-WelaAuditProfiles).catalog +$token=@($catalog|Where-Object id -CEQ 'Token Right Adjusted Events') +Assert ($token.Count -eq 1 -and $token[0].guid -ceq '0CCE924A-69AE-11D9-BED3-505054503030') 'Native attribution is bound to the canonical token GUID, never RPC.' +$review=Get-WelaEventMappingReview @(Import-Csv "$PSScriptRoot/../config/eid_subcategory_mapping.csv") $catalog 4703 +Assert ($review.State -ceq 'Conditional' -and $review.Candidates.Count -eq 2 -and -not $review.DetectionReady) 'Build-specific generation never erases historical candidates or grants Sigma credit.' +Assert-WelaTokenAttributionTimes $context ($start-100) ($start+400000) +Assert $true 'Typed monotonic native timing accepted.' +foreach($field in @('DisableStartedFileTime','DisableReturnedFileTime','RestoreStartedFileTime','RestoreReturnedFileTime','PrivilegeVerificationCompletedFileTime','OperationCompletedFileTime')){ + foreach($bad in @($true,'134345000000000000',0L,($start-200),($start+500000))){ + $copy=$context|ConvertTo-Json -Depth 8|ConvertFrom-Json;$copy.$field=$bad;$rejected=$false + try{Assert-WelaTokenAttributionTimes $copy ($start-100) ($start+400000)}catch{$rejected=$true} + Assert $rejected ('Malformed or out-of-envelope native timestamp refused: '+$field) + } +} +$copy=$context|ConvertTo-Json -Depth 8|ConvertFrom-Json;$copy.RestoreStartedFileTime=$start+50000;$rejected=$false;try{Assert-WelaTokenAttributionTimes $copy ($start-100) ($start+400000)}catch{$rejected=$true};Assert $rejected 'Nonmonotonic in-envelope timestamps refused.' +$definitions=@([pscustomobject]@{Name='SE_ADT_DETAILEDTRACKING_TOKENRIGHTADJ';Value=13317}) +$publisher='' +Assert ((Get-WelaTokenAttributionTask $definitions $publisher) -eq 13317) 'Independent native task definition and publisher XML bind runtime task despite generic event declaration0.' +foreach($bad in @(@(),@($definitions[0],$definitions[0]),@([pscustomobject]@{Name=$definitions[0].Name;Value=$true}),@([pscustomobject]@{Name=$definitions[0].Name;Value='13317'}),@([pscustomobject]@{Name=$definitions[0].Name;Value=13570}),@([pscustomobject]@{Name='Wrong';Value=13317}))){$rejected=$false;try{$null=Get-WelaTokenAttributionTask $bad $publisher}catch{$rejected=$true};Assert $rejected 'Missing/duplicate/mistyped/wrong native task definition refuses.'} +foreach($text in @($publisher.Replace('13317','13570'),$publisher.Replace('http://schemas.microsoft.com/win/2004/08/events','urn:wrong'),$publisher.Replace('TOKENRIGHTADJ','OTHER'),$publisher.Replace('54849625','54849626'),$publisher.Replace('','').Replace('',''))){$rejected=$false;try{$null=Get-WelaTokenAttributionTask $definitions $text}catch{$rejected=$true};Assert $rejected 'Native publisher task/identity mismatch refuses.'} +Write-Host "PASS: $count strict token attribution and catalog checks." diff --git a/tests/TokenRightAttribution.Windows.Tests.ps1 b/tests/TokenRightAttribution.Windows.Tests.ps1 new file mode 100644 index 00000000..4c1ec932 --- /dev/null +++ b/tests/TokenRightAttribution.Windows.Tests.ps1 @@ -0,0 +1,163 @@ +param([switch]$AllowDisposableAuditWrite) +$ErrorActionPreference='Stop' +if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess -or -not $AllowDisposableAuditWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable Windows fixture only.'} +$repo=Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force +Import-Module (Join-Path $repo 'modules/AuditCatalog.psm1') -Force +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/WefArrival.ps1') +. (Join-Path $repo 'scripts/WmiProbe.ps1') +. (Join-Path $repo 'scripts/ControlApplicability.ps1') +. (Join-Path $PSScriptRoot 'TokenRightAttributionEvidence.ps1') +Initialize-WelaWmiProbeNative +$root=New-WelaArrivalOutput (Join-Path $env:RUNNER_TEMP ('wela-token-right-attribution-'+[guid]::NewGuid().ToString('N'))) $PSScriptRoot +function Save($Name,$Value){ConvertTo-Json -InputObject $Value -Depth 24|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 24 -Compress} +function Masks{$m=Get-WelaEffectiveAuditPolicy;@($m.Keys|Sort-Object|ForEach-Object{"$_=$($m[$_])"}) -join ';'} +$guid='0CCE924A-69AE-11D9-BED3-505054503030';$auth='0CCE9231-69AE-11D9-BED3-505054503030' +$path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$name='SCENoApplyLegacyAuditPolicy' +foreach($service in @('Winmgmt','EventLog')){if((Get-Service $service).Status -ne 'Running'){throw 'Observation services must already be running.'}} +$hostContext=Get-WelaDefaultContext +if(-not(Test-WelaDefaultContextComplete $hostContext) -or $hostContext.ProductType -ne 3 -or $hostContext.DomainRole -ne 2 -or $hostContext.Build -notin @(20348,26100)){throw 'Only reviewed disposable standalone Server2022/2025 hosts are accepted.'} +$provider=Get-WinEvent -ListProvider 'Microsoft-Windows-Security-Auditing' +$schema=@($provider.Events|Where-Object{$_.Id -eq 4703 -and $_.Version -eq 0}) +if($schema.Count -ne 1 -or $provider.Id -ne [guid]'54849625-5478-4994-a5ba-3e3b0328c30d'){throw 'Exactly one installed version0 Security4703 schema is required.'} +$declaredTask=[int]$schema[0].Task.Value +Save 'provider-diagnostic.json' @{TaskType=$schema[0].Task.GetType().FullName;TaskValue=$schema[0].Task.Value;TaskName=$schema[0].Task.Name;TaskDisplay=$schema[0].Task.DisplayName;Tasks=@($provider.Tasks|ForEach-Object{@{Value=$_.Value;Name=$_.Name;Display=$_.DisplayName;Guid=[string]$_.EventGuid}})} +$publisher=Invoke-WelaNative wevtutil.exe @('gp','Microsoft-Windows-Security-Auditing','/ge:true','/gm:false','/f:xml') +$publisherText=$publisher.Output -join "`n";if($publisherText.Length -gt 4194304){throw 'Native publisher metadata exceeds fixture bound.'};[IO.File]::WriteAllText((Join-Path $root 'publisher.xml'),$publisherText) +$eventTask=Get-WelaTokenAttributionTask @($provider.Tasks) $publisherText +$computerProperties=[Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties();$computers=@([Environment]::MachineName,$computerProperties.HostName);if($computerProperties.DomainName){$computers+=$computerProperties.HostName+'.'+$computerProperties.DomainName};$computers=@($computers|Sort-Object -Unique) +function Channel{(Invoke-WelaNative wevtutil.exe @('gl','Security','/f:xml')).Output -join "`n"} +function Services{@(Get-Service Winmgmt,EventLog|Sort-Object Name|ForEach-Object{[pscustomobject]@{Name=$_.Name;Status=[string]$_.Status;StartType=[string]$_.StartType}})} +$originalChannel=Channel;$originalServices=Services +$nativeListing=Invoke-WelaNative auditpol.exe @('/list','/subcategory:*','/v') +$listing=$nativeListing.Output -join "`n" +foreach($row in @(@{Name='Token Right Adjusted Events';Guid=$guid},@{Name='Authorization Policy Change';Guid=$auth})){ + if($listing -notmatch [regex]::Escape($row.Guid)){throw 'Native audit listing omits a selected exact GUID.'} + if([Globalization.CultureInfo]::InstalledUICulture.TwoLetterISOLanguageName -eq 'en' -and -not @($nativeListing.Output|Where-Object{$_ -match [regex]::Escape($row.Guid) -and $_ -match [regex]::Escape($row.Name)}).Count){throw 'Native selected audit name and GUID disagree.'} +} +Save 'native-audit-catalog.json' @{Listing=$nativeListing.Output;Selected=@($guid,$auth)} +$canonical=(Import-WelaAuditProfiles).catalog +$mapping=Get-WelaEventMappingReview @(Import-Csv "$repo/config/eid_subcategory_mapping.csv") $canonical 4703 +if($mapping.State -cne 'Conditional' -or $mapping.Candidates.Count -ne 2 -or $mapping.DetectionReady){throw 'Historical4703 candidates must remain conditional with no readiness credit.'} +Save 'mapping-review.json' $mapping +$sources=[ordered]@{} +foreach($file in @('tests/TokenRightAttribution.Windows.Tests.ps1','tests/TokenRightAttributionNative.cs','tests/TokenRightAttributionEvidence.ps1','tests/TokenRightAttribution.Tests.ps1','modules/AuditProfiles.psm1','modules/AuditCatalog.psm1','scripts/Configuration.ps1','scripts/WefArrival.ps1','scripts/WmiProbe.ps1','scripts/WmiProbeNative.cs','scripts/ControlApplicability.ps1','config/audit_profiles.json','config/baselines.json','config/eid_subcategory_mapping.csv')){$sources[$file]=(Get-FileHash -LiteralPath "$repo/$file" -Algorithm SHA256).Hash.ToLowerInvariant()} +$beforeMasks=Get-WelaEffectiveAuditPolicy;$masks=Masks;$beforePrecedence=Get-WelaRegistryState $path $name;$beforeToken=[Wela.WmiProbe.Native]::Snapshot();$failure=$null;$errors=@() +Save 'original.json' @{Masks=$beforeMasks;Precedence=$beforePrecedence;Token=$beforeToken;Head=$env:GITHUB_SHA;Engine=$PSVersionTable.PSVersion.ToString();Host=$hostContext;Channel=$originalChannel;Services=$originalServices;Computers=$computers;Provider=[string]$provider.Id;Schema=@{Id=4703;Version=0;Task=$declaredTask;RuntimeTask=$eventTask;RuntimeTaskName='SE_ADT_DETAILEDTRACKING_TOKENRIGHTADJ';Template=$schema[0].Template}} +Add-Type -TypeDefinition @' +using System;using System.IO;using System.Text;using System.Threading.Tasks; +public static class WelaTokenFixturePipe { + public static async Task Read(TextReader reader) { + var text=new StringBuilder();var buffer=new char[2048];while(true){int n=await reader.ReadAsync(buffer,0,buffer.Length).ConfigureAwait(false);if(n==0)return text.ToString();if(n>1048576-text.Length)throw new InvalidDataException("Fixture output exceeded one Mi characters.");text.Append(buffer,0,n);} + } +} +'@ +function Worker([string]$Phase,[string]$Receipt){ + $all=@('-NoLogo','-NoProfile','-NonInteractive','-File',$worker,$repo,$Receipt) + foreach($a in $all){if($a.Contains('"') -or $a.EndsWith('\') -or $a -match '[\x00-\x1f]'){throw 'Ambiguous fixture argument.'}} + $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=(Get-Process -Id $PID).Path;$info.Arguments=(@($all|ForEach-Object{'"'+$_+'"'}) -join ' ');$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true + $process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false + try{ + if(-not $process.Start()){throw 'Owned worker did not start.'};$started=$true + $stdout=[WelaTokenFixturePipe]::Read($process.StandardOutput);$stderr=[WelaTokenFixturePipe]::Read($process.StandardError) + if(-not $process.WaitForExit(90000)){throw 'Owned worker exceeded90seconds.'} + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Owned worker drain exceeded5seconds.'} + [IO.File]::WriteAllText((Join-Path $root ($Phase+'-worker.txt')),$stdout.Result+"`n"+$stderr.Result) + if($process.ExitCode -ne 0){throw 'Owned native worker failed; see retained output.'} + [pscustomobject]@{ProcessId=$process.Id;Executable=$info.FileName} + }finally{ + if($started){$exited=$false;try{$exited=$process.HasExited}catch{$script:errors+=$_.ToString()};if(-not $exited){try{$process.Kill()}catch{$script:errors+=$_.ToString()};try{$exited=$process.WaitForExit(5000)}catch{$script:errors+=$_.ToString()}};if(-not $exited){$script:errors+='Owned worker termination unconfirmed.'}} + try{$process.Dispose()}catch{$script:errors+=$_.ToString()} + } +} +$worker=Join-Path $root 'worker.ps1';$receipt=Join-Path $root 'worker.json' +@' +param($Repo,$Result) +$ErrorActionPreference='Stop' +Add-Type -Path (Join-Path $Repo 'scripts/WmiProbeNative.cs') +Add-Type -Path (Join-Path $Repo 'tests/TokenRightAttributionNative.cs') +$executable=[Wela.TokenRightProbe.Native]::Executable() +$before=[Wela.WmiProbe.Native]::Snapshot() +$outcome=[Wela.TokenRightProbe.Native]::Run() +$after=[Wela.WmiProbe.Native]::Snapshot() +$outcome.OperationCompletedFileTime=[Wela.WmiProbe.Native]::UtcNow().ToFileTimeUtc() +[pscustomobject]@{ProcessId=$PID;ProcessName=$executable;Before=$before;After=$after;Outcome=$outcome}|ConvertTo-Json -Depth 24|Set-Content -LiteralPath $Result -Encoding UTF8 +if($outcome.Status -ne 'Adjusted' -or -not $outcome.Restored -or (($before|ConvertTo-Json -Depth 24 -Compress) -cne ($after|ConvertTo-Json -Depth 24 -Compress))){exit 1} +exit 0 +'@|Set-Content -LiteralPath $worker -Encoding UTF8 +try{ + if($beforeMasks.Count -ne 59){throw 'All59 masks required.'} + Set-ItemProperty -LiteralPath $path -Name $name -Value 1 -Type DWord + $phases=@(@{Name='TokenRightOnly';Token=1;Authorization=0},@{Name='AuthorizationOnly';Token=0;Authorization=1}) + $summaries=@() + foreach($phase in $phases){ + Set-WelaEffectiveAuditPolicy -Guid $guid -Mask $phase.Token -Mode exact + Set-WelaEffectiveAuditPolicy -Guid $auth -Mask $phase.Authorization -Mode exact + $prepared=Get-WelaEffectiveAuditPolicy + foreach($entry in $beforeMasks.Keys){$expected=$beforeMasks[$entry];if($entry -eq $guid){$expected=$phase.Token};if($entry -eq $auth){$expected=$phase.Authorization};if($prepared[$entry] -ne $expected){throw 'Prepared native policy differs from the exact selected two-mask change.'}} + Save ($phase.Name+'-prepared.json') @{Phase=$phase;Masks=$prepared;Precedence=Get-WelaRegistryState $path $name} + $receipt=Join-Path $root ($phase.Name+'-worker.json') + $record=Get-WinEvent -LogName Security -MaxEvents 1 -ErrorAction Stop;try{$watermark=[long]$record.RecordId}finally{$record.Dispose()} + $launched=[Wela.WmiProbe.Native]::UtcNow().ToFileTimeUtc() + $child=Worker $phase.Name $receipt + $observed=[Wela.WmiProbe.Native]::UtcNow().ToFileTimeUtc() + $result=Get-Content -Raw $receipt|ConvertFrom-Json + if($result.ProcessId -ne $child.ProcessId -or $result.ProcessName -ine $child.Executable -or $result.Outcome.Status -isnot [string] -or $result.Outcome.Status -cne 'Adjusted' -or $result.Outcome.Restored -isnot [bool] -or -not $result.Outcome.Restored -or $result.Outcome.DisableStartedFileTime -lt $launched -or $result.Outcome.OperationCompletedFileTime -gt $observed){throw 'Owned worker receipt identity, status or measured operation interval is invalid.'} + Assert-WelaTokenAttributionTimes $result.Outcome $launched $observed + $context=[pscustomobject]@{ProcessId=$child.ProcessId;ProcessName=$child.Executable;Sid=$result.Before.Sid;AuthenticationId=$result.Before.AuthenticationId;Computers=$computers;Task=$eventTask;Watermark=$watermark;DisableStartedFileTime=$result.Outcome.DisableStartedFileTime;OperationCompletedFileTime=$result.Outcome.OperationCompletedFileTime} + Save ($phase.Name+'-context.json') @{Context=$context;LaunchedFileTime=$launched;ObservedFileTime=$observed;Child=$child} + $exactStart=[DateTime]::FromFileTimeUtc($result.Outcome.DisableStartedFileTime);$exactEnd=[DateTime]::FromFileTimeUtc($result.Outcome.OperationCompletedFileTime) + $start=$exactStart.AddSeconds(-2);$end=$exactEnd.AddSeconds(2) + $query="*[System[Provider[@Name='Microsoft-Windows-Security-Auditing'] and EventID=4703 and TimeCreated[@SystemTime>='$($start.ToString('o'))' and @SystemTime<='$($end.ToString('o'))']]]" + $candidates=@{};$queryErrors=@();$attributedEvents=@();$deadline=[DateTime]::UtcNow.AddSeconds(15) + do{ + $reader=$null + try{ + $nativeQuery=[System.Diagnostics.Eventing.Reader.EventLogQuery]::new('Security',[System.Diagnostics.Eventing.Reader.PathType]::LogName,$query) + $reader=[System.Diagnostics.Eventing.Reader.EventLogReader]::new($nativeQuery) + $statuses=@($reader.LogStatus);if($statuses.Count -ne 1 -or $statuses[0].LogName -cne 'Security' -or $statuses[0].StatusCode -ne 0){throw 'Native query lacks exactly one successful Security channel status.'} + $count=0 + while($null -ne ($event=$reader.ReadEvent([TimeSpan]::FromSeconds(2)))){ + try{ + $count++;if($count -gt 512){throw 'Diagnostic candidate count exceeded512.'} + $raw=$event.ToXml();if($raw.Length -gt 65536){throw 'Candidate XML exceeded64Ki characters.'} + [xml]$xml=$raw;$data=@{};foreach($field in $xml.Event.EventData.Data){$data[[string]$field.Name]=[string]$field.'#text'} + $match=Get-WelaTokenAttributionMatch $raw $context + $candidates[[string]$event.RecordId]=[pscustomobject]@{RecordId=$event.RecordId;Xml=$raw;Data=$data;Attributed=($null -ne $match);Direction=if($match){$match.Direction}else{$null}} + }finally{$event.Dispose()} + } + }catch{$queryErrors+=@($_.ToString());break}finally{if($reader){$reader.Dispose()}} + $attributedEvents=@($candidates.Values|Where-Object{$_.Attributed}|Sort-Object RecordId) + if($attributedEvents.Count -ge 2){break};Start-Sleep -Milliseconds 250 + }while([DateTime]::UtcNow -lt $deadline) + Save ($phase.Name+'-candidates.json') @($candidates.Values|Sort-Object RecordId) + Save ($phase.Name+'-events.json') $attributedEvents + Save ($phase.Name+'-query.json') @{XPath=$query;ExactStart=$exactStart;ExactEnd=$exactEnd;QueryErrors=$queryErrors;CandidateCount=$candidates.Count;AttributedCount=$attributedEvents.Count;NoMatchingEvents=($candidates.Count -eq 0);DiagnosticOnly=$true} + if($queryErrors.Count){throw 'Native4703 observation failed; see retained query errors.'} + if($phase.Name -ceq 'TokenRightOnly' -and ($attributedEvents.Count -ne 2 -or @($attributedEvents|Where-Object Direction -CEQ Disable).Count -ne 1 -or @($attributedEvents|Where-Object Direction -CEQ Restore).Count -ne 1)){throw 'TokenRight-only requires exactly one actual disable and one restore4703.'} + if($phase.Name -ceq 'AuthorizationOnly' -and $attributedEvents.Count -ne 0){throw 'Inverse phase produced an unexpected attributable event; do not generalize the mapping.'} + if((Key (Get-WelaEffectiveAuditPolicy)) -cne (Key $prepared)){throw 'Prepared audit policy drifted during observation.'} + $summaries+=@([pscustomobject]@{Phase=$phase.Name;CandidateCount=$candidates.Count;AttributedCount=$attributedEvents.Count;ReadComplete=$true;AdjustedAndRestored=($result.Outcome.Status -eq 'Adjusted' -and $result.Outcome.Restored)}) + Save 'summary.json' $summaries + Write-Host "$($phase.Name): $($attributedEvents.Count) exact native4703 records, $($candidates.Count) bounded diagnostic candidates." + } + if(@($summaries|Where-Object{$_.AttributedCount -gt 0}).Count -eq 0){throw 'Neither selected policy phase produced an attributable4703 event.'} +}catch{$failure=$_.ToString();throw}finally{ + foreach($restoreGuid in @($guid,$auth)){try{Set-WelaEffectiveAuditPolicy -Guid $restoreGuid -Mask $beforeMasks[$restoreGuid] -Mode exact}catch{$errors+=$_.ToString()}} + try{if($beforePrecedence.ValueExists){Set-ItemProperty -LiteralPath $path -Name $name -Value $beforePrecedence.Value -Type $beforePrecedence.Type}else{Remove-ItemProperty -LiteralPath $path -Name $name -ErrorAction Stop}}catch{$errors+=$_.ToString()} + $afterToken=$null;$afterPrecedence=$null;$afterMasks=$null;$afterMaskKey=$null;$afterChannel=$null;$afterServices=$null + try{$afterToken=[Wela.WmiProbe.Native]::Snapshot()}catch{$errors+=$_.ToString()} + try{$afterPrecedence=Get-WelaRegistryState $path $name}catch{$errors+=$_.ToString()} + try{$afterMasks=Get-WelaEffectiveAuditPolicy;$afterMaskKey=@($afterMasks.Keys|Sort-Object|ForEach-Object{"$_=$($afterMasks[$_])"}) -join ';'}catch{$errors+=$_.ToString()} + try{$afterChannel=Channel}catch{$errors+=$_.ToString()} + try{$afterServices=Services}catch{$errors+=$_.ToString()} + $complete=$afterChannel -ceq $originalChannel -and (Key $afterServices) -ceq (Key $originalServices) -and $errors.Count -eq 0 -and $afterMaskKey -ceq $masks -and (Key $afterPrecedence) -ceq (Key $beforePrecedence) -and ((Key $beforeToken) -ceq (Key $afterToken)) + foreach($file in $sources.Keys){try{if((Get-FileHash -LiteralPath "$repo/$file" -Algorithm SHA256).Hash.ToLowerInvariant() -cne $sources[$file]){$errors+='Fixture source drift: '+$file;$complete=$false}}catch{$errors+=$_.ToString();$complete=$false}} + Save 'cleanup.json' @{Complete=$complete;Errors=$errors;Failure=$failure;AfterToken=$afterToken;AfterMasks=$afterMasks;AfterPrecedence=$afterPrecedence;AfterChannel=$afterChannel;AfterServices=$afterServices} + $artifactHashes=@(Get-ChildItem -LiteralPath $root -File -Recurse|Sort-Object FullName|ForEach-Object{[pscustomobject]@{Path=$_.FullName.Substring($root.Length+1).Replace('\','/');Sha256=(Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}}) + Save 'manifest.json' @{Head=$env:GITHUB_SHA;Status=if($complete -and -not $failure){'Passed'}else{'Failed'};Sources=$sources;Artifacts=$artifactHashes;Fixture='NativeSecurity4703Attribution';EventIds=@(4703);RuntimePolicyPhases=@('TokenRightOnly','AuthorizationOnly');OtherAuditMasksPreserved=57;NoSigmaCredit=$true;NoForwardingCredit=$true;HistoricalCandidatesRemainConditional=$true} + if(-not $complete){throw 'Native attribution fixture cleanup failed.'} +} +$global:LASTEXITCODE=0 diff --git a/tests/TokenRightAttributionEvidence.ps1 b/tests/TokenRightAttributionEvidence.ps1 new file mode 100644 index 00000000..19805333 --- /dev/null +++ b/tests/TokenRightAttributionEvidence.ps1 @@ -0,0 +1,56 @@ +# Test-only strict correlation. This helper never changes WELA scoring or policy. +function Get-WelaTokenAttributionMatch { + param([string]$Text,$Context) + if($Text.Length -gt 65536){throw 'Event XML exceeds the fixture bound.'} + $settings=[Xml.XmlReaderSettings]::new();$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=65536 + $reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Text),$settings);$xml=[Xml.XmlDocument]::new();$xml.XmlResolver=$null + try{$xml.Load($reader)}finally{$reader.Dispose()} + $ns=[Xml.XmlNamespaceManager]::new($xml.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event') + if($xml.DocumentElement.LocalName -cne 'Event' -or $xml.DocumentElement.NamespaceURI -cne $ns.LookupNamespace('e') -or $xml.SelectNodes('/e:Event/e:System',$ns).Count -ne 1 -or $xml.SelectNodes('/e:Event/e:EventData',$ns).Count -ne 1){return $null} + $system=$xml.SelectSingleNode('/e:Event/e:System',$ns) + foreach($field in @('Provider','EventID','Version','Level','Task','Opcode','Keywords','TimeCreated','EventRecordID','Channel','Computer')){if($system.SelectNodes('e:'+$field,$ns).Count -ne 1){return $null}} + $p=$system.SelectSingleNode('e:Provider',$ns) + if($p.GetAttribute('Name') -cne 'Microsoft-Windows-Security-Auditing' -or $p.GetAttribute('Guid') -ine '{54849625-5478-4994-a5ba-3e3b0328c30d}'){return $null} + foreach($pair in @(@('EventID','4703'),@('Version','0'),@('Level','0'),@('Opcode','0'),@('Task',[string]$Context.Task),@('Keywords','0x8020000000000000'),@('Channel','Security'))){if($system.SelectSingleNode('e:'+$pair[0],$ns).InnerText -cne $pair[1]){return $null}} + if(@($Context.Computers|Where-Object{$_ -ieq $system.SelectSingleNode('e:Computer',$ns).InnerText}).Count -ne 1){return $null} + $record=0L;if(-not[long]::TryParse($system.SelectSingleNode('e:EventRecordID',$ns).InnerText,[ref]$record) -or $record -le $Context.Watermark){return $null} + $data=@{};$fields=$xml.SelectNodes('/e:Event/e:EventData/e:Data',$ns) + foreach($node in $fields){$name=$node.GetAttribute('Name');if(-not $name -or $data.ContainsKey($name)){return $null};$data[$name]=$node.InnerText} + foreach($field in @('SubjectUserSid','SubjectLogonId','TargetUserSid','TargetLogonId','ProcessName','ProcessId','EnabledPrivilegeList','DisabledPrivilegeList')){if(-not $data.ContainsKey($field)){return $null}} + if($data.SubjectUserSid -cne $Context.Sid -or $data.TargetUserSid -cne $Context.Sid -or $data.SubjectLogonId -ine $Context.AuthenticationId -or $data.TargetLogonId -ine $Context.AuthenticationId -or $data.ProcessName -ine $Context.ProcessName){return $null} + if($data.ProcessId -cnotmatch '^0x[0-9a-fA-F]+$' -or [Convert]::ToUInt64($data.ProcessId.Substring(2),16) -ne [uint64]$Context.ProcessId){return $null} + $direction=$null + if($data.DisabledPrivilegeList -ceq 'SeDebugPrivilege' -and $data.EnabledPrivilegeList -ceq '-'){$direction='Disable'} + if($data.EnabledPrivilegeList -ceq 'SeDebugPrivilege' -and $data.DisabledPrivilegeList -ceq '-'){$direction='Restore'} + if(-not $direction){return $null} + $textTime=$system.SelectSingleNode('e:TimeCreated',$ns).GetAttribute('SystemTime');if($textTime -cnotmatch '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,7})?Z$'){return $null} + $time=[DateTime]::Parse($textTime,[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind).ToFileTimeUtc() + if($time -lt $Context.DisableStartedFileTime -or $time -gt $Context.OperationCompletedFileTime){return $null} + [pscustomobject]@{RecordId=$record;Direction=$direction;Utc=$textTime;Data=$data} +} +function Assert-WelaTokenAttributionTimes { + param($Operation,[long]$Launched,[long]$Observed) + $previous=$Launched + foreach($name in @('DisableStartedFileTime','DisableReturnedFileTime','RestoreStartedFileTime','RestoreReturnedFileTime','PrivilegeVerificationCompletedFileTime','OperationCompletedFileTime')){ + $value=$Operation.$name + if(($value -isnot [long] -and $value -isnot [int]) -or $value -le 0 -or $value -lt $previous -or $value -gt $Observed){throw 'Native operation timestamps must be typed, monotonic and within parent observations.'} + $previous=$value + } + if($Launched -gt $Observed -or ($Observed-$Launched) -gt 950000000){throw 'Parent operation envelope exceeds its bounded worker lifetime.'} +} +function Get-WelaTokenAttributionTask { + param($Definitions,[string]$PublisherXml) + $name='SE_ADT_DETAILEDTRACKING_TOKENRIGHTADJ' + $rows=@($Definitions|Where-Object{$_.Name -is [string] -and $_.Name -ceq $name}) + if($rows.Count -ne 1 -or $rows[0].Value -isnot [int] -or $rows[0].Value -ne 13317){throw 'The independently installed token-right task definition is absent or differs.'} + if($PublisherXml.Length -gt 4194304){throw 'Publisher XML exceeds its fixture bound.'} + $settings=[Xml.XmlReaderSettings]::new();$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=4194304 + $reader=[Xml.XmlReader]::Create([IO.StringReader]::new($PublisherXml),$settings);$xml=[Xml.XmlDocument]::new();$xml.XmlResolver=$null + try{$xml.Load($reader)}finally{$reader.Dispose()} + $root=$xml.DocumentElement + if($root.LocalName -cne 'provider' -or $root.NamespaceURI -cne 'http://schemas.microsoft.com/win/2004/08/events' -or $root.GetAttribute('name') -cne 'Microsoft-Windows-Security-Auditing' -or $root.GetAttribute('guid') -ine '54849625-5478-4994-a5ba-3e3b0328c30d'){throw 'Native publisher identity differs.'} + $ns=[Xml.XmlNamespaceManager]::new($xml.NameTable);$ns.AddNamespace('p','http://schemas.microsoft.com/win/2004/08/events') + $tasks=@($root.SelectNodes('p:tasks/p:task',$ns)|Where-Object{$_.GetAttribute('name') -ceq $name}) + if($tasks.Count -ne 1 -or $tasks[0].GetAttribute('value') -cne '13317'){throw 'Native publisher XML does not corroborate the fixed token-right task.'} + 13317 +} diff --git a/tests/TokenRightAttributionNative.cs b/tests/TokenRightAttributionNative.cs new file mode 100644 index 00000000..1d3b3cf6 --- /dev/null +++ b/tests/TokenRightAttributionNative.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; + +namespace Wela.TokenRightProbe { + public sealed class Privilege { public string Luid; public uint Attributes; } + public sealed class Outcome { + public string Status, Diagnostic, Luid; + public bool AdjustmentAttempted, Restored; + public uint OriginalAttributes; + public long DisableStartedFileTime, DisableReturnedFileTime, RestoreStartedFileTime, RestoreReturnedFileTime, PrivilegeVerificationCompletedFileTime, OperationCompletedFileTime; + public Privilege[] Before, Disabled, After; + } + public static class Native { + [StructLayout(LayoutKind.Sequential)] struct Luid { public uint Low; public int High; } + [StructLayout(LayoutKind.Sequential)] struct Entry { public Luid Id; public uint Attributes; } + [StructLayout(LayoutKind.Sequential)] struct One { public uint Count; public Luid Id; 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 handle); + [DllImport("kernel32.dll")] static extern void GetSystemTimePreciseAsFileTime(out long value); + [DllImport("kernel32.dll")] static extern void SetLastError(uint error); + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool QueryFullProcessImageName(IntPtr process,uint flags,StringBuilder path,ref uint length); + [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",SetLastError=true)] static extern bool GetTokenInformation(IntPtr token,int kind,IntPtr buffer,int length,out int needed); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool LookupPrivilegeValue(string system,string name,out Luid value); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool AdjustTokenPrivileges(IntPtr token,bool all,ref One value,uint length,IntPtr previous,IntPtr returned); + static string Hex(Luid id) { return "0x"+(((ulong)(uint)id.High<<32)|id.Low).ToString("x"); } + static long Now() { long value; GetSystemTimePreciseAsFileTime(out value); return value; } + public static string Executable() { + var path=new StringBuilder(32768);uint length=32768; + if(!QueryFullProcessImageName(GetCurrentProcess(),0,path,ref length)||length<1||length>=32768)throw new Win32Exception(Marshal.GetLastWin32Error()); + return path.ToString(); + } + static void PrimaryOnly() { + IntPtr thread; + if(OpenThreadToken(GetCurrentThread(),8,true,out thread)) { CloseHandle(thread); throw new InvalidOperationException("An impersonation token is not accepted."); } + int error=Marshal.GetLastWin32Error(); + if(error!=1008) throw new Win32Exception(error,"Cannot establish absence of an impersonation token."); + } + static Privilege[] Read(IntPtr token) { + int needed; bool first=GetTokenInformation(token,3,IntPtr.Zero,0,out needed); int error=Marshal.GetLastWin32Error(); + if(first||error!=122||needed<4||needed>65536) throw new InvalidOperationException("Unexpected token privilege size response."); + IntPtr buffer=Marshal.AllocHGlobal(needed); + try { + int returned; + if(!GetTokenInformation(token,3,buffer,needed,out returned))throw new Win32Exception(Marshal.GetLastWin32Error()); + int count=Marshal.ReadInt32(buffer); int size=Marshal.SizeOf(typeof(Entry)); + if(returned>needed||count<1||count>4096||4L+(long)count*size>returned)throw new InvalidOperationException("Truncated token privileges."); + var result=new List(); var seen=new HashSet(StringComparer.Ordinal); + for(int i=0;iString.CompareOrdinal(a.Luid,b.Luid));return result.ToArray(); + } finally { Marshal.FreeHGlobal(buffer); } + } + static void Change(IntPtr token,Luid id,uint attributes) { + var value=new One{Count=1,Id=id,Attributes=attributes};SetLastError(0); + bool ok=AdjustTokenPrivileges(token,false,ref value,0,IntPtr.Zero,IntPtr.Zero);int error=Marshal.GetLastWin32Error(); + if(!ok||error!=0)throw new Win32Exception(error,"The fixed privilege adjustment did not report complete success."); + } + static void Equal(Privilege[] expected,Privilege[] actual,string changed,bool enabled) { + if(expected==null||actual==null||expected.Length!=actual.Length)throw new InvalidOperationException("Privilege inventory changed."); + for(int i=0;i