Foreword

Skip the foreword if you want to get straight to the bug.

This bug is within the WinFsp kernel driver (winfsp-x64.sys). WinFsp ("Windows File System Proxy") is a popular open-source framework that lets user-mode programs implement file systems, its FSD exposes a control device that any standard user can talk to. That also makes its IOCTL surface an interesting local attack surface. Major libraries such as rclone, sshfs-win, JuiceFS, Cryptomator and many other open-source applications/libraries use WinFsp, so it's not a small library by any chance.

The bug is a textbook integer overflow passing bounds check -> OOB write. Because WinFsp is open source, we tried to make this analysis as source-aware as possible. Every claim below is tied to the relevant lines in the WinFsp source, which is also then confirmed against a live winfsp-x64.sys in a kernel debugger.

Kind of a funny story for this one actually. Since we've seen so many CTF orchestrators in the recent years, I had been discussing with a friend, @malware_owl, for some time now that it would be interesting to build a VR orchestrator that could use Claude or ChatGPT to assist in automatically finding potential bugs. The bugs found could then actually be manually verified for exploitation. We built (+ clanked) an orchestrator based on some interesting articles we'd read and decided to hop in a call one night to test it out by running it against WinFsp sourceless on a known vulnerable driver version in which malware_owl had previously found a vulnerability (see: CVE-2026-3006). The orchestrator actually did not find his vulnerability (a race-condition bug) but ended up finding a completely separate vulnerability that looked insanely strong as it effectively had an arbitrary heap overflow that also worked on the latest driver version.

After manually verifying the vulnerability and making sure that we actually have full control over the overflow variables, we ended up spending a few days just playing around to build up the fullchain. And then malware_owl mentioned that his bug worked from a low-IL cmd.exe so we also tried that with this bug and sure enough, it also worked. We also tested it against some major libraries which used WinFsp to make sure it worked in a more "normal" user environment (rclone, sshfs-win and Cryptomater).

Also just wanted to shout out to the maintainer Bill, he was very responsive and the fixes were applied promptly. The patch itself was only held back because of external circumstances out of all of our control (thanks Microsoft).

Yapping over, enjoy the vulnerability.

Overview

Disclosure Date: 13 July 2026

Product: WinFsp (Windows File System Proxy), winfsp-x64.sys

Source: winfsp on GitHub

Affected Build (tested): winfsp-2.2.26112 (2026 Beta1) on Windows 11 25H2 x64 (10.0.26200.8894 or any latest build, windows-build-agnostic bug). The defect is in a long-lived handler and is present in earlier 2.x builds. It was fixed in 2026 Beta2 (commit bd8b54c).

Bug class: [CWE-190] Integer Overflow or Wraparound -> [CWE-787] Out-of-bounds Write / [CWE-122] Heap-based Buffer Overflow

Issue/Bug Report: CVE-2026-7162

Severity: High (local privilege escalation from an unprivileged user to SYSTEM)

Attack Type: Local

CVSS 3.1 Base Score: 7.8

CVSS Vector: CVSS 3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Reporter(s): malware_owl and uhg

Timeline

  • 2026-06: Bug identified
  • 2026-06: Root cause confirmed against source and live kernel, and full local->SYSTEM chain achieved
  • 2026-06: Maintainer commits fix (bd8b54c)
  • 2026-07: Fixed release (2026 Beta2), CVE assigned, public disclosure

Codestuff

Proof-of-concept: Not published, out of respect for the maintainer's disclosure preference. This is a pretty simple bug and this writeup describes the vulnerability in enough detail, take it as a challenge upon yourself to build a POC if you want :)

The Vulnerability

Root Cause

Bug class: [CWE-190] Integer Overflow or Wraparound leading to a heap-based out-of-bounds write.

Vulnerability details: The handler for the FSP_FSCTL_NOTIFY file-system control code computes the size of a NonPagedPool allocation as a 32-bit size header + InputBufferLength, but then copies the full 64-bit size of InputBufferLength from a user buffer into that allocation.

High-level Overview

FSP_FSCTL_NOTIFY (IOCTL 0x921bb, METHOD_NEITHER) is reachable by any process that can open the WinFsp control device and create a volume.

The handler FspVolumeNotify packages the caller's input into a work item it ships to a system worker thread. The size math and the copy length come from the same user-supplied InputBufferLength, but are evaluated with different widths:

  • allocation size = FIELD_OFFSET(FSP_VOLUME_NOTIFY_WORK_ITEM, InputBuffer) + InputBufferLength, truncated to 32 bits -> (0x30 + len) & 0xFFFFFFFF
  • copy length = InputBufferLength (64-bit)

