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

FUSE blind reply completes and frees a fuse_ipc still linked on request_head β€” deterministic stale-queue-head corruption, 'Bad tailq' kernel panic

Summary

fuse_device_write matches daemon replies by ohd->unique on reply_head without checking fip->sent (:191-197), and fuse_ipc_tx's success path never dequeues the fip from request_head (fuse_ipc.c:266-286) - it assumes a reply implies a prior read. Uniques are sequential and predictable, so a daemon can reply to requests it never read: the waiter wakes, consumes, and fuse_ipc_put frees a fip that is still the head of the live request_head TAILQ. VERIFIED deterministically on stock INVARIANTS guest, twice from fresh resets: 41/41 blind replies accepted (incl. the FUSE_STATFS inside fuse_mount), 6-7 zero-byte read()s popping freed chunks, teardown 'panic: Bad tailq NEXT(0xfffff80117700380->tqh_last) != NULL' at fuse_ipc_tx+0x7e5 <- fuse_unmount. Non-INVARIANTS kernels: the same insert silently stores a kernel pointer into a freed, immediately recyclable fuse_ipc chunk (type-stable objcache). Distinct root cause from DF-0917 (timing/refcount) - neither fix closes the other. Secondary: un-replied waiters sleep 35s/op in tsleep without PCATCH - unkillable D-state wedge observed. Malicious/compromised daemon boundary (uid0-gated on stock config; operator+usermount expands); deterministic heap corruption of recycled fuse_ipc objects. Fix validated in-guest (fuse_ipc_remove on tx success): SIG1=0, clean umount, no panic.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3227 Β· 17 files
FileTypeDescriptionSize
df3227.c β€” 12.4 KB view raw
build.sh β€” 129 B view raw
run.sh β€” 161 B view raw
build.log β€” 65 B view raw
run.log β€” 1.1 KB view raw
daemon_mode0.log β€” 4.9 KB view raw
run2_summary.txt β€” 579 B view raw
serial_run2.txt β€” 2.3 KB view raw
panic.txt β€” 912 B view raw
serial_full.txt β€” 2.2 KB view raw
env.txt β€” 318 B view raw
fix.diff β€” 387 B view raw
fix_run.log β€” 1007 B view raw
fix_run2.log β€” 120 B view raw
verdict.json β€” 5.4 KB view raw
manifest.json β€” 1.1 KB view raw
VERDICT.md β€” 4.8 KB ↓ raw
VERDICT.md
↓ download raw

DF-3227 β€” VERDICT

Status: REPRODUCED (deterministic, no race) β€” impact: panic (kernel heap corruption class) β€” fix VALIDATED in-guest (module rebuild).

Root cause (line-by-line)

  1. fuse_ipc_get() assigns sequential uniques from fmp->unique (starts at 0, atomic_fetchadd_long, fuse_ipc.c:100) β€” after seeing ONE request the daemon can predict every future unique.
  2. fuse_ipc_tx() inserts the fip on both queues (fuse_ipc.c:258-261) and sleeps (fuse_ipc.c:266).
  3. fuse_device_write() matches the daemon's reply by fip->unique == ohd->unique while walking reply_head (fuse_device.c:191-196) β€” it never checks fip->sent, i.e. it never verifies the daemon actually read the request (fuse_device_read is what dequeues from request_head, fuse_device.c:148). On match it removes the fip from reply_head only, assigns fip->reply, sets replied, wakes the waiter (fuse_device.c:205-220).
  4. The woken waiter's success path (fuse_ipc.c:266-286) consumes the reply and returns; the caller (e.g. fuse_statfs, fuse_vfsops.c:395, 411) calls fuse_ipc_put() β†’ refcnt 1β†’0 β†’ objcache_put (fuse_ipc.c:110-117). Nothing ever removed the fip from request_head β€” the kernel just freed an object that is still the head of a live TAILQ.
  5. Every subsequent queue operation on request_head now touches freed memory: fuse_device_read pops the freed chunk (fuse_in_size() reads the request.len that fuse_buf_free zeroed β†’ uiomove(NULL, 0) β†’ read() returns 0 β€” impossible on a healthy /dev/fuse); objcache recycling double-links chunks; and the next TAILQ_INSERT_TAIL (any new fuse_ipc_tx) stores a kernel pointer through the dangling tqh_last of the freed entry β€” sys/queue.h:516 INVARIANTS check fires: panic: Bad tailq NEXT(0x...->tqh_last) != NULL, last 0x....

