From 59a83a20cc87fd10cd309f3d0f640a78c4694b87 Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Tue, 22 Sep 2026 08:08:17 +0900
Subject: [PATCH] Fix WEC variant decoding and native authorization fixture
---
scripts/WecAuthorizationNative.cs | 25 ++++++++++++++---------
tests/WecAuthorization.Tests.ps1 | 26 ++++++++++++++++++++++++
tests/WecAuthorization.Windows.Tests.ps1 | 10 ++++++---
tests/WecAuthorizationFixtureNative.cs | 25 +++++++++++++++++++++++
4 files changed, 73 insertions(+), 13 deletions(-)
create mode 100644 tests/WecAuthorizationFixtureNative.cs
diff --git a/scripts/WecAuthorizationNative.cs b/scripts/WecAuthorizationNative.cs
index 4b89e79a..d8a3f34b 100644
--- a/scripts/WecAuthorizationNative.cs
+++ b/scripts/WecAuthorizationNative.cs
@@ -31,6 +31,20 @@ namespace Wela.WecAuthorization {
}
if(!String.Equals(expected.ToString(),value,StringComparison.Ordinal))throw new ArgumentException("Unsupported authorization descriptor.");
}
+ // Public only for allocated-buffer ABI/type regression tests; performs no native calls.
+ public static object Decode(IntPtr buffer,uint size,int property) {
+ if(buffer==IntPtr.Zero||size<16||size>1048576||property!=0&&property!=6&&property!=7&&property!=10&&property!=11&&property!=19&&property!=27&&property!=31)throw new InvalidOperationException("Invalid native property buffer or selection.");
+ int type=Marshal.ReadInt32(buffer,12);
+ if(property==27){if(type!=2)throw new InvalidOperationException("Subscription type is not UInt32.");return unchecked((uint)Marshal.ReadInt32(buffer));}
+ if(property==0){if(type!=1)throw new InvalidOperationException("Enabled is not a scalar Boolean.");int value=Marshal.ReadInt32(buffer);if(value!=0&&value!=1)throw new InvalidOperationException("Invalid native Boolean.");return value==1;}
+ if(type==0&&property==6)return "";
+ if(type!=4)throw new InvalidOperationException("Expected scalar native string.");
+ IntPtr pointer=Marshal.ReadIntPtr(buffer);long offset=pointer.ToInt64()-buffer.ToInt64();
+ if(pointer==IntPtr.Zero||offset<16||offset>size-2)throw new InvalidOperationException("Native string pointer is outside its buffer.");
+ StringBuilder text=new StringBuilder();
+ for(int i=0;i<524288&&offset+2L*i+2<=size;i++){char c=(char)(ushort)Marshal.ReadInt16(pointer,2*i);if(c==0)return text.ToString();text.Append(c);}
+ throw new InvalidOperationException("Unterminated native string.");
+ }
static object Read(IntPtr h,int property) {
uint size=16;
for(int attempt=0;attempt<3;attempt++) {
@@ -39,16 +53,7 @@ namespace Wela.WecAuthorization {
uint used;bool ok=EcGetSubscriptionProperty(h,property,0,size,buffer,out used);int error=Marshal.GetLastWin32Error();
if(!ok){if(error!=122)throw new Win32Exception(error);if(used<=size||used>1048576)throw new InvalidOperationException("Invalid native property buffer size.");size=used;continue;}
if(used<16||used>size)throw new InvalidOperationException("Invalid native property length.");
- int type=Marshal.ReadInt32(buffer,12);
- if(property==27){if(type!=8)throw new InvalidOperationException("Subscription type is not UInt32.");return unchecked((uint)Marshal.ReadInt32(buffer));}
- if(property==0){if(type!=1)throw new InvalidOperationException("Enabled is not a scalar Boolean.");int value=Marshal.ReadInt32(buffer);if(value!=0&&value!=1)throw new InvalidOperationException("Invalid native Boolean.");return value==1;}
- if(type==0&&property==6)return "";
- if(type!=4)throw new InvalidOperationException("Expected scalar native string.");
- IntPtr pointer=Marshal.ReadIntPtr(buffer);long offset=pointer.ToInt64()-buffer.ToInt64();
- if(pointer==IntPtr.Zero||offset<16||offset>used-2)throw new InvalidOperationException("Native string pointer is outside its buffer.");
- StringBuilder text=new StringBuilder();
- for(int i=0;i<524288&&offset+2L*i+2<=used;i++){char c=(char)(ushort)Marshal.ReadInt16(pointer,2*i);if(c==0)return text.ToString();text.Append(c);}
- throw new InvalidOperationException("Unterminated native string.");
+ return Decode(buffer,used,property);
}finally{Marshal.FreeHGlobal(buffer);}
}
throw new InvalidOperationException("Native property changed repeatedly.");
diff --git a/tests/WecAuthorization.Tests.ps1 b/tests/WecAuthorization.Tests.ps1
index 9d49168f..bdc8198a 100644
--- a/tests/WecAuthorization.Tests.ps1
+++ b/tests/WecAuthorization.Tests.ps1
@@ -18,6 +18,32 @@ Assert ((Get-WelaWecAuthorizationKey @(Get-WelaWecAuthorizationSids @($sidB,$sid
foreach($good in @($authA,$authAB)){[Wela.WecAuthorization.Edit]::ValidateAuthorization($good);$count++}
foreach($bad in @('',$authA.Replace('GA','GR'),$authA.Replace('NSG:NS','SYG:SY'),($authA+$authA),$authA.Replace('11-22','011-22'),$authA.Replace('11-22','4294967296-22'),$authAB.Replace($sidB,$sidA),('O:NSG:NSD:(A;;GA;;;'+$sidB+')(A;;GA;;;'+$sidA+')'),($authA+'S:(AU;SA;GA;;;WD)'))){Reject {[Wela.WecAuthorization.Edit]::ValidateAuthorization($bad)}}
Assert ([Wela.WecAuthorization.Edit]::SourceSha256 -ceq (Get-WelaArrivalHash ([IO.File]::ReadAllBytes("$repo/scripts/WecAuthorizationNative.cs")))) 'Compiled native helper binds the exact source bytes'
+# Allocated EC_VARIANT buffers exercise the WEC ABI rather than EVT_VARIANT values.
+$buffer=[Runtime.InteropServices.Marshal]::AllocHGlobal(64)
+try {
+ for($i=0;$i -lt 64;$i++){[Runtime.InteropServices.Marshal]::WriteByte($buffer,$i,0)}
+ [Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,2)
+ Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,16,27) -eq [uint32]0) 'EcVarTypeUInt32 is 2 and source-initiated value is zero'
+ [Runtime.InteropServices.Marshal]::WriteInt32($buffer,0,1)
+ Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,16,27) -eq [uint32]1) 'Collector-initiated scalar remains distinguishable'
+ foreach($type in @(0,1,4,8,130)){[Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,$type);Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,16,27)} 'UInt32'}
+ [Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,1)
+ Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,16,0) -eq $true) 'Native scalar Boolean decodes true'
+ [Runtime.InteropServices.Marshal]::WriteInt32($buffer,0,0)
+ Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,16,0) -eq $false) 'Native scalar Boolean decodes false'
+ [Runtime.InteropServices.Marshal]::WriteInt32($buffer,0,2);Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,16,0)} 'Boolean'
+ [Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,4)
+ [Runtime.InteropServices.Marshal]::WriteIntPtr($buffer,[IntPtr]::Add($buffer,16));[Runtime.InteropServices.Marshal]::WriteInt16($buffer,16,65)
+ Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,20,31) -ceq 'A') 'String data is decoded within returned bounds'
+ Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,18,31)} 'Unterminated'
+ [Runtime.InteropServices.Marshal]::WriteIntPtr($buffer,[IntPtr]::Add($buffer,64));Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,20,31)} 'outside'
+ [Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,132);Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,20,31)} 'scalar'
+ Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,15,31)} 'buffer';Reject {[Wela.WecAuthorization.Edit]::Decode([IntPtr]::Zero,16,31)} 'buffer'
+ Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,16,1)} 'selection'
+ [Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,0)
+ Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,16,6) -ceq '') 'Absent description normalizes to empty'
+ Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,16,31)} 'scalar'
+}finally{[Runtime.InteropServices.Marshal]::FreeHGlobal($buffer)}
$before=Get-WelaWecAuthorizationDefinition $base;$after=Get-WelaWecAuthorizationDefinition ($base.Replace($authA,$authAB))
Assert ($before.SourceSids.Count -eq 1 -and $after.SourceSids.Count -eq 2 -and $before.PreservedKey -ceq $after.PreservedKey -and $before.WholeKey -cne $after.WholeKey) 'Only the explicit allow list is excluded from preserved XML'
foreach($bad in @($base.Replace('SourceInitiated','CollectorInitiated'),$base.Replace('>false','>true'),$base.Replace($authA,'D:(A;;GA;;;WD)'),$base.Replace($authA,''),$base.Replace($authA,$authAB.Replace($sidB,$sidA)),$base.Replace('Path="Security"','Path="Microsoft-Windows-Sysmon/Operational"'),$base.Replace('','bad'))){Reject {Get-WelaWecAuthorizationDefinition $bad}}
diff --git a/tests/WecAuthorization.Windows.Tests.ps1 b/tests/WecAuthorization.Windows.Tests.ps1
index 1719c989..a774a78b 100644
--- a/tests/WecAuthorization.Windows.Tests.ps1
+++ b/tests/WecAuthorization.Windows.Tests.ps1
@@ -15,12 +15,13 @@ function Key($Value){Get-WelaWecAuthorizationKey $Value}
function Services {@(Get-CimInstance Win32_Service -Filter "Name='Wecsvc' OR Name='Winmgmt' OR Name='EventLog' OR Name='WinRM'"|Sort-Object Name|Select-Object Name,State,StartMode)}
function Channel {$c=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('ForwardedEvents');try{[pscustomobject]@{Name=$c.LogName;Enabled=$c.IsEnabled;Mode=[string]$c.LogMode;MaximumBytes=$c.MaximumSizeInBytes;Path=$c.LogFilePath;SecurityDescriptor=$c.SecurityDescriptor}}finally{$c.Dispose()}}
function EnableChannel([bool]$Value){$c=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('ForwardedEvents');try{$c.IsEnabled=$Value;$c.SaveChanges()}finally{$c.Dispose()}}
-function Ids {@((Invoke-WelaNative 'wecutil.exe' @('es')).Output|ForEach-Object {$_.ToString().Trim()}|Where-Object {$_}|Sort-Object)}
-function Inventory {$ids=@(Ids);if($ids.Count -gt 64){throw 'Disposable inventory exceeds bound.'};@($ids|ForEach-Object {[pscustomobject]@{Id=$_;Xml=Read-WelaWecSubscriptionXml $_}})}
+Add-Type -Path (Join-Path $PSScriptRoot 'WecAuthorizationFixtureNative.cs')
+function Ids {[Wela.WecAuthorizationFixture.Inventory]::Read()|Sort-Object}
+function Inventory {$ids=@(Ids);Save 'last-observed-ids.json' $ids;@($ids|ForEach-Object {[pscustomobject]@{Id=$_;Xml=Read-WelaWecSubscriptionXml $_}})}
$nonce=[guid]::NewGuid().ToString('N');$id='WELA-Authorization-'+$nonce;$description='Owned authorization '+$nonce+' '+[char]0x65e5+[char]0x672c
$sidA='S-1-5-21-111111111-222222222-333333333-1234';$sidB='S-1-5-21-111111111-222222222-333333333-1235'
$root=Join-Path $env:RUNNER_TEMP ('wela-wec-authorization-'+$nonce);$null=New-Item -ItemType Directory $root
-function Save($Name,$Value){$Value|ConvertTo-Json -Depth 24|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8}
+function Save($Name,$Value){ConvertTo-Json -InputObject $Value -Depth 24|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8}
# Native -File argument binding cannot portably carry a string[] on both engines.
# This fixture wrapper supplies the selected array to the actual public script.
$wrapper=Join-Path $root 'invoke-public.ps1'
@@ -32,7 +33,9 @@ if($PSBoundParameters.ContainsKey('Id')){$p.WecAuthorizationId=$Id}
if($PSBoundParameters.ContainsKey('Sids')){$p.WecAuthorizationSourceSid=@($Sids.Split(';'))}
if($PSBoundParameters.ContainsKey('PlanPath')){$p.WecAuthorizationPlanPath=$PlanPath}
if($PSBoundParameters.ContainsKey('PlanHash')){$p.WecAuthorizationPlanHash=$PlanHash}
+$global:LASTEXITCODE=0
& $WelaPath @p
+exit $LASTEXITCODE
'@|Set-Content -LiteralPath $wrapper -Encoding UTF8
function Public([string[]]$Arguments,[string]$Output,[bool]$Success=$true){
$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$text=@(&$engine -NoLogo -NoProfile -NonInteractive -File $wrapper -WelaPath "$repo/WELA.ps1" @Arguments -OutputPath $Output 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior}
@@ -48,6 +51,7 @@ try {
$wec=@($beforeServices|Where-Object Name -eq Wecsvc);Assert ($wec.Count -eq 1 -and $wec[0].State -in @('Running','Stopped') -and $wec[0].StartMode -in @('Auto','Manual','Disabled')) 'Stable original service state required'
if($wec[0].StartMode -eq 'Disabled'){Set-Service Wecsvc -StartupType Manual};if($wec[0].State -eq 'Stopped'){Start-Service Wecsvc}
if(-not $beforeChannel.Enabled){EnableChannel $true}
+ Save 'original-console-enumeration.json' (Invoke-WelaNative 'wecutil.exe' @('es'))
$original=@(Inventory);Save 'original-inventory.json' $original;Assert (@(Ids) -notcontains $id) 'Unique owned subscription is initially absent'
$query=''
$xml=@"
diff --git a/tests/WecAuthorizationFixtureNative.cs b/tests/WecAuthorizationFixtureNative.cs
new file mode 100644
index 00000000..de6e5881
--- /dev/null
+++ b/tests/WecAuthorizationFixtureNative.cs
@@ -0,0 +1,25 @@
+// Read-only disposable-fixture inventory; bypasses console/native text decoding.
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using System.Text;
+namespace Wela.WecAuthorizationFixture {
+ public static class Inventory {
+ [DllImport("wecapi.dll",SetLastError=true)] static extern IntPtr EcOpenSubscriptionEnum(uint flags);
+ [DllImport("wecapi.dll",CharSet=CharSet.Unicode,SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcEnumNextSubscription(IntPtr enumeration,uint size,StringBuilder name,out uint used);
+ [DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcClose(IntPtr handle);
+ public static string[] Read() {
+ IntPtr handle=EcOpenSubscriptionEnum(0);if(handle==IntPtr.Zero)throw new Win32Exception(Marshal.GetLastWin32Error());
+ try {
+ var names=new List();
+ while(true) {
+ var name=new StringBuilder(4096);uint used;
+ if(!EcEnumNextSubscription(handle,4096,name,out used)) {int error=Marshal.GetLastWin32Error();if(error==259)return names.ToArray();throw new Win32Exception(error);}
+ if(used<2||used>4096||name.Length==0||name.Length+1!=used||names.Count>=64||names.Contains(name.ToString()))throw new InvalidOperationException("Disposable native subscription inventory is invalid, duplicated or exceeds its bound.");
+ names.Add(name.ToString());
+ }
+ }finally {EcClose(handle);}
+ }
+ }
+}