DragonFlyBSD Kernel Audit
← triage · dashboard
DF-0780

Heap buffer overflow in fuse_io_execute READ from oversized daemon reply — memcpy daemon-chosen size into fixed buffer

Summary

fuse_io_execute :2054 memcpy(bp->b_data, fuse_out_data(fip), fuse_out_data_size(fip)) — fuse_out_data_size is daemon-controlled (set to daemon write uio_resid at fuse_device.c:182). NO check fuse_out_data_size <= bp->b_bcount. bp->b_data is FUSE_BLKSIZE(PAGE_SIZE=4096). Daemon replies with >PAGE_SIZE bytes = heap overflow past bp->b_data. fuse_audit_length failure at fuse_device.c:212 only sets error=EPROTO returned to daemon write() does NOT set ohd->error so fuse_ipc_tx sees ohd->error==0 success. Advisory-only size check. Trigger: malicious FUSE daemon replies to FUSE_READ with 8192 bytes into 4096 bp. Slab grooming = arbitrary kernel write. Fix: if(olen > bp->b_bcount) error=EINVAL + ohd->error=EPROTO in audit.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0780 · 14 files
FileTypeDescriptionSize
evil_daemon.c trigger-source raw /dev/fuse malicious daemon; oversized FUSE_READ reply 10.6 KB view raw
build.sh build-script cc -O0 -g -o evil_daemon evil_daemon.c 175 B view raw
run.sh run-script load fuse, mount daemon (root), read as maxx 1.1 KB view raw
build.log build-log evil_daemon compile output 511 B view raw
run.log run-log decisive unpatched run: oversized READ -> panic 2.1 KB view raw
panic.txt panic-signature trap 0xc page fault in memcpy (fuse_io_execute OOB write) 2.0 KB view raw
env.txt environment uname, cc, /dev/fuse perms, maxx groups 1.5 KB view raw
fix.diff suggested-fix bound-check olen <= bp->b_bcount in fuse_io_execute READ 948 B view raw
fix_build.log build-log make -j6 nativekernel + patched fuse.ko (rc=0) 5.6 MB ↓ download
fix_run.log run-log patched kernel+module: EINVAL, no panic, 3/3 1.7 KB view raw
VERDICT.md verdict full narrative: mechanism, primitive, blocker, fix before/after 8.8 KB ↓ raw
README.md readme how to reproduce + validate the fix 2.9 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme how to reproduce + validate the fix
↓ download raw

DF-0780 — Heap buffer overflow in fuse_io_execute READ (oversized daemon reply)

Reproduction + fix-validation evidence pack.

What this proves

fuse_io_execute() (sys/vfs/fuse/fuse_vnops.c:2054) copies a FUSE daemon's READ reply into a kernel buffer-cache buffer with memcpy(bp->b_data, fuse_out_data(fip), fuse_out_data_size(fip)) and no check that fuse_out_data_size <= bp->b_bcount. The only existing guard, fuse_audit_length() in fuse_device_write(), is advisory: on failure it returns EPROTO to the daemon's write() only, while still completing the IPC with ohd->error == 0; so fuse_ipc_tx() returns success and the unchecked memcpy runs with the daemon-chosen length. A daemon that replies with more bytes than requested overflows bp->b_data.

Files

  • evil_daemon.c — self-contained raw /dev/fuse malicious daemon. Mounts a synthetic FUSE fs (/mnt/fuse) exposing target (8192 bytes) and answers every FUSE_READ with 131072 bytes.
  • build.shcc -O0 -g -o evil_daemon evil_daemon.c
  • run.sh — loads fuse, starts the daemon (root), then reads the file as maxx to trigger the overflow.
  • fix.diff — consumer-side bound check in fuse_io_execute (validated).
  • build.log / run.log / fix_run.log / fix_build.log / panic.txt / env.txt / VERDICT.md / manifest.json.

Reproduce (panic / proof of overflow)

On the audit guest (unprivileged maxx exists; root needed only to load the module and open /dev/fuse):

# as root, on the guest:
cp /usr/obj/usr/src/sys/X86_64_GENERIC/usr/src/sys/vfs/fuse/fuse.ko /root/fuse.ko
# as maxx:
cd poc/DF-0780 && sh build.sh
# as root:
sh run.sh            # maxx reads /mnt/fuse/target -> kernel panics

