From cd65133fc46a291ef86e36a1693b3744d005c0cc Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:51:31 +0900 Subject: [PATCH 1/8] test: verify fixed native token privilege adjustment feasibility --- .github/workflows/token-right-probe.yml | 38 +++++++++++ scripts/TokenRightProbeNative.cs | 90 +++++++++++++++++++++++++ tests/TokenRightProbe.Feasibility.ps1 | 63 +++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 .github/workflows/token-right-probe.yml create mode 100644 scripts/TokenRightProbeNative.cs create mode 100644 tests/TokenRightProbe.Feasibility.ps1 diff --git a/.github/workflows/token-right-probe.yml b/.github/workflows/token-right-probe.yml new file mode 100644 index 00000000..aa281902 --- /dev/null +++ b/.github/workflows/token-right-probe.yml @@ -0,0 +1,38 @@ +name: Native token right adjustment probe +on: + push: + branches: ['**'] + paths: + - 'scripts/TokenRightProbe*' + - 'tests/TokenRightProbe*' + - '.github/workflows/token-right-probe.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 adjustment feasibility in Windows PowerShell + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/TokenRightProbe.Feasibility.ps1 -AllowDisposableAuditWrite + - name: Native adjustment feasibility in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/TokenRightProbe.Feasibility.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-feasibility-*/ + if-no-files-found: error diff --git a/scripts/TokenRightProbeNative.cs b/scripts/TokenRightProbeNative.cs new file mode 100644 index 00000000..323d69c1 --- /dev/null +++ b/scripts/TokenRightProbeNative.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.InteropServices; + +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; + 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("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; } + 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 Date: Tue, 22 Sep 2026 11:59:26 +0900 Subject: [PATCH 2/8] test: measure fixed already-enabled debug privilege adjustment --- scripts/TokenRightProbeNative.cs | 4 ++-- tests/TokenRightProbe.Feasibility.ps1 | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/TokenRightProbeNative.cs b/scripts/TokenRightProbeNative.cs index 323d69c1..3807d522 100644 --- a/scripts/TokenRightProbeNative.cs +++ b/scripts/TokenRightProbeNative.cs @@ -66,10 +66,10 @@ namespace Wela.TokenRightProbe { try { PrimaryOnly(); if(!OpenProcessToken(GetCurrentProcess(),0x28,out token))throw new Win32Exception(Marshal.GetLastWin32Error()); - if(!LookupPrivilegeValue(null,"SeChangeNotifyPrivilege",out target))throw new Win32Exception(Marshal.GetLastWin32Error()); + if(!LookupPrivilegeValue(null,"SeDebugPrivilege",out target))throw new Win32Exception(Marshal.GetLastWin32Error()); result.Luid=Hex(target);result.Before=Read(token);Privilege found=null; foreach(var item in result.Before)if(item.Luid==result.Luid)found=item; - if(found==null||(found.Attributes&2)==0||(found.Attributes&4)!=0)throw new InvalidOperationException("SeChangeNotifyPrivilege must already be present and enabled; no new privilege is granted."); + if(found==null||(found.Attributes&2)==0||(found.Attributes&4)!=0)throw new InvalidOperationException("SeDebugPrivilege must already be present and enabled; no new privilege is granted."); result.OriginalAttributes=found.Attributes; try { result.DisableStartedFileTime=Now();result.AdjustmentAttempted=true; diff --git a/tests/TokenRightProbe.Feasibility.ps1 b/tests/TokenRightProbe.Feasibility.ps1 index 0dd80e4c..4f8d56a1 100644 --- a/tests/TokenRightProbe.Feasibility.ps1 +++ b/tests/TokenRightProbe.Feasibility.ps1 @@ -43,7 +43,7 @@ try{ $events=@(Get-WinEvent -LogName Security -FilterXPath $query -MaxEvents 256 -ErrorAction SilentlyContinue) foreach($event in $events){ $raw=$event.ToXml();[xml]$xml=$raw;$data=@{};foreach($field in $xml.Event.EventData.Data){$data[[string]$field.Name]=[string]$field.'#text'} - if($data.ProcessId -and [Convert]::ToInt64($data.ProcessId,16) -eq $result.ProcessId -and ($data.EnabledPrivilegeList -match 'SeChangeNotifyPrivilege' -or $data.DisabledPrivilegeList -match 'SeChangeNotifyPrivilege')){$matches+=@([pscustomobject]@{RecordId=$event.RecordId;Xml=$raw;Data=$data})} + if($data.ProcessId -and [Convert]::ToInt64($data.ProcessId,16) -eq $result.ProcessId -and ($data.EnabledPrivilegeList -match 'SeDebugPrivilege' -or $data.DisabledPrivilegeList -match 'SeDebugPrivilege')){$matches+=@([pscustomobject]@{RecordId=$event.RecordId;Xml=$raw;Data=$data})} $event.Dispose() } $matches=@($matches|Sort-Object RecordId -Unique) From 5bebb9a7a503c7c1304d2394c4e68cf7a03b2e4e Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:22:21 +0900 Subject: [PATCH 3/8] test: isolate native 4703 audit attribution and retain query diagnostics --- tests/TokenRightProbe.Feasibility.ps1 | 71 ++++++++++++++++++--------- 1 file changed, 49 insertions(+), 22 deletions(-) diff --git a/tests/TokenRightProbe.Feasibility.ps1 b/tests/TokenRightProbe.Feasibility.ps1 index 4f8d56a1..86a13920 100644 --- a/tests/TokenRightProbe.Feasibility.ps1 +++ b/tests/TokenRightProbe.Feasibility.ps1 @@ -30,28 +30,55 @@ exit 0 try{ if($beforeMasks.Count -ne 59){throw 'All59 masks required.'} Set-ItemProperty -LiteralPath $path -Name $name -Value 1 -Type DWord - Set-WelaEffectiveAuditPolicy -Guid $guid -Mask ($beforeMasks[$guid] -bor 1) -Mode exact - Set-WelaEffectiveAuditPolicy -Guid $auth -Mask 0 -Mode exact - $engine=(Get-Process -Id $PID).Path - & $engine -NoLogo -NoProfile -NonInteractive -File $worker $repo $receipt - if($LASTEXITCODE -ne 0){throw 'Native worker failed.'} - $result=Get-Content -Raw $receipt|ConvertFrom-Json - $start=[DateTime]::FromFileTimeUtc($result.Outcome.DisableStartedFileTime).AddSeconds(-1);$end=[DateTime]::FromFileTimeUtc($result.Outcome.RestoreReturnedFileTime).AddSeconds(1) - $query="*[System[EventID=4703 and TimeCreated[@SystemTime>='$($start.ToString('o'))' and @SystemTime<='$($end.ToString('o'))']]]" - $matches=@();$deadline=[DateTime]::UtcNow.AddSeconds(15) - do{ - $events=@(Get-WinEvent -LogName Security -FilterXPath $query -MaxEvents 256 -ErrorAction SilentlyContinue) - foreach($event in $events){ - $raw=$event.ToXml();[xml]$xml=$raw;$data=@{};foreach($field in $xml.Event.EventData.Data){$data[[string]$field.Name]=[string]$field.'#text'} - if($data.ProcessId -and [Convert]::ToInt64($data.ProcessId,16) -eq $result.ProcessId -and ($data.EnabledPrivilegeList -match 'SeDebugPrivilege' -or $data.DisabledPrivilegeList -match 'SeDebugPrivilege')){$matches+=@([pscustomobject]@{RecordId=$event.RecordId;Xml=$raw;Data=$data})} - $event.Dispose() - } - $matches=@($matches|Sort-Object RecordId -Unique) - if($matches.Count -ge 2){break};Start-Sleep -Milliseconds 250 - }while([DateTime]::UtcNow -lt $deadline) - Save 'events.json' $matches - if($matches.Count -lt 2){throw 'No two attributable actual4703 adjustment events were observed.'} - Write-Host "Native feasibility observed $($matches.Count) attributable4703 events with Token Right Adjusted success enabled and Authorization Policy Change disabled." + $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') + $engine=(Get-Process -Id $PID).Path + & $engine -NoLogo -NoProfile -NonInteractive -File $worker $repo $receipt + if($LASTEXITCODE -ne 0){throw 'Native worker failed.'} + $result=Get-Content -Raw $receipt|ConvertFrom-Json + $exactStart=[DateTime]::FromFileTimeUtc($result.Outcome.DisableStartedFileTime);$exactEnd=[DateTime]::FromFileTimeUtc($result.Outcome.RestoreReturnedFileTime) + $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) + $count=0 + while($null -ne ($event=$reader.ReadEvent())){ + 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'} + $time=[DateTime]::Parse([string]$xml.Event.System.TimeCreated.SystemTime,[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind).ToUniversalTime() + $identity=$data.ProcessId -and [Convert]::ToInt64($data.ProcessId,16) -eq $result.ProcessId -and $data.ProcessName -ieq $result.ProcessName -and $data.SubjectUserSid -ceq $result.Before.Sid -and $data.TargetUserSid -ceq $result.Before.Sid -and $data.SubjectLogonId -ieq $result.Before.AuthenticationId -and $data.TargetLogonId -ieq $result.Before.AuthenticationId + $privilege=$data.EnabledPrivilegeList -ceq 'SeDebugPrivilege' -or $data.DisabledPrivilegeList -ceq 'SeDebugPrivilege' + $exact=$identity -and $privilege -and $time -ge $exactStart -and $time -le $exactEnd + $candidates[[string]$event.RecordId]=[pscustomobject]@{RecordId=$event.RecordId;Xml=$raw;Data=$data;ExactIdentity=[bool]$identity;ExactInterval=($time -ge $exactStart -and $time -le $exactEnd);Attributed=[bool]$exact} + }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((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()} From 495f12a30f1b4c49160b7fc2ffe76a2f6906afa0 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:24:53 +0900 Subject: [PATCH 4/8] test: bound token attribution worker and retain independent cleanup evidence --- tests/TokenRightProbe.Feasibility.ps1 | 41 ++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/tests/TokenRightProbe.Feasibility.ps1 b/tests/TokenRightProbe.Feasibility.ps1 index 86a13920..574d1158 100644 --- a/tests/TokenRightProbe.Feasibility.ps1 +++ b/tests/TokenRightProbe.Feasibility.ps1 @@ -14,6 +14,31 @@ $guid='0CCE924A-69AE-11D9-BED3-505054503030';$auth='0CCE9231-69AE-11D9-BED3-5050 $path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$name='SCENoApplyLegacyAuditPolicy' $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()} +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.'} + }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) @@ -39,9 +64,7 @@ try{ 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') - $engine=(Get-Process -Id $PID).Path - & $engine -NoLogo -NoProfile -NonInteractive -File $worker $repo $receipt - if($LASTEXITCODE -ne 0){throw 'Native worker failed.'} + Worker $phase.Name $receipt $result=Get-Content -Raw $receipt|ConvertFrom-Json $exactStart=[DateTime]::FromFileTimeUtc($result.Outcome.DisableStartedFileTime);$exactEnd=[DateTime]::FromFileTimeUtc($result.Outcome.RestoreReturnedFileTime) $start=$exactStart.AddSeconds(-2);$end=$exactEnd.AddSeconds(2) @@ -52,8 +75,9 @@ try{ 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())){ + 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.'} @@ -82,9 +106,12 @@ try{ }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=[Wela.WmiProbe.Native]::Snapshot();$afterPrecedence=Get-WelaRegistryState $path $name - $complete=$errors.Count -eq 0 -and (Masks) -ceq $masks -and (Key $afterPrecedence) -ceq (Key $beforePrecedence) -and ((Key $beforeToken) -ceq (Key $afterToken)) - Save 'cleanup.json' @{Complete=$complete;Errors=$errors;Failure=$failure;AfterToken=$afterToken;AfterMasks=Get-WelaEffectiveAuditPolicy;AfterPrecedence=$afterPrecedence} + $afterToken=$null;$afterPrecedence=$null;$afterMasks=$null;$afterMaskKey=$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()} + $complete=$errors.Count -eq 0 -and $afterMaskKey -ceq $masks -and (Key $afterPrecedence) -ceq (Key $beforePrecedence) -and ((Key $beforeToken) -ceq (Key $afterToken)) + Save 'cleanup.json' @{Complete=$complete;Errors=$errors;Failure=$failure;AfterToken=$afterToken;AfterMasks=$afterMasks;AfterPrecedence=$afterPrecedence} if(-not $complete){throw 'Native feasibility fixture cleanup failed.'} } $global:LASTEXITCODE=0 From 160b573a992051040c8144b472a6befb8e72c5f3 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:34:43 +0900 Subject: [PATCH 5/8] test: bind native 4703 attribution to installed schema and exact restored context --- .gitattributes | 3 + .../workflows/native-token-attribution.yml | 47 ++++++++++++ .github/workflows/release.yml | 2 +- .github/workflows/token-right-probe.yml | 38 ---------- CHANGELOG-Japanese.md | 2 + CHANGELOG.md | 2 + docs/audit-catalog-mappings.md | 2 + docs/native-token-right-attribution.md | 25 +++++++ tests/TokenRightAttribution.Tests.ps1 | 45 ++++++++++++ ...> TokenRightAttribution.Windows.Tests.ps1} | 73 +++++++++++++++---- tests/TokenRightAttributionEvidence.ps1 | 40 ++++++++++ .../TokenRightAttributionNative.cs | 11 ++- website/docs/resources/changelog.ja.md | 2 + website/docs/resources/changelog.md | 2 + 14 files changed, 237 insertions(+), 57 deletions(-) create mode 100644 .github/workflows/native-token-attribution.yml delete mode 100644 .github/workflows/token-right-probe.yml create mode 100644 docs/native-token-right-attribution.md create mode 100644 tests/TokenRightAttribution.Tests.ps1 rename tests/{TokenRightProbe.Feasibility.ps1 => TokenRightAttribution.Windows.Tests.ps1} (52%) create mode 100644 tests/TokenRightAttributionEvidence.ps1 rename scripts/TokenRightProbeNative.cs => tests/TokenRightAttributionNative.cs (90%) diff --git a/.gitattributes b/.gitattributes index 7f4524b7..f7d7aa75 100644 --- a/.gitattributes +++ b/.gitattributes @@ -96,3 +96,6 @@ tests/NativeProviderConfigure.Windows.Tests.ps1 text eol=lf /modules/WecSubscriptionInventory.cs 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 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 52989c99..717931cb 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 -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 -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/.github/workflows/token-right-probe.yml b/.github/workflows/token-right-probe.yml deleted file mode 100644 index aa281902..00000000 --- a/.github/workflows/token-right-probe.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Native token right adjustment probe -on: - push: - branches: ['**'] - paths: - - 'scripts/TokenRightProbe*' - - 'tests/TokenRightProbe*' - - '.github/workflows/token-right-probe.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 adjustment feasibility in Windows PowerShell - if: matrix.engine == 'powershell' - shell: powershell - run: ./tests/TokenRightProbe.Feasibility.ps1 -AllowDisposableAuditWrite - - name: Native adjustment feasibility in PowerShell7 - if: matrix.engine == 'pwsh' - shell: pwsh - run: ./tests/TokenRightProbe.Feasibility.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-feasibility-*/ - if-no-files-found: error diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 3b6874eb..c6ab9b15 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) + - 明示的な `registry-sacl-recovery` を追加しました。整合する4つの元記録、レビュー済み計画のハッシュ、過去・現在ともに空の子キー観測、監査縮小と継承への個別同意を必須とし、追加が証明されたレジストリルートの明示的な監査ACEを1つだけ削除します。SACLのみのネイティブ書き込みで他の記述子フィールドとACEの順序を保持し、部分的な書き込みの証跡と元の記述子バイトとの一致を別々に報告します。過去のキー・操作者の同一性認証、ツリー全体の原子性、イベント生成、Sigmaの準備完了は保証しません。 (Related #373) (@Shirofune-Security) - Server 2022/2025とPowerShell 5.1/7でSMBポリシー設定の公開CLIを検証します。対応ホストで6項目の適用・再読取・再実行、非対応ホストのスキップ、元の型付き記録、無関係な設定の維持と完全な復元を確認します。イベント生成と実行時の有効化は別途検証します。(関連 #377) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7129758..e98e2194 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) + - Added opt-in `registry-sacl-recovery` for one proven explicit registry-root audit ACE, requiring four matching original records, a reviewed plan hash, empty historical/current descendants and separate audit-reduction/inheritance consent. Native SACL-only removal preserves unrelated descriptor fields and ACE order, retains partial-write evidence and reports original-byte equality separately; no authenticated historical key/operator identity, atomic-tree, event or Sigma claim. (Related #373) (@Shirofune-Security) - Add native public SMB policy configuration acceptance on Server 2022/2025 and PowerShell 5.1/7: six-policy apply/readback and idempotence on supported hosts, unsupported-host skips, typed original journals, unrelated-state preservation and exact cleanup. Event generation and runtime activation remain separate. (Related #377) (@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..63b5f5b9 --- /dev/null +++ b/docs/native-token-right-attribution.md @@ -0,0 +1,25 @@ +# 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. + +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 native final inventory equality. Individual syscall-return times 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..b7289eb0 --- /dev/null +++ b/tests/TokenRightAttribution.Tests.ps1 @@ -0,0 +1,45 @@ +$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;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','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.' +Write-Host "PASS: $count strict token attribution and catalog checks." diff --git a/tests/TokenRightProbe.Feasibility.ps1 b/tests/TokenRightAttribution.Windows.Tests.ps1 similarity index 52% rename from tests/TokenRightProbe.Feasibility.ps1 rename to tests/TokenRightAttribution.Windows.Tests.ps1 index 574d1158..0ce72511 100644 --- a/tests/TokenRightProbe.Feasibility.ps1 +++ b/tests/TokenRightAttribution.Windows.Tests.ps1 @@ -1,19 +1,47 @@ param([switch]$AllowDisposableAuditWrite) $ErrorActionPreference='Stop' -if(-not $AllowDisposableAuditWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable Windows fixture only.'} +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=Join-Path $env:RUNNER_TEMP ('wela-token-right-feasibility-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$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.'} +$eventTask=[int]$schema[0].Task.Value +$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()} +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=$eventTask;Template=$schema[0].Template}} Add-Type -TypeDefinition @' using System;using System.IO;using System.Text;using System.Threading.Tasks; public static class WelaTokenFixturePipe { @@ -34,6 +62,7 @@ function Worker([string]$Phase,[string]$Receipt){ 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()} @@ -44,11 +73,12 @@ $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 'scripts/TokenRightProbeNative.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() -[pscustomobject]@{ProcessId=$PID;ProcessName=(Get-Process -Id $PID).Path;Before=$before;After=$after;Outcome=$outcome}|ConvertTo-Json -Depth 24|Set-Content -LiteralPath $Result -Encoding UTF8 +[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 @@ -64,9 +94,16 @@ try{ 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') - Worker $phase.Name $receipt + $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 - $exactStart=[DateTime]::FromFileTimeUtc($result.Outcome.DisableStartedFileTime);$exactEnd=[DateTime]::FromFileTimeUtc($result.Outcome.RestoreReturnedFileTime) + 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) @@ -82,11 +119,8 @@ 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'} - $time=[DateTime]::Parse([string]$xml.Event.System.TimeCreated.SystemTime,[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind).ToUniversalTime() - $identity=$data.ProcessId -and [Convert]::ToInt64($data.ProcessId,16) -eq $result.ProcessId -and $data.ProcessName -ieq $result.ProcessName -and $data.SubjectUserSid -ceq $result.Before.Sid -and $data.TargetUserSid -ceq $result.Before.Sid -and $data.SubjectLogonId -ieq $result.Before.AuthenticationId -and $data.TargetLogonId -ieq $result.Before.AuthenticationId - $privilege=$data.EnabledPrivilegeList -ceq 'SeDebugPrivilege' -or $data.DisabledPrivilegeList -ceq 'SeDebugPrivilege' - $exact=$identity -and $privilege -and $time -ge $exactStart -and $time -le $exactEnd - $candidates[[string]$event.RecordId]=[pscustomobject]@{RecordId=$event.RecordId;Xml=$raw;Data=$data;ExactIdentity=[bool]$identity;ExactInterval=($time -ge $exactStart -and $time -le $exactEnd);Attributed=[bool]$exact} + $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()}} @@ -97,6 +131,8 @@ try{ 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 @@ -106,12 +142,17 @@ try{ }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 + $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()} - $complete=$errors.Count -eq 0 -and $afterMaskKey -ceq $masks -and (Key $afterPrecedence) -ceq (Key $beforePrecedence) -and ((Key $beforeToken) -ceq (Key $afterToken)) - Save 'cleanup.json' @{Complete=$complete;Errors=$errors;Failure=$failure;AfterToken=$afterToken;AfterMasks=$afterMasks;AfterPrecedence=$afterPrecedence} - if(-not $complete){throw 'Native feasibility fixture cleanup failed.'} + 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..443b7dfb --- /dev/null +++ b/tests/TokenRightAttributionEvidence.ps1 @@ -0,0 +1,40 @@ +# 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','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.'} +} diff --git a/scripts/TokenRightProbeNative.cs b/tests/TokenRightAttributionNative.cs similarity index 90% rename from scripts/TokenRightProbeNative.cs rename to tests/TokenRightAttributionNative.cs index 3807d522..c69a25de 100644 --- a/scripts/TokenRightProbeNative.cs +++ b/tests/TokenRightAttributionNative.cs @@ -2,6 +2,7 @@ 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; } @@ -9,7 +10,7 @@ namespace Wela.TokenRightProbe { public string Status, Diagnostic, Luid; public bool AdjustmentAttempted, Restored; public uint OriginalAttributes; - public long DisableStartedFileTime, DisableReturnedFileTime, RestoreStartedFileTime, RestoreReturnedFileTime; + public long DisableStartedFileTime, DisableReturnedFileTime, RestoreStartedFileTime, RestoreReturnedFileTime, OperationCompletedFileTime; public Privilege[] Before, Disabled, After; } public static class Native { @@ -21,6 +22,7 @@ namespace Wela.TokenRightProbe { [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); @@ -28,6 +30,11 @@ namespace Wela.TokenRightProbe { [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."); } @@ -79,7 +86,7 @@ namespace Wela.TokenRightProbe { } finally { if(result.AdjustmentAttempted) { result.RestoreStartedFileTime=Now();Change(token,target,result.OriginalAttributes);result.RestoreReturnedFileTime=Now(); - result.After=Read(token);Equal(result.Before,result.After,result.Luid,true);result.Restored=true; + result.After=Read(token);Equal(result.Before,result.After,result.Luid,true);result.Restored=true;result.OperationCompletedFileTime=Now(); } } } catch(Exception error) {result.Status=result.AdjustmentAttempted?"Unverified":"Refused";result.Diagnostic=error.ToString();} diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 854679ee..8af7cac9 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- Server2022/2025 と PowerShell5.1/7 で、Token Right Adjusted の正規GUIDに対する Security4703 の実機検証を追加しました。所有する子プロセスの既存権限を固定手順で無効化・復元し、候補となる2つの監査マスクを比較して、実イベント・実行条件・ハッシュとポリシー/トークンの復元を記録します。過去の候補は条件付きのまま維持し、製品用プローブ・全OS共通の対応関係・Sigma加点は追加しません。(関連 #380) (@Shirofune-Security) + - 明示的な `registry-sacl-recovery` を追加しました。整合する4つの元記録、レビュー済み計画のハッシュ、過去・現在ともに空の子キー観測、監査縮小と継承への個別同意を必須とし、追加が証明されたレジストリルートの明示的な監査ACEを1つだけ削除します。SACLのみのネイティブ書き込みで他の記述子フィールドとACEの順序を保持し、部分的な書き込みの証跡と元の記述子バイトとの一致を別々に報告します。過去のキー・操作者の同一性認証、ツリー全体の原子性、イベント生成、Sigmaの準備完了は保証しません。 (Related #373) (@Shirofune-Security) - Server 2022/2025とPowerShell 5.1/7でSMBポリシー設定の公開CLIを検証します。対応ホストで6項目の適用・再読取・再実行、非対応ホストのスキップ、元の型付き記録、無関係な設定の維持と完全な復元を確認します。イベント生成と実行時の有効化は別途検証します。(関連 #377) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 6b533916..d930c924 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,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) + - Added opt-in `registry-sacl-recovery` for one proven explicit registry-root audit ACE, requiring four matching original records, a reviewed plan hash, empty historical/current descendants and separate audit-reduction/inheritance consent. Native SACL-only removal preserves unrelated descriptor fields and ACE order, retains partial-write evidence and reports original-byte equality separately; no authenticated historical key/operator identity, atomic-tree, event or Sigma claim. (Related #373) (@Shirofune-Security) - Add native public SMB policy configuration acceptance on Server 2022/2025 and PowerShell 5.1/7: six-policy apply/readback and idempotence on supported hosts, unsupported-host skips, typed original journals, unrelated-state preservation and exact cleanup. Event generation and runtime activation remain separate. (Related #377) (@Shirofune-Security) From b5e244bb68e93f4a41376187318a72c60aaeb383 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:40:04 +0900 Subject: [PATCH 6/8] test: retain exact native publisher task metadata for attribution diagnosis --- tests/TokenRightAttribution.Windows.Tests.ps1 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/TokenRightAttribution.Windows.Tests.ps1 b/tests/TokenRightAttribution.Windows.Tests.ps1 index 0ce72511..e5f403f6 100644 --- a/tests/TokenRightAttribution.Windows.Tests.ps1 +++ b/tests/TokenRightAttribution.Windows.Tests.ps1 @@ -23,6 +23,9 @@ $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.'} $eventTask=[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) $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}})} From 10c50b9a74fe40556ed78cd63cfccb7c6bdafb17 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:43:31 +0900 Subject: [PATCH 7/8] test: corroborate runtime token task and measure full native verification --- docs/native-token-right-attribution.md | 4 +++- tests/TokenRightAttribution.Tests.ps1 | 9 +++++++-- tests/TokenRightAttribution.Windows.Tests.ps1 | 6 ++++-- tests/TokenRightAttributionEvidence.ps1 | 17 ++++++++++++++++- tests/TokenRightAttributionNative.cs | 4 ++-- 5 files changed, 32 insertions(+), 8 deletions(-) diff --git a/docs/native-token-right-attribution.md b/docs/native-token-right-attribution.md index 63b5f5b9..df3a5093 100644 --- a/docs/native-token-right-attribution.md +++ b/docs/native-token-right-attribution.md @@ -6,9 +6,11 @@ Microsoft's [Token Right Adjusted guidance](https://learn.microsoft.com/en-us/pr 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 native final inventory equality. Individual syscall-return times 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. +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. diff --git a/tests/TokenRightAttribution.Tests.ps1 b/tests/TokenRightAttribution.Tests.ps1 index b7289eb0..4b760199 100644 --- a/tests/TokenRightAttribution.Tests.ps1 +++ b/tests/TokenRightAttribution.Tests.ps1 @@ -3,7 +3,7 @@ $ErrorActionPreference='Stop' $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;OperationCompletedFileTime=$start+300000} +$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 '@ @@ -34,7 +34,7 @@ $review=Get-WelaEventMappingReview @(Import-Csv "$PSScriptRoot/../config/eid_sub 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','OperationCompletedFileTime')){ +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} @@ -42,4 +42,9 @@ foreach($field in @('DisableStartedFileTime','DisableReturnedFileTime','RestoreS } } $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('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 index e5f403f6..4c1ec932 100644 --- a/tests/TokenRightAttribution.Windows.Tests.ps1 +++ b/tests/TokenRightAttribution.Windows.Tests.ps1 @@ -22,10 +22,11 @@ if(-not(Test-WelaDefaultContextComplete $hostContext) -or $hostContext.ProductTy $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.'} -$eventTask=[int]$schema[0].Task.Value +$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}})} @@ -44,7 +45,7 @@ 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=$eventTask;Template=$schema[0].Template}} +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 { @@ -81,6 +82,7 @@ $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 diff --git a/tests/TokenRightAttributionEvidence.ps1 b/tests/TokenRightAttributionEvidence.ps1 index 443b7dfb..c60e657e 100644 --- a/tests/TokenRightAttributionEvidence.ps1 +++ b/tests/TokenRightAttributionEvidence.ps1 @@ -31,10 +31,25 @@ function Get-WelaTokenAttributionMatch { function Assert-WelaTokenAttributionTimes { param($Operation,[long]$Launched,[long]$Observed) $previous=$Launched - foreach($name in @('DisableStartedFileTime','DisableReturnedFileTime','RestoreStartedFileTime','RestoreReturnedFileTime','OperationCompletedFileTime')){ + 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.GetAttribute('name') -cne 'Microsoft-Windows-Security-Auditing' -or $root.GetAttribute('guid') -ine '54849625-5478-4994-a5ba-3e3b0328c30d'){throw 'Native publisher identity differs.'} + $tasks=@($root.SelectNodes('tasks/task')|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 index c69a25de..1d3b3cf6 100644 --- a/tests/TokenRightAttributionNative.cs +++ b/tests/TokenRightAttributionNative.cs @@ -10,7 +10,7 @@ namespace Wela.TokenRightProbe { public string Status, Diagnostic, Luid; public bool AdjustmentAttempted, Restored; public uint OriginalAttributes; - public long DisableStartedFileTime, DisableReturnedFileTime, RestoreStartedFileTime, RestoreReturnedFileTime, OperationCompletedFileTime; + public long DisableStartedFileTime, DisableReturnedFileTime, RestoreStartedFileTime, RestoreReturnedFileTime, PrivilegeVerificationCompletedFileTime, OperationCompletedFileTime; public Privilege[] Before, Disabled, After; } public static class Native { @@ -86,7 +86,7 @@ namespace Wela.TokenRightProbe { } finally { if(result.AdjustmentAttempted) { result.RestoreStartedFileTime=Now();Change(token,target,result.OriginalAttributes);result.RestoreReturnedFileTime=Now(); - result.After=Read(token);Equal(result.Before,result.After,result.Luid,true);result.Restored=true;result.OperationCompletedFileTime=Now(); + result.After=Read(token);Equal(result.Before,result.After,result.Luid,true);result.Restored=true;result.PrivilegeVerificationCompletedFileTime=Now();result.OperationCompletedFileTime=result.PrivilegeVerificationCompletedFileTime; } } } catch(Exception error) {result.Status=result.AdjustmentAttempted?"Unverified":"Refused";result.Diagnostic=error.ToString();} From 4d34305097ab403089f9655fc596eb2810befdc6 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:44:51 +0900 Subject: [PATCH 8/8] test: bind token task metadata to the native publisher XML namespace --- tests/TokenRightAttribution.Tests.ps1 | 4 ++-- tests/TokenRightAttributionEvidence.ps1 | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/TokenRightAttribution.Tests.ps1 b/tests/TokenRightAttribution.Tests.ps1 index 4b760199..b9038b9c 100644 --- a/tests/TokenRightAttribution.Tests.ps1 +++ b/tests/TokenRightAttribution.Tests.ps1 @@ -43,8 +43,8 @@ foreach($field in @('DisableStartedFileTime','DisableReturnedFileTime','RestoreS } $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='' +$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('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.'} +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/TokenRightAttributionEvidence.ps1 b/tests/TokenRightAttributionEvidence.ps1 index c60e657e..19805333 100644 --- a/tests/TokenRightAttributionEvidence.ps1 +++ b/tests/TokenRightAttributionEvidence.ps1 @@ -48,8 +48,9 @@ function Get-WelaTokenAttributionTask { $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.GetAttribute('name') -cne 'Microsoft-Windows-Security-Auditing' -or $root.GetAttribute('guid') -ine '54849625-5478-4994-a5ba-3e3b0328c30d'){throw 'Native publisher identity differs.'} - $tasks=@($root.SelectNodes('tasks/task')|Where-Object{$_.GetAttribute('name') -ceq $name}) + 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 }