Executive Summary
CVE-2026-40369 is an unprivileged arbitrary 12-byte kernel write primitive sitting inside nt!ExpGetProcessInformation in ntoskrnl.exe. The bug is reachable from any context that can issue NtQuerySystemInformation — including Chrome, Edge, and Firefox renderer sandboxes — because the user-mode SystemInformation pointer is forwarded into the worker without an adequate ProbeForWrite, and ProbeForWrite itself is a no-op when Length == 0. The result: any writable kernel virtual address can be incremented by three controlled-ish DWORDs from Medium IL, with no sandbox bypass tricks required beyond reaching the syscall surface.
VoidSec’s write-up dissects the exact code path on Windows 11 25H2 build 26100.8246 and chains the primitive into a Local Privilege Escalation that lifts a Medium-IL non-administrator process to NT AUTHORITY\SYSTEM. The chain diverges from Ori Nimron’s parallel public PoC: instead of stealing an existing SYSTEM token, it forges a SYSTEM primary token from scratch via NtCreateToken, after bootstrapping itself with three increment-only writes that flip the right bits inside the calling process’s own _TOKEN.Privileges. Below: the root-cause walkthrough, the five-phase LPE, vulnerable builds, and defensive notes for security engineers.
TL;DR: CVE-2026-40369 is an unprivileged arbitrary 12-byte kernel write primitive in
VoidSec — verbatim TL;DR from the original article.nt!ExpGetProcessInformation, reachable from any context that can callNtQuerySystemInformation, including Chrome, Edge and Firefox renderer sandboxes. The write is chained into a full LPE that lifts a Medium-IL non-administrator process up toNT AUTHORITY\SYSTEMviaNtCreateToken.
VoidSec had originally prepared this bug for Pwn2Own Berlin. A couple of days before the contest, Ori Nimron independently published a PoC for the same primitive on GitHub. With the primitive already out, VoidSec released the full technical write-up and a different exploitation strategy: rather than classical token theft, the chain forges a SYSTEM primary token from scratch via NtCreateToken.
Pre-Requisites
To follow the write-up end-to-end, you should be comfortable with:
- The
NtQuerySystemInformationsyscall, its information classes, and how the user-modeSystemInformationpointer is validated (or not) on the way down. - Hex-Rays / IDA Pro and basic Windows kernel reverse engineering. The decompile excerpts come from
ntoskrnl.exeon Windows 11 25H2 build 26100.8246, withImageBase = 0x140000000. - The
_TOKENobject layout. The field offsets used (ModifiedIdat+0x38,Privileges.Presentat+0x40,Privileges.Enabledat+0x48,SessionIdat+0x78) are canonical for the 25H2 servicing branch — cross-reference Vergilius when porting to other builds. - The WIL feature-state cache, and specifically 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 classicalSeDebug+OpenProcess+DuplicateTokenExdance.
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() degenerates into a no-op on zero-length buffers, any writable kernel virtual address can be targeted from user mode — including from 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), reproduced verbatim from the original article:
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; // 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 that assignment and the writes validates the pointer. - The size check latches
v13 = STATUS_INFO_LENGTH_MISMATCHbut flows through. The early return only fires whena3(theReturnLengthpointer) isNULL, 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 != 0it 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.
Why Every Defensive Layer Misses
ProbeForWrite:Length == 0returns immediately, address is not validated.- Outer switch class filter: no filter for
253; default falls into the worker dispatch. - Inner switch:
case 0xFDushares a block with5/57/148/252and 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 — the write is kernel-mode to a kernel address.
- HVCI: protects code pages, not arbitrary kernel data.
- KPP / PatchGuard: covers a curated set of structures only; most writable data is unprotected.
Controlled Write — Proving the Primitive
To demonstrate the primitive without crashing the box, VoidSec picked a target whose writes are 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 — they are informational. The 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 excluding Idle and 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 — the primitive depends on nothing 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 the original write-up; Ori Nimron’s disclosure (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 inside
_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 does not look like much. The LPE chain built around it has five phases.
Phase 1 — KASLR Break
VoidSec uses an undocumented leak that is not fixed yet, and therefore left out of the public write-up.
Phase 2 — Gate Flip (Feature_RestrictKernelAddressLeaks)
In VoidSec’s view, this is the most interesting bit of the chain because it bootstraps the rest of the exploit using only the 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 the gate has to come down before the token VA can be leaked.
VoidSec reverse-engineered the encoding from IsEnabledDeviceUsageNoInline’s fast path:
- 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” the feature, we need (low_byte & 0x11) == 0x10: cached set, enabled clear. Whether the new low byte lands in the 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 the 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 the current 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:
+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
VoidSec targets SeCreateTokenPrivilege instead of the usual SeDebug path. NtCreateToken’s only privilege gate is SeSinglePrivilegeCheck(SeCreateTokenPrivilege) — the same SepPrivilegeCheck mechanism — and it honours the bumped bits. The chain runs a three-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 is not 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
VoidSec 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.
Key Takeaways
ProbeForWrite(addr, 0, _)is a no-op — do not treat its presence at the head ofExpQuerySystemInformationas evidence that downstream worker paths are safe.- The
0xFDinformation class shares an inner-switch block with5,57,148,252, all of which forward the user pointer; the access-check gate (ExCheckFullProcessInformationAccess) only fires for148. - Per fire, the primitive bumps three adjacent DWORDs by (N, T, H) — running-process count, total active threads, total handles. There is no value control, only repetition; carry propagates through the DWORD.
- The chain bootstraps itself: a non-leaking primitive flips
Feature_RestrictKernelAddressLeaksoff, which then unlocks the token-VA leak viaSystemExtendedHandleInformation. - SYSTEM is reached by forging a token via
NtCreateToken, not by stealing one — reframing what “token abuse” can look like from kernel-write primitives. - The bug is reachable from browser renderer sandboxes (UNTRUSTED IL, Win32k lockdown, restricted token), which is why VoidSec frames the disclosure as a browser sandbox escape even though the primitive itself is a kernel LPE.
- None of the modern mitigations — SMAP, HVCI, KPP/PatchGuard — cover the write target. KPP only protects a curated subset of kernel data; the rest is fair game.
Defensive Recommendations
- Patch. Apply the most recent cumulative update for Windows 11 25H2 / Windows Server 2025 once Microsoft ships the fix; the regression window covers 26100.5074 through 26100.8328 on the 25H2 servicing branch.
- Audit syscall surface from sandboxed renderers. Chrome, Edge, and Firefox renderer sandboxes can still call
NtQuerySystemInformationwith arbitrary classes. Inventory the classes you expect to see and review WDAC / AppLocker / Defender Application Guard policies that constrain sandboxed processes. - Telemetry on
NtQuerySystemInformationclass0xFD. Unusual classes from non-trusted IL processes (UNTRUSTED, LOW) are detectable through ETW (Microsoft-Windows-Kernel-Audit-API-Calls) or EDR kernel callbacks. Particularly: many calls withLength == 0and a kernel-pointer-lookingSystemInformationare a strong signal. - Hunt for
NtCreateTokenfrom non-LSASS / non-system contexts. Legitimate use ofSeCreateTokenPrivilegeis extremely rare outside LSA. Any user-mode process that callsNtCreateTokenshould be treated as a high-priority lead, especially withUser = S-1-5-18. - Detect WIL feature-state cache tampering. Anomalous toggling of
Feature_RestrictKernelAddressLeaksat runtime — or, in dumps, a low-byte value of0x10for that feature — can hint at primitive use. - Defence-in-depth on browser renderers. Win32k lockdown, ACG, CIG, restricted tokens, and the JIT-less mode (where available) reduce the value of a single sandbox-escape primitive even when one exists.
- Constrain administrative attack surface. Local admin or Medium IL is sufficient to weaponise the primitive; LAPS, just-in-time admin, and credential-guard-style separation limit the blast radius of a single compromised endpoint.
- Validate behavioural detections. A controlled internal PoC (in an isolated VM, not production) is the only way to confirm that your EDR’s kernel-event coverage actually catches the abnormal
NtQuerySystemInformationpatterns; the bug class is shallow and will recur.
Conclusion
CVE-2026-40369 is a beautifully small bug with disproportionate consequences: a single zero-length argument turns ProbeForWrite off, the inner switch in ExpQuerySystemInformation routes class 0xFD into a probe-less worker, and the worker writes three DWORDs to the caller’s kernel-VA target. From a 12-byte increment-only write VoidSec assembles a five-phase LPE that flips a WIL feature gate, leaks the calling process’s own token VA, fuzz-walks the right privilege bits into place, and forges a SYSTEM primary token. The whole chain underlines two old lessons: zero-length validations are not validations, and modern kernel mitigations (SMAP, HVCI, KPP) protect specific surfaces — not every writable DWORD the kernel happens to expose.
Original text: “CVE-2026-40369: Twelve Bytes to Escape the Browser Sandbox” by voidsec at VoidSec.

