From a61181e86094f9c96dee81ce7b6569b776b9db2a Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:27:26 +0900 Subject: [PATCH] Checkpoint exact native one-byte file access probe --- .github/workflows/file-access-probe.yml | 43 +++++ WELA.ps1 | 14 ++ scripts/FileAccessProbe.ps1 | 214 ++++++++++++++++++++++++ scripts/FileAccessProbeNative.cs | 104 ++++++++++++ scripts/FileAccessProbeWorker.ps1 | 22 +++ tests/FileAccessProbe.Cli.Tests.ps1 | 14 ++ tests/FileAccessProbe.Windows.Tests.ps1 | 62 +++++++ 7 files changed, 473 insertions(+) create mode 100644 .github/workflows/file-access-probe.yml create mode 100644 scripts/FileAccessProbe.ps1 create mode 100644 scripts/FileAccessProbeNative.cs create mode 100644 scripts/FileAccessProbeWorker.ps1 create mode 100644 tests/FileAccessProbe.Cli.Tests.ps1 create mode 100644 tests/FileAccessProbe.Windows.Tests.ps1 diff --git a/.github/workflows/file-access-probe.yml b/.github/workflows/file-access-probe.yml new file mode 100644 index 00000000..1a67ca95 --- /dev/null +++ b/.github/workflows/file-access-probe.yml @@ -0,0 +1,43 @@ +name: Native one-byte file access probe +on: + push: + branches: ['**'] + paths: + - 'WELA.ps1' + - 'scripts/FileAccessProbe*' + - 'tests/FileAccessProbe*' + - '.github/workflows/file-access-probe.yml' + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + file-access-probe: + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Public CLI and actual file read in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/FileAccessProbe.Cli.Tests.ps1 + ./tests/FileAccessProbe.Windows.Tests.ps1 -AllowDisposablePolicyWrite + - name: Public CLI and actual file read in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/FileAccessProbe.Cli.Tests.ps1 + ./tests/FileAccessProbe.Windows.Tests.ps1 -AllowDisposablePolicyWrite + - name: Retain genuine XML, receipts and exact cleanup + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: file-access-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-file-access-*/ + if-no-files-found: error diff --git a/WELA.ps1 b/WELA.ps1 index 26475edf..41293d60 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -48,6 +48,10 @@ [string]$WmiProbeNamespace, [string]$WmiProbeOutputPath, [ValidateRange(1,30)][int]$WmiProbeTimeoutSeconds = 15, + [ValidateSet('Plan','Run')][string]$FileProbeAction = 'Plan', + [string]$FileProbePath, + [string]$FileProbeOutputPath, + [ValidateRange(1,30)][int]$FileProbeTimeoutSeconds = 15, [string[]]$WmiNamespace, [switch]$WmiIncludeChildren, [string]$RuleEvidencePath, @@ -184,6 +188,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/AppLockerProbe.ps1") . (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1") . (Join-Path $ScriptRoot "scripts/WmiProbe.ps1") +. (Join-Path $ScriptRoot "scripts/FileAccessProbe.ps1") . (Join-Path $ScriptRoot "scripts/PowerShellTranscription.ps1") Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop Import-Module (Join-Path $ScriptRoot "modules/RuleEligibility.psm1") -ErrorAction Stop @@ -1952,6 +1957,7 @@ Usage: ./WELA.ps1 event-measurement -MeasurementChannel Security -MeasurementAction Run -MeasurementOutputPath C:\Evidence\new-sample -MeasurementExportEvtx ./WELA.ps1 rule-eligibility -RuleEvidencePath reviewed-lab-evidence.json -ResultsPath evidence-review.json ./WELA.ps1 smb-auditing -SmbAction Configure -DryRun + ./WELA.ps1 file-access-probe -Help ./WELA.ps1 powershell-transcription -TranscriptionAction Plan -TranscriptDirectory C:\Transcripts -ResultsPath transcription-plan.json ./WELA.ps1 applocker-readiness -ResultsPath applocker.json ./WELA.ps1 applocker-readiness -AppLockerAction Plan -AppLockerPolicyPath operator-audit.xml @@ -2052,6 +2058,8 @@ if ($Cmd -eq 'intune-export' -and @($PSBoundParameters.Keys | Where-Object { $_ throw 'intune-export accepts only Intune target/export options, IncludeOptional and Help. No command was run.' } +if ($Cmd -ne 'file-access-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'FileProbe*'}).Count) {throw 'FileProbe options require file-access-probe.'} +if ($Cmd -eq 'file-access-probe' -and ($args.Count -gt 0 -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','FileProbeAction','FileProbePath','FileProbeOutputPath','FileProbeTimeoutSeconds','Help')}).Count)) {throw 'file-access-probe accepts only its dedicated options.'} if ($Cmd -ne 'evtx-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Evtx*'}).Count) {throw 'EVTX options require evtx-recovery. No command was run.'} if ($Cmd -eq 'evtx-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','EvtxAction','EvtxProbePath','EvtxArchivePath','EvtxOutputPath','Help')}).Count) {throw 'evtx-recovery accepts only its dedicated options. No command was run.'} if ($Cmd -ne 'audit-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Recovery*'}).Count) {throw 'Recovery options require audit-recovery. No command was run.'} @@ -2264,6 +2272,12 @@ switch ($Cmd.ToLower()) { $report if ($report.ExitCode) {exit $report.ExitCode} } + 'file-access-probe' { + if ($Help) {Write-Host 'Usage: file-access-probe [-FileProbeAction Plan] -FileProbePath C:\Audit\existing-file.txt; Run additionally requires -FileProbeOutputPath C:\Evidence\new-probe [-FileProbeTimeoutSeconds 15]. Reads one byte and discards it. Existing File System success policy, precedence and matching ReadData SACL are required; no policy, ACL or file-data writes. Local4663 success only, no failure/forwarding/Sigma credit. See docs/file-access-probe.md.';return} + $report=Invoke-WelaFileAccessProbe -Action $FileProbeAction -FilePath $FileProbePath -OutputPath $FileProbeOutputPath -TimeoutSeconds $FileProbeTimeoutSeconds + $report|ConvertTo-Json -Depth 28|Write-Output + exit ([int]$report.ExitCode) + } 'audit-recovery' { if ($Help) {Write-Host 'Usage: audit-recovery [-RecoveryAction Plan] -RecoveryJournalPath before.jsonl -RecoveryOriginalResultsPath results.json -RecoveryControlId IDs -RecoveryOutputPath new-directory; then -RecoveryAction Restore -RecoveryPlanPath reviewed-plan.json -RecoveryOutputPath new-directory [-Auto], or -DryRun without output. See docs/audit-recovery.md.';return} $report=Invoke-WelaAuditRecovery -Action $RecoveryAction -JournalPath $RecoveryJournalPath -OriginalResultsPath $RecoveryOriginalResultsPath -ControlId $RecoveryControlId -PlanPath $RecoveryPlanPath -OutputPath $RecoveryOutputPath -Auto:$Auto -DryRun:$DryRun diff --git a/scripts/FileAccessProbe.ps1 b/scripts/FileAccessProbe.ps1 new file mode 100644 index 00000000..6f99fdf3 --- /dev/null +++ b/scripts/FileAccessProbe.ps1 @@ -0,0 +1,214 @@ +# Explicit one-byte existing-file read and exact local Security4663 evidence. +function Initialize-WelaFileProbeNative { + Initialize-WelaWmiProbeNative + $source=Join-Path $PSScriptRoot 'FileAccessProbeNative.cs';$hash=(Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash + if(-not ('Wela.FileAccessProbe.FileHandle' -as [type])){Add-Type -Path $source -ErrorAction Stop;$script:WelaFileProbeNativeHash=$hash} + if($script:WelaFileProbeNativeHash -cne $hash){throw 'Loaded file probe helper differs from its source; start a fresh session.'} +} +function Test-WelaFileProbeInteger {param($Value) ($Value -is [int] -or $Value -is [long] -or $Value -is [uint32] -or $Value -is [uint64])} +function Assert-WelaFileProbePath { + param([string]$Path) + if(-not $Path -or $Path.Length -gt 240 -or $Path -cnotmatch '^[A-Za-z]:\\' -or $Path.Substring(2).Contains(':') -or $Path -match '["*?<>|/\x00-\x1f]|(^|\\)\.\.?($|\\)|[ .](\\|$)|\\$|\\\\'){throw 'Select one exact ordinary absolute local leaf file, at most 240 characters; links, streams, wildcards and remote paths are unsupported.'} +} +function Get-WelaFileProbeSources { + $sources=[ordered]@{} + foreach($name in @('WELA.ps1','scripts/FileAccessProbe.ps1','scripts/FileAccessProbeWorker.ps1','scripts/FileAccessProbeNative.cs','scripts/WmiProbe.ps1','scripts/WmiProbeNative.cs','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1','scripts/Configuration.ps1','scripts/CustomAuditProfiles.ps1','scripts/IpsecPrerequisites.ps1','modules/AuditProfiles.psm1','config/audit_profiles.json')) { + $sources[$name]=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot ('../'+$name)) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + } + [pscustomobject]$sources +} +function Get-WelaFileProbeKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 24 -Compress} +function Get-WelaFileProbeTokenKey { + param($Token) + foreach($name in @('Sid','Name','AuthenticationId','AuthenticationType','ImpersonationLevel','TokenSource')){if($Token.$name -isnot [string]){throw 'Incomplete typed file-reader token.'}} + if($Token.Sid -cnotmatch '^S-1-\d+(-\d+)+$' -or $Token.AuthenticationId -cnotmatch '^0x[0-9a-f]+$' -or $Token.TokenSource -cne 'Process' -or $Token.Groups -isnot [array] -or -not $Token.Groups.Count -or $Token.Privileges -isnot [array]){throw 'An ordinary native primary-token file reader is required.'} + foreach($group in $Token.Groups){if($group.Sid -isnot [string] -or $group.Sid -cnotmatch '^S-1-\d+(-\d+)+$' -or -not(Test-WelaFileProbeInteger $group.Attributes)){throw 'Incomplete typed file-reader group.'}} + foreach($privilege in $Token.Privileges){if($privilege.Luid -isnot [string] -or $privilege.Luid -cnotmatch '^0x[0-9a-f]+$' -or -not(Test-WelaFileProbeInteger $privilege.Attributes)){throw 'Incomplete typed file-reader privilege.'}} + Get-WelaFileProbeKey $Token +} +function Get-WelaFileProbeReaderKey { + param($Reader,[switch]$AuthorizationOnly) + foreach($name in @('UserSid','UserName','AuthenticationId','TokenId','ModifiedId','TokenType','Impersonation')){if($Reader.$name -isnot [string]){throw 'Incomplete typed reader observation.'}} + if($Reader.TokenType -cne 'Primary' -or $Reader.Impersonation -cne 'Absent' -or $Reader.ElevatedAdministrator -isnot [bool] -or -not $Reader.ElevatedAdministrator){throw 'An elevated primary-token reader with no impersonation is required.'} + if($AuthorizationOnly){Get-WelaFileProbeKey ($Reader|Select-Object UserSid,UserName,AuthenticationId,GroupSids,GroupCount,PrivilegeCount,ElevatedAdministrator,TokenType,Impersonation)} + else{Get-WelaFileProbeKey $Reader} +} +function Assert-WelaFileProbeSnapshot { + param($Snapshot) + foreach($name in @('Path','Identity','DescriptorBase64','StateKey')){if($Snapshot.$name -isnot [string] -or -not $Snapshot.$name){throw 'Incomplete typed file observation.'}} + Assert-WelaFileProbePath $Snapshot.Path + if($Snapshot.StateKey -cnotmatch '^[a-f0-9]{64}$' -or -not(Test-WelaFileProbeInteger $Snapshot.Size) -or $Snapshot.Size -le 0 -or -not(Test-WelaFileProbeInteger $Snapshot.SecurityInformation) -or $Snapshot.SecurityInformation -ne 511 -or -not(Test-WelaFileProbeInteger $Snapshot.Links) -or $Snapshot.Links -ne 1 -or -not(Test-WelaFileProbeInteger $Snapshot.Attributes) -or ($Snapshot.Attributes -band (16+1024+4096+16384+262144+4194304))){throw 'Only a complete nonempty ordinary single-link leaf-file observation is supported.'} + $Snapshot.LastWriteUtc=(ConvertTo-WelaArrivalUtc $Snapshot.LastWriteUtc).UtcDateTime.ToString('o') + if($Snapshot.Aces -isnot [array] -or $Snapshot.Aces.Count -gt 128){throw 'Missing or oversized file audit ACE inventory.'} + foreach($ace in $Snapshot.Aces){ + if($ace.Ordinary -isnot [bool] -or -not(Test-WelaFileProbeInteger $ace.Type) -or -not(Test-WelaFileProbeInteger $ace.Flags) -or -not(Test-WelaFileProbeInteger $ace.Mask) -or $ace.Binary -isnot [string]){throw 'Incomplete typed file audit ACE.'} + if($ace.Ordinary -and ($ace.Type -ne 2 -or $ace.Sid -isnot [string] -or $ace.Sid -cnotmatch '^S-1-\d+(-\d+)+$')){throw 'Incomplete ordinary file audit ACE.'} + } +} +function Get-WelaFileProbeSnapshot { + param([string]$Path) + Assert-WelaFileProbePath $Path;Initialize-WelaFileProbeNative + $handle=[Wela.FileAccessProbe.FileHandle]::new($Path,$false) + try{$handle.Observe()}finally{$handle.Dispose()} +} +function Get-WelaFileProbeState { + param([string]$Path) + Assert-WelaFileProbePath $Path;Initialize-WelaFileProbeNative + $services=@(Get-Service -Name EventLog,Winmgmt,RpcSs -ErrorAction Stop|Sort-Object Name|ForEach-Object {[pscustomobject]@{Name=$_.Name;Status=[string]$_.Status}}) + if($services.Count -ne 3 -or @($services|Where-Object Status -ne 'Running').Count){throw 'EventLog, Winmgmt and RpcSs must already be running.'} + $null=Get-WelaFileProbeReaderKey (Get-WelaChannelReader) + $tokenBefore=[Wela.WmiProbe.Native]::Snapshot();$snapshot=Get-WelaFileProbeSnapshot $Path + $hostState=Get-WelaChannelReadHost + $channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Security') + try{$log=[pscustomobject]@{Name=$channel.LogName;Enabled=$channel.IsEnabled;SecurityDescriptor=$channel.SecurityDescriptor;MaximumSize=$channel.MaximumSizeInBytes;Mode=[string]$channel.LogMode;Type=[string]$channel.LogType;Provider=$channel.OwningProviderName}}finally{$channel.Dispose()} + $policy=Get-WelaEffectiveAuditPolicy;$masks=[ordered]@{};foreach($guid in @($policy.Keys|Sort-Object)){$masks[$guid]=$policy[$guid]} + $engine=(Get-Process -Id $PID -ErrorAction Stop).Path + $reader=Get-WelaChannelReader;$token=[Wela.WmiProbe.Native]::Snapshot() + if((Get-WelaFileProbeTokenKey $tokenBefore) -cne (Get-WelaFileProbeTokenKey $token)){throw 'File prerequisite observation changed token groups or privileges.'} + [pscustomobject][ordered]@{Computer=[Environment]::MachineName;Host=$hostState;MachineGuid=(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid -ErrorAction Stop).MachineGuid;Services=$services;Reader=($reader|Select-Object UserSid,UserName,AuthenticationId,GroupSids,GroupCount,PrivilegeCount,ElevatedAdministrator,TokenType,Impersonation);Token=$token;File=$snapshot;AuditPolicies=[pscustomobject]$masks;Precedence=(Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy);Channel=$log;Engine=$engine;EngineHash=(Get-FileHash -LiteralPath $engine -Algorithm SHA256).Hash.ToLowerInvariant();Sources=(Get-WelaFileProbeSources)} +} +function Get-WelaFileProbeStateKey { + param($State) + Assert-WelaFileProbeSnapshot $State.File;$null=Get-WelaFileProbeTokenKey $State.Token + if($State.Computer -isnot [string] -or -not $State.Computer -or $State.MachineGuid -isnot [string] -or $State.MachineGuid -cnotmatch '^[a-fA-F0-9]{8}(-[a-fA-F0-9]{4}){3}-[a-fA-F0-9]{12}$' -or -not(Test-WelaFileProbeInteger $State.Host.ProductType) -or $State.Host.ProductType -notin @(1,2,3) -or -not(Test-WelaFileProbeInteger $State.Host.Build) -or $State.Host.Build -notin @(22000,22621,22631,20348,26100,26200) -or $State.Host.DomainJoined -isnot [bool]){throw 'Complete actual supported Windows host identity is required.'} + if($State.Services -isnot [array] -or $State.Services.Count -ne 3 -or (@($State.Services.Name)-join ',') -cne 'EventLog,RpcSs,Winmgmt'){throw 'Complete native service observations are required.'} + foreach($service in $State.Services){if($service.Status -isnot [string] -or $service.Status -cne 'Running'){throw 'Required services must already be running.'}} + $mask=$State.AuditPolicies.'0CCE921D-69AE-11D9-BED3-505054503030' + if(-not(Test-WelaFileProbeInteger $mask) -or $mask -notin @(1,3) -or $State.Precedence.ValueExists -isnot [bool] -or -not $State.Precedence.ValueExists -or $State.Precedence.Type -isnot [string] -or $State.Precedence.Type -cne 'DWord' -or -not(Test-WelaFileProbeInteger $State.Precedence.Value) -or $State.Precedence.Value -ne 1){throw 'File System success auditing and typed audit precedence DWORD1 must already be configured.'} + if($State.Channel.Name -isnot [string] -or $State.Channel.Name -cne 'Security' -or $State.Channel.Enabled -isnot [bool] -or -not $State.Channel.Enabled -or $State.Channel.SecurityDescriptor -isnot [string] -or -not $State.Channel.SecurityDescriptor){throw 'The Security channel must already be enabled with readable configuration.'} + if($State.Reader.TokenType -isnot [string] -or $State.Reader.TokenType -cne 'Primary' -or $State.Reader.Impersonation -isnot [string] -or $State.Reader.Impersonation -cne 'Absent' -or $State.Reader.ElevatedAdministrator -isnot [bool] -or -not $State.Reader.ElevatedAdministrator -or $State.Reader.UserSid -isnot [string] -or $State.Reader.UserSid -cne $State.Token.Sid){throw 'Complete elevated primary-token reader identity is required.'} + $sids=@($State.Token.Sid)+@($State.Token.Groups|Where-Object {($_.Attributes -band 4) -and -not($_.Attributes -band 16)}|ForEach-Object Sid) + $matches=@($State.File.Aces|Where-Object {$_.Ordinary -and $_.Type -eq 2 -and ($_.Flags -band 64) -and -not($_.Flags -band 8) -and ($_.Mask -band 1) -and $_.Sid -in $sids}) + if(-not $matches.Count){throw 'No existing ordinary success ReadData audit ACE matches this token on the selected file; no SACL is added.'} + foreach($name in @('Engine','EngineHash')){if($State.$name -isnot [string] -or -not $State.$name){throw 'Missing native engine identity.'}} + if($State.EngineHash -cnotmatch '^[a-f0-9]{64}$' -or -not @($State.Sources.PSObject.Properties).Count){throw 'Missing implementation fingerprints.'} + foreach($source in $State.Sources.PSObject.Properties){if($source.Value -isnot [string] -or $source.Value -cnotmatch '^[a-f0-9]{64}$'){throw 'Malformed implementation fingerprint.'}} + # Windows paths may change spelling/case while referring to this same native identity. + $State|ConvertTo-Json -Depth 24 -Compress +} +function Get-WelaFileProbeWatermark { + $result=Read-WelaChannelLatest Security + if($result.Status -isnot [string] -or $result.Status -cne 'EventObserved' -or -not(Test-WelaFileProbeInteger $result.Event.RecordId) -or $result.Event.RecordId -lt 1){throw 'A successful native Security query and positive record boundary are required.'} + [long]$result.Event.RecordId +} +function Get-WelaFileProbeOutputKey { + param([string]$Path) + $full=Resolve-WelaArrivalPath $Path;$item=Get-Item -LiteralPath $full -Force -ErrorAction Stop + if(-not $item.PSIsContainer){throw 'Probe output is not a directory.'} + $acl=Get-Acl -LiteralPath $full -ErrorAction Stop + Get-WelaFileProbeKey ([pscustomobject]@{Path=$item.FullName;CreatedUtc=$item.CreationTimeUtc.ToString('o');Attributes=[int]$item.Attributes;Security=$acl.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::Access -bor [Security.AccessControl.AccessControlSections]::Owner -bor [Security.AccessControl.AccessControlSections]::Group)}) +} +function Write-WelaFileProbeArtifact { + param([string]$Root,[string]$OutputKey,[string]$Name,[string]$Text) + if((Get-WelaFileProbeOutputKey $Root) -cne $OutputKey){throw 'Private probe output directory changed.'} + $bytes=[Text.UTF8Encoding]::new($false).GetBytes($Text);$path=Join-Path $Root $Name + $stream=[IO.File]::Open($path,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)}finally{$stream.Dispose()} + $hash=Get-WelaArrivalHash $bytes + if((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -cne $hash){throw 'Saved probe evidence hash differs.'} + [pscustomobject]@{Name=$Name;Sha256=$hash;Bytes=$bytes.Length} +} +function Assert-WelaFileProbeOperation { + param($Operation,$State,[string]$Nonce,[int]$ProcessId,[DateTimeOffset]$LaunchedUtc,[DateTimeOffset]$ObservedUtc) + foreach($name in @('Kind','Nonce','Executable','FilePath')){if($Operation.$name -isnot [string]){throw 'Untyped fixed file worker authority.'}} + if($Operation.Kind -cne 'WelaOneByteFileRead' -or $Operation.Nonce -cne $Nonce -or -not(Test-WelaFileProbeInteger $Operation.ProcessId) -or $Operation.ProcessId -ne $ProcessId -or $Operation.Executable -ine $State.Engine -or $Operation.FilePath -ine $State.File.Path){throw 'Unexpected fixed file worker identity.'} + $read=$Operation.Read + foreach($name in @('Clock','HandleId','BeforeKey','AfterKey')){if($read.$name -isnot [string]){throw 'Untyped native read receipt.'}} + if($read.Clock -cne 'GetSystemTimePreciseAsFileTime' -or $read.Succeeded -isnot [bool] -or -not $read.Succeeded -or -not(Test-WelaFileProbeInteger $read.ReadCalls) -or $read.ReadCalls -ne 1 -or -not(Test-WelaFileProbeInteger $read.BytesRead) -or $read.BytesRead -ne 1 -or $read.HandleId -cnotmatch '^0x[0-9a-f]+$' -or [Convert]::ToUInt64($read.HandleId.Substring(2),16) -eq 0 -or $read.BeforeKey -cne $State.File.StateKey -or $read.AfterKey -cne $State.File.StateKey){throw 'Expected exactly one successful byte read from the unchanged held file.'} + $start=ConvertTo-WelaArrivalUtc $read.StartedUtc;$end=ConvertTo-WelaArrivalUtc $read.CompletedUtc + if($LaunchedUtc -gt $ObservedUtc -or $start -lt $LaunchedUtc -or $end -lt $start -or $end -gt $ObservedUtc -or ($end-$start).TotalSeconds -gt 20){throw 'Invalid precise one-byte native read interval.'} + if((Get-WelaFileProbeTokenKey $Operation.BeforeToken) -cne (Get-WelaFileProbeTokenKey $Operation.AfterToken) -or (Get-WelaFileProbeTokenKey $Operation.BeforeToken) -cne (Get-WelaFileProbeTokenKey $State.Token) -or + (Get-WelaFileProbeReaderKey $Operation.BeforeReader) -cne (Get-WelaFileProbeReaderKey $Operation.AfterReader) -or (Get-WelaFileProbeReaderKey $Operation.BeforeReader -AuthorizationOnly) -cne (Get-WelaFileProbeKey $State.Reader)){throw 'Worker primary token differs from the caller or changed during the native read.'} + $read.StartedUtc=$start.UtcDateTime.ToString('o');$read.CompletedUtc=$end.UtcDateTime.ToString('o') +} +function Start-WelaFileProbeRead { + param($State,[string]$RequestPath,[string]$Nonce) + $fresh=Get-WelaFileProbeState $State.File.Path + if((Get-WelaFileProbeStateKey $fresh) -cne (Get-WelaFileProbeStateKey $State)){throw 'File probe prerequisites drifted before worker launch.'} + $watermark=Get-WelaFileProbeWatermark;$worker=Join-Path $PSScriptRoot 'FileAccessProbeWorker.ps1' + $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Engine;$info.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$worker+'" -RequestPath "'+$RequestPath+'" -Nonce '+$Nonce + $info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true + $info.StandardOutputEncoding=[Text.UTF8Encoding]::new($false,$true);$info.StandardErrorEncoding=$info.StandardOutputEncoding;$process=$null + try { + $launch=[DateTimeOffset][Wela.FileAccessProbe.FileHandle]::UtcNow();$process=[Diagnostics.Process]::Start($info) + $stdout=$process.StandardOutput.ReadToEndAsync();$stderr=$process.StandardError.ReadToEndAsync() + if(-not $process.WaitForExit(20000)){$process.Kill();$null=$process.WaitForExit(1000);throw 'File worker exceeded twenty seconds; the read may have been attempted.'} + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),1000)){throw 'File worker output did not complete.'} + if($stdout.Result.Length -gt 1048576 -or $stderr.Result.Length -gt 65536){throw 'File worker output exceeded its evidence bound.'} + if($process.ExitCode -ne 0 -or $stderr.Result){throw ('Fixed file worker failed: '+$stderr.Result)} + $operation=ConvertFrom-WelaArrivalJson $stdout.Result + Assert-WelaFileProbeOperation $operation $State $Nonce $process.Id $launch ([DateTimeOffset][Wela.FileAccessProbe.FileHandle]::UtcNow()) + $operation|Add-Member NoteProperty RecordIdBefore $watermark + $operation + }finally{if($process){try{if(-not $process.HasExited){$process.Kill();$null=$process.WaitForExit(1000)}}finally{$process.Dispose()}}} +} +function Read-WelaFileProbeEvents { + param($Operation) + # Keep out-of-interval candidates for diagnosis; the matcher never credits them. + $query="*[System[Provider[@Name='Microsoft-Windows-Security-Auditing'] and EventID=4663 and EventRecordID>$($Operation.RecordIdBefore)]]" + $reader=$null;$records=New-Object 'System.Collections.Generic.List[string]' + try { + $q=[Diagnostics.Eventing.Reader.EventLogQuery]::new('Security',[Diagnostics.Eventing.Reader.PathType]::LogName,$query);$q.TolerateQueryErrors=$false + $reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($q);$reader.BatchSize=16 + while($records.Count -lt 256){$event=$reader.ReadEvent([TimeSpan]::FromSeconds(1));if($null -eq $event){break};try{$xml=$event.ToXml();if($xml.Length -gt 131072){throw 'Security event exceeds the XML bound.'};$records.Add($xml)}finally{$event.Dispose()}} + $status=@($reader.LogStatus|ForEach-Object {[pscustomobject]@{LogName=$_.LogName;StatusCode=$_.StatusCode}});Assert-WelaChannelQueryStatus Security $status + [pscustomobject]@{Xml=@($records.ToArray());Capped=($records.Count -ge 256);Query=$query;MaximumEvents=256;LogStatus=$status} + }finally{if($reader){$reader.Dispose()}} +} +function Test-WelaFileProbeEvent { + param([string]$Xml,$Operation,$State) + $reader=$null + try { + if($Xml.Length -gt 131072){return $false} + $settings=[Xml.XmlReaderSettings]::new();$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=131072 + $reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Xml),$settings);$doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.Load($reader) + $ns=[Xml.XmlNamespaceManager]::new($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event') + if($doc.DocumentElement.LocalName -cne 'Event' -or $doc.DocumentElement.NamespaceURI -cne $ns.LookupNamespace('e') -or $doc.SelectNodes('/e:Event/e:System',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:EventData',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:UserData',$ns).Count){return $false} + $system=@{};foreach($name in @('Provider','EventID','Version','Keywords','EventRecordID','Channel','Computer','TimeCreated','Level','Task','Opcode')){$nodes=$doc.SelectNodes("/e:Event/e:System/e:$name",$ns);if($nodes.Count -ne 1){return $false};$system[$name]=$nodes[0]} + if($system.Provider.GetAttribute('Name') -cne 'Microsoft-Windows-Security-Auditing' -or $system.Provider.GetAttribute('Guid').Trim('{}') -ine '54849625-5478-4994-a5ba-3e3b0328c30d' -or $system.EventID.InnerText -cne '4663' -or $system.Version.InnerText -cne '1' -or $system.Keywords.InnerText -ine '0x8020000000000000' -or $system.Channel.InnerText -cne 'Security' -or $system.Level.InnerText -cne '0' -or $system.Task.InnerText -cne '12800' -or $system.Opcode.InnerText -cne '0' -or $system.EventRecordID.InnerText -cnotmatch '^[1-9][0-9]*$' -or [long]$system.EventRecordID.InnerText -le $Operation.RecordIdBefore){return $false} + $computers=@($State.Computer);if($State.Host.DomainJoined){$computers+=$State.Computer+'.'+$State.Host.Domain};if($system.Computer.InnerText -notin $computers){return $false} + $time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime');if($time -lt (ConvertTo-WelaArrivalUtc $Operation.Read.StartedUtc) -or $time -gt (ConvertTo-WelaArrivalUtc $Operation.Read.CompletedUtc)){return $false} + $data=@{};foreach($node in $doc.SelectSingleNode('/e:Event/e:EventData',$ns).ChildNodes){if($node.NodeType -eq 'Whitespace'){continue};if($node.NodeType -ne 'Element' -or $node.LocalName -cne 'Data' -or $node.NamespaceURI -cne $ns.LookupNamespace('e')){return $false};$name=$node.GetAttribute('Name');if(-not $name -or $data.ContainsKey($name) -or @($node.ChildNodes|Where-Object NodeType -eq Element).Count){return $false};$data[$name]=$node.InnerText} + foreach($name in @('SubjectUserSid','SubjectUserName','SubjectDomainName','SubjectLogonId','ObjectServer','ObjectType','ObjectName','HandleId','AccessList','AccessMask','ProcessId','ProcessName','ResourceAttributes')){if(-not $data.ContainsKey($name)){return $false}} + if($data.Count -ne 13 -or $data.ObjectServer -cne 'Security' -or $data.ObjectType -cne 'File' -or $data.ObjectName -ine $State.File.Path -or $data.ProcessName -ine $State.Engine -or $data.SubjectUserSid -cne $Operation.BeforeToken.Sid -or $data.AccessList.Trim() -cne '%%4416'){return $false} + foreach($name in @('SubjectLogonId','AccessMask','ProcessId','HandleId')){if($data[$name] -cnotmatch '^0x[0-9a-fA-F]+$'){return $false}} + if([Convert]::ToUInt64($data.SubjectLogonId.Substring(2),16) -ne [Convert]::ToUInt64($Operation.BeforeToken.AuthenticationId.Substring(2),16) -or [Convert]::ToUInt64($data.AccessMask.Substring(2),16) -ne 1 -or [Convert]::ToUInt64($data.ProcessId.Substring(2),16) -ne $Operation.ProcessId -or [Convert]::ToUInt64($data.HandleId.Substring(2),16) -ne [Convert]::ToUInt64($Operation.Read.HandleId.Substring(2),16)){return $false} + $true + }catch{$false}finally{if($reader){$reader.Dispose()}} +} +function Invoke-WelaFileAccessProbe { + param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$FilePath,[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15) + Assert-WelaFileProbePath $FilePath + if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new FileProbeOutputPath; Plan creates no output.'} + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaFileAccessProbe';Action=$Action;Status='Unverified';ExitCode=1;RecordedUtc=[datetime]::UtcNow.ToString('o');Before=$null;After=$null;Operation=$null;Candidates=0;Matches=0;Query=$null;Artifacts=@();Diagnostic='';OutputPath=$null;ConfigurationChanges=0;FileDataWrites=0;RetainedContentBytes=0;SigmaEvtxCredit=0;Scope='One current-token local file ReadData success only; failure access, other files/rights/users, inheritance, forwarding and Sigma are unverified. Reads may update native access metadata.'} + $outputKey=$null;$beforeKey=$null + try { + if($Action -eq 'Run'){$report.OutputPath=New-WelaArrivalOutput $OutputPath $script:ScriptRoot;$outputKey=Get-WelaFileProbeOutputKey $report.OutputPath} + $before=Get-WelaFileProbeState $FilePath;$beforeKey=Get-WelaFileProbeStateKey $before;$report.Before=$before + if($Action -eq 'Plan'){$report.After=$before;$report.Status='PrerequisitesObserved';$report.ExitCode=0;return $report} + $report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'before.json' ($before|ConvertTo-Json -Depth 24) + $nonce=[guid]::NewGuid().ToString('N');$intent=[pscustomobject]@{Kind='WelaOneByteFileReadIntent';Nonce=$nonce;Path=$before.File.Path;StateKey=$before.File.StateKey;ExpectedBytes=1;Outcome='Pending; interruption may leave an attempted read without a completion receipt.'} + $report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'intent.json' ($intent|ConvertTo-Json -Depth 8) + $operation=Start-WelaFileProbeRead $before (Join-Path $report.OutputPath 'before.json') $nonce;$report.Operation=$operation + if((Get-FileHash -LiteralPath (Join-Path $report.OutputPath 'before.json')).Hash.ToLowerInvariant() -cne $report.Artifacts[0].Sha256){throw 'Worker request evidence changed.'} + $report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'operation.json' ($operation|ConvertTo-Json -Depth 16) + $timer=[Diagnostics.Stopwatch]::StartNew();$matches=@() + do{$batch=Read-WelaFileProbeEvents $operation;$report.Candidates=@($batch.Xml).Count;$report.Query=$batch.Query + if($batch.Capped -isnot [bool] -or $batch.Capped){throw 'Security query reached its 256-event cap or completeness is unknown.'} + $matches=@($batch.Xml|Where-Object {Test-WelaFileProbeEvent $_ $operation $before});if($matches.Count){break};Start-Sleep -Milliseconds 250 + }while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds) + $report.Matches=$matches.Count + if($matches.Count -ne 1){$i=0;foreach($xml in @($batch.Xml|Select-Object -First 4)){$i++;$report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey ('candidate-'+$i+'.xml') $xml};throw 'Exactly one attributable native4663 was not observed in the precise read interval.'} + $report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'event.xml' $matches[0] + if((Get-WelaFileProbeWatermark) -lt $operation.RecordIdBefore){throw 'Security record boundary moved backwards.'} + $after=Get-WelaFileProbeState $before.File.Path;$report.After=$after + if((Get-WelaFileProbeStateKey $after) -cne $beforeKey){throw 'File identity/security, policy, channel, host, token or implementation changed during the probe.'} + $report.Status='FileReadObserved';$report.ExitCode=0 + }catch{$report.Diagnostic=$_.Exception.Message} + finally{if($report.Before -and -not $report.After){try{$report.After=Get-WelaFileProbeState $report.Before.File.Path}catch{$report.Diagnostic+=' Final observation failed: '+$_.Exception.Message}}} + if($report.OutputPath -and $outputKey){ + if($report.After){$report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'after.json' ($report.After|ConvertTo-Json -Depth 24)} + $null=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'manifest.json' ($report|ConvertTo-Json -Depth 28) + } + $report +} diff --git a/scripts/FileAccessProbeNative.cs b/scripts/FileAccessProbeNative.cs new file mode 100644 index 00000000..5d49e387 --- /dev/null +++ b/scripts/FileAccessProbeNative.cs @@ -0,0 +1,104 @@ +// A held existing local file handle: observe security and read exactly one byte. +// No file creation, data/security writes, backup semantics, or retained contents. +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Text; +namespace Wela.FileAccessProbe { + public sealed class Ace { public int Type,Flags,Mask; public string Sid,Binary; public bool Ordinary; } + public sealed class Observation { + public string Path,Identity,LastWriteUtc,DescriptorBase64,StateKey; + public long Size; public uint Attributes,Links; public int SecurityInformation; public Ace[] Aces; + } + public sealed class ReadReceipt { + public string StartedUtc,CompletedUtc,Clock,HandleId,BeforeKey,AfterKey; + public int ReadCalls,BytesRead; public bool Succeeded; + } + sealed class SecurityPrivilege : IDisposable { + [StructLayout(LayoutKind.Sequential)] struct Luid {public uint Low;public int High;} + [StructLayout(LayoutKind.Sequential)] struct Privileges {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("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr process,uint access,out IntPtr token); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenThreadToken(IntPtr thread,uint access,bool self,out IntPtr token); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool LookupPrivilegeValue(string system,string name,out Luid luid); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool AdjustTokenPrivileges(IntPtr token,bool all,ref Privileges requested,uint size,out Privileges previous,out uint required); + IntPtr token;Privileges previous; + public SecurityPrivilege() { + IntPtr thread;if(OpenThreadToken(GetCurrentThread(),8,true,out thread)){CloseHandle(thread);throw new InvalidOperationException("Impersonated file readers are unsupported.");} + int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error); + if(!OpenProcessToken(GetCurrentProcess(),0x28,out token))throw new Win32Exception(Marshal.GetLastWin32Error()); + try {Luid id;if(!LookupPrivilegeValue(null,"SeSecurityPrivilege",out id))throw new Win32Exception(Marshal.GetLastWin32Error()); + Privileges requested=new Privileges{Count=1,Id=id,Attributes=2};uint needed; + bool ok=AdjustTokenPrivileges(token,false,ref requested,(uint)Marshal.SizeOf(typeof(Privileges)),out previous,out needed); + error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"Existing SeSecurityPrivilege is required to inspect the SACL."); + }catch{CloseHandle(token);token=IntPtr.Zero;throw;} + } + public void Dispose(){if(token==IntPtr.Zero)return;try{Privileges ignored;uint needed;bool ok=AdjustTokenPrivileges(token,false,ref previous,(uint)Marshal.SizeOf(typeof(Privileges)),out ignored,out needed);int error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"SACL observation privilege restoration failed.");}finally{CloseHandle(token);token=IntPtr.Zero;}} + } + public sealed class FileHandle : IDisposable { + [StructLayout(LayoutKind.Sequential,Pack=4)] struct FileInfo {public uint Attributes;public long Created,Accessed,Written;public uint Volume,SizeHigh,SizeLow,Links,IndexHigh,IndexLow;} + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true,ExactSpelling=true)] static extern IntPtr CreateFileW(string path,uint access,uint share,IntPtr security,uint disposition,uint flags,IntPtr template); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll",SetLastError=true)] static extern uint GetFileType(IntPtr handle); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(IntPtr handle,out FileInfo info); + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true,ExactSpelling=true)] static extern uint GetFinalPathNameByHandleW(IntPtr handle,StringBuilder path,uint length,uint flags); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool ReadFile(IntPtr handle,[Out]byte[] buffer,uint count,out uint read,IntPtr overlapped); + [DllImport("kernel32.dll",ExactSpelling=true)] static extern void GetSystemTimePreciseAsFileTime(out long value); + [DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr memory); + [DllImport("advapi32.dll")] static extern uint GetSecurityInfo(IntPtr handle,uint kind,uint flags,out IntPtr owner,out IntPtr group,out IntPtr dacl,out IntPtr sacl,out IntPtr descriptor); + [DllImport("advapi32.dll")] static extern uint GetSecurityDescriptorLength(IntPtr descriptor); + IntPtr handle;readonly bool canRead;bool readAttempted;readonly string selected; + public static DateTime UtcNow(){long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value);} + public FileHandle(string path,bool readData) { + if(String.IsNullOrEmpty(path)||path.Length>240||!System.Text.RegularExpressions.Regex.IsMatch(path,@"^[A-Za-z]:\\"))throw new InvalidOperationException("Select an ordinary absolute local file path, at most 240 characters."); + if(path.Substring(2).IndexOf(':')>=0||path.IndexOfAny(new char[]{'"','*','?','<','>','|','/','\r','\n','\0'})>=0||!String.Equals(Path.GetFullPath(path),path,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Ambiguous file path refused."); + string root=Path.GetPathRoot(path);if(new DriveInfo(root).DriveType!=DriveType.Fixed)throw new InvalidOperationException("Only fixed local drives are supported."); + string part=root;foreach(string name in path.Substring(root.Length).Split('\\')) { + if(name.Length==0||name=="."||name==".."||name.EndsWith(".")||name.EndsWith(" "))throw new InvalidOperationException("Ambiguous file component refused."); + part=Path.Combine(part,name);if((File.GetAttributes(part)&FileAttributes.ReparsePoint)!=0)throw new InvalidOperationException("Reparse components are unsupported."); + } + selected=path;canRead=readData; + try { + // READ_CONTROL + ACCESS_SYSTEM_SECURITY + READ_ATTRIBUTES, optionally READ_DATA. + // Share read only: reject concurrent write/delete handles while this handle is held. + using(new SecurityPrivilege()){handle=CreateFileW(path,0x01020080U|(readData?1U:0U),1,IntPtr.Zero,3,0x00200000,IntPtr.Zero);if(handle==new IntPtr(-1)){handle=IntPtr.Zero;throw new Win32Exception(Marshal.GetLastWin32Error());}} + if(GetFileType(handle)!=1)throw new InvalidOperationException("The selected handle is not a disk file."); + Observe(); + }catch{Dispose();throw;} + } + public Observation Observe() { + if(handle==IntPtr.Zero)throw new ObjectDisposedException("FileHandle"); + FileInfo info;if(!GetFileInformationByHandle(handle,out info))throw new Win32Exception(Marshal.GetLastWin32Error()); + // No directories, links, EFS, offline/cloud recall, or empty data streams. + if((info.Attributes&(16U|1024U|4096U|16384U|0x40000U|0x400000U))!=0||info.Links!=1)throw new InvalidOperationException("Only ordinary local leaf files with one link are supported."); + long size=((long)info.SizeHigh<<32)|info.SizeLow;if(size<=0)throw new InvalidOperationException("The selected file must be nonempty."); + StringBuilder final=new StringBuilder(32768);uint length=GetFinalPathNameByHandleW(handle,final,(uint)final.Capacity,0); + if(length==0||length>=final.Capacity||!String.Equals(final.ToString(),@"\\?\"+selected,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Native final file path differs from the selected local path."); + string actual=final.ToString().Substring(4);IntPtr owner,group,dacl,sacl,descriptor; + uint error=GetSecurityInfo(handle,1,0x1ff,out owner,out group,out dacl,out sacl,out descriptor);if(error!=0)throw new Win32Exception((int)error,"Full current SDK descriptor observation (0x1ff) failed."); + byte[] bytes;try{uint count=GetSecurityDescriptorLength(descriptor);if(count<20||count>131072)throw new InvalidOperationException("File descriptor exceeds its observation bound.");bytes=new byte[count];Marshal.Copy(descriptor,bytes,0,(int)count);}finally{LocalFree(descriptor);} + RawSecurityDescriptor sd=new RawSecurityDescriptor(bytes,0);List entries=new List(); + if(sd.SystemAcl!=null)foreach(GenericAce ace in sd.SystemAcl){if(entries.Count>=128)throw new InvalidOperationException("File SACL exceeds 128 entries.");CommonAce common=ace as CommonAce;bool ordinary=common!=null&&!common.IsCallback&&common.AceType==AceType.SystemAudit;byte[] binary=new byte[ace.BinaryLength];ace.GetBinaryForm(binary,0);entries.Add(new Ace{Type=(int)ace.AceType,Flags=(int)ace.AceFlags,Mask=ordinary?common.AccessMask:0,Sid=ordinary?common.SecurityIdentifier.Value:null,Binary=Convert.ToBase64String(binary),Ordinary=ordinary});} + string identity=info.Volume+":"+info.IndexHigh+":"+info.IndexLow+":"+info.Created,encoded=Convert.ToBase64String(bytes),written=DateTime.FromFileTimeUtc(info.Written).ToString("o"); + string value=actual.ToUpperInvariant()+"|"+identity+"|"+size+"|"+written+"|"+info.Attributes+"|"+info.Links+"|"+encoded,key; + using(SHA256 sha=SHA256.Create()){key=BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(value))).Replace("-","").ToLowerInvariant();} + return new Observation{Path=actual,Identity=identity,Size=size,LastWriteUtc=written,Attributes=info.Attributes,Links=info.Links,DescriptorBase64=encoded,SecurityInformation=511,Aces=entries.ToArray(),StateKey=key}; + } + public ReadReceipt ReadOne(string expectedKey) { + if(!canRead||readAttempted)throw new InvalidOperationException("Exactly one explicitly requested data read is permitted."); + Observation before=Observe();if(!String.Equals(before.StateKey,expectedKey,StringComparison.Ordinal))throw new InvalidOperationException("Selected file changed before its one-byte read."); + byte[] buffer=new byte[1];readAttempted=true;uint count=0;DateTime started=UtcNow(),completed;bool success;int error; + try{success=ReadFile(handle,buffer,1,out count,IntPtr.Zero);error=Marshal.GetLastWin32Error();completed=UtcNow();}finally{Array.Clear(buffer,0,buffer.Length);} + if(!success)throw new Win32Exception(error,"The one-byte read failed.");if(count!=1)throw new InvalidOperationException("The fixed read did not return exactly one byte."); + Observation after=Observe();if(after.StateKey!=before.StateKey)throw new InvalidOperationException("Held file identity, data metadata or descriptor changed during the read."); + return new ReadReceipt{StartedUtc=started.ToString("o"),CompletedUtc=completed.ToString("o"),Clock="GetSystemTimePreciseAsFileTime",ReadCalls=1,BytesRead=1,Succeeded=true,HandleId="0x"+unchecked((ulong)handle.ToInt64()).ToString("x"),BeforeKey=before.StateKey,AfterKey=after.StateKey}; + } + public void Dispose(){if(handle!=IntPtr.Zero){CloseHandle(handle);handle=IntPtr.Zero;}} + } +} diff --git a/scripts/FileAccessProbeWorker.ps1 b/scripts/FileAccessProbeWorker.ps1 new file mode 100644 index 00000000..fdaeafce --- /dev/null +++ b/scripts/FileAccessProbeWorker.ps1 @@ -0,0 +1,22 @@ +param([Parameter(Mandatory)][string]$RequestPath,[Parameter(Mandatory)][ValidatePattern('^[a-f0-9]{32}$')][string]$Nonce) +$ErrorActionPreference='Stop';[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false) +if($args.Count){throw 'Unexpected file worker arguments.'} +$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -ErrorAction Stop +foreach($name in @('Configuration','WefArrival','ChannelRead','WmiProbe','FileAccessProbe')){. (Join-Path $PSScriptRoot ($name+'.ps1'))} +Initialize-WelaFileProbeNative +$requestFile=Get-Item -LiteralPath (Resolve-WelaArrivalPath $RequestPath) -ErrorAction Stop +if($requestFile.Length -gt 1048576){throw 'File worker request exceeds one MiB.'} +$state=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText($requestFile.FullName,[Text.UTF8Encoding]::new($false,$true))) +$null=Get-WelaFileProbeStateKey $state +$fresh=Get-WelaFileProbeState $state.File.Path +if((Get-WelaFileProbeStateKey $fresh) -cne (Get-WelaFileProbeStateKey $state)){throw 'Host, caller, source, file or audit prerequisites changed before worker access.'} +$handle=[Wela.FileAccessProbe.FileHandle]::new($state.File.Path,$true) +try { + if($handle.Observe().StateKey -cne $state.File.StateKey){throw 'Selected file changed before worker read.'} + $beforeReader=Get-WelaChannelReader;$beforeToken=[Wela.WmiProbe.Native]::Snapshot() + if((Get-WelaFileProbeTokenKey $beforeToken) -cne (Get-WelaFileProbeTokenKey $state.Token) -or (Get-WelaFileProbeReaderKey $beforeReader -AuthorizationOnly) -cne (Get-WelaFileProbeKey $state.Reader)){throw 'Worker does not preserve the expected caller token.'} + $read=$handle.ReadOne($state.File.StateKey) + $afterToken=[Wela.WmiProbe.Native]::Snapshot();$afterReader=Get-WelaChannelReader + [pscustomobject]@{Kind='WelaOneByteFileRead';Nonce=$Nonce;ProcessId=$PID;Executable=(Get-Process -Id $PID).Path;FilePath=$state.File.Path;BeforeReader=$beforeReader;AfterReader=$afterReader;BeforeToken=$beforeToken;AfterToken=$afterToken;Read=$read}|ConvertTo-Json -Depth 16 -Compress +}finally{$handle.Dispose()} diff --git a/tests/FileAccessProbe.Cli.Tests.ps1 b/tests/FileAccessProbe.Cli.Tests.ps1 new file mode 100644 index 00000000..38b72896 --- /dev/null +++ b/tests/FileAccessProbe.Cli.Tests.ps1 @@ -0,0 +1,14 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path +$cases=@( + @{Args=@('file-access-probe','-Help');Code=0;Pattern='Reads one byte and discards it'}, + @{Args=@('file-access-probe','-FileProbeAction','Run','-WhatIf');Code=1;Pattern='dedicated options'}, + @{Args=@('file-access-probe','extra','-Help');Code=1;Pattern='dedicated options'}, + @{Args=@('file-access-probe','-Auto','-Help');Code=1;Pattern='dedicated options'}, + @{Args=@('file-access-probe','-DryRun','-Help');Code=1;Pattern='dedicated options'}, + @{Args=@('help','-FileProbeAction','Run');Code=1;Pattern='require file-access-probe'}, + @{Args=@('file-access-probe','-FileProbePath','\\host\share\file');Code=1;Pattern='exact ordinary'}, + @{Args=@('file-access-probe','-FileProbePath','C:\file.txt','-FileProbeAction','Run');Code=1;Pattern='Run requires'}, + @{Args=@('file-access-probe','-FileProbePath','C:\file.txt','-FileProbeOutputPath','never-created');Code=1;Pattern='Plan creates no output'} +) +foreach($case in $cases){$old=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$output=@(& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old};if($code -ne $case.Code -or ($output -join "`n") -notmatch $case.Pattern){throw "CLI refusal failure: $($case.Args -join ' ') => $code / $($output -join ' ')"}} +Write-Host "Passed $($cases.Count) public file-access CLI assertions.";$global:LASTEXITCODE=0 diff --git a/tests/FileAccessProbe.Windows.Tests.ps1 b/tests/FileAccessProbe.Windows.Tests.ps1 new file mode 100644 index 00000000..254b1290 --- /dev/null +++ b/tests/FileAccessProbe.Windows.Tests.ps1 @@ -0,0 +1,62 @@ +param([switch]$AllowDisposablePolicyWrite) +$ErrorActionPreference='Stop' +if(-not $AllowDisposablePolicyWrite -or $env:GITHUB_ACTIONS -ne 'true' -or [Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Only an explicitly permitted disposable GitHub-hosted native Windows runner is supported.'} +$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -ErrorAction Stop +foreach($name in @('Configuration','WefArrival','ChannelRead','WmiProbe','FileAccessProbe','SelectedSaclConfiguration')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))} +Initialize-WelaFileProbeNative;Initialize-WelaSelectedSaclNative +$engine=(Get-Process -Id $PID).Path;$script:checks=0 +function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++} +function Policy-Key($Policy){$ordered=[ordered]@{};foreach($key in @($Policy.Keys|Sort-Object)){$ordered[$key]=$Policy[$key]};Get-WelaFileProbeKey $ordered} +function Invoke-PublicFileProbe([string[]]$Arguments,[string]$Log,[bool]$Success=$true){$old=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$lines=@(& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $script:ScriptRoot 'WELA.ps1') @Arguments 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old;$global:LASTEXITCODE=0};$text=$lines -join "`n";[IO.File]::WriteAllText($Log,$text,[Text.UTF8Encoding]::new($false));if(($Success -and $code -ne 0) -or (-not $Success -and $code -eq 0)){throw "Unexpected public CLI result $code : $text"};$start=$text.IndexOf('{');if($start -lt 0){throw 'Public CLI returned no JSON report.'};ConvertFrom-WelaArrivalJson $text.Substring($start)} +function Add-OwnedReadSacl([string]$Path){$privilege=[Wela.SelectedSacl.Privilege]::new();$target=$null;try{$target=[Wela.SelectedSacl.Target]::new('FileSystem',$Path);$before=$target.Read();$null=$target.Add($before.Identity,$before.DescriptorBase64,'S-1-1-0',1,64)}finally{if($target){$target.Dispose()};$privilege.Dispose()}} +$root=New-WelaArrivalOutput (Join-Path $env:RUNNER_TEMP ('wela-file-access-'+[guid]::NewGuid().ToString('N'))) $script:ScriptRoot +$targetRoot=Join-Path $root 'owned-targets';$null=New-Item -ItemType Directory $targetRoot +$file=Join-Path $targetRoot 'ReadCase.TxT';$plain=Join-Path $targetRoot 'WithoutAudit.txt' +[IO.File]::WriteAllText($file,'WELA owned harmless file probe fixture.',[Text.UTF8Encoding]::new($false));[IO.File]::WriteAllText($plain,'WELA owned file without a matching audit ACE.',[Text.UTF8Encoding]::new($false)) +$fileHash=(Get-FileHash $file).Hash;$beforePolicies=Get-WelaEffectiveAuditPolicy;$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$beforePrecedence=Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy +$originalToken=Get-WelaFileProbeTokenKey ([Wela.WmiProbe.Native]::Snapshot());$changed=$false;$cleanupErrors=@() +[IO.File]::WriteAllText((Join-Path $root 'original-audit-policy.json'),(Policy-Key $beforePolicies),[Text.UTF8Encoding]::new($false)) +[IO.File]::WriteAllText((Join-Path $root 'original-precedence.json'),(Get-WelaFileProbeKey $beforePrecedence),[Text.UTF8Encoding]::new($false)) +try { + $changed=$true;Set-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Type DWord -Value 1 + Set-WelaEffectiveAuditPolicy -Guid '0CCE921D-69AE-11D9-BED3-505054503030' -Mask 1 -Mode exact + Add-OwnedReadSacl $file + $before=Get-WelaFileProbeSnapshot $file;$beforeKey=$before.StateKey + $plan=Invoke-PublicFileProbe @('file-access-probe','-FileProbePath',$file.ToLowerInvariant()) (Join-Path $root 'plan.log') + Assert ($plan.Status -ceq 'PrerequisitesObserved' -and $plan.Before.File.Path -ieq $file -and $plan.Before.File.StateKey -ceq $beforeKey) 'public Plan accepts Windows path casing and verifies the exact existing file SACL/policy' + foreach($index in 1..2){ + $output=Join-Path $root ('run-'+$index) + $report=Invoke-PublicFileProbe @('file-access-probe','-FileProbeAction','Run','-FileProbePath',$file.ToLowerInvariant(),'-FileProbeOutputPath',$output) (Join-Path $root ('run-'+$index+'.log')) + Assert ($report.Status -ceq 'FileReadObserved' -and $report.Matches -eq 1) 'public one-byte read produced exactly one attributable actual4663' + Assert ($report.Operation.Read.ReadCalls -eq 1 -and $report.Operation.Read.BytesRead -eq 1 -and $report.RetainedContentBytes -eq 0 -and $report.SigmaEvtxCredit -eq 0) 'one byte is read without retaining contents or granting Sigma credit' + Assert ($report.Before.File.StateKey -ceq $beforeKey -and $report.After.File.StateKey -ceq $beforeKey -and (Get-FileHash $file).Hash -ceq $fileHash) 'existing file data, native identity and full descriptor remain unchanged' + Assert (Test-WelaFileProbeEvent ([IO.File]::ReadAllText((Join-Path $output 'event.xml'))) $report.Operation $report.Before) 'retained native XML matches operation PID, handle, SID, logon, path, right and precise interval' + foreach($artifact in $report.Artifacts){Assert ((Get-FileHash (Join-Path $output $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'retained artifact hash matches its public manifest'} + } + $missing=Invoke-PublicFileProbe @('file-access-probe','-FileProbePath',$plain) (Join-Path $root 'missing-sacl.log') $false + Assert ($missing.Status -ceq 'Unverified' -and $missing.Diagnostic -match 'No existing ordinary success ReadData' -and $null -eq $missing.Operation) 'real missing SACL refuses access without adding an ACE' + Set-WelaEffectiveAuditPolicy -Guid '0CCE921D-69AE-11D9-BED3-505054503030' -Mask 0 -Mode exact + $disabled=Invoke-PublicFileProbe @('file-access-probe','-FileProbeAction','Run','-FileProbePath',$file,'-FileProbeOutputPath',(Join-Path $root 'disabled-policy')) (Join-Path $root 'disabled-policy.log') $false + Assert ($disabled.Status -ceq 'Unverified' -and $disabled.Diagnostic -match 'File System success auditing' -and $null -eq $disabled.Operation) 'real disabled auditing refuses the byte read' + Assert (-not(Test-Path (Join-Path $root 'disabled-policy/intent.json'))) 'failed prerequisites produce no pending read intent' + Set-WelaEffectiveAuditPolicy -Guid '0CCE921D-69AE-11D9-BED3-505054503030' -Mask 1 -Mode exact + $oldState=Get-WelaFileProbeState $file + $replacement=Join-Path $targetRoot 'Replacement.txt';[IO.File]::WriteAllText($replacement,'WELA owned replacement.',[Text.UTF8Encoding]::new($false));Add-OwnedReadSacl $replacement + Remove-Item -LiteralPath $file -Force;Move-Item -LiteralPath $replacement -Destination $file + $message='';try{Start-WelaFileProbeRead $oldState (Join-Path $root 'not-used.json') ([guid]::NewGuid().ToString('N'))|Out-Null}catch{$message=$_.Exception.Message} + Assert ($message -match 'drifted before worker launch') 'real replaced native file identity refuses a stale preflight before launching a worker' + $held=[Wela.FileAccessProbe.FileHandle]::new($file,$false) + try{$denied=$false;try{Remove-Item -LiteralPath $file -Force -ErrorAction Stop}catch{$denied=$true};Assert $denied 'held native observation handle prevents deletion of the selected file'}finally{$held.Dispose()} +} finally { + if($changed){try{Set-WelaEffectiveAuditPolicy -Guid '0CCE921D-69AE-11D9-BED3-505054503030' -Mask $beforePolicies['0CCE921D-69AE-11D9-BED3-505054503030'] -Mode exact;if($beforePrecedence.ValueExists){Set-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Type $beforePrecedence.Type -Value $beforePrecedence.Value}else{Remove-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -ErrorAction Stop}}catch{$cleanupErrors+=$_.Exception.Message}} + $auditRestored=(Policy-Key (Get-WelaEffectiveAuditPolicy)) -ceq (Policy-Key $beforePolicies) + $precedenceRestored=(Get-WelaFileProbeKey (Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy)) -ceq (Get-WelaFileProbeKey $beforePrecedence) + $tokenRestored=(Get-WelaFileProbeTokenKey ([Wela.WmiProbe.Native]::Snapshot())) -ceq $originalToken + try{Remove-Item -LiteralPath $targetRoot -Recurse -Force -ErrorAction Stop}catch{$cleanupErrors+=$_.Exception.Message} + $cleanup=[pscustomobject]@{Complete=($auditRestored -and $precedenceRestored -and $tokenRestored -and -not(Test-Path $targetRoot) -and $cleanupErrors.Count -eq 0);AuditPoliciesRestored=$auditRestored;PrecedenceRestored=$precedenceRestored;TokenRestored=$tokenRestored;OwnedTargetsRemoved=(-not(Test-Path $targetRoot));Errors=$cleanupErrors;Checks=$script:checks;Engine=$PSVersionTable.PSVersion.ToString();AfterAuditPolicies=(Get-WelaEffectiveAuditPolicy);AfterPrecedence=(Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy)} + [IO.File]::WriteAllText((Join-Path $root 'cleanup.json'),($cleanup|ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false)) + if(-not $cleanup.Complete){throw "Native file-probe cleanup incomplete: $($cleanup|ConvertTo-Json -Compress -Depth 10)"} +} +Write-Host "Passed $script:checks actual native file-access assertions; all59 audit masks, typed precedence, privileges and owned-target cleanup verified. Evidence: $root" +$global:LASTEXITCODE=0