Introduction
CVE-2026-62735 is an integer overflow in HTTP.sys response-size accounting that can lead to local privilege escalation. This post follows the published research, including Huy Hoàng Nguyễn’s exploitation analysis, to explain how an undersized response buffer leads to memory corruption and privilege escalation.
This analysis was conducted while developing an exploit for Core Impact, where turning a vulnerability into a production-ready exploit requires reliable primitives and clean post-exploitation recovery.
Building on the existing research, this post provides a hands-on walkthrough detailing exploitation and teardown considerations.
ZDI credits Siyeon Wi with the discovery. Wi demonstrated an integer-overflow-based Windows privilege escalation at Pwn2Own Berlin 2026. Microsoft addressed CVE-2026-62735 in its August 11, 2026 security updates.
Test environment: Windows 11 26H1, OS build 10.0.28000.1719. Behavior may differ on other Windows builds or configurations.
The walkthrough is organized around the following stages:
- Vulnerability: How a 32-bit integer overflow in UlpCreateInternalResponseOld triggers a nonpaged pool overflow.
- Nonpaged pool grooming: Positioning named pipe DATA_ENTRY allocations adjacent to the vulnerable HTTP.sys buffer.
- Out-of-bounds read: Corrupting an adjacent pipe entry's DataSize field to leak kernel pointers via PeekNamedPipe.
- Arbitrary read: Redirecting Queue.Flink to a forged user-mode entry and IRP to read arbitrary kernel addresses.
- Arbitrary write: Forcing NPFS to complete a forged kernel-resident IRP to overwrite arbitrary memory.
- Privilege escalation: Walking ActiveProcessLinks to replace the current process token with the SYSTEM token.
- Teardown: Clearing the corrupted queue state to bypass link validation and prevent a kernel bugcheck.
Vulnerability
In the legacy response-construction path, UlpCreateInternalResponseOld calculates the combined header size using 32-bit arithmetic. When this sum wraps, HTTP.sys allocates an undersized internal response buffer from the nonpaged pool, leading to an out-of-bounds write when the headers are later serialized using their full 64-bit lengths.
Figure 1. The response-size calculation in UlpCreateInternalResponseOld, with the 32-bit arithmetic and allocation call highlighted.
To trigger the vulnerability, we can use the HTTP Server API:
- Start an HTTP server and client on localhost, then receive a GET request.
- Build a response with repeated known headers and a trailer. We size the headers so the 32-bit sum overflows into a small allocation, while the actual header data remains enough to write past the end of the buffer.
- Submit the response with HttpSendHttpResponse. HTTP.sys allocates the undersized UlIR buffer and overflows into the adjacent nonpaged pool during header generation.
Figure 2. Debugger trace of the HTTP.sys response-construction path.
Nonpaged pool grooming
Named pipe writes populate the nonpaged pool with data queue entries. The layout alternates persistent allocations (“victims”) with temporary allocations (“holes”) that are later freed. Freeing the temporary entries creates UlIR-sized holes directly ahead of the victim allocations, positioning the HTTP.sys buffer so the overflows lands in a neighboring victim entry.
Figure 3. Adjacent HTTP.sys and data queue entry allocations.
Out-of-bounds read
Overwriting the adjacent pipe entry's DataSize field turns the out-of-bounds write into an out-of-bounds read.
Figure 4. Data queue entry metadata before and after corruption.
Buffered pipe writes create a DATA_ENTRY consisting of metadata followed by the written payload. Pool grooming positions one of these entries adjacent to the vulnerable HTTP.sys allocation. The HTTP.sys overflow overwrites the entry’s metadata, increasing its DataSize field while preserving its buffered status and leaving the underlying data allocation unchanged.
When PeekNamedPipe is called, NPFS relies on the manipulated length and reads past the inline data allocation, copying neighboring kernel-pool bytes into a user-mode buffer. Because PeekNamedPipe reads pipe data without draining the queue, we can call it across our victim handles to find which pipe returns more bytes than originally written, leaking the adjacent queue pointers in the process.
Arbitrary read
The out-of-bounds write corrupts the entry’s forward queue link (Queue.Flink), redirecting it to a forged DATA_ENTRY in controlled user-mode memory. When a sufficiently large PeekNamedPipe request traverses the queue, NPFS eventually processes this forged entry.
The forged entry is marked as unbuffered. NPFS therefore retrieves the payload through an associated I/O request packet (IRP), rather than from inline pool memory. The IRP’s controlled source-buffer pointer supplies a caller-selected address for the read.
Figure 5. Debugger view of the forged data entry and associated IRP.
Reaching the forged entry requires a peek buffer large enough to accommodate all preceding valid entries, the corrupted entry’s advertised length, and the target payload size. For byte-stream pipes, NPFS traverses queue entries while output space remains. Once the corrupted entry’s advertised bytes have been processed, NPFS follows its overwritten Flink pointer to the forged entry. If the output buffer fills before that point, traversal stops.
Once NPFS encounters the unbuffered entry, it copies memory from the address specified through the controlled IRP. Stripping the preceding pipe data from the peek output isolates the leaked kernel bytes, and because PeekNamedPipe doesn't consume the queue, we can reuse the corrupted link for repeated reads.
Arbitrary write
To build an arbitrary write primitive, we force NPFS to complete a forged IRP.
First, a genuine pending NPFS pipe-write IRP is read from kernel memory and used as a structural template, preserving its header, ownership metadata, and CancelRoutine pointer. NpReadDataQueue atomically tests and clears this pointer before queuing the request for completion. The forged IRP resides in kernel memory, within the buffer of a separate pending helper-pipe request. Its completion-status storage (UserIosb) and synthetic list head reside in pinned user-mode memory.
Relevant IRP fields:
- Flags: Setting IRP_BUFFERED_IO | IRP_INPUT_OPERATION tells the I/O manager to copy data from AssociatedIrp.SystemBuffer to UserBuffer upon completion (in I/O manager terms, an input operation copies data from the kernel buffer back to the caller’s destination buffer). Flag bit 0x8000 also selects a specific lifetime-count path, while IRP_DEFER_IO_COMPLETION is omitted.
- AssociatedIrp.SystemBuffer & UserBuffer: Point to the source address to read from and the destination address to overwrite. IoStatus.Status and IoStatus.Information are initialized to permit copy-back. Information specifies the transfer length in bytes, which NPFS updates when completing the entry.
- CurrentLocation & ThreadListEntry: Setting CurrentLocation to StackCount + 1 skips any stale stack check completion callbacks inherited from the template IRP. ThreadListEntry forms reciprocal links with a synthetic list head in user-mode memory, allowing IopDequeueIrpFromThread to pass link-integrity checks during unlinking without insertion into a real thread IRP list. MdlAddress, UserEvent, OriginalFileObject, and tail pointers are also cleared to bypass unrelated MDL, event, and file-object handling.
- Preventing deallocation: Omitting IRP_DEALLOCATE_BUFFER from Flags stops the I/O manager from freeing SystemBuffer. Likewise, combining flag bit 0x8000 with Overlay.AllocationSize.QuadPart=4 keeps the internal reference count non-zero during IopCompleteRequest, preventing IoFreeIrp from freeing the forged IRP itself.
To trigger completion, a separate forged DATA_ENTRY is presented to NPFS as a buffered write entry pointing to the kernel-resident forged IRP. Its quota metadata indicates a stalled write awaiting pipe capacity. A consuming read, such as ReadFile, drains pipe data and frees capacity. NPFS updates the queued entry’s final status and transfer length, places it on a temporary completion list, and invokes IofCompleteRequest. The I/O manager’s buffered completion routine (IopProcessBufferedIoCompletion) then performs the SystemBuffer-to-UserBuffer copy using the length in IoStatus.Information, turning I/O completion into an arbitrary-write primitive.
Figure 6. Annotated IRP fields and token-field information in the debugger.
Privilege escalation
With the arbitrary read/write primitives, we locate the current process's EPROCESS structure via the owning-thread pointer in the leaked IRP and walk ActiveProcessLinks to find the SYSTEM process. Then, we can replace the current process's EPROCESS.Token with the SYSTEM token.
Figure 7. Command output from the privilege-escalation demonstration.
Teardown
Within npfs.sys, NpSetClosingPipeState drains a data queue only while its QueueState is not Empty. In addition, NpRemoveDataQueueEntry contains a guard that returns without traversing list entries if the queue state is Empty.
After exploitation, the victim entry remains in the queue with corrupted doubly linked list pointers. If NPFS attempts normal removal, NpRemoveDataQueueEntry verifies reciprocal link integrity. Detecting the corrupted pointers triggers a fast-fail kernel bugcheck.
Before exiting, we use the arbitrary write to set the victim queue's QueueState to Empty and zero out BytesInQueue. NPFS then takes the empty-queue path, bypassing the routine that would otherwise validate, unlink, and free the corrupted structure.
Conclusion
CVE-2026-62735 is a classic example of 32-bit size truncation surviving inside legacy *Old code paths in HTTP.sys. Because the initial INT_MAX check only inspects the already wrapped 32-bit header sum, a single unguarded addition is enough to turn a routine response buffer into a nonpaged pool overflow.