

Executive Summary
CVE-2026-40369 is an unprivileged, arbitrary twelve-byte kernel write that lives in nt!ExpGetProcessInformation and is reachable from anything able to issue an NtQuerySystemInformation syscall — a set that explicitly includes the renderer sandboxes of Chrome, Edge and Firefox. When the caller asks for information class 0xFD (253) with a zero-length buffer, the kernel forwards the caller-supplied user pointer into a process-walk worker that performs three DWORD increments against it without ever validating the destination. Because ProbeForWrite() short-circuits on a zero-length buffer, the usual user-versus-kernel address gate never runs, so any writable kernel virtual address becomes a legal target from Medium integrity — or lower.
This write-up walks the root cause from the syscall entry down to the unchecked inc dword ptr [rbx], then chains the weak primitive into a full local privilege escalation that lifts a Medium-IL, non-administrator process to NT AUTHORITY\SYSTEM. Rather than the classical steal-a-SYSTEM-token route, the chain forges a brand-new SYSTEM primary token from scratch with NtCreateToken after using the primitive itself to flip a feature gate and leak its own token’s kernel address. The following analysis is a paraphrased engineering walk-through of VoidSec’s disclosure; the decompiled listings, the counter dump and the demo video are reproduced verbatim, with attribution, from the original.
The short version: NtQuerySystemInformation(class = 0xFD) hands a caller-controlled pointer to nt!ExpGetProcessInformation, which then performs three kernel-mode DWORD writes to that pointer with no destination validation when Length == 0. Since ProbeForWrite() becomes a no-op for zero-length buffers, an attacker can aim the write at any mapped, writable kernel virtual address — including from inside a browser renderer sandbox.
The author originally saved this bug for Pwn2Own Berlin. A couple of days before the competition, Ori Nimron independently published a public PoC for the same primitive on GitHub, so with the surprise gone VoidSec released the full technical analysis and its own exploitation strategy. That strategy deliberately diverges from Ori’s classical token-theft approach: instead of stealing an existing SYSTEM token, it forges one from nothing through NtCreateToken.
Pre-Requisites
To follow the chain end to end, it helps to already be comfortable with the following:
- The
NtQuerySystemInformationsyscall, its information classes, and how the user-modeSystemInformationpointer is (or is not) validated as it travels down into the kernel. - Hex-Rays / IDA Pro and basic Windows kernel reverse engineering. The decompiled excerpts come from
ntoskrnl.exeon Windows 11 25H2 build 26100.8246, withImageBase = 0x140000000. - The
_TOKENobject layout. The fields in play —ModifiedIdat+0x38,Privileges.Presentat+0x40,Privileges.Enabledat+0x48,SessionIdat+0x78— are the canonical offsets for the 25H2 servicing branch; cross-reference Vergilius when porting to another build. - The WIL feature-state cache, and specifically how
Feature_RestrictKernelAddressLeaksgates the kernel-pointer-leaking information classes ofNtQuerySystemInformation(classes 11, 64, 66, and friends). NtCreateTokenand theSeCreateTokenPrivilege/SeTcbPrivilege/SeImpersonatePrivilegetrio, which together let you materialise a forged SYSTEM token without the classical SeDebug +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() turns into a no-op on zero-length buffers, any writable kernel virtual address can be targeted from user mode, browser renderer sandboxes included.
Call Graph and Code Path
The path from the syscall stub down to the offending write is short:
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
Here is the 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 jump out of this listing:
- The
v99 = v95 = (unsigned int *)a1assignment on thea5 == 253path aliasesv99directly to the caller-controlled pointer. Nothing between that assignment and the writes re-validates it. - The size check sets
v13 = STATUS_INFO_LENGTH_MISMATCHbut keeps executing. The early return only fires whena3(theReturnLengthpointer) is NULL, andExpQuerySystemInformationalways passes a kernel-stack local there. For any normal caller that return is unreachable, so the writes land before any exit branch consults the latched status.
The Unchecked Dispatch
ExpQuerySystemInformation issues the ProbeForWrite at the head of the function (only when the call originates in user mode), then runs an outer switch on the information class, followed by 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;
...
}
}
There is a second call site to ExpGetProcessInformation later in the same function, and it 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);
That contrast is exactly what makes the first call site exploitable: there is no probe of a4 keyed to its actual role as a write target — only the generic head-of-function probe, whose Length argument is the user-chosen Length.
The Probe, a No-Op
nt!ProbeForWrite looks like this:
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, regardless ofAddress. Neither the user-VA upper-bound check nor the alignment check runs.- The upper-bound test (
v3 >= 0x7FFFFFFF0000LL) is the user-mode address ceiling. With a non-zeroLengthit would reject any kernel-VA target; withLength == 0we simply never reach it. - The page-touch loop is what actually faults on a bad address. For
Length == 0it is skipped, so no exception is raised even whenAddressis unmapped or sits in kernel space.
Where Every Defensive Layer Fails
Walking down the stack of would-be mitigations, each one is either bypassed or simply irrelevant on the 253 path:
ProbeForWrite: withLength == 0it returns immediately; the address is never validated.- Outer switch class filter: no filter for 253;
defaultfalls straight 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 the medium DACL: only sets a flag used for thread-start masking; it 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.
Proving the Primitive: a Controlled Write
To demonstrate the write in a way that could be observed without crashing the box, the author picked a target readable through a non-destructive side channel. nt!IoConfigurationInformation is the global CONFIGURATION_INFORMATION struct returned by IoGetConfigurationInformation; conveniently, 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 effect on the running system — they are just numbers consumed by user-mode tooling. The measurement procedure is:
- Resolve the current kernel base via
kd -kl(a one-shot lookup). - Read the six DWORDs with
NtQuerySystemInformation(7, buf, 24, &ret). - Trigger the bug:
NtQuerySystemInformation(0xFD, target, 0, &ret). - Read the six DWORDs again.
The before/after counters came out as:
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
The trigger syscall returned STATUS_INFO_LENGTH_MISMATCH (0xC0000004) with ReturnLength = 12. Interpreting the deltas:
+0delta = 310 = the number of running processes, excludingIdleand any the worker skips viaExpSysInfoShouldSkipProcess.+4delta = 4419 = the sum ofPsGetProcessActiveThreadCountacross those processes.+8delta = 159307 = the sum ofObGetProcessHandleCount.+12,+16,+20unchanged — the write region is exactly twelve bytes wide.
Crucially, the test ran from a non-elevated process, so the primitive needs no 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 out of scope here; Ori Nimron’s write-up (see references) covers it in detail.
Understanding the Primitive
Per single invocation of NtQuerySystemInformation(0xFD, target, 0, &ret), the effect is:
*(DWORD*)(target + 0) += N, where N = the running process count (minusIdleand a small skip-list).*(DWORD*)(target + 4) += T, where T = the sum ofPsGetProcessActiveThreadCount.*(DWORD*)(target + 8) += H, where H = the sum ofObGetProcessHandleCount.target + 12 ..is left untouched.
The targeting constraints are what shape the whole exploit:
- The target can sit anywhere in writable kernel virtual memory that is mapped at call time.
- The target does not need to be 4-byte aligned. The three DWORD writes can straddle adjacent structure fields — a property the final exploit leans on to land a single privilege bit inside
_TOKEN.Privileges. - The target cannot be a user-mode VA, an unmapped kernel VA, HVCI-protected code, or KPP-protected data.
From the Primitive to LPE
A twelve-byte write whose values you don’t fully control doesn’t look like much. The chain built around it has five phases.
Phase 1 – KASLR Break
An undocumented leak provides the kernel base. Since it is not fixed yet, the author leaves the specifics out. 🙂
Phase 2 – Gate Flip (Feature_RestrictKernelAddressLeaks)
This is, in the author’s view, the most interesting part of the chain, because it bootstraps the rest of the exploit using nothing but the primitive itself.
On recent Windows builds, the kernel-pointer-leaking information classes of NtQuerySystemInformation — most importantly class 64, SystemExtendedHandleInformation, which would happily hand back our token’s kernel VA — are gated behind Feature_RestrictKernelAddressLeaks. While the gate is active those classes return zeroed pointers, so to leak the token VA the gate has to come down first.
The encoding reverse-engineered from the fast path of IsEnabledDeviceUsageNoInline 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 treat the feature as disabled, 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 in the “not cached” state (bit 4 clear), the WIL fallback (wil_details_FeatureStateCache_TryEnableDeviceUsageFastPath) re-asserts cached+enabled and our write effectively 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 therefore spawns short-lived child helper processes until N sits in a winning state.
Phase 3 – Token VA Leak
With the gate off, leaking our own token’s kernel address is straightforward:
- Call
OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &h)to obtain a handle to our own primary token. - Call
NtQuerySystemInformation(SystemExtendedHandleInformation = 0x40, ...), which returns an array ofSYSTEM_HANDLE_TABLE_ENTRY_INFO_EX, each carrying the kernelObjectpointer. - Walk the array and match on
(UniqueProcessId == GetCurrentProcessId(), HandleValue == h). The matched entry’sObjectfield is our_TOKENkernel VA.
The 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
Instead of the usual SeDebug route, the author targets SeCreateTokenPrivilege. NtCreateToken’s only privilege gate is SeSinglePrivilegeCheck(SeCreateTokenPrivilege) — the same SepPrivilegeCheck mechanism — and it honours our bumped bits.
The chain runs three steps, each of which independently waits for its target bit to land on the random walk produced by repeated class-253 fires. None of the three has 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 unreachable from the user’s desktop.SeImpersonatePrivilege(orSeAssignPrimaryTokenPrivilege+SeIncreaseQuotaPrivilege): callCreateProcessWithTokenW(forgedToken, cmd.exe, CREATE_NEW_CONSOLE, "winsta0\\default"), falling back toCreateProcessAsUserW.
The per-round write pattern hits two targets and reads the state back 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 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 gives no value control, only repetition.
Phase 5 – SYSTEM Shell
The chain returns the moment the first shell spawns successfully. 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
Several builds were inspected against the same ExpGetProcessInformation code path. Note that Windows 11 24H2 and 25H2 share 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. The bug is not yet present — 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
- A single missing early-return turns a status code into an arbitrary write: the length check latches
STATUS_INFO_LENGTH_MISMATCHbut execution keeps going straight into the three DWORD increments. ProbeForWriteis not a kernel-address guard whenLength == 0— the zero-length fast path returns before any bound or alignment check, so a zero-length buffer neutralises the probe entirely.- The information-class dispatch groups 253 with 5 / 57 / 148 / 252, and only class 148 gets an access check — class 253 forwards the raw user pointer with nothing gating the write.
- A weak, value-uncontrolled twelve-byte increment is still LPE-grade: unaligned writes that straddle struct fields let carry propagation walk bits into
_TOKEN.Privileges. - The exploit bootstraps itself — the same primitive flips
Feature_RestrictKernelAddressLeaksoff so that class 64 will leak the token VA needed for the rest of the chain. - Forging a SYSTEM token with
NtCreateToken(onceSeCreateTokenPrivilegeis set) is a clean alternative to classical token theft — no target process to open, noDuplicateTokenEx. - The syscall reaches into browser renderer sandboxes, so this is a sandbox-to-SYSTEM building block, not merely a local-admin curiosity.
Defensive Recommendations
- Patch to a fixed 26100 LCU. The vulnerable window is 25H2 26100.5074–26100.8328 (and Server 2025 26100.32690); prioritise the cumulative update that closes
ExpGetProcessInformation, and track by LCU level, not feature version. - Do not treat 24H2-only inventory as safe by feature name alone — 24H2 and 25H2 share build 26100, so audit the actual LCU on every host.
- Watch for anomalous
NtQuerySystemInformationusage, especially class0xFD(253) calls with a zeroLength, and calls originating from renderer/sandboxed processes that have no business enumerating system process information. - Alert on
NtCreateTokenfrom unexpected callers. Legitimate use ofSeCreateTokenPrivilegeis rare; a Medium-IL, non-service process forging a primary token is a strong signal. - Flag suspicious token realignment and spawns —
SetTokenInformation(TokenSessionId, ...)followed byCreateProcessWithTokenW/CreateProcessAsUserWyielding a SYSTEMcmd.exein an interactive session. - Keep HVCI/VBS and KDP enabled. They do not stop this specific data write, but they raise the cost of the broader post-exploitation and constrain what other primitives an attacker can pair with it.
- Harden the browser attack surface: keep Chrome/Edge/Firefox current, keep site isolation and Win32k lockdown on, and treat renderer compromise as one syscall away from a kernel write until the OS is patched.
- Hunt for helper-process spray patterns — bursts of short-lived child processes used to tune the increment value (N) before a write are an unusual, detectable behaviour.
Conclusion
CVE-2026-40369 is a compact reminder that a single skipped early-return in a hot kernel path can collapse an entire wall of mitigations. The write is weak on paper — twelve bytes, values you don’t control — yet by leaning on unaligned targeting, carry propagation, and the primitive’s own ability to switch off a kernel-address-leak gate, it composes into a reliable, sandbox-reachable path from Medium IL to NT AUTHORITY\SYSTEM. The NtCreateToken forging strategy is a tidy demonstration that once you can set one privilege bit, you don’t need to steal anyone’s token — you can simply mint your own. Patch the affected 26100 LCUs, and treat renderer-reachable syscalls as first-class kernel attack surface.
References
- Ori Nimron’s parallel disclosure: github.com/orinimron123/CVE-2026-40369-EXPLOIT
- Vergilius Project — Windows kernel struct layouts per build.
NtQuerySystemInformationreference (information classes and buffer semantics).TOKEN_PRIVILEGESandProbeForWritedocumentation.ZwCreateToken/NtCreateToken— WDK reference for the token-creation syscall used in Phase 4.- Privilege constants — LUID values for
SeCreateTokenPrivilege,SeTcbPrivilege,SeImpersonatePrivilege, and friends.
Original text: “CVE-2026-40369: Twelve Bytes to Escape the Browser Sandbox” by voidsec (Paolo Stagno) at VoidSec.