Expected on the unpatched kernel: guest dies, dfbsd-qemu/boot.log shows

panic: assertion "obj != NULL" failed in vm_object_hold_shared ...
--- trap 000000000000000c ---
memcpy() at memcpy+0xfa

Validate the fix

scp fix.diff dfbsd:/root/fix.diff
vm.sh run_root 'cd /usr/src && patch -p1 --forward < /root/fix.diff'
vm.sh run_root 'cd /usr/src/sys/vfs/fuse && make obj && make && \
                cp fuse.ko /root/fuse.ko'
vm.sh run_root 'cd /usr/src && nohup sh -c "make -j6 nativekernel \
                KERNCONF=X86_64_GENERIC; echo done" </dev/null >/root/nk.log 2>&1 &'
# …poll /root/nk.log for "done"…
vm.sh run_root 'cp /usr/obj/.../kernel.stripped /boot/kernel/kernel; \
                cp /usr/obj/.../kernel.debug /boot/kernel/kernel.debug'
vm.sh down && vm.sh up
sh run.sh           # maxx reads -> cat: Invalid argument, NO panic, guest up

Threat model / scope (read VERDICT.md for detail)

The trigger is root-only on default DragonFly (kldload + /dev/fuse root:operator 0660 + mount capability check all require root), so this is a root→kernel hardening gap / DoS, not an unprivileged→root escalation on a default system. The bug is a genuine memory-safety defect and is fixed.

VERDICT.md verdict full narrative: mechanism, primitive, blocker, fix before/after
↓ download raw

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:

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

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix. ./run.sh (oversized FUSE_READ reply) panics the unpatched #0 baseline + stock fuse.ko (panic: assertion obj!=NULL in vm_object_hold_shared, trap 0xc page fault in memcpy, 3/3 runs) and does NOT panic the single-fix kernel #1 + patched fuse.ko (cat returns 'Invalid argument', guest stays up, 3/3 runs) => the consumer-side bound check (olen > bp->b_bcount => refuse memcpy) closes the OOB write. fix.diff applies cleanly (git apply --check OK) and the patched kernel/module builds rc=0.

