# DF-0780 — Verdict

**Heap buffer overflow in `fuse_io_execute` READ from oversized daemon reply**

## Verdict: REPRODUCED (heap OOB write → panic); fix VALIDATED

| field | value |
|---|---|
| status | reproduced |
| reproduced | yes (deterministic, 3/3) |
| impact | panic (kernel heap OOB write — `memcpy` of daemon-controlled size into a fixed buffer-cache buffer) |
| confidence | certain |
| class | heap buffer overflow (CWE-122) |
| severity (audit) | High |

---

## 1. The bug — confirmed at source and at runtime

`fuse_io_execute()` handles buffered READ on a FUSE filesystem
(`sys/vfs/fuse/fuse_vnops.c:2031`).  For `BUF_CMD_READ` it allocates an IPC,
sets the requested size to the buffer length, transacts it, and on success
copies the daemon's reply into the kernel buffer **with no bound check**:

```c
/* fuse_vnops.c:2044 */
case BUF_CMD_READ:
    fip = fuse_ipc_get(fmp, sizeof(*fri));
    fri = fuse_ipc_fill(fip, FUSE_READ, fnp->ino, proc0.p_ucred);
    fri->offset = bp->b_loffset;
    fri->size   = bp->b_bcount;          /* requested size == buffer size */
    fri->fh     = fnp->fh;
    error = fuse_ipc_tx(fip);
    if (error == 0) {
        memcpy(bp->b_data, fuse_out_data(fip),
               fuse_out_data_size(fip));   /* <-- NO check vs bp->b_bcount */
        ...
```

`bp->b_data` is a buffer-cache buffer of `bp->b_bcount` bytes (FUSE_BLKSIZE =
4096 for a single block, or a cluster of up to MAXBSIZE via `cluster_readx`,
`fuse_vnops.c:1373`).  `fuse_out_data_size(fip)` is the daemon-chosen reply
length (`fip->reply.len - sizeof(fuse_out_header)`, `fuse.h:270`).

The finding's whole point is that the only existing size guard is
**advisory only**.  `fuse_device_write()` (`fuse_device.c:165`) receives the
daemon's reply, `fuse_buf_alloc()`s a buffer of the *full* daemon write size
(line 182), stores it as `fip->reply` (line 205), then runs
`fuse_audit_length()` (line 212).  When the audit fails it sets a **local**
`error = EPROTO` that is returned *only to the daemon's write() syscall*
(line 213, 222); it does **not** set `ohd->error`, and the IPC is completed
and the waiter woken **regardless** (lines 219-220).  Consequently
`fuse_ipc_tx()` (`fuse_ipc.c:247`) only sees `ohd->error == 0` and returns 0
(success), so `fuse_io_execute` enters the `error == 0` branch and performs
the unchecked `memcpy` with the daemon-controlled length.

So a daemon that replies to `FUSE_READ` with N > requested bytes makes the
kernel `memcpy` N bytes into a smaller buffer ⇒ heap OOB write of
`N - bp->b_bcount` bytes past `bp->b_data`.

## 2. Live reproduction

`evil_daemon.c` is a self-contained raw `/dev/fuse` protocol daemon that:
opens `/dev/fuse`, mounts a synthetic FUSE filesystem exposing one regular
file `target` (inode 2, size 8192), serves `FUSE_INIT/STATFS/GETATTR/LOOKUP/
OPEN/READ`, and answers **every** `FUSE_READ` with **131072** bytes
(`REPLY_DATA_SIZE`) regardless of the requested size.

It is built and started as **root** (it must open `/dev/fuse` and mount).
The overflow itself is triggered by the **unprivileged user `maxx`** reading
the file: `cat /mnt/fuse/target`.

Result on the unpatched audit-source kernel (`#0`) + stock `fuse.ko`,
deterministic 3/3 fresh-reset runs:

```
[daemon] READ node=2 off=0 reqsize=8192  REPLYING 131072 bytes (OVERFLOW 122880 past reqsize buf)
panic: assertion "obj != NULL" failed in vm_object_hold_shared at vm_object.c:330
--- trap 000000000000000c, rip = ffffffff80bcac8a ---
memcpy() at memcpy+0xfa
db>
```

`trap 0xc` is a page fault; the faulting RIP is inside `memcpy()` — i.e. the
OOB *write* in `fuse_io_execute` ran off the end of `bp->b_data`'s mapped KVA
into an unmapped page.  `vm_fault` then dereferenced a corrupted/out-of-range
`vm_object` and panicked on `obj != NULL`.  The fault originates in exactly
the unchecked `memcpy` the finding names, on the FUSE READ path.

## 3. Primitive characterization

* **what the attacker controls:** the overflow content (the reply bytes —
  fully attacker-chosen) **and** the overflow size (`fuse_out_data_size` =
  whatever the daemon writes minus 16).  This is a strong arbitrary-content
  heap-write primitive *in principle*.