For example, for len = 0xFFFFFFFF:

  • allocation = (0x30 + 0xFFFFFFFF) & 0xFFFFFFFF = 0x2F (a 0x40 NonPagedPoolNx block, tag FspI)
  • copy = 0xFFFFFFFF

The result is a controlled overflow out of a 0x40 pool chunk into its neighbours.

Execution Flow Analysis

A DeviceIoControl(FSP_FSCTL_NOTIFY) on a WinFsp volume handle is dispatched in src/sys/fsctl.c:

c
// src/sys/fsctl.c :: FspFsctlFileSystemControl()  (FSCTL control-device dispatch)
case FSP_FSCTL_NOTIFY:
    if (0 != IrpSp->FileObject->FsContext2)
        Result = FspVolumeNotify(FsctlDeviceObject, Irp, IrpSp);
    break;

The control code itself is defined in inc/winfsp/fsctl.h:

c
// inc/winfsp/fsctl.h
#define FSP_FSCTL_NOTIFY                \
    CTL_CODE(FILE_DEVICE_FILE_SYSTEM, 0x800 + 'n', METHOD_NEITHER, FILE_ANY_ACCESS) // = 0x921bb

So the path looks something like this:

DeviceIoControl(hVolume, FSP_FSCTL_NOTIFY, userbuf, 0xFFFFFFFF, ...)
  -> FspFsctlFileSystemControl              (METHOD_NEITHER: Type3InputBuffer = userbuf)
   -> FspVolumeNotify                       (src/sys/volume.c)
    -> FspAllocNonPaged(0x30 + len)         (32-bit truncated size)
     -> RtlCopyMemory(dst, userbuf, len)    (full-width copy for OOB write)

METHOD_NEITHER means Type3InputBuffer is the raw user pointer and InputBufferLength is taken directly from the caller, both fully attacker-controlled.

The only precondition is the dispatcher gate, the handler runs only when the file object's FsContext2 is non-NULL, i.e. a volume context must be established on the handle. An unprivileged user obtains a handle by calling FspFsctlCreateVolume(L"WinFsp.Disk", ...). The control device \Device\WinFsp.Disk grants Everyone read access, so no admin rights, driver-load rights, or authentication are required. The attacker model is any code running locally at Medium IL or higher, and in the case of WinFsp it is also runnable from a Low IL shell.

Vulnerable Function Analysis

