Exploiting CVE-2024-30088: A TOCTOU Race in the Windows Kernel

Noman Nasir MinhasPublished kernel-exploitationwindows-internalssecurity-researchcve-2024-30088

A deep-dive into CVE-2024-30088 — a TOCTOU race in ntoskrnl's AuthzBasepCopyoutInternalSecurityAttributes, exploited by flipping a user-memory pointer to redirect the kernel's own copy into kernel space, then pivoting the fixed-value write through an I/O Ring into SYSTEM.

Share

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.

x64 Virtual Address Space Layout

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

FieldValue
CVECVE-2024-30088
ClassTOCTOU race condition (CWE-367)
Vulnerable functionAuthzBasepCopyoutInternalSecurityAttributes (ntoskrnl.exe)
Reachable viaNtQueryInformationToken with the TokenAccessInformation class
CVSS7.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 buildsWindows 10 1507–22H2, Windows 11 21H2–23H2, Server 2016–2022 (including 23H2 Server Core)
Fixed inJune 11, 2024 — KB5039211 (Win10) / KB5039212 (Win11), OS builds 22621.3737 / 22631.3737
Demonstrated bycarrot_c4k3, Pwn2Own Vancouver 2024 (Windows Local EoP, $15,000)
CISA KEVAdded 2024-10-15
StatusExploited 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:

C
// _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 == 0x30

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

C
// 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.

C
// 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:

  1. The kernel builds the UNICODE_STRING in the user buffer and writes Buffer pointing at the name's intended landing spot inside the buffer.
  2. The racing thread swaps name->Buffer for a kernel-mode target.
  3. RtlCopyUnicodeString reads 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:

C
// 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 attribute

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

TEXT
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 0x00650075

0x00650075 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.

C
// 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 1

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

TEXT
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 0x00650075 tail of "TSA://ProcUnique" — land on the low 32 bits of RegBuffers. The upper 32 bits of the pointer were zero (the field was NULL) and stay zero. The corrupted pointer is now 0x0000000000650075`: 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 CompletionUserEvent pointer, 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.

C
// 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.

MitigationDoes it stop CVE-2024-30088?Why
SMEPNoNo user-mode code is executed in kernel context
SMAPNoThe 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 stackNoNo return address is overwritten; no control-flow hijack
kCFGNoNo indirect call is redirected
HVCINoNo new executable page is created; the entire chain is data writes
KASLRPartialThe chain needs kernel object addresses — obtained via NtQuerySystemInformation at Medium IL, no kernel-base leak required
KDPNoKDP 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:

  1. A syscall storm from one process. The trigger thread calls NtQueryInformationToken in 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.
  2. 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.
  3. 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.Token between observations catch exactly this diff.
  4. 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

  1. The kernel trusted memory it does not own. AuthzBasepCopyoutInternalSecurityAttributes built a UNICODE_STRING in the caller's user-mode buffer, wrote its fields, and then read them back through RtlCopyUnicodeString. A pointer stored in user memory is data, and data the user owns can change underneath the kernel.

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

  3. 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 of ProcUnique) 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.

  4. 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.sys pass 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.

  5. Data-only attacks route around KDP. KDP protects static kernel data, but the chain targets dynamic pool objects — an IORING_OBJECT and an _EPROCESS — that no KDP entry covers. The defense stack narrows the menu of corruptible data; it does not clear the table.

  6. Bug hunting by API contract pays. The bug was found by auditing every RtlCopyUnicodeString call site in ntoskrnl — a routine that assumes its UNICODE_STRING lives 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.

  7. The virtual-memory leg is more valuable than the physical-memory leg. The ThrottleStop bug required a vulnerable driver. This bug lives in ntoskrnl.exe itself, 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:

Found it useful? Share it
← Back to all posts