* **where it lands:** `bp->b_data` is a **buffer-cache KVA** buffer, not a
  slab object.  The overflow runs into adjacent buffer-cache KVA (another
  buffer's data, or — as observed — an unmapped page).  This is **not** the
  slab heap, so the classic slab-grooming → function-pointer/`ucred`
  overwrite technique does **not** transfer directly.
* **observed behaviour on the default GENERIC (INVARIANTS-on) guest:** the
  write page-faults immediately (no graceful corruption window on this run).

## 4. Exploit chain to uid=0 — BLOCKED by a valid hard blocker

Per Phase 6, the chain to `uid=0` must be exercisable by an **unprivileged
user end-to-end**.  This bug is **not**, and I verified there is no
unprivileged path:

| gate | requirement | maxx (uid 1001) | source |
|---|---|---|---|
| load FUSE code | `kldload fuse` (root) | cannot | fuse is `optional fuse`, not in GENERIC (`sys/conf/files:2061-2067`) |
| talk to FUSE | `open("/dev/fuse")` — `root:operator 0660` | **denied** (not in `operator`) | `fuse_device.c:313` |
| mount FUSE | `caps_priv_check(SYSCAP_NOMOUNT_FUSE)` — root | denied | `fuse_vfsops.c:155`, `sys/sys/caps.h:227` |

All three preconditions are **root-only** on a default DragonFly system.
`mount_fusefs` is **not** setuid, `vfs.usermount=0`, and no FUSE mount exists
by default.  An unprivileged user therefore **cannot be the daemon** (cannot
open `/dev/fuse`) and **cannot shape the overflow**, so there is no
unpriv→root escalation.  This is the valid hard blocker enumerated in Phase
6: *"the write is reachable only from an already-root context (… devfs
root:operator node with no group membership …), so there is no privilege
boundary to cross (root→kernel is game-over by definition)."*

I did not fabricate a `uid=0`.  I exhausted the unprivileged paths
(`/dev/fuse` perms, setuid helpers, devfs rules, default mounts) — none
exist.  The realistic impact is therefore:

* **root → kernel:** a malicious or compromised root-run FUSE daemon can
  corrupt kernel memory.  This is a hardening gap / DoS — root can already
  `kldload` an arbitrary module, so it is not a new privilege boundary.
* **operator → root:** an `operator`-group member can open `/dev/fuse` and run
  the daemon, but still cannot `mount` FUSE without root (capability check),
  so even operator cannot self-trigger the bug on a default system.
* **DoS:** any user that can read a file on an already-mounted (root-set-up)
  FUSE filesystem can be the *victim* that panics the kernel — but they do
  not control the overflow content.

So: real memory-safety defect, real panic/DoS, **not** an unpriv→root
escalation on default DragonFly.  It would become a real escalation primitive
on a system where an unprivileged user *can* run a FUSE daemon (e.g. a
setuid `fusermount`-style helper, or `vfs.usermount`+capability grants), at
which point the attacker-controlled heap write would be a serious primitive.

## 5. The fix (validated)

`fix.diff` adds a consumer-side bound check in `fuse_io_execute`: if the
daemon returned more bytes than the requested size (`olen > bp->b_bcount`),
refuse the copy (treat as `EINVAL`) instead of overflowing.  Minimal,
targeted at the root cause; the unchecked `memcpy` can no longer execute.

This matches the spirit of the finding's proposal (clamp the reply `len` to
the expected size and error out if exceeded).  (The finding additionally
suggests making `fuse_audit_length` authoritative by setting
`ohd->error = -EPROTO`; that is a worthwhile **defense-in-depth** change at
the protocol layer so *all* consumers are protected, but the consumer-side
bound check alone fully closes this specific OOB write, so it is what I
validated.)

### Before / after (single-fix build)

* **before** — `#0` + stock `fuse.ko`: oversized READ reply ⇒ `memcpy` OOB
  ⇒ `panic: assertion "obj != NULL" …` / `trap 0xc` in `memcpy` (3/3 runs).
* **after** — `#1` (`make -j6 nativekernel` from patched `/usr/src`) +
  patched `fuse.ko`: oversized READ reply ⇒ bound check fires ⇒
  `bp->b_error = EINVAL`, `cat: /mnt/fuse/target: Invalid argument`,
  **no panic, guest stays up** (3/3 runs).

`fix_status = fixed`.

## 6. PoC changes vs. the as-filed package

There was no prior PoC package for DF-0780 (the finding folder did not
exist).  I authored the entire evidence pack from scratch:
`evil_daemon.c` (raw `/dev/fuse` malicious daemon), `build.sh`, `run.sh`,
`fix.diff`, and this `VERDICT.md` + logs.  The only iteration during testing
was bumping `REPLY_DATA_SIZE` from 8192 → 131072: the buffer-cache read path
uses `cluster_readx`, which on a sequential `cat` aggregates two 4 KiB
blocks into one 8192-byte `bp`, so an 8192-byte reply fit exactly (no
overflow); 131072 bytes guarantees the overflow regardless of cluster size.