The bug is within FspVolumeNotify in src/sys/volume.c (permalink to the last affected commit: src/sys/volume.c#L1361). The user-controlled length is read at [1], used to size a NonPagedPool allocation at [2] where the add is truncated to 32 bits, and then used in full at the copy [3]:

c
// src/sys/volume.c :: FspVolumeNotify()
...
PVOID InputBuffer       = IrpSp->Parameters.FileSystemControl.Type3InputBuffer;
ULONG InputBufferLength = IrpSp->Parameters.FileSystemControl.InputBufferLength; // [1] user-controlled length
FSP_VOLUME_NOTIFY_WORK_ITEM *NotifyWorkItem = 0;

if (0 == InputBufferLength)
    return FspVolumeNotifyLock(FsvolDeviceObject);
...
// [2] alloc size, evaluated 32-bit -> wraps for large InputBufferLength
NotifyWorkItem = FspAllocNonPaged(
    FIELD_OFFSET(FSP_VOLUME_NOTIFY_WORK_ITEM, InputBuffer) + InputBufferLength);
if (0 == NotifyWorkItem)
{
    Result = STATUS_INSUFFICIENT_RESOURCES;
    goto fail;
}

try
{
    ProbeForRead(InputBuffer, InputBufferLength, 1);
    // [3] copy length uses full InputBufferLength -> OOB write
    RtlCopyMemory(NotifyWorkItem->InputBuffer, InputBuffer, InputBufferLength);
    NotifyWorkItem->InputBufferLength = InputBufferLength;
}
except (EXCEPTION_EXECUTE_HANDLER)   // catches the guard-page fault
{
    Result = GetExceptionCode();
    Result = FsRtlIsNtstatusExpected(Result) ? STATUS_INVALID_USER_BUFFER : Result;
    goto fail;
}
...

The allocator and tag come from src/sys/driver.h:

c
// src/sys/driver.h
#define FSP_ALLOC_INTERNAL_TAG  'IpsF'   // "FspI"
#define FspAllocNonPaged(Size)  ExAllocatePoolWithTag(NonPagedPool, Size, FSP_ALLOC_INTERNAL_TAG)

And the allocated structure (note the header that becomes the 0x30 constant):

c
// src/sys/volume.c
typedef struct
{
    WORK_QUEUE_ITEM WorkItem;              // 0x00
    PDEVICE_OBJECT  FsvolDeviceObject;     // 0x20
    ULONG           InputBufferLength;     // 0x28
    FSP_FSCTL_DECLSPEC_ALIGN UINT8 InputBuffer[];  // 0x30 -> FIELD_OFFSET
} FSP_VOLUME_NOTIFY_WORK_ITEM;

FIELD_OFFSET(..., InputBuffer) is 0x30. The alloc-copy mismatch happens because FIELD_OFFSET + InputBufferLength is computed in 32 bits, while RtlCopyMemory takes the full InputBufferLength. ProbeForRead runs after the allocation and only validates that [base, base+Length) lies in user address space. It does not require the pages to be mapped and does nothing to constrain the allocation size relative to the copy length.

Some overflow math:

Length = 0xFFFFFFFF                          (ULONG, the maximum)

[2] allocation size, added and truncated in 32 bits:
    (0x30 + 0xFFFFFFFF) & 0xFFFFFFFF = 0x2F
    -> the pool rounds the 0x2F request up to a 0x40 block
       (0x10 pool header + 0x30 usable)

    the same add WITHOUT truncation would have been:
    0x30 + 0xFFFFFFFF = 0x10000002F          (~4 GB)

[3] copy length, used at full width:
    0xFFFFFFFF                               (~4 GB)

The 32-bit truncation allows the integer overflow to happen, so the alloc becomes very small (0x10000002F to 0x2F), while the copy still runs for 0xFFFFFFFF bytes. InputBuffer[] sits at struct offset 0x30, the very end of the 0x40 block's usable space, so the copy begins writing exactly at the block boundary and every copied byte lands in the adjacent chunk. A clean linear NonPagedPool overflow :)

The truncation can also be confirmed directly in the shipped binary. The size argument is built with a 32-bit lea, while the copy length is the full 64-bit register.

asm
; winfsp-x64.sys : FspVolumeNotify
...
mov   r14d, dword ptr [r8+10h]      ; r14d = InputBufferLength (32-bit)
lea   edx,  [r14+30h]               ; edx  = (len + 0x30)  -> 32-bit add -> wraps
mov   r8d,  49707346h               ; tag 'FspI'
call  qword ptr [ExAllocatePool...] ; alloc((len+0x30) & 0xFFFFFFFF)
...
lea   rcx, [rdi+30h]                ; dst = block + 0x30 (InputBuffer field)
mov   r8,  r14                      ; copy length = full InputBufferLength
call  <memmove>                     ; overflow here (RtlCopyMemory -> inlined SSE memmove)
...

For InputBufferLength = 0xFFFFFFFF, edx = 0x2F (a 0x40 block once the pool header is added) and the copy length is 0xFFFFFFFF. Because the copy destination is block + 0x30 and the source is a single committed page followed by a guard page, the streaming copy writes a controlled number of bytes into the adjacent 0x40 pool chunk and then faults on the source read, caught by the __except, so a bugcheck isn't triggered.

It's worthy to note that in order to get our overflow, we need an extremely large InputBufferLength (which forces len ~= 0xFFFFFFFF), which in turn forces the allocation to wrap to <= 0x2F. The corrupted allocation is therefore always a 0x40 block, meaning exploitation of this bug is effectively locked to the 0x40 NonPagedPoolNx size class.

Exploitation

This section is primarily for those who are unfamiliar with Windows exploitation techniques. The technique used is a well-known technique (NonPagedPool exploitation with named pipes) that has already been documented really well, and there are also countless other writeups out there that explains the practical implementation of this technique. What I discuss below will be brief and specific to this vulnerability.

I also recommend not reading past here if you want to try building a POC for yourself as an exercise :)

Exploit strategy

Turn the 0x40 NonPagedPool overflow into a full local->SYSTEM by corrupting an adjacent npfs named-pipe data-queue entry (NP_DATA_QUEUE_ENTRY, pool tag NpFr). When we think NonPagedPool overflow, we should immediately also think about named pipes. For those unfamiliar, here's a timeless guide on NPP exploitation with named pipes by vp777.

The working primitive is a stalled internal write queued via FSCTL_PIPE_INTERNAL_WRITE, which produces a 0x40 NonPagedPoolNx NpFr entry, the same size class and subsegment as FspI, which allows it to land adjacent to our overflown chunk.