BEFORE (unpatched #0 + stock fuse.ko):
  [daemon] READ node=2 off=0 reqsize=8192 REPLYING 131072 bytes (OVERFLOW 122880)
  panic: assertion "obj != NULL" failed in vm_object_hold_shared at vm_object.c:330
  --- trap 000000000000000c --- memcpy() at memcpy+0xfa
  guest DOWN (3/3 runs)
AFTER (#1 + patched fuse.ko):
  cat: /mnt/fuse/target: Invalid argument  (cat rc=1)
  daemon writev: Protocol error
  guest UP, no panic (3/3 runs)
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Wed Jul 8 10:07:29 UTC 2026 (single-fix build; FUSE is module-only so the fix is exercised via the rebuilt fuse.ko)

Confirmed kernel references

Detail

Exploit chain

Memory-corruption primitive (attacker-controlled-content heap OOB write into a buffer-cache KVA buffer), BUT the chain to uid=0 is BLOCKED by a VALID Phase-6 hard blocker: 'the write is reachable only from an already-root context.' All three trigger gates are root-only on default DragonFly: (1) FUSE is module-only (sys/conf/files:2061 'optional fuse', NOT in X86_64_GENERIC) so kldload fuse requires root; (2) /dev/fuse is created UID_ROOT GID_OPERATOR mode 0660 (fuse_device.c:313) and maxx (uid 1001, not in operator) CANNOT open it; (3) mounting FUSE requires caps_priv_check(SYSCAP_NOMOUNT_FUSE) (fuse_vfsops.c:155) = root. mount_fusefs is not setuid and vfs.usermount=0. maxx therefore cannot be the daemon, cannot shape the overflow, and cannot self-trigger the bug. Exhausted the unprivileged paths (/dev/fuse perms, setuid helpers, devfs rules, default mounts) -- none exist. No uid=0 was fabricated. Realistic impact: root->kernel hardening gap / kernel DoS (a malicious or compromised root-run FUSE daemon corrupts kernel memory; a user reading an already-mounted FUSE fs can be the panic victim but controls no overflow content). Additionally the overflow lands in buffer-cache KVA (not the slab heap) where it page-faults immediately on this guest, so even for an operator/root attacker the standard slab-groom->function-pointer/ucred escalation does not transfer directly. The bug is a genuine memory-safety defect worth fixing (it would become a real escalation primitive on any system that lets an unprivileged user run a FUSE daemon, e.g. a setuid fusermount helper). No exploit.c authored because there is no unprivileged path to escalate. Full narrative in findings/poc/DF-0780/VERDICT.md.

Evidence (decisive lines)

UNPATCHED (#0 + stock fuse.ko), maxx reads /mnt/fuse/target:
[daemon] READ node=2 off=0 reqsize=8192  REPLYING 131072 bytes (OVERFLOW 122880 past reqsize buf)
=> guest DOWN. boot.log:
panic: assertion "obj != NULL" failed in vm_object_hold_shared at vm_object.c:330
--- trap 000000000000000c, rip = ffffffff80bcac8a ---
memcpy() at memcpy+0xfa 0xffffffff80bcac8a
Stopped at Debugger+0x7c   (DDB)
Reproduced 3/3 fresh-reset runs.

PATCHED (#1 + patched fuse.ko, same PoC):
cat: /mnt/fuse/target: Invalid argument  (cat rc=1)
daemon writev: Protocol error   (advisory audit still fires)
guest UP, no panic. 3/3 runs.

PoC changes

No prior PoC package existed (the findings/poc/DF-0780/ folder was absent). Authored the entire evidence pack from scratch: evil_daemon.c (raw /dev/fuse malicious daemon that handshakes FUSE_INIT/STATFS/GETATTR/LOOKUP/OPEN and replies to every FUSE_READ with 131072 bytes), build.sh, run.sh (root loads fuse.ko + mounts daemon; maxx reads the file), fix.diff, VERDICT.md, README.md, manifest.json, and all logs. Only iteration during testing: bumped REPLY_DATA_SIZE 8192 -> 131072, because the read path uses cluster_readx which aggregates two 4KiB blocks into one 8192-byte bp on a sequential cat, so an 8192-byte reply fit exactly (no overflow); 131072 guarantees the overflow regardless of cluster size.

Verified recommended fix

In sys/vfs/fuse/fuse_vnops.c fuse_io_execute() READ branch, before the memcpy, check the daemon's reply data length against the buffer: compute olen = fuse_out_data_size(fip); if (olen > bp->b_bcount) { fuse_ipc_put(fip); bp->b_resid = bp->b_bcount; bp->b_flags |= B_ERROR | B_INVAL; bp->b_error = EINVAL; } else { memcpy(bp->b_data, fuse_out_data(fip), olen); ... }. This directly prevents the OOB write at the root cause (the unchecked memcpy). Matches the spirit of the finding's proposal (clamp reply len to expected size and error if exceeded). Full git-apply-able diff in findings/poc/DF-0780/fix.diff; supersedes the finding's broader two-part suggestion -- the consumer bound check alone fully closes this specific overflow and is what was validated.

Verdict

REPRODUCED. fuse_io_execute() (sys/vfs/fuse/fuse_vnops.c:2054) copies a FUSE daemon's READ reply into a fixed buffer-cache buffer with memcpy(bp->b_data, fuse_out_data(fip), fuse_out_data_size(fip)) and NO check that fuse_out_data_size <= bp->b_bcount. The only existing guard, fuse_audit_length() in fuse_device_write (fuse_device.c:212), is advisory: on failure it returns EPROTO solely to the daemon's write() (line 213/222) while still completing the IPC and waking the waiter (lines 219-220) with ohd->error still 0, so fuse_ipc_tx (fuse_ipc.c:276) returns success and the unchecked memcpy runs with the daemon-chosen length. A raw /dev/fuse daemon (evil_daemon.c) replying to FUSE_READ with 131072 bytes when the kernel requested 8192 deterministically panics the unpatched #0 kernel 3/3 fresh-reset runs: 'panic: assertion obj!=NULL in vm_object_hold_shared' with trap 0xc (page fault) whose faulting RIP is inside memcpy() at memcpy+0xfa -- i.e. the OOB write ran past bp->b_data's mapped KVA. Real heap OOB write (CWE-122), confirmed at source and at runtime.