返回资讯中心
外部精选
软件工程
#开发者实时流

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

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 ren…

Hacker News 最新DeepLogin2026/10/27 · 昨天29 分钟阅读

以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。

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.

To follow this end-to-end, you’ll want to be comfortable with:

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.

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):

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:

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.

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:

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.

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.

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.

Per single invocation of NtQuerySystemInformation(0xFD, target, 0, &ret):

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:

Undocumented leak, as it is not fixed yet :)

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:

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.

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

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.

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.

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.

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →