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)
PoC verification
Evidence pack
findings/poc/DF-0780 · 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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/fusemalicious daemon. Mounts a synthetic FUSE fs (/mnt/fuse) exposingtarget(8192 bytes) and answers everyFUSE_READwith 131072 bytes.build.sh—cc -O0 -g -o evil_daemon evil_daemon.crun.sh— loadsfuse, starts the daemon (root), then reads the file asmaxxto trigger the overflow.fix.diff— consumer-side bound check infuse_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.
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_datais 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/ucredoverwrite 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
kldloadan arbitrary module, so it is not a new privilege boundary. - operator → root: an
operator-group member can open/dev/fuseand run the daemon, but still cannotmountFUSE 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+ stockfuse.ko: oversized READ reply ⇒memcpyOOB ⇒panic: assertion "obj != NULL" …/trap 0xcinmemcpy(3/3 runs). - after —
#1(make -j6 nativekernelfrom patched/usr/src) + patchedfuse.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
fixedVALIDATED 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)
Confirmed kernel references
- sys/vfs/fuse/fuse_vnops.c:2044
- sys/vfs/fuse/fuse_vnops.c:2048
- sys/vfs/fuse/fuse_vnops.c:2054
- sys/vfs/fuse/fuse_device.c:182
- sys/vfs/fuse/fuse_device.c:205
- sys/vfs/fuse/fuse_device.c:212
- sys/vfs/fuse/fuse_device.c:219
- sys/vfs/fuse/fuse_device.c:313
- sys/vfs/fuse/fuse_ipc.c:274
- sys/vfs/fuse/fuse_ipc.c:276
- sys/vfs/fuse/fuse_util.c:132
- sys/vfs/fuse/fuse_vfsops.c:155
- sys/conf/files:2061
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.
No comments yet.