CVE-2026-40369: Twelve Bytes to Escape the Browser Sandbox - VoidSec
Back to Blog
Share this post
Facebook<br>Twitter<br>LinkedIn<br>Email<br>VK<br>Reddit<br>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<br>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)<br>-> nt!NtQuerySystemInformation<br>-> nt!ExpQuerySystemInformation (probes SystemInformation, dispatches by class)<br>-> 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(<br>__int64 a1, // SystemInformation pointer (caller-controlled)<br>unsigned int a2, // Length<br>_DWORD *a3, // ReturnLength out<br>_DWORD *a4, // optional session-id filter<br>int a5) // information class (5 / 57 / 148 / 252 / 253)<br>unsigned int *v85, *v95, *v99;<br>...<br>v95 = (unsigned int *)a1;<br>...<br>if ( a5 == 252 ) { ...; v90 = v95; v85 = NULL; }<br>else {<br>v90 = NULL;<br>if ( a5 == 253 ) {<br>v77 = 0;<br>v86 = 12;<br>v71 = 12;<br>v87 = 0;<br>v99 = v95; // PreviousMode;<br>if ( a5 != 148 || (result = ExCheckFullProcessInformationAccess(PreviousMode), result >= 0) )<br>...<br>SeAccessCheck(SeMediumDaclSd, ...);<br>...
/* main process-walk loop */<br>NextProcess = (__int64 *)PsIdleProcess;<br>while ( 1 ) {<br>if ( !NextProcess ) { ...; return v70; }<br>if ( !ExpSysInfoShouldSkipProcess((__int64)NextProcess)<br>&& (!a4 || NextProcess != PsIdleProcess) )<br>SessionId = PsGetSessionId((__int64)NextProcess);<br>if ( (!a4 || SessionId == *a4)<br>&& PsIsProcessInSilo((struct _KPROCESS *)NextProcess, CurrentServerSilo) )<br>break; /* fall through to the per-process body below */<br>NextProcess = ExGetNextProcess(NextProcess, v76, v21, v22);
if ( a5 == 253 ) {<br>v25 = v99;<br>++*v99; /* WRITE #1: [target+0] += 1 */<br>v25[1] += PsGetProcessActiveThreadCount((__int64)NextProcess); /* WRITE #2: [target+4] += threads */<br>v25[2] += ObGetProcessHandleCount((struct _EX_RUNDOWN_REF *)NextProcess, 0LL);<br>/* WRITE #3: [target+8] += handles */<br>...
Two facts to extract from this listing:
The v99 = v95 = (unsigned int *)a1 assignment on the a5 == 253 path...