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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0912 Β· 14 files
FileTypeDescriptionSize
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
README.md readme build/run/expected + threat model
↓ download 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:

  1. Integer underflow at fuse_util.c:89: size_t len = ohd->len - sizeof(struct fuse_out_header); wraps to ~SIZE_MAX if a malicious daemon sets ohd->len < 16.
  2. Three opcodes pass unconditionally β€” FUSE_FORGET (:96-98), FUSE_GETXATTR (:150-152), FUSE_LISTXATTR (:153-155) all hard-code res = true, so any claimed ohd->len (including an underflowed one) passes the audit for those opcodes.
  3. The consumer fuse_ipc_tx (fuse_ipc.c:247) and per-opcode consumers use fuse_out_data_size(fip) = fip->reply.len - 16 (actual buffer) for their fixed-size struct dereferences, NOT ohd->len - 16. A daemon that claims the expected ohd->len while writing a short buffer therefore passes the audit and triggers a heap OOB read in the consumer (fuse_vfsops.c:fuse_statfs:399 reads 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_ffree contain non-zero kernel pointers (e.g. 0xfffff8004f... direct-map heap addresses and 0xffffffff82... kernel-image addresses) that vary byte-for-byte across iterations β€” proof of a kernel heap OOB read.
  • ./fused_df0912 underflow: statfs() fails with EPROTO β€” the audit rejects the reply (the underflowed len=~SIZE_MAX fails 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 logs EPROTO audit). No OOB read, no leak.
  • ./fused_df0912 underflow: same EPROTO as before (now via the explicit actual_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).

VERDICT.md verdict full narrative: mechanism, 3-opcode reachability analysis, underflow demo, fix validation
↓ download raw

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:

  1. Daemon writes a 16-byte buffer (just the fuse_out_header), 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:188,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 would return only goes back to the daemon's write() syscall, not to the kernel-side consumer β€” and even that doesn't fire because the audit passed (the claimed len matched).

  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 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 len still fails the len == sizeof(...) check, so the audit returns -1 and the daemon's write() gets EPROTO. BUT β€” critically β€” the IPC still completes regardless (fuse_device.c:218), ohd->error is 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 confirms statfs() 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 len would 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.c from scratch (no prior PoC existed for DF-0912): a fork-based malicious FUSE daemon (parent = /dev/fuse I/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).
  • underflow mode: short STATFS with claimed ohd->len=8 (< 16) β†’ demonstrates the size_t underflow AND that the audit's "rejection" is advisory-only (consumer still reads OOB).
  • Wrote build.sh, run.sh, fix.diff, this VERDICT.md, manifest.json.

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:

  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; β€” directly addresses the DF-0912 integer-underflow concern.
  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(). This fixes the advisory-only nature of the audit (the consumer no longer proceeds when the audit fails).
  3. 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.ko sha 680333bb...): leak reproduced β€” kernel pointers and ASCII strings in statfs() output (above). Both OOB mode and underflow mode leak.
  • Patched (rebuilt fuse.ko sha 45031678..., 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 in both OOB and underflow modes; f_iosize = 0 (vs 4096 on baseline) confirms fuse_statfs returns early before reading fuse_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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED 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)
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 (kernel unchanged; fuse.ko module-only rebuild sha 450316784ccd15a03d3026260ff9ad893c7257be1a685486b4c6d29288feb43c, loaded via kldunload/kldload)

Confirmed kernel references

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.