CVE-2026-40369: Twelve Bytes to Escape the Browser Sandbox

CVE-2026-40369: Twelve Bytes to Escape the Browser Sandbox

Original text: “CVE-2026-40369: Twelve Bytes to Escape the Browser Sandbox”voidsec, VoidSec (20 May 2026). Hex-Rays excerpts, exploit pseudo-code, and offsets below are reproduced verbatim with attribution captions; surrounding prose is paraphrased.

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 nt!ExpGetProcessInformation, reachable from any context that can call NtQuerySystemInformation, including Chrome, Edge and Firefox renderer sandboxes. The write is chained into a full LPE that lifts a Medium-IL non-administrator process up to NT AUTHORITY\SYSTEM via NtCreateToken.

VoidSec — verbatim TL;DR from the original article.

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 NtQuerySystemInformation syscall, its information classes, and how 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 come from ntoskrnl.exe on Windows 11 25H2 build 26100.8246, with ImageBase = 0x140000000.
  • The _TOKEN object layout. The field offsets used (ModifiedId at +0x38, Privileges.Present at +0x40, Privileges.Enabled at +0x48, SessionId at +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_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() 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: 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), 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 *)a1 assignment on the a5 == 253 path makes v99 an alias of the caller-controlled pointer. Nothing between that assignment and the writes validates the pointer.
  • The size check latches 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 || 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 == 0 returns immediately, irrespective of Address. Neither the user-VA upper-bound check nor the alignment check executes.
  • The upper-bound check (v3 >= 0x7FFFFFFF0000LL) is the user-mode address ceiling. If Length != 0 it would reject any kernel-VA target; with Length == 0 we never reach it.
  • The page-touch loop is the actual access-fault trigger. For Length == 0 it is skipped, so no exception is raised even when Address is unmapped or in kernel space.

Why Every Defensive Layer Misses

  • ProbeForWrite: Length == 0 returns immediately, address is not validated.
  • Outer switch class filter: no filter for 253; default falls into the worker dispatch.
  • Inner switch: case 0xFDu shares a block with 5 / 57 / 148 / 252 and forwards the user pointer.
  • ExCheckFullProcessInformationAccess: gated on a5 == 148; bypassed for a5 == 253.
  • SeAccessCheck against 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_MISMATCH but 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:

  1. Resolve the current kernel base via kd -kl (one-shot).
  2. Read the six DWORDs via NtQuerySystemInformation(7, buf, 24, &ret).
  3. Trigger: NtQuerySystemInformation(0xFD, target, 0, &ret).
  4. 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:

  • +0 delta = 310 = number of running processes excluding Idle and processes the worker skips via ExpSysInfoShouldSkipProcess.
  • +4 delta = 4419 = sum of PsGetProcessActiveThreadCount over those processes.
  • +8 delta = 159307 = sum of ObGetProcessHandleCount.
  • +12, +16, +20 unchanged. 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 of PsGetProcessActiveThreadCount.
  • *(DWORD*)(target + 8) += H: H = sum of ObGetProcessHandleCount.
  • 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 of SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX, each containing the kernel Object pointer.
  • Walk the array and match by (UniqueProcessId == GetCurrentProcessId(), HandleValue == h). The matched entry’s Object field is our _TOKEN kernel 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: call NtCreateToken to 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: call SetTokenInformation(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.
  • SeImpersonatePrivilege or SeAssignPrimaryTokenPrivilege + SeIncreaseQuotaPrivilege: call CreateProcessWithTokenW(forgedToken, cmd.exe, CREATE_NEW_CONSOLE, "winsta0\\default"), with fallback to CreateProcessAsUserW.

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.

Source: original VoidSec article — full LPE run from Medium-IL non-admin to SYSTEM shell.

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 of ExpQuerySystemInformation as evidence that downstream worker paths are safe.
  • The 0xFD information class shares an inner-switch block with 5, 57, 148, 252, all of which forward the user pointer; the access-check gate (ExCheckFullProcessInformationAccess) only fires for 148.
  • 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_RestrictKernelAddressLeaks off, which then unlocks the token-VA leak via SystemExtendedHandleInformation.
  • 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 NtQuerySystemInformation with arbitrary classes. Inventory the classes you expect to see and review WDAC / AppLocker / Defender Application Guard policies that constrain sandboxed processes.
  • Telemetry on NtQuerySystemInformation class 0xFD. 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 with Length == 0 and a kernel-pointer-looking SystemInformation are a strong signal.
  • Hunt for NtCreateToken from non-LSASS / non-system contexts. Legitimate use of SeCreateTokenPrivilege is extremely rare outside LSA. Any user-mode process that calls NtCreateToken should be treated as a high-priority lead, especially with User = S-1-5-18.
  • Detect WIL feature-state cache tampering. Anomalous toggling of Feature_RestrictKernelAddressLeaks at runtime — or, in dumps, a low-byte value of 0x10 for 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 NtQuerySystemInformation patterns; 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.

Comments are closed.