β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-0915

fuse_device_write trusts daemon ohd->len over actual write size yielding heap OOB read and overflow write

Summary

fuse_device.c:182 fuse_buf_alloc(fb,uio_resid). :188 ohd=fb.buf daemon controls ohd->len. :205 fip->reply=fb stores UNCLAMPED buffer reply.len=uio_resid. :212 fuse_audit_length validates ohd->len NOT fb.len. :218 Complete the IPC regardless. fuse_ipc_tx checks ohd->error==0 only. Consumers use fuse_out_data_size=fip->reply.len-16=uio_resid-16. Two primitives: (1) OOB READ: daemon writes 16B ohd->len=144 consumer reads 128B past kmalloc(16). (2) HEAP OVERFLOW WRITE: FUSE_READ daemon ohd->len=4112 but writes 12288+ fuse_out_data_size=12272 memcpy(bp->b_data[4096],...) overflow 8176B. Root cause device-level companion to DF-0912/DF-0895. Fix: validate ohd->len<=fb.len clamp fb.len=ohd->len.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0915 Β· 15 files
FileTypeDescriptionSize
harness.c trigger-source deterministic userspace transcription of the vulnerable path; prints 8176-byte overflow extent 7.2 KB view raw
fused.c trigger-source live FUSE daemon: mounts /mnt/fuse, replies to FUSE_READ with ohd->len lie -> kernel heap overflow 11.1 KB view raw
build.sh build-script cc -O2 build of harness + fused 290 B view raw
run.sh run-script runs harness then (if root) fused 443 B view raw
run.log run-log harness output + live panic summary 1.4 KB view raw
panic.txt panic-signature unpatched #0 panic: vm_object_hold_shared obj==NULL during overflow memcpy 443 B view raw
fix.diff suggested-fix git-apply-able: validate ohd->len<=fb.len + clamp fb.len=ohd->len in fuse_device_write 979 B view raw
fix_build.log build-log single-fix nativekernel build output (rc=0) 5.6 MB ↓ download
fix_run.log run-log before/after contrast: panic (unpatched) vs read()=4096 no panic (patched) 2.3 KB view raw
fix_daemon.log run-log patched-run daemon log: malicious READ reply sent, read returned 4096, no panic 2.2 KB view raw
env.txt environment uname, cc version, /dev/fuse perms, vfs.usermount 345 B view raw
VERDICT.md verdict full narrative: mechanism, primitives, threat model, fix rationale 6.7 KB ↓ raw
README.md readme build/run/expected + file index 2.2 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 build/run/expected + file index
↓ download raw

DF-0915 β€” PoC: fuse_device_write trusts daemon ohd->len over actual write size

Build

cc -O2 -o harness harness.c     # deterministic primitive proof
cc -O2 -o fused fused.c         # live FUSE daemon (heap overflow trigger)

(build.sh runs both.)

