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)
PoC verification
Evidence pack
findings/poc/DF-3227 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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)
fuse_ipc_get()assigns sequential uniques fromfmp->unique(starts at 0,atomic_fetchadd_long, fuse_ipc.c:100) β after seeing ONE request the daemon can predict every future unique.fuse_ipc_tx()inserts the fip on both queues (fuse_ipc.c:258-261) and sleeps (fuse_ipc.c:266).fuse_device_write()matches the daemon's reply byfip->unique == ohd->uniquewhile walkingreply_head(fuse_device.c:191-196) β it never checksfip->sent, i.e. it never verifies the daemon actually read the request (fuse_device_readis what dequeues fromrequest_head, fuse_device.c:148). On match it removes the fip fromreply_headonly, assignsfip->reply, setsreplied, wakes the waiter (fuse_device.c:205-220).- 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) callsfuse_ipc_put()β refcnt 1β0 βobjcache_put(fuse_ipc.c:110-117). Nothing ever removed the fip fromrequest_headβ the kernel just freed an object that is still the head of a live TAILQ. - Every subsequent queue operation on
request_headnow touches freed memory:fuse_device_readpops the freed chunk (fuse_in_size()reads therequest.lenthatfuse_buf_freezeroed βuiomove(NULL, 0)β read() returns 0 β impossible on a healthy /dev/fuse); objcache recycling double-links chunks; and the nextTAILQ_INSERT_TAIL(any newfuse_ipc_tx) stores a kernel pointer through the danglingtqh_lastof the freed entry βsys/queue.h:516INVARIANTS 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_waittsleeps 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
fixedPatched 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']
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).
No comments yet.