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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0895 Β· 16 files
FileTypeDescriptionSize
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
README.md readme claim, threat model, reproduce instructions
↓ download 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.
VERDICT.md verdict full narrative: mechanism, evidence, fix validation
↓ download raw

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):

  1. Daemon writes a short buffer. It opens /dev/fuse, services the FUSE_STATFS request, and writes only the 16-byte fuse_out_header while setting ohd->len = 96 (the value fuse_audit_length expects for sizeof(fuse_out_header) + sizeof(fuse_statfs_out) = 16 + 80) and ohd->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).

  2. The audit is bypassed. fuse_audit_length(ihd, ohd) (fuse_util.c:87-89) computes size_t len = ohd->len - sizeof(struct fuse_out_header) β€” using the daemon-claimed ohd->len, not the actual buffer length fb.len. For FUSE_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.

  3. The IPC completes regardless. fuse_device_write does "Complete the IPC regardless of above result" β€” it sets fip->done and wakes the kernel-side waiter (fuse_device.c:218-220). The EPROTO it returns only goes back to the daemon's write() syscall, not to the kernel-side consumer.

  4. The kernel-side consumer OOB-reads. fuse_ipc_tx (fuse_ipc.c:247) sees ohd->error == 0, returns 0. fuse_statfs then does fso = fuse_out_data(fip) (fuse_vfsops.c:399) = fb.buf + 16 (the pointer past the 16-byte allocation) and dereferences the full sizeof(struct fuse_statfs_out) = 80 bytes (fso->st.frsize, .blocks, .bfree, .bavail, .files, .ffree), copying them into sbp-> and thence to userspace via statfs(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.c from scratch (no prior PoC existed for DF-0895): a fork-based malicious FUSE daemon (parent = /dev/fuse I/O loop; child = mount + statfs() Γ—3). Mode init additionally short-replies FUSE_INIT.
  • Removed a statvfs() call that failed to compile (DragonFly userland does not expose struct statvfs); the STATVFS kernel path is identical to STATFS (same reply, same bug, same fix) and is exercised implicitly, so the statfs leak is sufficient proof.
  • Wrote build.sh, run.sh, fix.diff, this VERDICT.md, manifest.json.

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:

  1. fuse_util.c:fuse_audit_length β€” take the actual written length (actual_len) as a parameter and compute len = actual_len - sizeof( fuse_out_header) instead of ohd->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;.)
  2. fuse_device.c:fuse_device_write β€” pass fb.len (the actual uio_resid that sized the allocation) to the audit, and on audit failure set ohd->error = -EPROTO so fuse_ipc_tx propagates the error to the kernel-side consumer, which then aborts (if (error) return error) before dereferencing the too-short buffer via fuse_out_data().
  3. 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 in statfs() output (above).
  • Patched (rebuilt fuse.ko sha 91d55743..., loaded via kldunload/kldload; kernel itself unchanged since fuse is a module): re-running the SAME PoC yields all-zero statfs() fields across 3 iterations; the daemon's short write is rejected (actual_write=-1 / kernel returns EPROTO); 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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. 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)
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 kernel UNCHANGED (fuse is module-only). Fix validated by rebuilding only fuse.ko (in-tree make at sys/vfs/fuse, rc=0, fix_build.log) and swapping it via kldunload/kldload: unpatched fuse.ko sha 680333bb... -> patched fuse.ko sha 91d55743... (size d3000 -> d000). kern.version stays '#0'.

Confirmed kernel references

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.