What ran on the guest (stock #0 INVARIANTS kernel, fresh with-src resets)

  • Run 1: 41/41 blind replies accepted (incl. unique 1 β€” the FUSE_STATFS issued inside fuse_mount, fuse_vfsops.c:238, completing the bug before mount(2) even returns); 6 zero-byte reads (SIG-1); teardown umount β†’ after the un-replied waiters' 35 s timeouts spliced around the stale head β†’ panic: Bad tailq NEXT(0xfffff80117700380->tqh_last) != NULL, last 0xfffff8011718bd60 / fuse_ipc_tx+0x7e5 ← fuse_unmount β†’ guest down in DDB (panic.txt, serial_full.txt).
  • Run 2 (fresh reset): blind_ok=41, SIG1=7 (daemon_mode0.log, run2_summary.txt, serial_run2.txt); teardown wedged uninterruptibly (kill -9 ineffective β€” fuse_ipc_wait tsleeps without PCATCH) until forced reset. The freed-while-linked state is 100% deterministic; the exact panic site depends on teardown shape (insert-time when the stale tail is reached).

Why this is NOT a duplicate of DF-0917

DF-0917 = the device read/write paths drop ipc_lock then dereference fip with no refcount, racing the 35 s timeout free β€” a timing UAF; its fix (refcount_acquire around the device paths) does nothing for this finding, because here the free is fully synchronized: the waiter wakes, consumes, and puts after the write path released the fip normally. The defect is the missing dequeue of a blind-reply-completed fip β€” a protocol/lifetime invariant violation with zero timing requirements. (DF-0918 covers a related but different NULL-reply panic via fuse_device_clear.)

Exploitability

Attacker = the fuse daemon (root on stock config; operator group opens the fd, uid 0 mounts — identical trust boundary to DF-0915/0917/3191/ 3192; no unpriv→uid0 chain exists on this guest for this family). The primitive itself is deterministic kernel heap corruption: on non-INVARIANTS kernels the poisoned insert writes a kernel pointer at a fixed offset (tqe_next) into a freed, immediately-recyclable fuse_ipc chunk (type-stable objcache) — corrupting a live IPC object's queue links; plus unbounded freed-object deliveries to userspace and an unkillable 35 s/op D-state wedge. INVARIANTS kernels (this guest) panic at the first poisoned insert.

Fix validation

fix.diff adds fuse_ipc_remove(fip) to the tx success path (membership-checked under ipc_lock β†’ no-op for well-behaved daemons). Rebuilt fuse.ko in-guest, rebooted, reran the identical PoC: blind_ok=41 (replies still accepted β€” protocol unchanged for honest daemons), SIG1=0, reads deliver live requests or time out on a genuinely empty queue, umount -f rc=0, guest up, no panic (fix_run.log, fix_run2.log).

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Patched fuse.ko (fuse_ipc_remove on tx success): identical PoC run shows blind_ok=41 (replies still accepted), SIG1_zero_byte_reads=0 (no freed fip ever popped), reads deliver live requests or time out on a genuinely empty queue, forced umount returns 0, no panic, guest stays up. Baseline vs patched logs in run.log/daemon_mode0.log vs fix_run.log/fix_run2.log.

['findings/poc/DF-3227/fix_run.log', 'findings/poc/DF-3227/fix_run2.log', 'findings/poc/DF-3227/fix.diff']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (fuse.ko module rebuilt in-guest with fix.diff, module-level fix)

Confirmed kernel references

Detail

Exploit chain

daemon reads FUSE_INIT (learns unique=0 base) -> predicts unique N -> write(/dev/fuse, reply unique=N) without ever reading request N -> fuse_device_write dequeues fip from reply_head only, sets replied, wakes waiter -> waiter consumes reply, fuse_ipc_put frees fip STILL LINKED as request_head head -> stale chunk popped by subsequent read()s (0-byte reads SIG-1) or recycled+double-linked (duplicate request delivery) -> later TAILQ_INSERT_TAIL writes through dangling tqh_last into freed/recycled heap: INVARIANTS panic (observed), production: silent pointer store into recycled fuse_ipc chunk at fixed offset (tqe_next), corrupting a live IPC object's queue links/fields.

Evidence (decisive lines)

["findings/poc/DF-3227/panic.txt β€” 'panic: Bad tailq NEXT(0xfffff80117700380->tqh_last) != NULL' fuse_ipc_tx+0x7e5 <- fuse_unmount (run 1)", 'findings/poc/DF-3227/daemon_mode0.log β€” 41 blind replies accepted, SIG-1 zero-byte reads x6', 'findings/poc/DF-3227/run2_summary.txt + serial_run2.txt β€” fresh-reset repro: blind_ok=41 SIG1=7', 'findings/poc/DF-3227/fix_run2.log β€” patched module: blind_ok=41 SIG1=0, umount -f rc=0, no panic']

PoC changes

Adapted the DF-0917 daemon harness: removed the 35s race-delay entirely (bug needs no race), added predicted-unique blind replies with ENOMSG retry, alarm-guarded interleaved read()s to pop the stale queue head (SIG-1 zero-byte detection + duplicate-unique detection SIG-2), and a teardown umount phase that triggers the insert-time panic.

Verified recommended fix

fuse_ipc_tx success path must fuse_ipc_remove(fip) before returning to the caller (see fix.diff); belt-and-suspenders: fuse_device_write can additionally reject replies whose fip->sent != 1 (Linux moves the request pending->processing on read, achieving the same).

Verdict

REPRODUCED deterministically (no race) on the stock INVARIANTS guest, twice from fresh resets: a FUSE daemon that blind-replies to predictable uniques (fuse_device_write matches ohd->unique without checking fip->sent, fuse_device.c:192) completes waiters whose fip is still linked on fmp->request_head; fuse_ipc_tx's success path never dequeues (fuse_ipc.c:266-286), so the waiter's fuse_ipc_put frees a still-queued object. Observable: 41/41 blind replies accepted and 6-7 zero-byte read()s per run popping freed fips (SIG-1, impossible on a healthy /dev/fuse), then teardown panic 'panic: Bad tailq NEXT(0x...->tqh_last) != NULL' in fuse_ipc_tx+0x7e5 <- fuse_unmount (INVARIANTS TAILQ_INSERT through the dangling tqh_last of the freed entry; sys/queue.h:516). On non-INVARIANTS kernels the same insert silently writes a kernel pointer into freed/recycled fuse_ipc chunks. Root cause is DISTINCT from DF-0917 (timing UAF from the missing refcount around the device paths' lock drop): this is a missing-dequeue invariant violation reachable with zero timing luck; DF-0917's refcount fix does not close it and this fix does not close DF-0917's. Attacker is the fuse daemon β€” root/operator-gated on stock config (same trust boundary as DF-0915/0917/3191/3192), so no unpriv->uid0 chain exists on this guest; the primitive is deterministic kernel heap corruption plus an unkillable-D-state wedge (35s uninterruptible fuse_ipc_wait per un-replied op).