Integrate reviewed CLI, channel and native probe changes

This commit is contained in:
Shirofune-Security committed 2026-09-22 07:25:37 +09:00
commit 0d1adfb442
30 files changed
+1636 -4

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
// Read-only process-token observations. No privilege or authorization changes.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Security.Principal;
namespace Wela.AppLockerScript {
public sealed class Group { public string Sid; public uint Attributes; }
public sealed class Privilege { public string Luid; public uint Attributes; }
public sealed class Token {
public string Sid, Name, TokenId, ModifiedId, AuthenticationId, AuthenticationType, ImpersonationLevel, TokenSource;
public Group[] Groups; public Privilege[] Privileges;
}
public static class Native {
public const string SourceSha256="__WELA_SOURCE_SHA256__";
[DllImport("kernel32.dll",ExactSpelling=true)] static extern void GetSystemTimePreciseAsFileTime(out long value);
public static DateTime UtcNow() {long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value);}
[StructLayout(LayoutKind.Sequential)] struct Luid {public uint Low; public int High;}
[StructLayout(LayoutKind.Sequential)] struct Statistics {public Luid TokenId,AuthenticationId;public long Expiration;public int Type,Level;public uint Charged,Available,Groups,Privileges;public Luid Modified;}
[StructLayout(LayoutKind.Sequential)] struct SidAndAttributes {public IntPtr Sid;public uint Attributes;}
[StructLayout(LayoutKind.Sequential)] struct TokenGroups {public uint Count;public SidAndAttributes First;}
[StructLayout(LayoutKind.Sequential)] struct LuidAndAttributes {public Luid Luid;public uint Attributes;}
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess();
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentThread();
[DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr h);
[DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr p,uint access,out IntPtr t);
[DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenThreadToken(IntPtr p,uint access,bool self,out IntPtr t);
[DllImport("advapi32.dll",SetLastError=true)] static extern bool GetTokenInformation(IntPtr t,int cls,IntPtr data,int length,out int needed);
static string Hex(Luid id) {return "0x"+(((ulong)(uint)id.High<<32)|id.Low).ToString("x");}
static IntPtr Read(IntPtr token,int cls,out int length) {
GetTokenInformation(token,cls,IntPtr.Zero,0,out length);
if(Marshal.GetLastWin32Error()!=122||length<4||length>65536)throw new InvalidOperationException("Unknown or oversized token information.");
IntPtr data=Marshal.AllocHGlobal(length);
if(!GetTokenInformation(token,cls,data,length,out length)){int error=Marshal.GetLastWin32Error();Marshal.FreeHGlobal(data);throw new Win32Exception(error);}
return data;
}
static Token ReadToken(IntPtr token,string source) {
Token result=new Token();result.TokenSource=source;using(WindowsIdentity identity=new WindowsIdentity(token)){result.Sid=identity.User.Value;result.Name=identity.Name;result.AuthenticationType=identity.AuthenticationType;result.ImpersonationLevel=identity.ImpersonationLevel.ToString();}
int length;IntPtr p=Read(token,10,out length);
try {if(length<Marshal.SizeOf(typeof(Statistics)))throw new InvalidOperationException("Truncated token statistics.");Statistics stats=(Statistics)Marshal.PtrToStructure(p,typeof(Statistics));if(stats.Type!=1)throw new InvalidOperationException("A primary token is required.");result.AuthenticationId=Hex(stats.AuthenticationId);result.TokenId=Hex(stats.TokenId);result.ModifiedId=Hex(stats.Modified);}finally{Marshal.FreeHGlobal(p);}
p=Read(token,2,out length);
try {int count=Marshal.ReadInt32(p),offset=(int)Marshal.OffsetOf(typeof(TokenGroups),"First"),size=Marshal.SizeOf(typeof(SidAndAttributes));if(count<0||count>4096||offset+(long)count*size>length)throw new InvalidOperationException("Invalid token groups.");List<Group> groups=new List<Group>();for(int i=0;i<count;i++){SidAndAttributes g=(SidAndAttributes)Marshal.PtrToStructure(IntPtr.Add(p,offset+i*size),typeof(SidAndAttributes));groups.Add(new Group{Sid=new SecurityIdentifier(g.Sid).Value,Attributes=g.Attributes});}groups.Sort((a,b)=>String.CompareOrdinal(a.Sid,b.Sid));result.Groups=groups.ToArray();}finally{Marshal.FreeHGlobal(p);}
p=Read(token,3,out length);
try {int count=Marshal.ReadInt32(p),size=Marshal.SizeOf(typeof(LuidAndAttributes));if(count<0||count>4096||4+(long)count*size>length)throw new InvalidOperationException("Invalid token privileges.");List<Privilege> privileges=new List<Privilege>();for(int i=0;i<count;i++){LuidAndAttributes v=(LuidAndAttributes)Marshal.PtrToStructure(IntPtr.Add(p,4+i*size),typeof(LuidAndAttributes));privileges.Add(new Privilege{Luid=Hex(v.Luid),Attributes=v.Attributes});}privileges.Sort((a,b)=>String.CompareOrdinal(a.Luid,b.Luid));result.Privileges=privileges.ToArray();}finally{Marshal.FreeHGlobal(p);}
return result;
}
public static Token Child(IntPtr handle) {
IntPtr token=IntPtr.Zero;
if(!OpenProcessToken(handle,8,out token))throw new Win32Exception(Marshal.GetLastWin32Error());
try {if(IsTokenRestricted(token))throw new InvalidOperationException("Restricted child token unsupported.");return ReadToken(token,"ChildProcess");}finally{CloseHandle(token);}
}
[DllImport("advapi32.dll")] static extern bool IsTokenRestricted(IntPtr token);
public static Token Snapshot() {
IntPtr thread=IntPtr.Zero,process=IntPtr.Zero;
if(OpenThreadToken(GetCurrentThread(),8,true,out thread)){CloseHandle(thread);throw new InvalidOperationException("Impersonated callers are unsupported.");}
int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error);
if(!OpenProcessToken(GetCurrentProcess(),8,out process))throw new Win32Exception(Marshal.GetLastWin32Error());
try {if(IsTokenRestricted(process))throw new InvalidOperationException("Restricted caller unsupported.");return ReadToken(process,"Process");}finally{CloseHandle(process);}
}
[StructLayout(LayoutKind.Sequential)] struct FileInformation {
public uint Attributes;public System.Runtime.InteropServices.ComTypes.FILETIME Creation,Access,Write;
public uint Volume,SizeHigh,SizeLow,Links,IndexHigh,IndexLow;
}
[DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(IntPtr file,out FileInformation info);
public static string FileId(IntPtr handle) {
FileInformation info;if(!GetFileInformationByHandle(handle,out info))throw new Win32Exception(Marshal.GetLastWin32Error());
if((info.Attributes&0x410)!=0)throw new InvalidOperationException("One ordinary non-reparse file identity is required.");
return info.Volume.ToString("x8")+":"+info.IndexHigh.ToString("x8")+info.IndexLow.ToString("x8");
}
public static async System.Threading.Tasks.Task<string> ReadLineBoundedAsync(System.IO.StreamReader reader,int limit) {
char[] buffer=new char[1];System.Text.StringBuilder text=new System.Text.StringBuilder();
while(true){int count=await reader.ReadAsync(buffer,0,1).ConfigureAwait(false);if(count==0)throw new InvalidOperationException("Owned child ended before its ready marker.");if(buffer[0]=='\n')return text.ToString().TrimEnd('\r');if(text.Length>=limit)throw new InvalidOperationException("Owned child line exceeds the bound.");text.Append(buffer[0]);}
}
public static async System.Threading.Tasks.Task<string> ReadBoundedAsync(System.IO.StreamReader reader,int limit) {
char[] buffer=new char[256];System.Text.StringBuilder text=new System.Text.StringBuilder();
while(true){int count=await reader.ReadAsync(buffer,0,buffer.Length).ConfigureAwait(false);if(count==0)return text.ToString();if(text.Length+count>limit)throw new InvalidOperationException("Owned child output exceeds the bound.");text.Append(buffer,0,count);}
}
}
}
+225
View File
@@ -0,0 +1,225 @@
# One fixed native Windows PowerShell script. Never prepares policy/services/channels.
function Get-WelaAppLockerScriptKey { param($Value) ConvertTo-Json -InputObject $Value -Depth 30 -Compress }
function Get-WelaAppLockerScriptSources {
$sources=[ordered]@{}
foreach($path in @('WELA.ps1','scripts/AppLockerScriptProbe.ps1','scripts/AppLockerScriptNative.cs','scripts/AppLockerScriptWorker.ps1','scripts/AppLockerReadiness.ps1','scripts/WefArrival.ps1')) {
$sources[$path]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $path) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
}
[pscustomobject]$sources
}
function Initialize-WelaAppLockerScriptNative {
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'A native 64-bit Windows process is required.'}
$bytes=[IO.File]::ReadAllBytes((Join-Path $script:ScriptRoot 'scripts/AppLockerScriptNative.cs'))
$hash=Get-WelaArrivalHash $bytes
if(-not ('Wela.AppLockerScript.Native' -as [type])) {
$text=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff)
if(([regex]::Matches($text,'__WELA_SOURCE_SHA256__')).Count -ne 1){throw 'Unexpected native source fingerprint placeholder.'}
Add-Type -TypeDefinition $text.Replace('__WELA_SOURCE_SHA256__',$hash) -ErrorAction Stop
}
if([Wela.AppLockerScript.Native]::SourceSha256 -cne $hash){throw 'Loaded helper differs from current source; start a fresh PowerShell process.'}
}
function Get-WelaAppLockerScriptReader { [Wela.AppLockerScript.Native]::Snapshot() }
function Get-WelaAppLockerScriptUtcNow { [Wela.AppLockerScript.Native]::UtcNow() }
function Get-WelaAppLockerScriptExecutionPolicy {
$values=[ordered]@{InheritedProcessValue=$env:PSExecutionPolicyPreference;Machine=@();User=@()}
foreach($scope in @('Machine','User')) {
$base=if($scope -eq 'Machine'){[Microsoft.Win32.Registry]::LocalMachine}else{[Microsoft.Win32.Registry]::CurrentUser}
foreach($path in @('SOFTWARE\Policies\Microsoft\Windows\PowerShell','SOFTWARE\Microsoft\PowerShell\1\ShellIds\Microsoft.PowerShell')) {
$key=$base.OpenSubKey($path,$false)
try {
foreach($name in @('EnableScripts','ExecutionPolicy')) {
$present=$null -ne $key -and $name -cin @($key.GetValueNames())
$values[$scope]+=[pscustomobject]@{Path=$path;Name=$name;Present=[bool]$present;Kind=$(if($present){[string]$key.GetValueKind($name)}else{$null});Value=$(if($present){$key.GetValue($name,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)}else{$null})}
}
}finally{if($key){$key.Dispose()}}
}
}
[pscustomobject]$values
}
function Get-WelaAppLockerScriptServices {
# Direct SCM observations precede every CIM connection; observation must not
# implicitly start stopped WMI or AppLocker generation dependencies.
$rows=@()
foreach($name in @('Winmgmt','EventLog','AppIDSvc')) {
$service=Get-Service -Name $name -ErrorAction Stop
if($null -eq $service -or $service.Name -ine $name -or $service.Status -ne [ServiceProcess.ServiceControllerStatus]::Running){throw ($name+' must already be running; no CIM connection or child was started.')}
$rows+=[pscustomobject]@{Name=$name;Status=[string]$service.Status}
}
$rows
}
function Get-WelaAppLockerScriptState {
$services=@(Get-WelaAppLockerScriptServices)
$hostState=Get-WelaAppLockerHost
$computer=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
$version=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction Stop
$machine=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid -ErrorAction Stop
$channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Microsoft-Windows-AppLocker/MSI and Script')
try {$log=[ordered]@{Name=$channel.LogName;Enabled=$channel.IsEnabled;SecurityDescriptor=$channel.SecurityDescriptor;MaximumSize=$channel.MaximumSizeInBytes;Mode=[string]$channel.LogMode;LogFilePath=$channel.LogFilePath}}finally{$channel.Dispose()}
$source=Resolve-WelaArrivalPath (Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe')
[pscustomobject][ordered]@{Services=$services;Host=$hostState;MachineGuid=$machine.MachineGuid;UBR=$version.UBR;Computer=[Environment]::MachineName;Domain=[string]$computer.Domain;LocalPolicy=(Get-WelaAppLockerPolicySnapshot Local);EffectivePolicy=(Get-WelaAppLockerPolicySnapshot Effective);Management=(Get-WelaAppLockerManagement);Service=(Get-WelaAppLockerService);Channel=$log;ExecutionPolicy=(Get-WelaAppLockerScriptExecutionPolicy);Source=$source;SourceHash=(Get-FileHash -LiteralPath $source -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()}
}
function Get-WelaAppLockerScriptStateKey {
param($State)
foreach($status in @($State.Host.Status,$State.LocalPolicy.Status,$State.EffectivePolicy.Status,$State.Service.Status,$State.Service.State,$State.Management.Status)){if($status -isnot [string]){throw 'Native context status must be a typed string.'}}
$services=@($State.Services)
if($services.Count -ne 3 -or @($services|Where-Object{$_.Name -isnot [string] -or $_.Status -isnot [string] -or $_.Status -cne 'Running'}).Count -or (($services.Name -join ',') -cne 'Winmgmt,EventLog,AppIDSvc')){throw 'Complete running-service preflight evidence is required.'}
if($State.Host.Status -cne 'Candidate' -or $State.Host.Is64BitProcess -isnot [bool] -or -not $State.Host.Is64BitProcess -or $State.Host.ProductType -notin @(1,3) -or ($State.Host.ProductType -eq 1 -and $State.Host.Build -notin @(22000,22621,22631,26100,26200)) -or ($State.Host.ProductType -eq 3 -and $State.Host.Build -notin @(20348,26100))){throw 'A reviewed native Windows 11 or Server 2022/2025 member host is required; DCs are excluded.'}
foreach($policy in @($State.LocalPolicy,$State.EffectivePolicy)){if($policy.Status -cne 'Observed' -or $policy.Policy.HasUnknownPolicyData -isnot [bool] -or $policy.Policy.HasUnknownPolicyData){throw 'Local and effective GP policy must be readable and understood.'}}
$collection=@($State.EffectivePolicy.Policy.Collections|Where-Object Type -CEQ 'Script')
if($collection.Count -ne 1 -or $collection[0].EnforcementMode -isnot [string] -or $collection[0].EnforcementMode -cne 'AuditOnly' -or ($collection[0].RuleCount -isnot [int] -and $collection[0].RuleCount -isnot [long]) -or $collection[0].RuleCount -lt 1){throw 'An existing nonempty effective Script AuditOnly collection is required.'}
if($State.Service.Status -cne 'Observed' -or $State.Service.State -cne 'Running'){throw 'AppIDSvc must already be running.'}
if($State.Channel.Enabled -isnot [bool] -or -not $State.Channel.Enabled -or $State.Channel.Name -cne 'Microsoft-Windows-AppLocker/MSI and Script' -or -not $State.Channel.SecurityDescriptor){throw 'The native MSI and Script channel must already be enabled and readable.'}
if($State.Management.Status -cne 'Observed' -or $State.SourceHash -cnotmatch '^[a-f0-9]{64}$' -or -not $State.MachineGuid -or -not $State.Computer){throw 'Incomplete management, machine or source observation.'}
Get-WelaAppLockerScriptKey $State
}
function Get-WelaAppLockerScriptAuthorizationKey {
param($Token)
if($Token.Sid -cnotmatch '^S-1-\d+(-\d+)+$' -or $Token.AuthenticationId -cnotmatch '^0x[0-9a-f]+$' -or $null -eq $Token.Groups -or $null -eq $Token.Privileges){throw 'Incomplete actual token evidence.'}
Get-WelaAppLockerScriptKey ([ordered]@{Sid=$Token.Sid;AuthenticationId=$Token.AuthenticationId;Groups=$Token.Groups;Privileges=$Token.Privileges})
}
function New-WelaAppLockerScriptText {
param([string]$Template,[string]$Nonce)
if($Nonce -cnotmatch '^[a-f0-9]{32}$' -or ([regex]::Matches($Template,'__WELA_SCRIPT_NONCE__')).Count -ne 3){throw 'Unexpected fixed worker template or nonce.'}
$Template.Replace('__WELA_SCRIPT_NONCE__',$Nonce)
}
function Read-WelaAppLockerScriptBoundary {
$reader=$null;$record=$null
try {
$query=[Diagnostics.Eventing.Reader.EventLogQuery]::new('Microsoft-Windows-AppLocker/MSI and Script',[Diagnostics.Eventing.Reader.PathType]::LogName,'*');$query.ReverseDirection=$true;$query.TolerateQueryErrors=$false
$reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($query);$reader.BatchSize=1
$record=$reader.ReadEvent([TimeSpan]::FromSeconds(5))
$status=@($reader.LogStatus)
if($status.Count -ne 1 -or $status[0].LogName -cne 'Microsoft-Windows-AppLocker/MSI and Script' -or $status[0].StatusCode -ne 0){throw 'Incomplete native channel query status.'}
if($null -eq $record){return [long]0}
if($record.LogName -cne $status[0].LogName -or $record.RecordId -le 0){throw 'Invalid native record boundary.'}
[long]$record.RecordId
}finally{if($record){$record.Dispose()};if($reader){$reader.Dispose()}}
}
function Start-WelaAppLockerScriptProcess {
param([string]$Root,$State,$Reader,[string]$SourcesKey)
$nonce=[guid]::NewGuid().ToString('N');$path=Join-Path $Root ('wela-script-'+$nonce+'.ps1')
$template=[IO.File]::ReadAllText((Join-Path $script:ScriptRoot 'scripts/AppLockerScriptWorker.ps1'))
$scriptBytes=[Text.UTF8Encoding]::new($false).GetBytes((New-WelaAppLockerScriptText $template $nonce))
$artifact=Write-WelaArrivalArtifact $Root ([IO.Path]::GetFileName($path)) ([Text.UTF8Encoding]::new($false).GetString($scriptBytes))
$source=$null;$scriptFile=$null;$process=$null;$started=$false;$stderr=$null
try {
$source=[IO.File]::Open($State.Source,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
$scriptFile=[IO.File]::Open($path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
$sourceId=[Wela.AppLockerScript.Native]::FileId($source.SafeFileHandle.DangerousGetHandle());$scriptId=[Wela.AppLockerScript.Native]::FileId($scriptFile.SafeFileHandle.DangerousGetHandle())
if((Get-FileHash -LiteralPath $State.Source -Algorithm SHA256).Hash.ToLowerInvariant() -cne $State.SourceHash -or (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -cne $artifact.Sha256){throw 'The held native source or generated script differs before launch.'}
if((Get-WelaAppLockerScriptKey (Get-WelaAppLockerScriptSources)) -cne $SourcesKey){throw 'Source changed before script launch.'}
$readerKey=Get-WelaAppLockerScriptKey $Reader
if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey){throw 'Caller token changed before launch.'}
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Source;$info.Arguments='-NoLogo -NoProfile -NonInteractive -File "'+$path+'"';$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardInput=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true;$info.WorkingDirectory=$Root
$process=[Diagnostics.Process]::new();$process.StartInfo=$info
$start=(Get-WelaAppLockerScriptUtcNow)
$started=$process.Start();if(-not $started){throw 'The fixed script process did not start.'}
$stderr=[Wela.AppLockerScript.Native]::ReadBoundedAsync($process.StandardError,4096)
$ready=[Wela.AppLockerScript.Native]::ReadLineBoundedAsync($process.StandardOutput,256)
if(-not $ready.Wait(30000)){throw 'The fixed script did not reach its ready marker within thirty seconds.'}
$line=$ready.GetAwaiter().GetResult()
if($line -cnotmatch ('^WELA_SCRIPT_READY_'+$nonce+'\|(FullLanguage|ConstrainedLanguage)\|5\.1\.[0-9.]+$')){throw ('Unexpected fixed script ready marker: '+$line)}
$child=[Wela.AppLockerScript.Native]::Child($process.Handle)
if((Get-WelaAppLockerScriptAuthorizationKey $child) -cne (Get-WelaAppLockerScriptAuthorizationKey $Reader)){throw 'The actual child primary/logon authorization differs from the caller.'}
if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey){throw 'Caller token changed while the fixed child started.'}
$stdout=[Wela.AppLockerScript.Native]::ReadBoundedAsync($process.StandardOutput,4096)
$process.StandardInput.WriteLine('WELA_SCRIPT_GO_'+$nonce);$process.StandardInput.Close()
if(-not $process.WaitForExit(10000)){throw 'The fixed child did not complete within ten seconds of release.'}
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'The fixed child output did not complete within five seconds.'}
$out=$stdout.GetAwaiter().GetResult();$err=$stderr.GetAwaiter().GetResult();$end=(Get-WelaAppLockerScriptUtcNow)
if($process.ExitCode -ne 0 -or $out.TrimEnd("`r","`n") -cne ('WELA_SCRIPT_COMPLETE_'+$nonce) -or $err){throw ('The fixed script did not complete correctly. Exit='+$process.ExitCode+' Error='+$err)}
if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey -or [Wela.AppLockerScript.Native]::FileId($source.SafeFileHandle.DangerousGetHandle()) -cne $sourceId -or [Wela.AppLockerScript.Native]::FileId($scriptFile.SafeFileHandle.DangerousGetHandle()) -cne $scriptId){throw 'Reader or held file identity changed during script execution.'}
[pscustomobject][ordered]@{ProcessId=$process.Id;UserSid=$Reader.Sid;ChildToken=$child;NativePowerShell=$State.Source;NativePowerShellSha256=$State.SourceHash;NativePowerShellFileId=$sourceId;ScriptPath=$path;ScriptSha256=$artifact.Sha256;ScriptFileId=$scriptId;ScriptArtifact=$artifact;Nonce=$nonce;Arguments=$info.Arguments;StartedUtc=$start.ToString('o');CompletedUtc=$end.ToString('o');Clock='GetSystemTimePreciseAsFileTime';Ready=$line;Marker=$out.TrimEnd("`r","`n");ExitCode=$process.ExitCode}
}catch{
$message=$_.Exception.Message
if($stderr -and $stderr.Status -eq [Threading.Tasks.TaskStatus]::RanToCompletion){$message+=' Native stderr: '+$stderr.GetAwaiter().GetResult()}
throw $message
}finally{
try{if($process){try{if($started -and -not $process.HasExited){$process.Kill();if(-not $process.WaitForExit(5000)){throw 'Owned script process termination is unconfirmed.'}}}finally{$process.Dispose()}}}
finally{if($scriptFile){$scriptFile.Dispose()};if($source){$source.Dispose()}}
}
}
function Read-WelaAppLockerScriptEvents {
param([long]$Boundary)
$query="*[System[Provider[@Name='Microsoft-Windows-AppLocker'] and (EventID=8005 or EventID=8006) and EventRecordID>$Boundary]]"
$reader=$null;$record=$null;$xml=@();$bytes=0
try {
$nativeQuery=[Diagnostics.Eventing.Reader.EventLogQuery]::new('Microsoft-Windows-AppLocker/MSI and Script',[Diagnostics.Eventing.Reader.PathType]::LogName,$query);$nativeQuery.ReverseDirection=$false;$nativeQuery.TolerateQueryErrors=$false
$reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($nativeQuery);$reader.BatchSize=16
while($null -ne ($record=$reader.ReadEvent([TimeSpan]::FromSeconds(2)))) {
try{$text=$record.ToXml();$bytes+=[Text.Encoding]::UTF8.GetByteCount($text);if($xml.Count -ge 255 -or $bytes -gt 1048576){throw 'Native script query reached its 256-event/one-MiB cap.'};$xml+=$text}finally{$record.Dispose();$record=$null}
}
$status=@($reader.LogStatus);if($status.Count -ne 1 -or $status[0].LogName -cne 'Microsoft-Windows-AppLocker/MSI and Script' -or $status[0].StatusCode -ne 0){throw 'Incomplete native script query status.'}
[pscustomobject]@{Xml=$xml;Query=$query;Bytes=$bytes;Complete=$true}
}finally{if($record){$record.Dispose()};if($reader){$reader.Dispose()}}
}
function Test-WelaAppLockerScriptEvent {
param([string]$Xml,$Process,$State,[long]$Boundary)
$reader=$null
try {
$settings=New-Object Xml.XmlReaderSettings;$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=4194304
$reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Xml),$settings);$doc=New-Object Xml.XmlDocument;$doc.XmlResolver=$null;$doc.Load($reader)
$ns=New-Object Xml.XmlNamespaceManager($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event');$ns.AddNamespace('a','http://schemas.microsoft.com/schemas/event/Microsoft.Windows/1.0.0.0')
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:UserData/a:RuleAndFileData',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:EventData',$ns).Count) {return $false}
$system=@{};foreach ($name in @('Provider','EventID','Version','EventRecordID','Channel','Computer','TimeCreated')) {$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-AppLocker' -or $system.Provider.GetAttribute('Guid').Trim('{}') -ine 'cbda4dbf-8d5d-4f69-9578-be14aa540d22' -or $system.EventID.InnerText -cnotin @('8005','8006') -or $system.Version.InnerText -cne '0' -or $system.EventRecordID.InnerText -notmatch '^[1-9][0-9]*$' -or $system.Channel.InnerText -cne 'Microsoft-Windows-AppLocker/MSI and Script') {return $false}
$computers=@($State.Computer);if($State.Host.PartOfDomain){$computers+=$State.Computer+'.'+$State.Domain};if($system.Computer.InnerText -notin $computers){return $false}
$time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime')
if($time.UtcDateTime -lt ([DateTimeOffset]::Parse($Process.StartedUtc)).UtcDateTime -or $time.UtcDateTime -gt (ConvertTo-WelaArrivalUtc $Process.CompletedUtc).UtcDateTime -or [long]$system.EventRecordID.InnerText -le $Boundary){return $false}
$data=@{};foreach($node in $doc.SelectSingleNode('/e:Event/e:UserData/a:RuleAndFileData',$ns).ChildNodes){if($node.NodeType -eq 'Whitespace'){continue};if($node.NodeType -ne 'Element' -or $node.NamespaceURI -cne $ns.LookupNamespace('a') -or $data.ContainsKey($node.LocalName) -or @($node.ChildNodes|Where-Object NodeType -eq Element).Count){return $false};$data[$node.LocalName]=$node.InnerText}
if($data.PolicyName -cne 'SCRIPT' -or $data.TargetUser -cne $Process.UserSid -or $data.TargetProcessId -notmatch '^[1-9][0-9]*$' -or [long]$data.TargetProcessId -ne $Process.ProcessId){return $false}
# AppLocker may render the exact Windows directory through this documented path variable.
$eventPath=$data.FilePath
if($eventPath -imatch '^%OSDRIVE%\\'){$eventPath=[IO.Path]::GetPathRoot($State.Source).TrimEnd('\')+$eventPath.Substring(9)}
return $eventPath -ieq $Process.ScriptPath
} catch {return $false} finally {if($reader){$reader.Dispose()}}
}
function Invoke-WelaAppLockerScriptProbe {
param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15)
if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new AppLockerScriptOutputPath; Plan does not write files.'}
Initialize-WelaAppLockerScriptNative
$sources=Get-WelaAppLockerScriptSources;$sourceKey=Get-WelaAppLockerScriptKey $sources
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaAppLockerScriptProbe';Action=$Action;Status='Unverified';ExitCode=1;RecordedUtc=(Get-WelaAppLockerScriptUtcNow).ToString('o');Sources=$sources;Before=$null;After=$null;ReaderBefore=$null;ReaderAfter=$null;ReaderInterval='After output/context preparation, through child execution and actual event queries; final metadata is checked separately';Boundary=$null;Process=$null;EventId=$null;Decision=$null;Artifacts=@();Diagnostic='';OutputPath=$null;PolicyChanges=0;ReadyRuleCredit=0;CspPolicyState='Unknown';Scope='One fixed native Windows PowerShell5.1 Script-collection event. Other engines, collections, forwarding and Sigma/backend validation are not tested. Sysmon excluded.'}
try {
$before=Get-WelaAppLockerScriptState;$report.Before=$before;$key=Get-WelaAppLockerScriptStateKey $before
if($Action -eq 'Plan'){$report.ReaderBefore=(Get-WelaAppLockerScriptReader);$report.Status='PrerequisitesObserved';$report.ExitCode=0;return $report}
$report.OutputPath=New-WelaArrivalOutput -Path $OutputPath -SourcePath $script:ScriptRoot
if((Get-WelaAppLockerScriptStateKey (Get-WelaAppLockerScriptState)) -cne $key){throw 'Context changed during output preparation.'}
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'before.json' ($before|ConvertTo-Json -Depth 24)
$reader=(Get-WelaAppLockerScriptReader);$report.ReaderBefore=$reader;$readerKey=Get-WelaAppLockerScriptKey $reader
$report.Boundary=Read-WelaAppLockerScriptBoundary
if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey){throw 'Reader changed during the actual boundary query.'}
$process=Start-WelaAppLockerScriptProcess -Root $report.OutputPath -State $before -Reader $reader -SourcesKey $sourceKey;$report.Process=$process
$report.Artifacts+= $process.ScriptArtifact
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'process.json' ($process|ConvertTo-Json -Depth 16)
$timer=[Diagnostics.Stopwatch]::StartNew();$matches=@();$batch=$null
do {
if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey){throw 'Reader changed before script event query.'}
$batch=Read-WelaAppLockerScriptEvents $report.Boundary
if($batch.Complete -isnot [bool] -or -not $batch.Complete){throw 'Script query completeness is unknown.'}
if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey){throw 'Reader changed during script event query.'}
$matches=@($batch.Xml|Where-Object{Test-WelaAppLockerScriptEvent $_ $process $before $report.Boundary})
if($matches.Count -gt 1){throw 'Multiple exact script records make the result ambiguous.'}
if($matches.Count -eq 1){break}
Start-Sleep -Milliseconds 250
}while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds)
$report.ReaderAfter=(Get-WelaAppLockerScriptReader)
if((Get-WelaAppLockerScriptKey $report.ReaderAfter) -cne $readerKey){throw 'Reader changed before completion of the actual query interval.'}
if($matches.Count -ne 1){
# Retain only bounded candidates bearing the owned unique script name.
$owned=@($batch.Xml|Where-Object{$_ -like ('*wela-script-'+$process.Nonce+'.ps1*')}|Select-Object -First 4)
for($i=0;$i -lt $owned.Count;$i++){$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath ('candidate-'+$i+'.xml') $owned[$i]}
throw 'No exact native Script8005/8006 event arrived within the timeout.'
}
$report.After=Get-WelaAppLockerScriptState
if((Get-WelaAppLockerScriptStateKey $report.After) -cne $key -or (Get-WelaAppLockerScriptKey (Get-WelaAppLockerScriptSources)) -cne $sourceKey -or (Get-FileHash -LiteralPath $process.ScriptPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $process.ScriptSha256){throw 'Host, policy, service, channel, execution-policy observation or implementation changed.'}
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'after.json' ($report.After|ConvertTo-Json -Depth 24)
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'event.xml' $matches[0]
$event=[xml]$matches[0];$report.EventId=[int]$event.Event.System.EventID
$report.Decision=if($report.EventId -eq 8005){'Allowed'}else{'AllowedWouldBlockIfEnforced'}
$report.Status='NativeScriptEventObserved';$report.ExitCode=0
}catch{$report.Diagnostic=$_.Exception.Message}
if($report.OutputPath){$json=$report|ConvertTo-Json -Depth 32;if([Text.Encoding]::UTF8.GetByteCount($json) -gt 2097152){throw 'The script probe report exceeded its two-MiB bound.'};$null=Write-WelaArrivalArtifact $report.OutputPath 'manifest.json' $json}
$report
}
+10
View File
@@ -0,0 +1,10 @@
# Fixed locally generated script; no external inputs or configuration writes.
$ErrorActionPreference = 'Stop'
[Console]::Out.WriteLine(('WELA_SCRIPT_READY___WELA_SCRIPT_NONCE__|' + $ExecutionContext.SessionState.LanguageMode + '|' + $PSVersionTable.PSVersion))
# .NET Framework's redirected-input writer may emit an encoding preamble.
# Read the owned pipe through a BOM-aware reader, without changing console state.
$pipeReader = [IO.StreamReader]::new([Console]::OpenStandardInput(), [Text.UTF8Encoding]::new($false, $true), $true, 128, $true)
try { $release = $pipeReader.ReadLine() } finally { $pipeReader.Dispose() }
if ($release -cne 'WELA_SCRIPT_GO___WELA_SCRIPT_NONCE__') { exit 17 }
[Console]::Out.WriteLine('WELA_SCRIPT_COMPLETE___WELA_SCRIPT_NONCE__')
exit 0
+265
View File
@@ -0,0 +1,265 @@
# Explicit one-byte existing-file read and exact local Security4663 evidence.
# Resolve target scope before reading any external native-helper source or hashing dependencies.
# This small literal helper opens metadata only and never reads target bytes.
function Initialize-WelaFileProbeScopeNative {
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'The file probe requires native 64-bit Windows.'}
$definition=@'
using System;using System.ComponentModel;using System.Runtime.InteropServices;using System.Text;
namespace Wela.FileAccessScope {
public sealed class Observation {public string Path;public uint Links,Attributes;}
public static class Native {
public const string SourceSha256="__WELA_FILE_SCOPE_SOURCE_SHA256__";
[StructLayout(LayoutKind.Sequential,Pack=4)] struct Info {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 bool GetFileInformationByHandle(IntPtr handle,out Info info);
[DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true,ExactSpelling=true)] static extern uint GetFinalPathNameByHandleW(IntPtr handle,StringBuilder path,uint length,uint flags);
public static Observation Observe(string path) {
IntPtr handle=CreateFileW(path,0x80,7,IntPtr.Zero,3,0x00200000,IntPtr.Zero);
if(handle==new IntPtr(-1))throw new Win32Exception(Marshal.GetLastWin32Error(),"Metadata-only target-scope observation failed.");
try{Info info;if(!GetFileInformationByHandle(handle,out info))throw new Win32Exception(Marshal.GetLastWin32Error());
StringBuilder final=new StringBuilder(32768);uint length=GetFinalPathNameByHandleW(handle,final,(uint)final.Capacity,0);
if(length==0||length>=final.Capacity||!final.ToString().StartsWith(@"\\?\",StringComparison.Ordinal))throw new InvalidOperationException("Canonical local target scope is unknown.");
return new Observation{Path=final.ToString().Substring(4),Links=info.Links,Attributes=info.Attributes};
}finally{CloseHandle(handle);}
}
}
}
'@
$hash=Get-WelaArrivalHash ([Text.UTF8Encoding]::new($false).GetBytes($definition))
if(-not ('Wela.FileAccessScope.Native' -as [type])){Add-Type -TypeDefinition $definition.Replace('__WELA_FILE_SCOPE_SOURCE_SHA256__',$hash) -ErrorAction Stop}
if([Wela.FileAccessScope.Native]::SourceSha256 -cne $hash){throw 'Loaded file scope helper differs; start a fresh session.'}
}
function Assert-WelaFileProbeScopeObservation {
param([string]$SelectedPath,$Selected,$SourceFile,$Engine)
foreach($item in @($Selected,$SourceFile,$Engine)){if($item.Path -isnot [string] -or -not $item.Path){throw 'Incomplete canonical target scope.'};Assert-WelaFileProbePath $item.Path}
$sourceRoot=$SourceFile.Path.Substring(0,$SourceFile.Path.LastIndexOf('\')+1)
if($Selected.Path.StartsWith($sourceRoot,[StringComparison]::OrdinalIgnoreCase)){throw 'Select a file outside the WELA source tree; implementation targets are unsupported.'}
if($Selected.Path.Equals($Engine.Path,[StringComparison]::OrdinalIgnoreCase)){throw 'The active PowerShell engine cannot be the selected file target.'}
if($Selected.Path -ine $SelectedPath -or -not(Test-WelaFileProbeInteger $Selected.Links) -or $Selected.Links -ne 1 -or -not(Test-WelaFileProbeInteger $Selected.Attributes) -or ($Selected.Attributes -band 1040)){throw 'Only ordinary canonical single-link file targets are supported; aliases and reparse targets are refused.'}
}
function Assert-WelaFileProbeTargetScope {
param([string]$Path)
Initialize-WelaFileProbeScopeNative
$selected=[Wela.FileAccessScope.Native]::Observe($Path)
$source=[Wela.FileAccessScope.Native]::Observe((Join-Path $script:ScriptRoot 'WELA.ps1'))
$engine=[Wela.FileAccessScope.Native]::Observe((Get-Process -Id $PID -ErrorAction Stop).Path)
Assert-WelaFileProbeScopeObservation $Path $selected $source $engine
}
function Initialize-WelaFileProbeNative {
Initialize-WelaWmiProbeNative
$source=Join-Path $PSScriptRoot 'FileAccessProbeNative.cs';$bytes=[IO.File]::ReadAllBytes($source);$hash=Get-WelaArrivalHash $bytes
if(-not ('Wela.FileAccessProbe.FileHandle' -as [type])){
$definition=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).Replace('__WELA_FILE_PROBE_SOURCE_SHA256__',$hash)
Add-Type -TypeDefinition $definition -ErrorAction Stop
}
if([Wela.FileAccessProbe.FileHandle]::SourceSha256 -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','NativePath','Identity','DescriptorBase64','StateKey')){if($Snapshot.$name -isnot [string] -or -not $Snapshot.$name){throw 'Incomplete typed file observation.'}}
Assert-WelaFileProbePath $Snapshot.Path
if($Snapshot.NativePath -cnotmatch '^\\Device\\[^\\]+\\'){throw 'Incomplete native NT file path observation.'}
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;Assert-WelaFileProbeTargetScope $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','Phase','HandleId','BeforeKey','AfterKey')){if($read.$name -isnot [string]){throw 'Untyped native read receipt.'}}
if($read.Phase -cne 'OneByteReadAndHeldIdentityReadback' -or $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;$returned=ConvertTo-WelaArrivalUtc $read.ReadReturnedUtc;$end=ConvertTo-WelaArrivalUtc $read.CompletedUtc
if($LaunchedUtc -gt $ObservedUtc -or $start -lt $LaunchedUtc -or $returned -lt $start -or $end -lt $returned -or $end -gt $ObservedUtc -or ($end-$start).TotalSeconds -gt 20){throw 'Invalid precise one-byte/readback operation 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.ReadReturnedUtc=$returned.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 -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 -and $data.ObjectName -ine $State.File.NativePath) -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 measured one-byte/readback phase.'}
$report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'event.xml' $matches[0]
if((Get-WelaFileProbeWatermark) -lt $operation.RecordIdBefore){throw 'Security record boundary moved backwards.'}
$after=Get-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
}
+112
View File
@@ -0,0 +1,112 @@
// 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,NativePath,Identity,LastWriteUtc,DescriptorBase64,StateKey;
public long Size; public uint Attributes,Links; public int SecurityInformation; public Ace[] Aces;
}
public sealed class ReadReceipt {
public string StartedUtc,ReadReturnedUtc,CompletedUtc,Clock,Phase,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);
public const string SourceSha256="__WELA_FILE_PROBE_SOURCE_SHA256__";
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);
// Bind the NT volume name from this same held handle: Security4663 may use it.
StringBuilder native=new StringBuilder(32768);uint nativeLength=GetFinalPathNameByHandleW(handle,native,(uint)native.Capacity,2);
if(nativeLength==0||nativeLength>=native.Capacity||!System.Text.RegularExpressions.Regex.IsMatch(native.ToString(),@"^\\Device\\[^\\]+\\"))throw new InvalidOperationException("Native NT file path observation failed.");
string nativePath=native.ToString();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<Ace> entries=new List<Ace>();
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()+"|"+nativePath.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,NativePath=nativePath,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(),returned;bool success;int error;
try{success=ReadFile(handle,buffer,1,out count,IntPtr.Zero);error=Marshal.GetLastWin32Error();returned=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.");
// Measure the real completed phase, including the existing held-handle identity/security readback.
// Retain the immediate ReadFile return separately; add no delay or timestamp padding.
DateTime completed=UtcNow();
return new ReadReceipt{StartedUtc=started.ToString("o"),ReadReturnedUtc=returned.ToString("o"),CompletedUtc=completed.ToString("o"),Phase="OneByteReadAndHeldIdentityReadback",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;}}
}
}
+22
View File
@@ -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()}