The NP_DATA_QUEUE_ENTRY is a linked-list node that npfs walks when servicing pipe I/O. Overflowing the neighbour's entry so its Flink points at a user-mode forged entry gives a walk that npfs follows straight out of the kernel into attacker-controlled userland. SMAP is enabled on the machine (CR4.SMAP=1) but is not enforced on this IRQL-0 queue-walk path, so the kernel dereferences the forged user-mode structures freely. From there, it's relatively trivial to achieve arbitrary r/w:

  • Arbitrary read: the forged entry is a type-1 (unbuffered) entry whose IRP SystemBuffer the attacker controls. PeekNamedPipe makes npfs copy *(SystemBuffer) back to user mode -> read any kernel address.
  • Arbitrary write: a forged _IRP image is planted in a kernel-resident NpFr buffered entry, then completed through the forged entry. IopProcessBufferedIoCompletion performs memcpy(UserBuffer, SystemBuffer, Information) with both pointers set by the attacker -> write to any kernel address.

Since the arbitrary read is a relative-read, we can use it to obtain our EPROCESS, which we can then use to walk ActiveProcessLinks to find our SYSTEM EPROCESS, which then allows us to read its EPROCESS.Token. Afterwards, use the arbitrary write to overwrite our own EPROCESS.Token with the stolen system token for LPE.

Exploit Flow

