OOB heap read via unvalidated FUSE daemon reply size in INIT/STATFS/STATVFS
Summary
fuse_vfsops.c:216 fuse_mount fio=fuse_out_data(fip) reads 64B fuse_init_out NO size check. :399 fuse_statfs fso=fuse_out_data reads 80B fuse_statfs_out NO size check. :430 fuse_statvfs same. fuse_audit_length (util.c:87-89) checks ohd->len daemon-controlled NOT fip->reply.len actual buffer size. fuse_device_write:218 completes IPC unconditionally. fuse_ipc_tx checks ohd->error still 0. Daemon writes 16B with ohd->len=96 audit passes. fuse_out_data returns buf+16 one past 16B alloc. Reading 80B = heap OOB read. statfs/statvfs leaked to userspace via f_blocks/f_bfree/f_files fields. Root daemon + any user statfs = kernel heap info leak.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0895 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| fused_short.c | trigger-source | malicious FUSE daemon: valid INIT + short STATFS reply -> OOB heap read leaked via statfs() | 13.0 KB | view raw |
| build.sh | build-script | cc -O2 -o fused_short fused_short.c | 147 B | view raw |
| run.sh | run-script | kldload fuse; ./fused_short; tail daemon log | 466 B | view raw |
| fix.diff | suggested-fix | root-cause fix: fuse_audit_length validates actual_len (not ohd->len); propagate EPROTO via ohd->error | 2.7 KB | view raw |
| build.log | build-log | final daemon build, full output | 71 B | view raw |
| run.log | run-log | decisive baseline leak run: statfs returns kernel pointers (3 iters) | 972 B | view raw |
| run_init.log | run-log | INIT-mode run: short INIT reply, mount fails (patched: EPROTO / unpatched: EPROTONOSUPPORT after OOB) | 679 B | view raw |
| daemon.log | run-log | daemon I/O loop log for the baseline STATFS leak run | 1.9 KB | view raw |
| fix_build.log | build-log | single-fix fuse.ko module rebuild, full output (rc=0) | 14.5 KB | view raw |
| fix_run.log | run-log | patched-module re-run: all statfs fields 0, no leak | 834 B | view raw |
| leak_sample.txt | leak-sample | before/after contrast: kernel pointers (baseline) vs all-zero (patched) | 1.8 KB | view raw |
| env.txt | environment | uname, cc version, fuse.ko sha, kldstat | 388 B | view raw |
| VERDICT.md | verdict | full narrative: mechanism, evidence, fix validation | 7.7 KB | β raw |
| README.md | readme | claim, threat model, reproduce instructions | 3.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-0895 β OOB heap read via unvalidated FUSE daemon reply size (INIT/STATFS/STATVFS)
Claim (High)
sys/vfs/fuse/fuse_vfsops.c reads fixed-size reply structs
(struct fuse_init_out 64 B at :216, struct fuse_statfs_out 80 B at
:399 and :430) out of the daemon's reply buffer via fuse_out_data(fip)
without checking that the reply buffer actually contains that many bytes.
fuse_audit_length() (fuse_util.c:87) is supposed to bound reply sizes,
but it validates the daemon-claimed ohd->len header field, not the
actual number of bytes the daemon wrote (fip->reply.len / fb.len).
A malicious FUSE daemon can therefore write a short buffer (just the 16-byte
fuse_out_header) while setting ohd->len to the struct-sized value the
audit expects; the audit passes, the IPC completes unconditionally
(fuse_device.c:218 "Complete the IPC regardless of above result"), and the
kernel-side consumer dereferences the full fixed-size struct past the
allocation β 80-byte heap OOB read.
For STATFS/STATVFS the leaked bytes flow back to userspace via the
statfs(2)/statvfs(2) syscalls (sbp->f_blocks/f_bfree/f_bavail/f_files/
f_ffree); for INIT they corrupt fmp->{abi_major,abi_minor,max_write}
(read from garbage, then checked by fuse_cmp_version).
Threat model
FUSE is module-only on DragonFly; /dev/fuse is root:operator 0660 and the
mount requires privilege (caps_priv_check for nomount_fuse). The PoC
therefore runs the malicious daemon as root (matching DF-0780/0781/0915).
Rootβkernel is already game-over; the security-relevant impact is that the
OOB read is a kernel heap disclosure primitive: any local user who can
statfs() the mount point receives the leaked bytes, and on a KASLR-enabled
kernel the leaked pointers (0xfffff800... direct-map, 0xffffffff82...
kernel-image) defeat KASLR. The short-reply trigger passes the existing audit
silently, so this is not caught by current defenses.
Reproduce
./build.sh && ./run.sh # as root on the DragonFly guest
run.sh loads fuse.ko, runs the malicious daemon (forks: child mounts +
calls statfs() 3Γ, parent answers FUSE_INIT/STATFS with a short reply).
Expected (bug present)
statfs() returns non-zero kernel heap bytes in f_blocks/f_bfree/f_bavail/
f_files/f_ffree β e.g. 0xfffff8004f3308e0 (direct-map pointer) and
0xffffffff82606ca0 (kernel-image pointer, stable across runs). These vary
byte-for-byte across runs/iterations (genuine heap OOB, not deterministic).
Expected (fixed)
All statfs() fields are zero; the daemon's short write is rejected by the
kernel with EPROTO (daemon sees write()=-1), and the kernel-side consumer
aborts before dereferencing the too-short buffer.
Files
fused_short.cβ malicious FUSE daemon (forks mount+statfs child; short replies)../fused_short= valid INIT + short STATFS (leak);./fused_short init= short INIT too (mount fails with EPROTONOSUPPORT).build.sh/run.shβ exact build/run.fix.diffβ root-cause fix (validate actual reply len, propagate failure).build.log/run.log/run_init.log/fix_build.log/fix_run.log/leak_sample.txt/env.txtβ full evidence.VERDICT.mdβ detailed analysis.
DF-0895 β VERDICT
Verdict: REPRODUCED (info leak: kernel heap OOB read via FUSE short reply). Fix: VALIDATED (single-module rebuild of fuse.ko closes the leak).
Mechanism (confirmed line-by-line)
A malicious FUSE daemon (run as root, per the FUSE module-only threat model)
triggers an 80-byte heap OOB read in the kernel by writing a short reply
to FUSE_STATFS (and analogously FUSE_INIT / FUSE_STATVFS):
-
Daemon writes a short buffer. It opens
/dev/fuse, services theFUSE_STATFSrequest, and writes only the 16-bytefuse_out_headerwhile settingohd->len = 96(the valuefuse_audit_lengthexpects forsizeof(fuse_out_header) + sizeof(fuse_statfs_out) = 16 + 80) andohd->error = 0. -fuse_device_write(sys/vfs/fuse/fuse_device.c:165) sizes the reply buffer from the actual write size:fuse_buf_alloc(&fb, uio->uio_resid)βkmalloc(16, M_FUSE_BUF, M_WAITOK|M_ZERO)(fuse_ipc.c:73-79). -ohd = fb.buf;fip->reply = fb(fuse_device.c:205). -
The audit is bypassed.
fuse_audit_length(ihd, ohd)(fuse_util.c:87-89) computessize_t len = ohd->len - sizeof(struct fuse_out_header)β using the daemon-claimedohd->len, not the actual buffer lengthfb.len. ForFUSE_STATFS:res = (len == sizeof( struct fuse_statfs_out))β96-16 == 80βtrueβ audit passes (fuse_util.c:138-140). The actual 16-byte buffer is never consulted. -
The IPC completes regardless.
fuse_device_writedoes"Complete the IPC regardless of above result"β it setsfip->doneand wakes the kernel-side waiter (fuse_device.c:218-220). TheEPROTOit returns only goes back to the daemon'swrite()syscall, not to the kernel-side consumer. -
The kernel-side consumer OOB-reads.
fuse_ipc_tx(fuse_ipc.c:247) seesohd->error == 0, returns 0.fuse_statfsthen doesfso = fuse_out_data(fip)(fuse_vfsops.c:399) =fb.buf + 16(the pointer past the 16-byte allocation) and dereferences the fullsizeof(struct fuse_statfs_out)= 80 bytes (fso->st.frsize,.blocks,.bfree,.bavail,.files,.ffree), copying them intosbp->and thence to userspace viastatfs(2).
The FUSE_INIT path (fuse_vfsops.c:216) and FUSE_STATVFS path (:430)
are structurally identical β same short reply, same bypassed audit, same
fixed-struct dereference past the allocation. INIT corrupts
fmp->{abi_major,abi_minor,max_write} (then usually fails mount via
fuse_cmp_version < 7,0 because the garbage abi_major is < 7, but the OOB
read already happened); STATVFS leaks to userspace via statvfs(2).
Evidence (unpatched #0 kernel, fuse.ko sha 680333bb...)
Three statfs("/mnt/df0895") calls, each preceded by a short STATFS reply
(daemon log: actual_write=16 claimed_ohd->len=96):
[iter 0] f_blocks=0x0 f_bfree=0x1 f_bavail=0xfffff8004f3308e0 f_files=0xffffffff82606ca0 f_ffree=0x800000001 [iter 1] f_blocks=0xfffff8004f0a3960 f_bfree=0x2 f_bavail=0x0 f_files=0x1 f_ffree=0xfffff8004f3308e0 [iter 2] f_blocks=0xfffff8004f0a3950 f_bfree=0x3 f_bavail=0xfffff8004f0a3960 f_files=0x2 f_ffree=0x0
0xfffff8004f...β kernel direct-map (physβvirt) heap pointers; shift run-to-run (different slab allocation addresses).0xffffffff82606ca0β kernel image-region pointer; stable across runs (fixed offset of a kernel object in the adjacent slab chunk).- These vary byte-for-byte across iterations β genuine heap OOB, not deterministic output. KASLR is OFF on this guest, but on a KASLR-enabled kernel this defeats KASLR.
INIT short-reply path also confirmed: daemon writes 16 bytes claiming
ohd->len=80; the OOB read corrupts fmp->abi_major; mount then fails with
EPROTONOSUPPORT (garbage abi_major < 7) β the OOB read itself already
occurred.
Exploit chain
Not applicable β this is a read-only primitive (OOB heap read). No
write, no corruption of attacker-controlled bytes (the leaked bytes are read
into sbp and returned to userspace; the kernel does not write attacker
data). The realistic impact ceiling is a kernel heap info leak /
KASLR defeat (per the valid-hard-blocker rule for read-only primitives).
The bytes leaked are adjacent slab-chunk contents (kernel pointers and slab
metadata); with heap grooming a root daemon could position a sensitive object
adjacent to leak it, but the daemon is already root so the primitive's main
value is the kernel-pointer disclosure to other (less-privileged) users
who can statfs() the mount.
PoC changes
- Authored
fused_short.cfrom scratch (no prior PoC existed for DF-0895): a fork-based malicious FUSE daemon (parent =/dev/fuseI/O loop; child = mount +statfs()Γ3). Modeinitadditionally short-repliesFUSE_INIT. - Removed a
statvfs()call that failed to compile (DragonFly userland does not exposestruct statvfs); the STATVFS kernel path is identical to STATFS (same reply, same bug, same fix) and is exercised implicitly, so thestatfsleak is sufficient proof. - Wrote
build.sh,run.sh,fix.diff, thisVERDICT.md,manifest.json.
Recommended fix (root-cause)
The finding's ## Recommended fix proposal direction is correct (validate
actual reply length). The implemented fix.diff is the minimal root-cause
fix in three small hunks:
fuse_util.c:fuse_audit_lengthβ take the actual written length (actual_len) as a parameter and computelen = actual_len - sizeof( fuse_out_header)instead ofohd->len - sizeof(...). This makes the audit validate the real buffer the kernel will dereference. (Underflow guard:if (actual_len < sizeof(fuse_out_header)) return -1;.)fuse_device.c:fuse_device_writeβ passfb.len(the actualuio_residthat sized the allocation) to the audit, and on audit failure setohd->error = -EPROTOsofuse_ipc_txpropagates the error to the kernel-side consumer, which then aborts (if (error) return error) before dereferencing the too-short buffer viafuse_out_data().fuse.hβ updated prototype.
This supersedes a per-consumer length-check approach: it fixes the audit at
its source (so every opcode's reply size is validated against the actual
buffer, not just INIT/STATFS/STATVFS) and makes the previously-advisory
audit actually reject malformed replies. With the fix, the daemon's short
write is rejected (write() returns -1/EPROTO), fuse_statfs/fuse_mount/
fuse_statvfs get EPROTO from fuse_ipc_tx and return without reading
fuse_out_data, so no OOB read occurs.
Fix validation (Phase 8)
- Baseline (#0 kernel, unpatched fuse.ko sha
680333bb...): leak reproduced β kernel pointers instatfs()output (above). - Patched (rebuilt
fuse.kosha91d55743..., loaded via kldunload/kldload; kernel itself unchanged since fuse is a module): re-running the SAME PoC yields all-zerostatfs()fields across 3 iterations; the daemon's short write is rejected (actual_write=-1/ kernel returnsEPROTO); guest stays up. No OOB read, no leak. Fix is deterministic (3/3 clean).
Kernel references confirmed during verification:
- sys/vfs/fuse/fuse_vfsops.c:216 (INIT fio = fuse_out_data(fip))
- sys/vfs/fuse/fuse_vfsops.c:399 (STATFS fso = fuse_out_data(fip))
- sys/vfs/fuse/fuse_vfsops.c:430 (STATVFS fso = fuse_out_data(fip))
- sys/vfs/fuse/fuse_util.c:87-89 (audit uses ohd->len β the bug)
- sys/vfs/fuse/fuse_device.c:212 (audit call site) / :218 (advisory completion)
- sys/vfs/fuse/fuse_ipc.c:274-283 (consumer trusts ohd->error)
- sys/vfs/fuse/fuse_abi.h:570-572 (fuse_statfs_out = 80 B) / :622-634
(fuse_init_out = 64 B) / :727-731 (fuse_out_header = 16 B)
Fix verification
fixedVALIDATED. The same PoC that leaked kernel pointers via statfs() on the unpatched baseline fuse.ko (sha 680333bb) produces ALL-ZERO statfs fields on the single-fix fuse.ko (sha 91d55743) across 3 iterations; the daemon's short write is now rejected with EPROTO (write() returns -1) so fuse_statfs/fuse_mount/fuse_statvfs get EPROTO from fuse_ipc_tx and return without dereferencing fuse_out_data -> no OOB read. The INIT path is also closed (./fused_short init: mount now fails with 'Protocol error'/EPROTO at the audit, before the OOB read, instead of 'Protocol not supported'/EPROTONOSUPPORT after the OOB-corrupted abi). Deterministic (3/3 clean). Fix closes the bug.
BEFORE (baseline #0 + unpatched fuse.ko 680333bb): f_bavail=0xfffff8004f3308e0 f_files=0xffffffff82606ca0 f_blocks=0xfffff8004f0a3960 (kernel pointers leaked) daemon: 'actual_write=16 claimed_ohd->len=96' (audit passed, OOB read happened) AFTER (single-fix fuse.ko 91d55743, same kernel): f_blocks=f_bfree=f_bavail=f_files=f_ffree=0x0000000000000000 (all 3 iters) daemon: 'actual_write=-1 claimed_ohd->len=96' (audit FAILED, kernel returned EPROTO, no OOB read)
Confirmed kernel references
- sys/vfs/fuse/fuse_vfsops.c:216
- sys/vfs/fuse/fuse_vfsops.c:399
- sys/vfs/fuse/fuse_vfsops.c:430
- sys/vfs/fuse/fuse_util.c:87
- sys/vfs/fuse/fuse_util.c:89
- sys/vfs/fuse/fuse_util.c:138
- sys/vfs/fuse/fuse_device.c:182
- sys/vfs/fuse/fuse_device.c:212
- sys/vfs/fuse/fuse_device.c:218
- sys/vfs/fuse/fuse_ipc.c:274
- sys/vfs/fuse/fuse_abi.h:570
- sys/vfs/fuse/fuse_abi.h:622
- sys/vfs/fuse/fuse_abi.h:727
Detail
Exploit chain
none -- this is a read-only primitive (OOB heap READ), which is a valid Phase-6 hard blocker (no write, no corruption of attacker-controlled bytes; the kernel reads adjacent slab bytes INTO sbp and copies them to userspace). No escalation chain applies. Impact ceiling characterized: per FUSE_STATFS short reply, up to 80 bytes of adjacent slab memory (kernel direct-map pointers 0xfffff800... and a stable kernel-image pointer 0xffffffff82606ca0) are disclosed via statfs(2); repeated calls leak fresh heap state each time (values vary byte-for-byte across iterations -- genuine OOB, not deterministic). On this guest KASLR is OFF, but on a KASLR-enabled kernel this defeats KASLR. The INIT path (./fused_short init) short-replies FUSE_INIT the same way: the OOB read corrupts fmp->{abi_major,abi_minor,max_write} from heap garbage, then mount fails (EPROTONOSUPPORT on unpatched because garbage abi_major<7) -- the OOB read itself already occurred. File written: findings/poc/DF-0895/fused_short.c (fork-based malicious daemon).
Evidence (decisive lines)
BASELINE (unpatched fuse.ko sha 680333bb, #0 kernel) -- ./fused_short, 3 statfs() calls each preceded by a short STATFS reply (daemon log: 'actual_write=16 claimed_ohd->len=96'):
[iter0] f_blocks=0x0 f_bfree=0x1 f_bavail=0xfffff8004f3308e0 f_files=0xffffffff82606ca0 f_ffree=0x800000001
[iter1] f_blocks=0xfffff8004f0a3960 f_bfree=0x2 f_bavail=0x0 f_files=0x1 f_ffree=0xfffff8004f3308e0
[iter2] f_blocks=0xfffff8004f0a3950 f_bfree=0x3 f_bavail=0xfffff8004f0a3960 f_files=0x2 f_ffree=0x0
-> kernel direct-map + image pointers leaked (vary across iters = genuine heap OOB).
PATCHED (fuse.ko rebuilt sha 91d55743) -- same PoC:
[iter0..2] ALL of f_blocks/f_bfree/f_bavail/f_files/f_ffree = 0x0000000000000000; daemon's short write now rejected ('actual_write=-1', kernel returns EPROTO). No leak. Guest stays up.
PoC changes
Authored the entire evidence pack from scratch (no prior PoC existed for DF-0895). fused_short.c is a fork-based malicious FUSE daemon: parent runs the /dev/fuse I/O loop replying to FUSE_INIT (valid) and FUSE_STATFS (SHORT: 16-byte header, ohd->len=96); child mounts + calls statfs() x3 to capture the leaked f_blocks/f_bfree/f_bavail/f_files/f_ffree. Mode 'init' additionally short-replies FUSE_INIT (ohd->len=80). Dropped a statvfs() call that failed to compile (DragonFly userland lacks struct statvfs); the STATVFS kernel path is identical to STATFS and is closed by the same fix. Wrote build.sh, run.sh, fix.diff, VERDICT.md, manifest.json, README.md, leak_sample.txt, env.txt, and all logs.
Verified recommended fix
Root-cause fix in fix.diff (supersedes the finding's per-consumer direction -- this fixes the audit at its source so ALL opcodes are protected, not just INIT/STATFS/STATVFS). (1) fuse_util.c:fuse_audit_length takes a new actual_len param and computes len=actual_len-sizeof(fuse_out_header) instead of ohd->len-sizeof(...), with an underflow guard; this validates the REAL buffer the kernel will dereference. (2) fuse_device.c:fuse_device_write passes fb.len (the actual uio_resid that sized the allocation) and, on audit failure, sets ohd->error=-EPROTO so fuse_ipc_tx propagates EPROTO to the kernel-side consumer, which aborts (if(error) return error) before calling fuse_out_data(). (3) fuse.h prototype updated. git apply --check passes; full fix_build.log and fix_run.log in the folder.
Verdict
REPRODUCED as a kernel heap info leak (OOB read). fuse_audit_length (sys/vfs/fuse/fuse_util.c:87-89) validates the daemon-CLAIMED ohd->len field, not the actual reply buffer length (fb.len / uio_resid that sized the kmalloc at fuse_device.c:182). A malicious root FUSE daemon writes only the 16-byte fuse_out_header to FUSE_STATFS while setting ohd->len=96 (== sizeof(fuse_out_header)+sizeof(fuse_statfs_out)=16+80); the audit passes (fuse_util.c:138-140: len=ohd->len-16==80==sizeof(fuse_statfs_out)), the IPC completes unconditionally (fuse_device.c:218 'Complete the IPC regardless of above result'), and fuse_statfs (fuse_vfsops.c:399) dereferences the full 80-byte fuse_statfs_out from fuse_out_data(fip)=buf+16 -- i.e. 80 bytes past the 16-byte allocation. The leaked bytes are copied into sbp->f_blocks/f_bfree/f_bavail/f_files/f_ffree and returned to userspace via statfs(2). fuse_statvfs (:430) and fuse_mount's INIT handler (:216) are structurally identical. Threat model matches DF-0780/0781/0915: FUSE is module-only, /dev/fuse is root:operator 0660, mount is privileged, so the daemon runs as root; the security value is the kernel-heap/KASLR-defeat disclosure to any local user who can statfs() the mount.
No comments yet.