If you are here, I assume you have read "Windows Internals You Need To Know Before Kernel Exploitation" and "The Kernel Attack Surface: How Windows Internals Enable Exploitation" already. If you haven't, I recommend you go through them first.
The ThrottleStop article covered the physical-memory leg of the Memory Manager attack surface — a signed third-party driver handing MmMapIoSpace to any user-mode caller. This article covers the virtual-memory leg, and it is a different animal entirely. There is no vulnerable driver here. There is no BYOVD. The bug lives in ntoskrnl.exe itself, in the kernel's own security-attribute copyout path, and the exploitation is a pure virtual-memory attack: during a copyout, the kernel builds a UNICODE_STRING structure in the attacker's user-mode buffer, writes its fields, and then reads those fields back and writes through them — and the attacker races to change what the structure's Buffer pointer points to in the instant between the write-down and the read-back.
#The Memory Manager as an Attack Surface
In The Kernel Attack Surface, Section 3 mapped the Memory Manager into three legs: virtual memory (the address space, section objects, views, and address translation), page tables (the PTE format and the self-referencing PML4 entry), and physical memory (MmMapIoSpace and the master key it represents). The ThrottleStop article walked the physical-memory leg to its conclusion — a driver that maps any physical address into kernel virtual space and reads or writes it directly.
This article walks the virtual-memory leg, and it does so without a driver. The attack surface is not a device object or an IOCTL handler. It is the kernel's own handling of a user-mode pointer during a copyout operation. The kernel builds a Buffer pointer into a structure in user memory, and then it writes through that pointer. The virtual address space is the weapon: the attacker does not need to map physical memory, walk page tables, or flip a PTE. They only need to change what a user-mode pointer points to, at the right moment.
#CVE-2024-30088 Overview
CVE-2024-30088 is a time-of-check to time-of-use (TOCTOU) race condition in AuthzBasepCopyoutInternalSecurityAttributes, a function in ntoskrnl.exe that copies a token's security attributes out to user mode. It was found and demonstrated by carrot_c4k3 at Pwn2Own Vancouver 2024, in the Windows Local EoP category — used to escalate from a low-privilege user to SYSTEM on a fully patched Windows 11 23H2 target, netting a $15,000 payout (half the category maximum, since it was not the contest's first Windows kernel entry). Microsoft patched it in the June 11, 2024 Patch Tuesday, and it was added to CISA's Known Exploited Vulnerabilities catalog on October 15, 2024 (remediation due November 5, 2024) after being exploited in the wild.
| Field | Value |
|---|---|
| CVE | CVE-2024-30088 |
| Class | TOCTOU race condition (CWE-367) |
| Vulnerable function | AuthzBasepCopyoutInternalSecurityAttributes (ntoskrnl.exe) |
| Reachable via | NtQueryInformationToken with the TokenAccessInformation class |
| CVSS | 7.0 High — AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H (Attack Complexity: High — it is a race) |
| Affected builds | Windows 10 1507–22H2, Windows 11 21H2–23H2, Server 2016–2022 (including 23H2 Server Core) |
| Fixed in | June 11, 2024 — KB5039211 (Win10) / KB5039212 (Win11), OS builds 22621.3737 / 22631.3737 |
| Demonstrated by | carrot_c4k3, Pwn2Own Vancouver 2024 (Windows Local EoP, $15,000) |
| CISA KEV | Added 2024-10-15 |
| Status | Exploited in the wild |
The vulnerability is notable for what it is not. It is not a buffer overflow, not a use-after-free, not a missing bounds check. It is a race — a gap between the moment the kernel writes a pointer into user memory and the moment it reads that pointer back and writes through it. That gap is the entire attack surface.
#The Vulnerable Function
AuthzBasepCopyoutInternalSecurityAttributes is part of the Authorization (Authz) subsystem. Its job is to copy a token's security attributes — the name/value pairs attached to a token, including claim attributes and the kernel's own token security attributes — from kernel space into a caller-supplied user-mode buffer. It is reached when user mode asks the kernel for the token's access information: NtQueryInformationToken with the TokenAccessInformation information class returns a TOKEN_ACCESS_INFORMATION structure whose last field, SecurityAttributes, is an opaque pointer to exactly the data this function copies out. One of the attributes present on ordinary process tokens is the per-process unique attribute TSA://ProcUnique — remember that name, because it is the fixed value the whole exploit is built around.
carrot_c4k3 found the bug by auditing the roughly eighty RtlCopyUnicodeString call sites in ntoskrnl.exe. That audit angle matters: RtlCopyUnicodeString was only ever designed to process UNICODE_STRING structures residing in kernel mode. It performs no probing of the destination's Buffer pointer — it assumes the caller guarantees the structure lives in trusted memory. Any kernel path that hands it a UNICODE_STRING built in user memory inherits that assumption, and the assumption is false.
The function begins by copying a fixed-size header structure out to the user buffer:
// _AUTHZBASEP_SECURITY_ATTRIBUTES_INFORMATION — 0x30 bytes
// The header the kernel copies to user mode before walking the attribute list.
typedef struct _AUTHZBASEP_SECURITY_ATTRIBUTES_INFORMATION {
ULONG SecurityAttributeCount; // +0x00
LIST_ENTRY SecurityAttributesList; // +0x08
ULONG WorkingSecurityAttributeCount; // +0x18
LIST_ENTRY WorkingSecurityAttributesList; // +0x20
} AUTHZBASEP_SECURITY_ATTRIBUTES_INFORMATION; // sizeof == 0x30The SecurityAttributesList is a doubly-linked list of security-attribute structures. Each attribute carries a name (a UNICODE_STRING) and a value. After copying the header, the kernel walks this list and copies each attribute's name and value out to user mode. Critically, it builds the attribute structures — including each name's UNICODE_STRING — directly inside the user-supplied output buffer, and then fills in the name via RtlCopyUnicodeString and the value via AuthzBasepCopyoutInternalSecurityAttributeValues.
The vulnerable pattern, reduced to its essence:
// Simplified vulnerable pattern in AuthzBasepCopyoutInternalSecurityAttributes
// The kernel builds each attribute's UNICODE_STRING in the user-mode output
// buffer, writes its fields, then reads those fields back through
// RtlCopyUnicodeString — a write-then-read-back double fetch on user memory.
NTSTATUS AuthzBasepCopyoutInternalSecurityAttributes(
PTOKEN token,
PVOID userBuffer) // user-supplied output buffer
{
// 1. Copy the header structure to user mode
AUTHZBASEP_SECURITY_ATTRIBUTES_INFORMATION info;
// ... populate info from the token's security attributes ...
RtlCopyMemory(userBuffer, &info, sizeof(info));
// 2. Walk the SecurityAttributesList and copy each attribute's name
for (each attribute in info.SecurityAttributesList) {
// The attribute structure lives in the user-supplied buffer.
// The kernel writes the name's UNICODE_STRING fields HERE —
// Length, MaximumLength, and Buffer, pointing at a location
// inside the user buffer where the name should land.
PUNICODE_STRING name = &attribute->Name;
// 3. Copy the name through the user-mode structure.
// BUG: RtlCopyUnicodeString re-reads name->Buffer (and the
// bounds fields) from user memory and writes through them,
// with no probe and no re-validation. Between the kernel
// writing these fields in step 2 and this read-back, a racing
// thread can swap name->Buffer for any kernel address.
RtlCopyUnicodeString(name, &kernelName);
// ^ destination = name->Buffer (re-read from user memory)
// ^ source = kernelName (the token's attribute name)
}
}The critical detail is in step 3. RtlCopyUnicodeString(destination, source) copies the source string into the destination buffer — and the destination is name->Buffer, a pointer field living in user memory, re-read by the copy routine itself. The kernel does not copy the name into a kernel-owned buffer first. It does not pin or re-validate the destination. It writes a pointer into the attacker's memory, reads it back, and writes through it.
#The Root Cause: A TOCTOU Race
The root cause is the gap between the time the kernel writes the UNICODE_STRING fields into user memory and the time RtlCopyUnicodeString reads them back. On a multi-processor system, another thread can swap the Buffer pointer in that window.
This is the classic TOCTOU pattern from Section 6 of The Kernel Attack Surface — but with a twist. The textbook TOCTOU is a value double-fetch: the kernel reads a value, validates it, reads it again, and the attacker flips the value between the two reads. This bug is a pointer race with the shape inverted: the kernel writes a pointer into user memory, then reads it back and uses it as a write destination. A value is written and then read back, rather than read twice — and the attacker flips the pointer between the write-down and the read-back.
The kernel's mistake is treating memory it does not own as if it cannot change. The UNICODE_STRING lives in the caller's user-mode output buffer. The user controls that memory, and the user can change it at any time. The kernel's own address space is protected by the supervisor bit and the page tables, but a pointer stored in user memory is just data, and data the user owns can change underneath the kernel.
There is no lock here. No page pinning. No capture of the destination into a kernel-owned variable before the copy. The kernel writes the fields, and then — some number of instructions later — RtlCopyUnicodeString reads them back and writes through them. Everything in between is the race window. And nothing faults when the attacker wins it: the write is performed by the kernel itself, at supervisor privilege, and Windows does not enable SMAP, so even the read-back of the user-mode structure is ungated by hardware.
#The Exploitation: Flipping the Buffer Pointer
The exploitation is a two-thread race. One thread triggers the vulnerable copyout repeatedly. The other thread flips the Buffer pointer of the attribute name between the pointer the kernel wrote and a kernel-mode target, in a tight loop.
The setup is simpler than it sounds, because nothing needs to be forged: the attacker queries their own process token. An ordinary process token already carries the TSA://ProcUnique attribute, so the attacker calls NtQueryInformationToken with TokenAccessInformation, pointing the output buffer at memory they control. The kernel copies the header out, builds the attribute list and the name's UNICODE_STRING directly in that output buffer, and walks them — while the attacker's second thread hammers the Buffer field.
// Two-thread race harness — the core of the exploit
// Thread 1: repeatedly trigger the vulnerable copyout
// Thread 2: flip the Buffer pointer between the pointer the kernel
// wrote and a kernel target, in a tight loop
// Thread 1 — trigger the vulnerable path
DWORD WINAPI TriggerThread(LPVOID param) {
while (!g_done) {
// NtQueryInformationToken with TokenAccessInformation reaches
// AuthzBasepCopyoutInternalSecurityAttributes
NtQueryInformationToken(
hToken, // the attacker's own process token
TokenAccessInformation,
g_userBuffer, // output buffer (attacker-controlled)
g_bufferSize,
&returnLength);
}
return 0;
}
// Thread 2 — flip the Buffer pointer
DWORD WINAPI RaceThread(LPVOID param) {
PUNICODE_STRING name = &g_attribute->Name;
while (!g_done) {
name->Buffer = (PWSTR)g_kernelTarget; // the flip
name->Buffer = (PWSTR)g_userAddress; // restore the sane value
}
return 0;
}When the race is won, the sequence is:
- The kernel builds the
UNICODE_STRINGin the user buffer and writesBufferpointing at the name's intended landing spot inside the buffer. - The racing thread swaps
name->Bufferfor a kernel-mode target. RtlCopyUnicodeStringreads the swapped pointer back and copies the attribute name through it.
The kernel's own copy — a legitimate, well-intentioned operation — lands in kernel memory at an attacker-chosen address. The kernel wrote to its own address space, using a pointer it had stored in the attacker's memory, because the pointer changed between the write-down and the read-back.
#The Write Primitive: Fixed Value, Arbitrary Address
The result is an arbitrary-address write with a fixed value — the inverse of a classic write-what-where. The "where" is fully arbitrary: the flipped Buffer pointer can be any kernel address. The "what" is not chosen by the attacker at all:
// The primitive, in one line:
// *(kernelTarget) = "TSA://ProcUnique"; // 32 bytes, UTF-16
//
// - "where" = the flipped Buffer pointer (fully arbitrary)
// - "what" = the attribute name — the fixed string "TSA://ProcUnique",
// a per-process unique token security attribute the kernel
// copies out. The attacker cannot choose these bytes.
// - "size" = the name's length — 32 bytes for this attributeIn the Pwn2Own writeup, carrot_c4k3 puts it plainly: "We do not control the contents of the string, but we know what they are." That is the whole design problem of this exploit. A write primitive whose value you cannot choose is a strange tool — you cannot write a SYSTEM token pointer with it, cannot write zeros on demand, cannot write most of the values a privilege escalation normally needs.
What you can do is aim. The value is fixed but known in advance, so the exploit is built around what those exact 32 bytes do when they land in the right place. The bytes are "TSA://ProcUnique" encoded as UTF-16:
T S A : / / P r o c U n i q u e (16 code units = 32 bytes, UTF-16LE)
Byte layout of the trailing DWORD — the code units 'u' and 'e':
'u' = 0x0075 'e' = 0x0065
little-endian in memory: 75 00 65 00 -> the DWORD 0x006500750x00650075 is roughly 6.6 MB — a perfectly valid user-mode address. The tail of a kernel-stamped attribute name is, by accident, a pointer into the low user address space. The rest of the exploit chain exists to make that accident useful.
#The Exploit Chain
The full chain is: race → fixed 32-byte write into an IORING_OBJECT → I/O Ring arbitrary read/write → token swap → SYSTEM.
#The Race Harness
The two threads are pinned to separate cores with SetThreadAffinityMask, so the trigger and the flip run concurrently on different processors. Both run in tight loops. The trigger thread calls NtQueryInformationToken as fast as it can; the flip thread alternates the Buffer pointer between the kernel-written value and the kernel target as fast as it can. Before each attempt, the attacker creates a fresh I/O ring, so a new IORING_OBJECT — the intended landing zone for the write — exists at a known address for that iteration.
// Pin the two threads to separate cores so the race is a true
// concurrent race, not a time-sliced one.
SetThreadAffinityMask(hTriggerThread, 1 << 0); // core 0
SetThreadAffinityMask(hRaceThread, 1 << 1); // core 1The window is narrow, but the loop is fast, and the attacker only needs to win once. Each iteration of the trigger thread is a fresh attempt; each iteration of the flip thread is a fresh coin flip. Over thousands of iterations, the race is won within seconds.
#Aiming the Fixed Value
The target is the freshly created IORING_OBJECT — the kernel object behind a Windows I/O ring. On a fresh object, the RegBuffers pointer (the table of registered buffers the ring copies to and from) is still NULL. The exploit aims the 32-byte write so it begins exactly 28 bytes before RegBuffers:
IORING_OBJECT (freshly allocated)
├── header / neighboring fields <- write bytes [0..28) (clobbered)
│ ...
│ CompletionUserEvent <- clobbered — collateral damage
└── RegBuffers (still NULL) <- write bytes [28..32) (the payload)Two things happen at once:
- The payload: the last 4 bytes of the write — the
0x00650075tail of"TSA://ProcUnique"— land on the low 32 bits ofRegBuffers. The upper 32 bits of the pointer were zero (the field was NULL) and stay zero. The corrupted pointer is now0x0000000000650075`: a user-mode address, inside the attacker's own memory, where a fake buffer-registration table is waiting. - The collateral: the preceding 28 bytes of the write clobber the object's header fields — including the neighboring
CompletionUserEventpointer, which the kernel dereferences when the I/O ring operation completes. That is a guaranteed crash, unless the exploit repairs it first.
#From One Write to Arbitrary Read/Write
With RegBuffers pointing at attacker-controlled memory, the attacker controls the ring's buffer-registration table — which is the setup for the I/O Ring arbitrary kernel read/write technique documented by Yarden Shafir ("One I/O Ring to Rule Them All"): by faking the registered-buffer metadata, operations submitted to the ring can be made to read from and write to arbitrary kernel addresses.
But there is a deadline. The corrupted CompletionUserEvent will be dereferenced at the end of the syscall and crash the system — after the write has occurred but before the attacker can keep going. The corrupted RegBuffers affords exactly one arbitrary write before that happens, and the exploit spends it on the only thing that matters: nulling out the damaged CompletionUserEvent so the syscall completes cleanly.
From there the ring gives full arbitrary kernel read/write, and the endgame is the one already documented in Section 4 of The Kernel Attack Surface: read the SYSTEM token, overwrite the attacker's _EPROCESS.Token with it, and spawn a shell. Classic token swapping — a data-only write, no code execution, no control-flow hijack.
#Finding the Kernel Addresses
The chain needs two kernel addresses: where the fresh IORING_OBJECT lives (so the write can be aimed), and where the attacker's _EPROCESS is (for the token swap). No KASLR-breaking information leak is required on Windows — NtQuerySystemInformation is callable at Medium integrity level (any standard user) and hands out kernel object addresses for handles the process holds. Query your own io ring handle and your own process handle, and the addresses fall out. On the Xbox One/Series port of this same exploit (SystemOS), where ExIsRestrictedCaller blocks that syscall from a Low-IL UWP sandbox, carrot_c4k3 instead leaked the kernel base with a prefetch timing side channel and corrupted the SeMediumDaclSd security descriptor to re-enable the query — the same bug, harder constraints, the same chain.
#The Patch
Microsoft patched CVE-2024-30088 in the June 11, 2024 Patch Tuesday. The fix changes AuthzBasepCopyoutInternalSecurityAttributes so that the security attribute name is first copied into a kernel-stack local variable, and only then written back to the user buffer — and it does so only when the call originates from user mode.
// Patched pattern (per the patch diff in the public analyses) —
// the name is staged in a kernel-owned buffer first
WCHAR stackBuffer[MAX]; // kernel stack
UNICODE_STRING localName = { ... stackBuffer ... };
RtlCopyUnicodeString(&localName, &kernelName); // copy to local first
// ... then write localName's fields back to the user buffer, in a
// separate, validated step.In the decompiled patch, a stack UNICODE_STRING (v18) backed by a stack buffer (v17) is populated by RtlCopyUnicodeString before being written back out to the user structure — and in a kernel debugger session on the patched build, the first argument at the RtlCopyUnicodeString call site is a kernel stack address, not the user-mode buffer. The kernel no longer reads a destination pointer out of user memory and writes through it.
The user-mode gating is not arbitrary. The same function is also invoked in-kernel — tcpip.sys calls it with destinations in kernel memory, where the pattern is safe because the user cannot touch kernel memory. A blanket fix would have broken that caller, so the fix scopes itself to the case that is actually dangerous. The fix is also gated behind velocity feature flags, the usual Windows servicing machinery for behavioral changes.
The fix is not a lock, not page pinning, and not an MDL. It is a staging buffer. The lesson is subtle: the fix did not make the check and the use atomic. It removed the use of the user pointer entirely for the copy, and replaced it with a kernel-owned intermediate. That is the correct fix for a pointer race — not to lock the pointer, but to stop trusting it.
#Why It Bypasses the Mitigation Stack
CVE-2024-30088 is a data-only attack, and it slides past the mitigation stack for the same reason every data-only attack does: it never executes attacker code and never hijacks control flow. It won at Pwn2Own on a fully patched Windows 11 23H2 — every mitigation in the table below was enabled, and none of them mattered.
| Mitigation | Does it stop CVE-2024-30088? | Why |
|---|---|---|
| SMEP | No | No user-mode code is executed in kernel context |
| SMAP | No | The write lands on a supervisor page at supervisor privilege — and Windows does not enable SMAP anyway, so even the read-back of the user-mode structure is ungated |
| CET / shadow stack | No | No return address is overwritten; no control-flow hijack |
| kCFG | No | No indirect call is redirected |
| HVCI | No | No new executable page is created; the entire chain is data writes |
| KASLR | Partial | The chain needs kernel object addresses — obtained via NtQuerySystemInformation at Medium IL, no kernel-base leak required |
| KDP | No | KDP protects static kernel data (the SSDT, CI policy structures, driver configuration) in EPT. IORING_OBJECT and _EPROCESS are dynamic pool allocations KDP does not cover — the chain simply targets data KDP does not protect |
The KDP row is the interesting one, and it deserves honesty rather than the easy answer. KDP — as the defense post covers it — is the preventive, hypervisor-enforced control over kernel data tampering, and it is the mitigation that data-only attacks are supposed to run into. But KDP's protection list is a list of static structures. A pool-allocated IORING_OBJECT created seconds ago and a per-process _EPROCESS are not on it. The exploit does not defeat KDP; it routes around it, by choosing a target that was never on the protected list.
The rest of the stack is irrelevant because the attack never does the things those mitigations watch for. It does not execute code, so SMEP and CET are silent. It does not create executable pages, so HVCI is silent. It writes known bytes through the kernel's own copy — and the only defense in the stack that watches kernel data does not watch this data.
#Detection
The race leaves a detectable footprint, but it is subtle. The indicators are behavioral, not signature-based:
- A syscall storm from one process. The trigger thread calls
NtQueryInformationTokenin a tight loop, thousands of times per second. The flip thread makes no syscalls at all — it only writes to the process's own memory — so the flip itself is invisible. The anomaly is the volume: a single low-privilege process hammering one token query with no legitimate purpose. - Multi-core CPU burn from a single low-privilege process. The race harness pins two threads to separate cores and spins them at full speed. A low-privilege process pegging two cores while issuing a token-query storm is a red flag.
- Token swap without a logon event. The endgame overwrites the token pointer directly. The process becomes SYSTEM without a corresponding logon (Event ID 4624) or privilege-assignment event. A SYSTEM process with no logon lineage is suspicious, and EDR products that snapshot
_EPROCESS.Tokenbetween observations catch exactly this diff. - Audit events out of pattern. Event ID 4672 (special privileges assigned) and 4688 (process creation) can reveal a process that acquired SYSTEM privileges without the normal path.
#Key Takeaways
-
The kernel trusted memory it does not own.
AuthzBasepCopyoutInternalSecurityAttributesbuilt aUNICODE_STRINGin the caller's user-mode buffer, wrote its fields, and then read them back throughRtlCopyUnicodeString. A pointer stored in user memory is data, and data the user owns can change underneath the kernel. -
This TOCTOU is a pointer race with the shape inverted. The textbook TOCTOU flips a value between two reads. This bug flips a pointer between a write and a read-back — the kernel writes the structure into user memory, then trusts what it reads back from it. The kernel's own copy becomes the write primitive.
-
A fixed value is not a dead primitive. The write always carries the same 32 bytes of
TSA://ProcUnique, and the attacker cannot change a single one of them. The exploit works because the value is known in advance — the write is aimed so that the string's trailing DWORD (0x00650075, the tail ofProcUnique) lands on the low half of a NULL pointer and turns it into a user-mode pointer. When you cannot choose the bytes, you choose where they land. -
The fix was a staging buffer, not a lock. Microsoft removed the direct use of the user pointer by copying the name into a kernel-stack variable first — and scoped the fix to user-mode-originated calls, because in-kernel callers like
tcpip.syspass kernel-mode destinations where the pattern is safe. The correct fix for a pointer race is to stop trusting the pointer, not to lock it. -
Data-only attacks route around KDP. KDP protects static kernel data, but the chain targets dynamic pool objects — an
IORING_OBJECTand an_EPROCESS— that no KDP entry covers. The defense stack narrows the menu of corruptible data; it does not clear the table. -
Bug hunting by API contract pays. The bug was found by auditing every
RtlCopyUnicodeStringcall site in ntoskrnl — a routine that assumes itsUNICODE_STRINGlives in kernel mode and probes nothing. Any kernel API with an implicit "trusted caller memory" contract is a TOCTOU mine the moment a path hands it user memory. -
The virtual-memory leg is more valuable than the physical-memory leg. The ThrottleStop bug required a vulnerable driver. This bug lives in
ntoskrnl.exeitself, ran against a fully patched Windows 11 23H2, and even ported to the Xbox SystemOS — no BYOVD, no driver blocklist, no trust boundary to cross except the kernel's own pointer validation.
Further reading:
- carrot_c4k3's Pwn2Own Vancouver 2024 writeup — the primary source for this article: the bug, the race, the I/O Ring chain, and the Xbox port
- tykawaii98's public PoC — standalone C++ trigger with the patch-side analysis
- One I/O Ring to Rule Them All (Yarden Shafir) — the I/O Ring arbitrary read/write technique the endgame pivots through
- Rapid7 Metasploit module —
cve_2024_30088_authz_basep - maldev Go package — CVE-2024-30088 privilege escalation
- AttackerKB — CVE-2024-30088
- CISA Known Exploited Vulnerabilities catalog
- Windows Internals, Part 1 (Yosifovich, Ionescu, Russinovich, Solomon) — memory management and the virtual address space
- The companion defense post and The Kernel Attack Surface for the full series context