The layout used by the overflow: FspAllocNonPaged (WinFsp's wrapper around ExAllocatePoolWithTag) returns a usable pointer p (the 0x40 pool block starts at p-0x10). The copy destination is p+0x30, which is the start of the next 0x40 block (the victim). The source bytes therefore map onto the victim as src[0x00..0x10] -> victim pool header, src[0x10..0x40] -> the NP_DATA_QUEUE_ENTRY struct. The source region is a single committed page whose last 0x50 bytes hold the forged image, immediately followed by an unmapped guard page. The inlined SSE memmove reads src[0x10..0x4F] ahead of writing, so the guard must sit at src+0x50 (a guard at src+0x40 faults the read early and lands only the pool header). At src+0x50 the whole 0x40 victim is forged and the copy faults on the source read at offset 0x50, caught by the driver's __except.

The pool looks something like this:

|<0x40 bytes>|<0x40 bytes>|
|   WinFSP   |   Victim   |
|   Chunk    |   Chunk    |
┌──┬─────────┬──┬─────────┐
│  │         │  │         │
│PH│         │PH│         │
│  │         │  │         │
└──┴─────────┴──┴─────────┘
             :────────────►
                Overflow   
                direction

Hence, we use the following exploit flow to achieve LPE:

  1. Open the WinFsp control device and create a volume (FspFsctlCreateVolume), giving a handle that accepts FSP_FSCTL_NOTIFY.
  2. Send an empty FSP_IOCTL_TRANSACT request or IRPs will not be processed by WinFSP.
  3. On a single byte-mode overlapped named pipe, post thousands of stalled FSCTL_PIPE_INTERNAL_WRITE requests (each 8 bytes of 0x5a filler). Each queues one 0x40-size NpFr NP_DATA_QUEUE_ENTRY which fills our pool with 0x40-size NpFr chunks. We then punch holes with CancelIoEx.
  4. Call DeviceIoControl(FSP_FSCTL_NOTIFY, src, 0xFFFFFFFF, ...). The 0x40-size FspI chunk is allocated into one of our freed holes and lands adjacent to a live NpFr entry. Overflow and corrupt NP_DATA_QUEUE_ENTRY so its Flink points at a user-mode forged entry and its DataSize is enlarged to expose neighbouring chunks.
  5. PeekNamedPipe on the corrupted pipe now leaks neighbouring chunks. Parse the leaked data for a live type-1 NpFr entry, dereference its pended write IRP to obtain our EPROCESS (IRP.Tail.Overlay.Thread at +0x98 -> KTHREAD.Process at +0x220 -> our EPROCESS, matched by UniqueProcessId at +0x1d0).
  6. Walk down EPROCESS.ActiveProcessLinks (+0x1d8) to UniqueProcessId == 4 (SYSTEM) and read EPROCESS.Token (+0x248).
  7. Build a forged _IRP image with SystemBuffer = &SYSTEM.Token, UserBuffer = &our.Token, IoStatus.Information = 8, and Flags = IRP_BUFFERED_IO | IRP_INPUT_OPERATION (0x50). WriteFile it into a kernel NpFr buffered entry so the image lives at a kernel address, then reconfigure the forged entry to a type-0 stalled write whose IRP is that forged IRP. Use a 1-byte ReadFile to trigger the write.
  8. Win

Vulnerability Trigger Verification

Run as an unprivileged (Low-IL/Medium-IL) shell on Windows 11 (tested 10.0.26200.8894) with a WinFsp volume mounted, spawning a SYSTEM shell:

[*] init
[-] attempt 1: groom/read miss -> clean exit, relaunch
[+] attempt 2: arbitrary kernel READ working
[+] our EPROCESS  = ffff8107e5b1c080
[+] SYSTEM EPROCESS = ffff8107e6688040  token = ffffb2049c2ca8f0
[+] forged kernel IRP = ffff8107e6a41430
[+] SYSTEM token written to our EPROCESS (ffffb2049c2ca8f0)
[+] blep

Independent confirmation from the spawned shell:

> whoami
nt authority\system

(Video from 10.0.26200.8457 when the vuln was first discovered)

LowIL cmd -> SYSTEM cmd

Next Steps

Detection

  • The overflow is driven by FSP_FSCTL_NOTIFY with an absurdly long InputBufferLength (~0xFFFFFFFF). A driver or EDR hook on the WinFsp control IOCTL surface can flag InputBufferLength values that exceed any sane notify payload. Legitimate callers send small FSP_FSCTL_NOTIFY_INFO records.
  • The groom sprays thousands of FSCTL_PIPE_INTERNAL_WRITE (stalled internal writes) on a single named pipe in a tight window, immediately followed by a burst of CancelIoEx. An unusual burst of internal-write FSCTLs on one pipe handle is a strong signal.
  • A surviving exploit leaves 0x40 FspI/NpFr blocks with inconsistent headers. Failed attempts surface as 0x13A KERNEL_MODE_HEAP_CORRUPTION (or 0x50) bugchecks referencing the NonPagedPool LFH.

Mitigations

The maintainer fixed the bug in commit bd8b54c (2026 Beta2) by rejecting oversized input before the allocation:

c
// inc/winfsp/fsctl.h
#define FSP_FSCTL_NOTIFY_INFO_SIZEMAX   (0x7fffffffU)

// src/sys/volume.c :: FspVolumeNotify()
if (0 == InputBufferLength)
    return FspVolumeNotifyLock(FsvolDeviceObject);

if (FSP_FSCTL_NOTIFY_INFO_SIZEMAX < InputBufferLength)   // (added)
    return STATUS_INVALID_PARAMETER;

Any InputBufferLength <= 0x7fffffff keeps 0x30 + len below 2^32, so the 32-bit add can no longer wrap, and the request for such a large allocation simply fails cleanly instead of truncating to a 0x2F-byte block. Lengths above the cap are rejected with STATUS_INVALID_PARAMETER before any pool is touched.

Variant Analysis

The same pattern, a user-controlled length used both as a (possibly truncated) allocation size and as a copy length, was audited across FspAllocNonPaged(... + len) / RtlCopyMemory(..., len) sites in the driver. Notable nearby allocation sites reviewed:

  • src/sys/volume.c mount-point handling (FspAllocNonPaged(InputBufferLength))
  • src/sys/devctl.c, src/sys/mountdev.c (FIELD_OFFSET(...) + <user length> allocations)
  • src/sys/iop.c request/work-item allocations

The notify path is the cleanest instance because METHOD_NEITHER hands the length and buffer to the handler directly and the size add is emitted as a 32-bit lea. A sibling FSCTL handler on the same dispatcher already caps its input length before allocating.

Afterword

Extremely clean bug with a strong primitive. Actually, both malware_owl and I are relatively new to Windows exploitation compared to those that are doing this full-time so this was a good exercise for the both of us not only in analysing the Windows kernel but also properly building the full end-to-end LPE chain. Being theoretically trivial to exploit is one thing, but it is another thing entirely when one has little to no experience in doing it. "Trivially exploitable" still resulted in tens of hours of frustration :^)

I guess we really are in the age of AI...

I think many prominent security researchers (and CTF players) have already given their thought on the whole shtick with AI, but by now personally experiencing it I think it has become a lot clearer where everything stands. Is AI extremely capable now? Yes. Can AI discover vulnerabilities entirely on its own by just throwing it at a target? Sometimes. But the strongest uses of AI in cybersecurity we've seen still ultimately requires having a good harness, especially when it comes to finding high impact bugs (I like to think of them as the true "good" bugs).

At the end of the day, having a good grasp of the theoretical knowledge behind vulnerabilities and having a strong foundation is still required to build a good harness. In that effect, I personally feel it is still important to take a step back and do things ourselves from time to time.

Thanks for reading.