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)
PoC verification
Evidence pack
findings/poc/DF-0915 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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):
./harnessPrints the overflow math: a daemon writing 12288 bytes while claimingohd->len=4112drives an 8176-byte heap overflow write pastbp->b_data. -
Live kernel trigger (must be root β
/dev/fuseis 0660 root:operator andmount("fuse")needsuid==0):kldload fuse mkdir -p /mnt/fuse ./fusedThe daemon forks: the childmount()s/mnt/fuse(blocks on FUSE_INIT, answered by the parent's/dev/fuseI/O loop), then opens/mnt/fuse/pwnedandread()s it. On the FUSE_READ request the parent replies with 12288 actual bytes claimingohd->len=4112, triggering the consumermemcpyoverflow atfuse_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 atdb>.
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 clampsfb.len = ohd->lenso 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 fromboot.log.fix.diffβ git-apply-able fix (validate + clampfb.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.
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:
:182fuse_buf_alloc(&fb, uio->uio_resid)βfb.len = uio_resid(the ACTUAL bytes the daemon wrote);:183uiomove(fb.buf, uio->uio_resid, uio)copies the daemon's bytes in;:188ohd = fb.bufβ the daemon controlsohd->len(the CLAIMED length);:205fip->reply = fbβ stores the unclamped buffer, sofip->reply.len = uio_resid;:212fuse_audit_length(ihd, ohd)validatesohd->len(CLAIMED) against the request β neverfb.len(ACTUAL);:218completes 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)
-
Heap overflow WRITE (the real, dangerous primitive β CWE-787). Daemon replies to
FUSE_READ(fri->size=4096)CLAIMINGohd->len=4112but writing 12288 bytes. Audit passes (4112-16=4096 <= 4096); consumer doesmemcpy(bp->b_data[4096], ..., 12288-16=12272)β 8176-byte heap overflow write, content fully daemon-controlled. Confirmed live: the overflow page-faults inmemcpy(corrupts an adjacentvm_objectpointer βpanic: assertion "obj != NULL" failed in vm_object_hold_shared). -
OOB read on reply buffer β the finding's secondary framing. With the current code
fb.len = uio_residalways matches the allocation, so the daemon cannot make the consumer read past its own reply allocation viaohd->lenalone; the exploitable direction is the overflow-write above. (A daemon that CLAIMSohd->len > uio_residwould, post-fix, be rejected withEINVAL.)
Reproduction
- Deterministic harness (
harness.c) β transcribes the exact vulnerable path (fuse_buf_alloc+ unclampedfip->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 claimingohd->len=4112. A child process opens/mnt/fuse/pwnedandread()s it, driving the buffer-cache β strategy βfuse_io_threadβfuse_io_executeβmemcpyoverflow. 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/fuseiscrw-rw---- root:operatorandmount("fuse",...)requirescaps_priv_check(SYSCAP_NOMOUNT_FUSE)which needsuid == 0(fuse_vfsops.c:155,kern_caps.c:311).vfs.usermount=0on this guest andmaxxis NOT in theoperatorgroup, 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 theFUSE_READ(if root mounts withallow_other), but the overflow BYTES are authored by the root daemon, not bymaxx, somaxxcannot 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=1AND added the user tooperator, 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 achunk_mark_*/WEIRD_ADDRKASSERT before a cleanuid0lands; 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.lentoohd->lensofuse_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
fixedVALIDATED 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).
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.
No comments yet.