mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-07 15:04:45 +02:00
test: verify fixed native token privilege adjustment feasibility
This commit is contained in:
1 parent
70a812556d
commit
cd65133fc4
3 files changed
+191
No files matched your search
@@ -0,0 +1,38 @@
|
||||
name: Native token right adjustment probe
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
paths:
|
||||
- 'scripts/TokenRightProbe*'
|
||||
- 'tests/TokenRightProbe*'
|
||||
- '.github/workflows/token-right-probe.yml'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
token-right-probe:
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-2022, windows-2025]
|
||||
engine: [powershell, pwsh]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
- name: Native adjustment feasibility in Windows PowerShell
|
||||
if: matrix.engine == 'powershell'
|
||||
shell: powershell
|
||||
run: ./tests/TokenRightProbe.Feasibility.ps1 -AllowDisposableAuditWrite
|
||||
- name: Native adjustment feasibility in PowerShell7
|
||||
if: matrix.engine == 'pwsh'
|
||||
shell: pwsh
|
||||
run: ./tests/TokenRightProbe.Feasibility.ps1 -AllowDisposableAuditWrite
|
||||
- name: Retain native events and cleanup
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: token-right-probe-${{ matrix.os }}-${{ matrix.engine }}
|
||||
path: ${{ runner.temp }}/wela-token-right-feasibility-*/
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Wela.TokenRightProbe {
|
||||
public sealed class Privilege { public string Luid; public uint Attributes; }
|
||||
public sealed class Outcome {
|
||||
public string Status, Diagnostic, Luid;
|
||||
public bool AdjustmentAttempted, Restored;
|
||||
public uint OriginalAttributes;
|
||||
public long DisableStartedFileTime, DisableReturnedFileTime, RestoreStartedFileTime, RestoreReturnedFileTime;
|
||||
public Privilege[] Before, Disabled, After;
|
||||
}
|
||||
public static class Native {
|
||||
[StructLayout(LayoutKind.Sequential)] struct Luid { public uint Low; public int High; }
|
||||
[StructLayout(LayoutKind.Sequential)] struct Entry { public Luid Id; public uint Attributes; }
|
||||
[StructLayout(LayoutKind.Sequential)] struct One { public uint Count; public Luid Id; public uint Attributes; }
|
||||
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess();
|
||||
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentThread();
|
||||
[DllImport("kernel32.dll", SetLastError=true)] static extern bool CloseHandle(IntPtr handle);
|
||||
[DllImport("kernel32.dll")] static extern void GetSystemTimePreciseAsFileTime(out long value);
|
||||
[DllImport("kernel32.dll")] static extern void SetLastError(uint error);
|
||||
[DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr process,uint access,out IntPtr token);
|
||||
[DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenThreadToken(IntPtr thread,uint access,bool self,out IntPtr token);
|
||||
[DllImport("advapi32.dll",SetLastError=true)] static extern bool GetTokenInformation(IntPtr token,int kind,IntPtr buffer,int length,out int needed);
|
||||
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool LookupPrivilegeValue(string system,string name,out Luid value);
|
||||
[DllImport("advapi32.dll",SetLastError=true)] static extern bool AdjustTokenPrivileges(IntPtr token,bool all,ref One value,uint length,IntPtr previous,IntPtr returned);
|
||||
static string Hex(Luid id) { return "0x"+(((ulong)(uint)id.High<<32)|id.Low).ToString("x"); }
|
||||
static long Now() { long value; GetSystemTimePreciseAsFileTime(out value); return value; }
|
||||
static void PrimaryOnly() {
|
||||
IntPtr thread;
|
||||
if(OpenThreadToken(GetCurrentThread(),8,true,out thread)) { CloseHandle(thread); throw new InvalidOperationException("An impersonation token is not accepted."); }
|
||||
int error=Marshal.GetLastWin32Error();
|
||||
if(error!=1008) throw new Win32Exception(error,"Cannot establish absence of an impersonation token.");
|
||||
}
|
||||
static Privilege[] Read(IntPtr token) {
|
||||
int needed; bool first=GetTokenInformation(token,3,IntPtr.Zero,0,out needed); int error=Marshal.GetLastWin32Error();
|
||||
if(first||error!=122||needed<4||needed>65536) throw new InvalidOperationException("Unexpected token privilege size response.");
|
||||
IntPtr buffer=Marshal.AllocHGlobal(needed);
|
||||
try {
|
||||
int returned;
|
||||
if(!GetTokenInformation(token,3,buffer,needed,out returned))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
int count=Marshal.ReadInt32(buffer); int size=Marshal.SizeOf(typeof(Entry));
|
||||
if(returned>needed||count<1||count>4096||4L+(long)count*size>returned)throw new InvalidOperationException("Truncated token privileges.");
|
||||
var result=new List<Privilege>(); var seen=new HashSet<string>(StringComparer.Ordinal);
|
||||
for(int i=0;i<count;i++) { var item=(Entry)Marshal.PtrToStructure(IntPtr.Add(buffer,4+i*size),typeof(Entry)); string id=Hex(item.Id); if(!seen.Add(id))throw new InvalidOperationException("Duplicate token privilege."); result.Add(new Privilege{Luid=id,Attributes=item.Attributes}); }
|
||||
result.Sort((a,b)=>String.CompareOrdinal(a.Luid,b.Luid));return result.ToArray();
|
||||
} finally { Marshal.FreeHGlobal(buffer); }
|
||||
}
|
||||
static void Change(IntPtr token,Luid id,uint attributes) {
|
||||
var value=new One{Count=1,Id=id,Attributes=attributes};SetLastError(0);
|
||||
bool ok=AdjustTokenPrivileges(token,false,ref value,0,IntPtr.Zero,IntPtr.Zero);int error=Marshal.GetLastWin32Error();
|
||||
if(!ok||error!=0)throw new Win32Exception(error,"The fixed privilege adjustment did not report complete success.");
|
||||
}
|
||||
static void Equal(Privilege[] expected,Privilege[] actual,string changed,bool enabled) {
|
||||
if(expected==null||actual==null||expected.Length!=actual.Length)throw new InvalidOperationException("Privilege inventory changed.");
|
||||
for(int i=0;i<expected.Length;i++) {
|
||||
uint attributes=expected[i].Attributes;
|
||||
if(expected[i].Luid==changed&&!enabled)attributes&=~2U;
|
||||
if(expected[i].Luid!=actual[i].Luid||attributes!=actual[i].Attributes)throw new InvalidOperationException("Unexpected token privilege state.");
|
||||
}
|
||||
}
|
||||
public static Outcome Run() {
|
||||
var result=new Outcome{Status="Refused",Diagnostic=""};IntPtr token=IntPtr.Zero;Luid target=new Luid();
|
||||
try {
|
||||
PrimaryOnly();
|
||||
if(!OpenProcessToken(GetCurrentProcess(),0x28,out token))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
if(!LookupPrivilegeValue(null,"SeChangeNotifyPrivilege",out target))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
result.Luid=Hex(target);result.Before=Read(token);Privilege found=null;
|
||||
foreach(var item in result.Before)if(item.Luid==result.Luid)found=item;
|
||||
if(found==null||(found.Attributes&2)==0||(found.Attributes&4)!=0)throw new InvalidOperationException("SeChangeNotifyPrivilege must already be present and enabled; no new privilege is granted.");
|
||||
result.OriginalAttributes=found.Attributes;
|
||||
try {
|
||||
result.DisableStartedFileTime=Now();result.AdjustmentAttempted=true;
|
||||
Change(token,target,found.Attributes&~2U);result.DisableReturnedFileTime=Now();
|
||||
result.Disabled=Read(token);Equal(result.Before,result.Disabled,result.Luid,false);
|
||||
result.Status="Adjusted";
|
||||
} finally {
|
||||
if(result.AdjustmentAttempted) {
|
||||
result.RestoreStartedFileTime=Now();Change(token,target,result.OriginalAttributes);result.RestoreReturnedFileTime=Now();
|
||||
result.After=Read(token);Equal(result.Before,result.After,result.Luid,true);result.Restored=true;
|
||||
}
|
||||
}
|
||||
} catch(Exception error) {result.Status=result.AdjustmentAttempted?"Unverified":"Refused";result.Diagnostic=error.ToString();}
|
||||
finally {if(token!=IntPtr.Zero)CloseHandle(token);}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
param([switch]$AllowDisposableAuditWrite)
|
||||
$ErrorActionPreference='Stop'
|
||||
if(-not $AllowDisposableAuditWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable Windows fixture only.'}
|
||||
$repo=Split-Path $PSScriptRoot -Parent
|
||||
Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
|
||||
. (Join-Path $repo 'scripts/Configuration.ps1')
|
||||
. (Join-Path $repo 'scripts/WmiProbe.ps1')
|
||||
Initialize-WelaWmiProbeNative
|
||||
$root=Join-Path $env:RUNNER_TEMP ('wela-token-right-feasibility-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
|
||||
function Save($Name,$Value){ConvertTo-Json -InputObject $Value -Depth 24|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8}
|
||||
function Key($Value){ConvertTo-Json -InputObject $Value -Depth 24 -Compress}
|
||||
function Masks{$m=Get-WelaEffectiveAuditPolicy;@($m.Keys|Sort-Object|ForEach-Object{"$_=$($m[$_])"}) -join ';'}
|
||||
$guid='0CCE924A-69AE-11D9-BED3-505054503030';$auth='0CCE9231-69AE-11D9-BED3-505054503030'
|
||||
$path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$name='SCENoApplyLegacyAuditPolicy'
|
||||
$beforeMasks=Get-WelaEffectiveAuditPolicy;$masks=Masks;$beforePrecedence=Get-WelaRegistryState $path $name;$beforeToken=[Wela.WmiProbe.Native]::Snapshot();$failure=$null;$errors=@()
|
||||
Save 'original.json' @{Masks=$beforeMasks;Precedence=$beforePrecedence;Token=$beforeToken;Head=$env:GITHUB_SHA;Engine=$PSVersionTable.PSVersion.ToString()}
|
||||
$worker=Join-Path $root 'worker.ps1';$receipt=Join-Path $root 'worker.json'
|
||||
@'
|
||||
param($Repo,$Result)
|
||||
$ErrorActionPreference='Stop'
|
||||
Add-Type -Path (Join-Path $Repo 'scripts/WmiProbeNative.cs')
|
||||
Add-Type -Path (Join-Path $Repo 'scripts/TokenRightProbeNative.cs')
|
||||
$before=[Wela.WmiProbe.Native]::Snapshot()
|
||||
$outcome=[Wela.TokenRightProbe.Native]::Run()
|
||||
$after=[Wela.WmiProbe.Native]::Snapshot()
|
||||
[pscustomobject]@{ProcessId=$PID;ProcessName=(Get-Process -Id $PID).Path;Before=$before;After=$after;Outcome=$outcome}|ConvertTo-Json -Depth 24|Set-Content -LiteralPath $Result -Encoding UTF8
|
||||
if($outcome.Status -ne 'Adjusted' -or -not $outcome.Restored -or (($before|ConvertTo-Json -Depth 24 -Compress) -cne ($after|ConvertTo-Json -Depth 24 -Compress))){exit 1}
|
||||
exit 0
|
||||
'@|Set-Content -LiteralPath $worker -Encoding UTF8
|
||||
try{
|
||||
if($beforeMasks.Count -ne 59){throw 'All59 masks required.'}
|
||||
Set-ItemProperty -LiteralPath $path -Name $name -Value 1 -Type DWord
|
||||
Set-WelaEffectiveAuditPolicy -Guid $guid -Mask ($beforeMasks[$guid] -bor 1) -Mode exact
|
||||
Set-WelaEffectiveAuditPolicy -Guid $auth -Mask 0 -Mode exact
|
||||
$engine=(Get-Process -Id $PID).Path
|
||||
& $engine -NoLogo -NoProfile -NonInteractive -File $worker $repo $receipt
|
||||
if($LASTEXITCODE -ne 0){throw 'Native worker failed.'}
|
||||
$result=Get-Content -Raw $receipt|ConvertFrom-Json
|
||||
$start=[DateTime]::FromFileTimeUtc($result.Outcome.DisableStartedFileTime).AddSeconds(-1);$end=[DateTime]::FromFileTimeUtc($result.Outcome.RestoreReturnedFileTime).AddSeconds(1)
|
||||
$query="*[System[EventID=4703 and TimeCreated[@SystemTime>='$($start.ToString('o'))' and @SystemTime<='$($end.ToString('o'))']]]"
|
||||
$matches=@();$deadline=[DateTime]::UtcNow.AddSeconds(15)
|
||||
do{
|
||||
$events=@(Get-WinEvent -LogName Security -FilterXPath $query -MaxEvents 256 -ErrorAction SilentlyContinue)
|
||||
foreach($event in $events){
|
||||
$raw=$event.ToXml();[xml]$xml=$raw;$data=@{};foreach($field in $xml.Event.EventData.Data){$data[[string]$field.Name]=[string]$field.'#text'}
|
||||
if($data.ProcessId -and [Convert]::ToInt64($data.ProcessId,16) -eq $result.ProcessId -and ($data.EnabledPrivilegeList -match 'SeChangeNotifyPrivilege' -or $data.DisabledPrivilegeList -match 'SeChangeNotifyPrivilege')){$matches+=@([pscustomobject]@{RecordId=$event.RecordId;Xml=$raw;Data=$data})}
|
||||
$event.Dispose()
|
||||
}
|
||||
$matches=@($matches|Sort-Object RecordId -Unique)
|
||||
if($matches.Count -ge 2){break};Start-Sleep -Milliseconds 250
|
||||
}while([DateTime]::UtcNow -lt $deadline)
|
||||
Save 'events.json' $matches
|
||||
if($matches.Count -lt 2){throw 'No two attributable actual4703 adjustment events were observed.'}
|
||||
Write-Host "Native feasibility observed $($matches.Count) attributable4703 events with Token Right Adjusted success enabled and Authorization Policy Change disabled."
|
||||
}catch{$failure=$_.ToString();throw}finally{
|
||||
foreach($restoreGuid in @($guid,$auth)){try{Set-WelaEffectiveAuditPolicy -Guid $restoreGuid -Mask $beforeMasks[$restoreGuid] -Mode exact}catch{$errors+=$_.ToString()}}
|
||||
try{if($beforePrecedence.ValueExists){Set-ItemProperty -LiteralPath $path -Name $name -Value $beforePrecedence.Value -Type $beforePrecedence.Type}else{Remove-ItemProperty -LiteralPath $path -Name $name -ErrorAction Stop}}catch{$errors+=$_.ToString()}
|
||||
$afterToken=[Wela.WmiProbe.Native]::Snapshot();$afterPrecedence=Get-WelaRegistryState $path $name
|
||||
$complete=$errors.Count -eq 0 -and (Masks) -ceq $masks -and (Key $afterPrecedence) -ceq (Key $beforePrecedence) -and ((Key $beforeToken) -ceq (Key $afterToken))
|
||||
Save 'cleanup.json' @{Complete=$complete;Errors=$errors;Failure=$failure;AfterToken=$afterToken;AfterMasks=Get-WelaEffectiveAuditPolicy;AfterPrecedence=$afterPrecedence}
|
||||
if(-not $complete){throw 'Native feasibility fixture cleanup failed.'}
|
||||
}
|
||||
$global:LASTEXITCODE=0
|
||||
Reference in new issue
Block a user