Run

  • Deterministic harness (any user, no kernel impact): ./harness Prints the overflow math: a daemon writing 12288 bytes while claiming ohd->len=4112 drives an 8176-byte heap overflow write past bp->b_data.

  • Live kernel trigger (must be root β€” /dev/fuse is 0660 root:operator and mount("fuse") needs uid==0): kldload fuse mkdir -p /mnt/fuse ./fused The daemon forks: the child mount()s /mnt/fuse (blocks on FUSE_INIT, answered by the parent's /dev/fuse I/O loop), then opens /mnt/fuse/pwned and read()s it. On the FUSE_READ request the parent replies with 12288 actual bytes claiming ohd->len=4112, triggering the consumer memcpy overflow at fuse_vnops.c:2054.

Expected (bug present, unpatched #0 GENERIC)

  • harness: OVERFLOW = 8176 bytes past bp->b_data / HEAP OVERFLOW WRITE of 8176 bytes confirmed.
  • fused: kernel panic in memcpy β€” panic: assertion "obj != NULL" failed in vm_object_hold_shared (page fault during the overflow memcpy). VM down at db>.

Expected (after fix.diff applied + fuse.ko rebuilt/loaded)

  • harness: unchanged (it transcribes the unpatched logic β€” it is the proof of the primitive, not of the fix).
  • fused: read() returned 4096, no panic, VM stays up. The fix clamps fb.len = ohd->len so the consumer memcpy copies only 4096 bytes (exact fit).

Files

  • harness.c β€” deterministic userspace transcription of the vulnerable path.
  • fused.c β€” live FUSE daemon that triggers the real kernel heap overflow.
  • build.sh / run.sh β€” exact build/run commands.
  • VERDICT.md β€” full narrative + threat model + fix rationale.
  • panic.txt β€” the unpatched panic signature from boot.log.
  • fix.diff β€” git-apply-able fix (validate + clamp fb.len).
  • fix_build.log β€” full single-fix kernel build output (rc=0).
  • fix_run.log β€” before/after contrast.
  • env.txt β€” guest environment.
  • manifest.json β€” artifact catalog.
VERDICT.md verdict full narrative: mechanism, primitives, threat model, fix rationale
↓ download raw

DF-0915 β€” fuse_device_write trusts daemon ohd->len over actual write size

Verdict: REPRODUCED β€” heap overflow write (CWE-787), confirmed by deterministic harness + live kernel panic; fix VALIDATED (panic β†’ no panic).

The bug

fuse_device_write (sys/vfs/fuse/fuse_device.c) allocates the daemon's reply buffer from the actual number of bytes written (uio_resid) and never clamps the stored length to the claimed length the daemon puts in the FUSE out header:

  • :182 fuse_buf_alloc(&fb, uio->uio_resid) β†’ fb.len = uio_resid (the ACTUAL bytes the daemon wrote);
  • :183 uiomove(fb.buf, uio->uio_resid, uio) copies the daemon's bytes in;
  • :188 ohd = fb.buf β€” the daemon controls ohd->len (the CLAIMED length);
  • :205 fip->reply = fb β€” stores the unclamped buffer, so fip->reply.len = uio_resid;
  • :212 fuse_audit_length(ihd, ohd) validates ohd->len (CLAIMED) against the request β€” never fb.len (ACTUAL);
  • :218 completes the IPC regardless of the audit result, so even an audit failure cannot stop the consumer.

Consumers use fuse_out_data_size(fip) = fip->reply.len - 16 = uio_resid - 16 (fuse.h:271-274). So a daemon that writes more bytes than it claims drives a memcpy past the consumer's destination buffer.

The most dangerous consumer is the FUSE_READ path (fuse_vnops.c:fuse_io_execute, lines 2043-2055):

case BUF_CMD_READ:
    ...
    fri->size = bp->b_bcount;          /* request size, e.g. 4096 or 8192   */
    ...
    memcpy(bp->b_data, fuse_out_data(fip),
           fuse_out_data_size(fip));    /* = uio_resid - 16  (ACTUAL, unclamped) */

The FUSE_READ audit case is ohd->len - 16 <= fri->size (fuse_util.c:132-134), so a daemon that CLAIMS ohd->len = 16 + fri->size passes the audit while ACTUALLY writing far more β†’ the consumer memcpy overflows bp->b_data (sized to the request) by the difference.

The primitives (both confirmed)

  1. Heap overflow WRITE (the real, dangerous primitive β€” CWE-787). Daemon replies to FUSE_READ(fri->size=4096) CLAIMING ohd->len=4112 but writing 12288 bytes. Audit passes (4112-16=4096 <= 4096); consumer does memcpy(bp->b_data[4096], ..., 12288-16=12272) β†’ 8176-byte heap overflow write, content fully daemon-controlled. Confirmed live: the overflow page-faults in memcpy (corrupts an adjacent vm_object pointer β†’ panic: assertion "obj != NULL" failed in vm_object_hold_shared).

  2. OOB read on reply buffer β€” the finding's secondary framing. With the current code fb.len = uio_resid always matches the allocation, so the daemon cannot make the consumer read past its own reply allocation via ohd->len alone; the exploitable direction is the overflow-write above. (A daemon that CLAIMS ohd->len > uio_resid would, post-fix, be rejected with EINVAL.)

Reproduction

  • Deterministic harness (harness.c) β€” transcribes the exact vulnerable path (fuse_buf_alloc + unclamped fip->reply + fuse_out_data_size + consumer memcpy) and prints the overflow extent: 8176 bytes, all daemon-controlled.
  • Live FUSE daemon (fused.c) β€” opens /dev/fuse, mounts /mnt/fuse (handles FUSE_INIT/STATFS/LOOKUP/GETATTR/OPEN), then on FUSE_READ replies with 12288 actual bytes claiming ohd->len=4112. A child process opens /mnt/fuse/pwned and read()s it, driving the buffer-cache β†’ strategy β†’ fuse_io_thread β†’ fuse_io_execute β†’ memcpy overflow. Result on the unpatched #0 GENERIC kernel:

panic: assertion "obj != NULL" failed in vm_object_hold_shared at vm_object.c:330 Trace: vm_object_hold_shared -> vm_fault -> trap_pfault -> trap -> memcpy+0xfa

(page fault during the overflow memcpy: bp->b_data + 8176 overran into a corrupted vm_object pointer; VM down at the db> prompt).

Threat model / reachability / Phase 6 escalation assessment

  • /dev/fuse is crw-rw---- root:operator and mount("fuse",...) requires caps_priv_check(SYSCAP_NOMOUNT_FUSE) which needs uid == 0 (fuse_vfsops.c:155, kern_caps.c:311). vfs.usermount=0 on this guest and maxx is NOT in the operator group, so the FUSE daemon (which controls the overflow content/size) must be started by root on the default GENERIC config. An unprivileged consumer (maxx) CAN trigger the FUSE_READ (if root mounts with allow_other), but the overflow BYTES are authored by the root daemon, not by maxx, so maxx cannot shape the corruption to escalate himself.
  • This is a rootβ†’kernel heap overflow (a robustness/hardening gap + DoS) on the default config. It is NOT an unprivilegedβ†’root escalation on default GENERIC: the valid hard blocker "the write is reachable only from an already-root context" applies (root daemon authors the overflow; rootβ†’kernel is game-over by definition; there is no privilege boundary for the daemon to cross).
  • Conditional escalation path (NOT default config): IF an admin set vfs.usermount=1 AND added the user to operator, an unprivileged user could run the daemon and gain a full heap-overflow-write primitive (content + size attacker-controlled, no SMAP/SMEP/KASLR on this guest). On GENERIC (options INVARIANTS) slab grooming would very likely trip a chunk_mark_*/WEIRD_ADDR KASSERT before a clean uid0 lands; on a non-default INVARIANTS-OFF build the primitive would be directly weaponizable. These are documented as conditional, not default-GENERIC.

Outcome: primitive fully characterized (8176-byte heap overflow write, daemon-controlled); escalation blocked by the root-only-daemon hard blocker on default GENERIC. Impact on default GENERIC = memory corruption / panic (DoS) from a root-started (or conditionally-unprivileged) malicious daemon.

The fix

sys/vfs/fuse/fuse_device.c, right after ohd = fb.buf; (line 188):

if (ohd->len < sizeof(*ohd) || ohd->len > fb.len) {
    fuse_buf_free(&fb);
    return EINVAL;          /* daemon claimed more than it wrote */
}
fb.len = ohd->len;          /* clamp so consumers see the claimed, audit-validated size */
  • rejects a daemon that claims ohd->len > uio_resid (would be an OOB read);
  • clamps fb.len to ohd->len so fuse_out_data_size = ohd->len - 16 (the audit-validated length), closing the overflow-write for every consumer.

Validated: unpatched kernel panics in memcpy on the same PoC; patched fuse.ko (identical code path) returns read()=4096 and the VM stays up. See fix.diff and fix_run.log.

How to reproduce

ssh dfbsd-maxx 'cd poc/DF-0915 && cc -O2 -o harness harness.c && ./harness'   # deterministic proof
# live kernel trigger (as root):
ssh dfbsd 'kldload fuse && mkdir -p /mnt/fuse && cd /home/maxx/poc/DF-0915 && cc -O2 -o fused fused.c && ./fused'

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix. BEFORE (unpatched fuse.ko / #0 kernel): the identical daemon PoC (FUSE_READ reply 12288 actual / ohd->len=4112) panics in memcpy ('assertion obj != NULL failed in vm_object_hold_shared'), VM down. AFTER (patched fuse.ko: ohd->len<=fb.len check + fb.len=ohd->len clamp): the SAME malicious trigger is sent but the clamp makes fuse_out_data_size = ohd->len-16 = 4096 (not 12272), the consumer memcpy copies exactly 4096 bytes into the request-sized bp->b_data, read() returns 4096, and the VM stays UP with no panic. The fix closes the overflow.

BEFORE (unpatched): panic: assertion 'obj != NULL' failed in vm_object_hold_shared / memcpy+0xfa / VM down at db>. AFTER (patched fuse.ko) daemon log: [daemon] reply ... actual_write=12288 claimed_ohd->len=4112 <<<<< DF-0915 TRIGGER / [child] read returned 4096 / [child] child exiting rc=0 / vm status after run: UP (no panic).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 (fix validated by rebuilding fuse.ko with the patch and kldload-ing it -- the fix lives entirely in fuse_device.c which compiles into the loadable fuse.ko)

Confirmed kernel references

Detail

Exploit chain

Primitive = 8176-byte heap overflow WRITE (CWE-787), content and size fully controlled by the FUSE daemon, into the kernel buffer-cache buffer bp->b_data via fuse_vnops.c:2054 memcpy. Confirmed live (panic in memcpy). Phase 6 escalation assessment: on the DEFAULT GENERIC config the FUSE daemon that authors the overflow bytes MUST be started by root -- /dev/fuse is 0660 root:operator (maxx NOT in operator group, verified), and mount('fuse') requires caps_priv_check(SYSCAP_NOMOUNT_FUSE) which needs uid==0 (fuse_vfsops.c:155, kern_caps.c:311), and vfs.usermount=0. The unprivileged consumer (maxx) CAN trigger FUSE_READ on a root-mounted allow_other FS, but the overflow CONTENT is authored by the root daemon, not maxx, so maxx cannot shape the corruption to escalate himself. This is a root->kernel heap overflow (hardening/robustness gap + DoS) on default config -- blocked by the valid Phase-6 hard blocker 'the write is reachable only from an already-root context'. Conditional escalation path (NOT default): if an admin set vfs.usermount=1 AND added the user to operator, an unprivileged user could become the daemon and gain the full heap-overflow-write primitive (no SMAP/SMEP/KASLR on this guest); on GENERIC (options INVARIANTS) slab grooming would very likely trip a chunk_mark/weirdaddr KASSERT before a clean uid0, and on a non-default INVARIANTS-OFF build the primitive would be directly weaponizable.

Evidence (decisive lines)

harness: [consumer] OVERFLOW = 8176 bytes past bp->b_data / RESULT: HEAP OVERFLOW WRITE of 8176 bytes confirmed. Daemon fully controls the 8176 overflow bytes. Live (unpatched #0): fuse_mount(fused): FUSE UABI 7.28 -> child read() triggers FUSE_READ -> daemon replies 12288 actual / ohd->len=4112 claimed -> panic: assertion 'obj != NULL' failed in vm_object_hold_shared at vm_object.c:330 / Trace: vm_object_hold_shared -> vm_fault -> trap_pfault -> trap -> memcpy+0xfa (page fault DURING the overflow memcpy; VM down at db>).

PoC changes

Created findings/poc/DF-0915/ from scratch (no prior scaffolding). Wrote harness.c (deterministic transcription of the vulnerable device_write + FUSE_READ consumer memcpy, proving the 8176-byte overflow extent) and fused.c (a live FUSE daemon: opens /dev/fuse, forks, child mounts /mnt/fuse via mount(2), parent runs the /dev/fuse I/O loop answering FUSE_INIT/STATFS/LOOKUP/GETATTR/OPEN and replying to FUSE_READ with 12288 actual bytes claiming ohd->len=4112 to trigger the real kernel overflow). Fixed daemon attr-type matching (root inode must be S_IFDIR to satisfy the KKASSERT at fuse_vnops.c:81). Added build.sh/run.sh, VERDICT.md, README.md, manifest.json, env.txt, panic.txt, fix.diff, fix_run.log, fix_daemon.log, fix_build.log.

Verified recommended fix

In fuse_device.c:fuse_device_write, immediately after 'ohd = fb.buf;' (line 188) add: if (ohd->len < sizeof(*ohd) || ohd->len > fb.len) { fuse_buf_free(&fb); return EINVAL; } and then fb.len = ohd->len; This rejects a daemon claiming more than it wrote (OOB-read direction) and clamps the stored reply length to the daemon-claimed, audit-validated length so every consumer's fuse_out_data_size = ohd->len-16 cannot exceed what fuse_audit_length checked against the request. The fix is a single hunk in fix.diff; supersedes the finding proposal (adds the explicit reject for ohd->len>fb.len and ohd->len<16).

Verdict

REPRODUCED. The bug is real and confirmed by code trace + deterministic harness + live kernel panic. In fuse_device_write (fuse_device.c:182) the daemon reply buffer is sized to the ACTUAL write (fuse_buf_alloc(&fb, uio_resid) -> fb.len=uio_resid) and stored unclamped at :205 (fip->reply=fb). fuse_audit_length (:212, fuse_util.c:87) validates only the daemon-CLAIMED ohd->len against the request, never fb.len, and :218 completes the IPC regardless. Consumers use fuse_out_data_size=fip->reply.len-16=uio_resid-16 (fuse.h:271). The FUSE_READ consumer (fuse_vnops.c:2054) does memcpy(bp->b_data, fuse_out_data, fuse_out_data_size) where bp->b_data is sized to the REQUEST. A daemon CLAIMING ohd->len=16+req_size (passes audit) while ACTUALLY writing 12288 bytes drives an 8176-byte heap overflow write past bp->b_data, content fully daemon-controlled. Live proof: the overflow page-faults in memcpy and panics ('assertion obj != NULL failed in vm_object_hold_shared' -- adjacent vm_object pointer corrupted). The finding's 'Primitive 1 OOB read on reply buffer' is a mischaracterization: since fb.len always equals the allocation, ohd->len alone cannot induce an OOB read of the reply buffer; the exploitable direction is the overflow-write (Primitive 2), which is fully real.