core-jmp core-jmpdeath of core jump

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

CVE-2026-40369 is an unprivileged twelve-byte arbitrary kernel write in nt!ExpGetProcessInformation, reachable via NtQuerySystemInformation from browser renderer sandboxes. Root-cause analysis and a full LPE chain that forges a SYSTEM token with NtCreateToken.

oxfemale August 4, 2026 16 min read 112 reads
Export PDF
CVE-2026-40369: Twelve Bytes to Escape the Browser Sandbox
Original text: “CVE-2026-40369: Twelve Bytes to Escape the Browser Sandbox”voidsec (Paolo Stagno), VoidSec (20 May 2026). Code excerpts, register/offset dumps and the demo video below are reproduced verbatim with attribution captions.
CVE-2026-40369: a twelve-byte arbitrary kernel write in nt!ExpGetProcessInformation, reachable from the browser renderer sandbox
CVE-2026-40369 — a twelve-byte unprivileged kernel write reachable through NtQuerySystemInformation. Source: original article.

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 NtQuerySystemInformation syscall, its information classes, and how the user-mode SystemInformation pointer 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.exe on Windows 11 25H2 build 26100.8246, with ImageBase = 0x140000000.
  • The _TOKEN object layout. The fields in play — ModifiedId at +0x38, Privileges.Present at +0x40, Privileges.Enabled at +0x48, SessionId at +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_RestrictKernelAddressLeaks gates the kernel-pointer-leaking information classes of NtQuerySystemInformation (classes 11, 64, 66, and friends).
  • NtCreateToken and the SeCreateTokenPrivilege / SeTcbPrivilege / SeImpersonatePrivilege trio, which together let you materialise a forged SYSTEM token without 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() 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: 0x140AE08A0
  • nt!ExpQuerySystemInformation: 0x140ADBB10
  • nt!ExpGetProcessInformation: 0x140ADA6D0
  • Crash site (inc dword ptr [rbx]): 0x140ADAAFE
  • nt!ProbeForWrite: 0x14017C9F0
  • nt!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 *)a1 assignment on the a5 == 253 path aliases v99 directly to the caller-controlled pointer. Nothing between that assignment and the writes re-validates it.
  • The size check sets v13 = STATUS_INFO_LENGTH_MISMATCH but keeps executing. The early return only fires when a3 (the ReturnLength pointer) is NULL, and ExpQuerySystemInformation always 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 == 0 returns immediately, regardless of Address. 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-zero Length it would reject any kernel-VA target; with Length == 0 we simply never reach it.
  • The page-touch loop is what actually faults on a bad address. For Length == 0 it is skipped, so no exception is raised even when Address is 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: with Length == 0 it returns immediately; the address is never validated.
  • Outer switch class filter: no filter for 253; default falls straight 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 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_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.

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:

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

  • +0 delta = 310 = the number of running processes, excluding Idle and any the worker skips via ExpSysInfoShouldSkipProcess.
  • +4 delta = 4419 = the sum of PsGetProcessActiveThreadCount across those processes.
  • +8 delta = 159307 = the sum of ObGetProcessHandleCount.
  • +12, +16, +20 unchanged — 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 (minus Idle and a small skip-list).
  • *(DWORD*)(target + 4) += T, where T = the sum of PsGetProcessActiveThreadCount.
  • *(DWORD*)(target + 8) += H, where H = the sum of ObGetProcessHandleCount.
  • 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 of SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX, each carrying the kernel Object pointer.
  • Walk the array and match on (UniqueProcessId == GetCurrentProcessId(), HandleValue == h). The matched entry’s Object field is our _TOKEN kernel 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: 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 unreachable from the user’s desktop.
  • SeImpersonatePrivilege (or SeAssignPrimaryTokenPrivilege + SeIncreaseQuotaPrivilege): call CreateProcessWithTokenW(forgedToken, cmd.exe, CREATE_NEW_CONSOLE, "winsta0\\default"), falling back to CreateProcessAsUserW.

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:

Source: original article.

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_MISMATCH but execution keeps going straight into the three DWORD increments.
  • ProbeForWrite is not a kernel-address guard when Length == 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_RestrictKernelAddressLeaks off so that class 64 will leak the token VA needed for the rest of the chain.
  • Forging a SYSTEM token with NtCreateToken (once SeCreateTokenPrivilege is set) is a clean alternative to classical token theft — no target process to open, no DuplicateTokenEx.
  • 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 NtQuerySystemInformation usage, especially class 0xFD (253) calls with a zero Length, and calls originating from renderer/sandboxed processes that have no business enumerating system process information.
  • Alert on NtCreateToken from unexpected callers. Legitimate use of SeCreateTokenPrivilege is rare; a Medium-IL, non-service process forging a primary token is a strong signal.
  • Flag suspicious token realignment and spawnsSetTokenInformation(TokenSessionId, ...) followed by CreateProcessWithTokenW/CreateProcessAsUserW yielding a SYSTEM cmd.exe in 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.
  • NtQuerySystemInformation reference (information classes and buffer semantics).
  • TOKEN_PRIVILEGES and ProbeForWrite documentation.
  • 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.

oxfemale Vulnerability research, reverse engineering, and exploit development.
// Discussion