fuse_audit_length validates daemon-claimed ohd->len (with integer underflow) instead of actual buffer size, unconditionally passes 3 opcodes
Summary
fuse_util.c:89 size_t len = ohd->len - sizeof(fuse_out_header). ohd->len daemon-controlled uint32. ohd->len<16 underflows size_t to ~SIZE_MAX. NO comparison against actual buffer fip->reply.len. :97 FUSE_FORGET res=true unconditional. :150-155 FUSE_GETXATTR/FUSE_LISTXATTR res=true. Consumers use fuse_out_data_size(fip)=reply.len-16 NOT ohd->len-16. Mismatch: daemon claims expected ohd->len writes short buffer => consumer OOB read (DF-0895). Daemon claims expected ohd->len writes long buffer => consumer memcpy OOB write (fuse_vnops.c:2054). Root cause of DF-0895 audit bypass. Fix: add bufsize param validate ohd->len<=bufsize.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0912 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| fused_df0912.c | trigger-source | malicious FUSE daemon: short STATFS reply with claimed ohd->len (OOB mode) or ohd->len<16 (underflow mode) | 15.5 KB | view raw |
| build.sh | build-script | cc -O2 -o fused_df0912 fused_df0912.c | 150 B | view raw |
| run.sh | run-script | load fuse, run daemon in OOB + underflow modes | 698 B | view raw |
| README.md | readme | build/run/expected + threat model | 3.2 KB | β raw |
| VERDICT.md | verdict | full narrative: mechanism, 3-opcode reachability analysis, underflow demo, fix validation | 10.9 KB | β raw |
| build.log | build-log | daemon build output (cc 8.3, guest) | 112 B | view raw |
| run.log | run-log | decisive OOB-mode run with kernel pointer leaks | 1.1 KB | view raw |
| leak_sample.txt | leak-sample | multi-run leak sample: OOB runs 1-3 + underflow run, showing varying kernel pointers and ASCII strings | 4.6 KB | view raw |
| env.txt | environment | uname, cc version, fuse.ko sha256, kldstat | 582 B | view raw |
| fix.diff | suggested-fix | root-cause fix: fuse_audit_length takes actual_len (underflow-guarded); fuse_device_write sets ohd->error=-EPROTO on audit failure; shared with DF-0895 | 2.7 KB | view raw |
| fix_build.log | build-log | single-module fuse.ko rebuild with fix applied (rc=0) | 10.9 KB | view raw |
| fix_run.log | run-log | patched-kernel PoC re-run: all-zero fields (no OOB), f_iosize=0 (consumer returns early) | 3.7 KB | view 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-0912 β PoC
Claim: fuse_audit_length (sys/vfs/fuse/fuse_util.c:87-89) validates
the daemon-CLAIMED ohd->len field instead of the actual reply
buffer size (fb.len from fuse_buf_alloc(&fb, uio->uio_resid) in
fuse_device_write). Two consequences:
- Integer underflow at
fuse_util.c:89:size_t len = ohd->len - sizeof(struct fuse_out_header);wraps to~SIZE_MAXif a malicious daemon setsohd->len < 16. - Three opcodes pass unconditionally β
FUSE_FORGET(:96-98),FUSE_GETXATTR(:150-152),FUSE_LISTXATTR(:153-155) all hard-coderes = true, so any claimedohd->len(including an underflowed one) passes the audit for those opcodes. - The consumer
fuse_ipc_tx(fuse_ipc.c:247) and per-opcode consumers usefuse_out_data_size(fip) = fip->reply.len - 16(actual buffer) for their fixed-size struct dereferences, NOTohd->len - 16. A daemon that claims the expectedohd->lenwhile writing a short buffer therefore passes the audit and triggers a heap OOB read in the consumer (fuse_vfsops.c:fuse_statfs:399reads 80 bytes from a 16-byte kmalloc).
This is the same root cause as DF-0895 and DF-0915; DF-0912 additionally calls out the integer underflow and the 3 unconditional-pass opcodes.
Build
cc -O2 -o fused_df0912 fused_df0912.c
Run (as root on the DragonFly guest)
kldload fuse # if not already loaded ./fused_df0912 # OOB-read demo: short STATFS, claimed ohd->len=96 ./fused_df0912 underflow # underflow demo: claimed ohd->len=8 < 16
Expected output (bug present β unpatched #0 kernel)
./fused_df0912:statfs()succeeds;f_blocks/f_bfree/f_bavail/f_files/f_ffreecontain non-zero kernel pointers (e.g.0xfffff8004f...direct-map heap addresses and0xffffffff82...kernel-image addresses) that vary byte-for-byte across iterations β proof of a kernel heap OOB read../fused_df0912 underflow:statfs()fails withEPROTOβ the audit rejects the reply (the underflowedlen=~SIZE_MAXfails the(len == 80)check for STATFS). This shows the underflow is real but, for size-checked opcodes, the size check still catches it; for the 3 unconditional-pass opcodes it would NOT be caught (but those are unreachable in this kernel β see VERDICT.md).
Expected output (FIXED kernel β single-fix fuse.ko)
./fused_df0912:statfs()returns all-zero fields; daemon's short write is rejected (write()returns -1, kernel logsEPROTOaudit). No OOB read, no leak../fused_df0912 underflow: sameEPROTOas before (now via the explicitactual_len < sizeof(fuse_out_header)guard in the fixed audit).
Threat model
FUSE is module-only on DragonFly; /dev/fuse is root:operator 0660 and the
mount requires privilege, so the malicious daemon runs as root
(consistent with DF-0780/0895/0915). Rootβkernel is already game-over, but
the primitive is a genuine kernel heap disclosure: any local user who can
statfs() the mount point receives the leaked bytes (the audit bypass means
a short reply whose claimed ohd->len matches the expected struct size
passes silently).
DF-0912 β VERDICT
Verdict: REPRODUCED (kernel heap OOB read / info leak via FUSE audit trusting daemon-claimed ohd->len instead of actual buffer size).
Fix: VALIDATED (single-module rebuild of fuse.ko closes the leak β clean before/after).
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 while setting ohd->len to the value the audit expects:
-
Daemon writes a 16-byte buffer (just the
fuse_out_header), 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:188,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 would return only goes back to the daemon'swrite()syscall, not to the kernel-side consumer β and even that doesn't fire because the audit passed (the claimed len matched). -
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 integer underflow (DF-0912 sub-claim #1)
At fuse_util.c:89, if a daemon sets ohd->len < 16 (e.g. ohd->len = 8),
the subtraction ohd->len - sizeof(struct fuse_out_header) wraps the
size_t to ~SIZE_MAX. The PoC's underflow mode demonstrates this:
- For size-checked opcodes (STATFS, INIT, etc.), the underflowed
lenstill fails thelen == sizeof(...)check, so the audit returns -1 and the daemon'swrite()getsEPROTO. BUT β critically β the IPC still completes regardless (fuse_device.c:218),ohd->erroris left at 0, and the consumer (fuse_statfs) still proceeds to OOB-read 80 bytes past the 16-byte buffer. The PoC's underflow-mode output confirmsstatfs()succeeds and leaks the same kernel pointers (0xfffff8004f...,0xffffffff82606ca0) as the OOB mode.
This proves the audit is advisory-only β even when it "catches" a
malformed reply, the rejection only affects the daemon's write() return
value; the kernel consumer is unaffected because ohd->error is never set
by the audit-failure path.
- For the 3 unconditional-pass opcodes (below), the underflowed
lenwould pass the audit regardless β but those opcodes are unreachable (see next section).
The 3 unconditional-pass opcodes (DF-0912 sub-claim #2) β dead code
The finding flags three opcodes where fuse_audit_length hard-codes
res = true:
- FUSE_FORGET (fuse_util.c:96-98)
- FUSE_GETXATTR (fuse_util.c:150-152)
- FUSE_LISTXATTR (fuse_util.c:153-155)
Reachability analysis (grep of sys/vfs/fuse/):
- FUSE_FORGET: the kernel sends it via fuse_forget_node
(fuse_util.c:71) using fuse_ipc_tx_noreply (fuse_util.c:74).
No reply is ever read or audited β fuse_audit_length is never called
on a FUSE_FORGET reply. The res = true is harmless dead code.
- FUSE_GETXATTR / FUSE_LISTXATTR: there is no caller in the kernel
β grep -rn 'FUSE_GETXATTR\|FUSE_LISTXATTR' sys/vfs/fuse/ finds them only
in fuse_audit_length's switch and fuse_get_ops's name table, never in
a fuse_ipc_fill(.., FUSE_GETXATTR, ..). These opcodes are never issued,
so their replies are never audited.
Conclusion on the 3 opcodes: they are defense-in-depth gaps (the
res = true is arguably correct for GETXATTR/LISTXATTR since their replies
are variable-length and the consumer uses fuse_out_data_size(fip) to bound
the read; and harmless for FORGET since no reply exists). The live,
exploitable primitive is the OOB read via claimed-len mismatch on the
reachable opcodes (INIT/STATFS/STATVFS and others).
Evidence (unpatched #0 kernel, fuse.ko sha 680333bb...)
Multiple statfs("/mnt/df0912") calls, each preceded by a short STATFS reply
(daemon log: actual_write=16 claimed_ohd->len=96):
[OOB run1 iter0] f_blocks=0xfffff8004f0a25c0 f_bavail=0xfffff8004f0a25d0 f_ffree=0xfffff8004f0a2610
[OOB run1 iter1] f_blocks=0xfffff8004f0a25d0 f_bavail=0xfffff8004f0a2610 f_ffree=0xfffff8004f0a2570
[OOB run2 iter0] f_blocks=0xfffff8004f0a25d0 f_bavail=0x68732e646c697562 ("build/sh..") f_ffree=0xfffff8004f0a2540
[OOB run3 iter1] f_blocks=0x2b6364747362696c ("listdtc+") f_bavail=0x66645f6465737566 ("fused_df") f_ffree=0x706172772d6f746c
[underflow iter0] f_bavail=0xfffff8004f3301e0 f_files=0xffffffff82606ca0 (audit rejected write but consumer STILL read OOB)
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).- ASCII strings (
"fused_df","build/sh","listdtc+") β leaked from adjacent slab chunks containing the daemon's own binary/path strings. - 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.
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 per this bug). Per the valid-hard-blocker rule for read-only primitives,
there is no escalation chain to develop. The realistic impact ceiling is a
kernel heap info leak / KASLR defeat: the bytes leaked are adjacent
slab-chunk contents (kernel pointers and slab metadata); any local user who
can statfs() the mount receives them. (The daemon is already root per the
FUSE threat model, but the primitive's value is the kernel-pointer disclosure
to other users who can statfs() the mount β the audit bypass means a
short reply whose claimed ohd->len matches the expected struct size passes
silently.)
PoC changes
- Authored
fused_df0912.cfrom scratch (no prior PoC existed for DF-0912): a fork-based malicious FUSE daemon (parent =/dev/fuseI/O loop; child = mount +statfs()Γ3). - Default mode: valid INIT, short STATFS with claimed
ohd->len=96β OOB read (audit bypass via claimed-len match). underflowmode: short STATFS with claimedohd->len=8(< 16) β demonstrates thesize_tunderflow AND that the audit's "rejection" is advisory-only (consumer still reads OOB).- Wrote
build.sh,run.sh,fix.diff, thisVERDICT.md,manifest.json.
Recommended fix (root-cause)
The finding's ## Recommended fix proposal direction is correct ("add bufsize
param validate ohd->len<=bufsize"). The implemented fix.diff (shared with
DF-0895, same root cause) 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;β directly addresses the DF-0912 integer-underflow concern.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(). This fixes the advisory-only nature of the audit (the consumer no longer proceeds when the audit fails).fuse.hβ updated prototype.
This matches the DF-0895 root-cause fix (same bug, same code path, same three files). It 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, and makes the previously-advisory audit actually reject malformed replies.
Fix validation (Phase 8)
- Baseline (#0 kernel, unpatched
fuse.kosha680333bb...): leak reproduced β kernel pointers and ASCII strings instatfs()output (above). Both OOB mode and underflow mode leak. - Patched (rebuilt
fuse.kosha45031678..., loaded via kldunload/kldload; kernel itself unchanged since fuse is a module): re-running the SAME PoC yields all-zerostatfs()fields across 3+ iterations in both OOB and underflow modes;f_iosize = 0(vs 4096 on baseline) confirmsfuse_statfsreturns early before readingfuse_out_data(fip); the daemon's short write is rejected (actual_write=-1). Guest stays up. No OOB read, no leak. Fix is deterministic (3/3 clean in OOB mode, 3/3 clean in underflow mode).
Kernel references confirmed during verification:
- sys/vfs/fuse/fuse_util.c:87-89 (audit uses ohd->len β the bug; underflow site)
- sys/vfs/fuse/fuse_util.c:96-98 (FUSE_FORGET res=true β dead code, no reply)
- sys/vfs/fuse/fuse_util.c:138-140 (FUSE_STATFS size check β bypassed via claimed len)
- sys/vfs/fuse/fuse_util.c:150-155 (FUSE_GETXATTR/LISTXATTR res=true β dead code, no caller)
- sys/vfs/fuse/fuse_device.c:182 (fuse_buf_alloc(&fb, uio->uio_resid) β actual buffer size)
- sys/vfs/fuse/fuse_device.c:205 (fip->reply = fb β stores actual-size buffer)
- 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 == 0)
- 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) β OOB read site)
- sys/vfs/fuse/fuse_vfsops.c:430 (STATVFS fso = fuse_out_data(fip))
Fix verification
fixedVALIDATED the fix: ./fused_df0912 leaked kernel pointers (0xfffff8004f0a25c0, 0xffffffff82606ca0, ASCII strings) on the unpatched baseline fuse.ko (sha 680333bb...) in BOTH OOB and underflow modes, and does NOT leak on the single-fix fuse.ko (sha 45031678...) -- all statfs() fields are zero, f_iosize=0 (vs 4096 baseline, confirming fuse_statfs returned early before reading fuse_out_data), daemon write rejected (actual_write=-1). Fix is deterministic: 3/3 clean in OOB mode, 3/3 clean in underflow mode. Guest stayed up throughout. => fix closes the bug.
baseline (unpatched 680333bb): [iter0] f_blocks=0xfffff8004f0a25c0 f_bavail=0xfffff8004f0a25d0 f_ffree=0xfffff8004f0a2610 f_iosize=4096 patched (45031678): [iter0] f_blocks=0x0 f_bfree=0x0 f_bavail=0x0 f_files=0x0 f_ffree=0x0 f_iosize=0 (all-zero, 3/3 runs, both modes) daemon log baseline: actual_write=16 claimed_ohd->len=96 (audit passed, consumer OOB-read) daemon log patched: actual_write=-1 claimed_ohd->len=96 (audit rejected write; ohd->error=-EPROTO; consumer returned early)
Confirmed kernel references
- sys/vfs/fuse/fuse_util.c:87
- sys/vfs/fuse/fuse_util.c:89
- sys/vfs/fuse/fuse_util.c:96
- sys/vfs/fuse/fuse_util.c:138
- sys/vfs/fuse/fuse_util.c:150
- sys/vfs/fuse/fuse_util.c:153
- 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:218
- sys/vfs/fuse/fuse_ipc.c:274
- sys/vfs/fuse/fuse_vfsops.c:399
- sys/vfs/fuse/fuse_vfsops.c:430
Detail
Exploit chain
none -- this is a read-only primitive (OOB heap READ). No write, no corruption of attacker-controlled bytes (leaked bytes are read INTO sbp and returned to userspace; kernel does not write attacker data via this bug). Per the valid-hard-blocker rule for read-only primitives, there is no escalation chain to develop. Realistic impact ceiling: kernel heap info leak / KASLR defeat (80 bytes per statfs() call, adjacent slab-chunk contents: kernel direct-map pointers 0xfffff8004f..., kernel-image pointer 0xffffffff82606ca0, ASCII strings from adjacent allocations). No write primitive exists in this specific bug path (DF-0780 covers the separate FUSE_READ memcpy overflow-write companion).
Evidence (decisive lines)
[OOB run1 iter0] f_blocks=0xfffff8004f0a25c0 f_bavail=0xfffff8004f0a25d0 f_ffree=0xfffff8004f0a2610
[OOB run3 iter1] f_blocks=0x2b6364747362696c ("listdtc+") f_bavail=0x66645f6465737566 ("fused_df")
[underflow iter0] f_bavail=0xfffff8004f3301e0 f_files=0xffffffff82606ca0 (audit rejected daemon write=-1 but consumer STILL read OOB and returned success)
daemon log: 'reply unique=1 error=0 payload=0 actual_write=16 claimed_ohd->len=96' (OOB mode -- audit passed via claimed len) / 'actual_write=-1 claimed_ohd->len=8' (underflow mode -- audit rejected write but IPC completed regardless)
PoC changes
Authored fused_df0912.c from scratch (no prior PoC existed for DF-0912): a fork-based malicious FUSE daemon. Default mode sends valid INIT + short STATFS (claimed ohd->len=96, 16-byte write) to trigger the OOB read via claimed-len bypass. 'underflow' mode sends short STATFS with ohd->len=8 (<16) to demonstrate the size_t underflow AND that the audit is advisory-only (consumer still OOB-reads even when the audit rejects). Added build.sh, run.sh, fix.diff, VERDICT.md, manifest.json, leak_sample.txt (multi-run OOB + underflow samples). fix.diff is the root-cause fix shared with DF-0895 (same bug).
Verified recommended fix
fix.diff (matches DF-0895 root-cause fix, same bug): (1) fuse_util.c:fuse_audit_length takes actual_len parameter and computes len=actual_len-sizeof(fuse_out_header) with an explicit underflow guard 'if (actual_len < sizeof(fuse_out_header)) return -1' -- directly addressing the DF-0912 integer-underflow; (2) fuse_device.c:fuse_device_write passes fb.len (actual uio_resid) to the audit AND sets ohd->error=-EPROTO on audit failure so fuse_ipc_tx propagates the error and the consumer aborts before dereferencing the too-short buffer (fixes the advisory-only nature); (3) fuse.h updated prototype. Matches the finding's 'add bufsize param validate ohd->len<=bufsize' proposal direction. Full git-apply-able diff in findings/poc/DF-0912/fix.diff.
Verdict
REPRODUCED. fuse_audit_length (sys/vfs/fuse/fuse_util.c:87-89) validates the daemon-CLAIMED ohd->len field instead of the actual reply buffer size (fb.len from fuse_buf_alloc at fuse_device.c:182). A malicious FUSE daemon writing a 16-byte header-only buffer while setting ohd->len=96 (the value the audit expects for STATFS) passes the audit silently; fuse_ipc_tx (fuse_ipc.c:274) sees ohd->error==0 and returns 0; fuse_statfs (fuse_vfsops.c:399) then dereferences fuse_out_data(fip)=fb.buf+16 for sizeof(fuse_statfs_out)=80 bytes -- an 80-byte heap OOB read whose bytes flow back to userspace via statfs(2). Confirmed by leaking kernel direct-map heap pointers (0xfffff8004f0a25c0 etc., shifting run-to-run) and a stable kernel-image pointer (0xffffffff82606ca0). The integer underflow (ohd->len<16 wraps size_t to ~SIZE_MAX at :89) is real and demonstrated by the 'underflow' mode, which ALSO leaks -- proving the audit is advisory-only: even when the audit 'rejects' the reply, the IPC completes regardless (fuse_device.c:218 'Complete the IPC regardless'), ohd->error is left at 0, and the consumer still OOB-reads. The 3 unconditional-pass opcodes (FUSE_FORGET/GETXATTR/LISTXATTR at :96/:150/:153) are DEAD CODE: FUSE_FORGET uses fuse_ipc_tx_noreply (no reply ever audited); FUSE_GETXATTR/FUSE_LISTXATTR have no kernel caller (grep finds them only in the audit switch and name table). They are a defense-in-depth gap, not a live trigger. Root cause identical to DF-0895/DF-0915; threat model is root daemon (FUSE module-only, /dev/fuse root:operator 0660, mount requires privilege) -- primitive value is kernel-pointer disclosure to any user who can statfs() the mount.
No comments yet.