CVE-2026-40369: Twelve Bytes to Escape the Browser Sandbox - VoidSec
CVE-2026-40369: 12-byte kernel write in Windows allows sandbox escape to SYSTEM.
Summary
A newly disclosed vulnerability, CVE-2026-40369, allows an attacker to escape browser sandboxes on Windows and achieve SYSTEM privileges. The flaw resides in NtQuerySystemInformation, specifically within the nt!ExpGetProcessInformation function, which performs unchecked kernel-mode writes. This vulnerability was reportedly prepared for Pwn2Own Berlin and a public Proof-of-Concept was released prior to this write-up.
Full text
Share this post Facebook Twitter LinkedIn Email VK Reddit WhatsApp CVE-2026-40369: Twelve Bytes to Escape the Browser Sandbox Posted by: voidsec Post Date: May 20, 2026 voidsec2026-05-20T12:41:12+02:00 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 call NtQuerySystemInformation, 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 via NtCreateToken. 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. Table of Contents Pre-Requisites To follow this end-to-end, you’ll want to be comfortable with: The NtQuerySystemInformation syscall, its information classes, and the way the user-mode SystemInformation pointer 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.exe on Windows 11 25H2 build 26100.8246, with ImageBase = 0x140000000. The _TOKEN object layout. The field offsets I’ll touch (ModifiedId at +0x38, Privileges.Present at +0x40, Privileges.Enabled at +0x48, SessionId at +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_RestrictKernelAddressLeaks gates the kernel-pointer-leaking information classes of NtQuerySystemInformation (classes 11, 64, 66, etc.). NtCreateToken and the SeCreateTokenPrivilege / SeTcbPrivilege / SeImpersonatePrivilege trio 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: 0x140AE08A0 nt!ExpQuerySystemInformation: 0x140ADBB10 nt!ExpGetProcessInformation: 0x140ADA6D0 Crash site (inc dword ptr [rbx]): 0x140ADAAFE nt!ProbeForWrite: 0x14017C9F0 nt!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 *)a1 assignment on the a5 == 253 path makes v99 an alias of the caller-controlled pointer. Nothing between this assignment and the writes validates that pointer. The size check sets v13 = STATUS_INFO_LENGTH_MISMATCH but flows through. The early return only fires when a3 (the ReturnLength pointer) is NULL, and ExpQuerySystemInformation always 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
Indicators of Compromise
- cve — CVE-2026-40369