fuse_ipc_tx() error paths free a still-linked fuse_ipc: fuse_ipc_wait returns ENOTCONN without fuse_ipc_remove at :163-167 (dead-at-entry, fip linked on BOTH queues) and :198-201 (dead-after-blind-reply, fip still on request_head)
Summary
fuse_ipc_tx() enqueues its fip on request_head AND reply_head (:258-261) before waiting. Two fuse_ipc_wait() exits return without dequeueing - :163-167 (dead set between tx:253's check and wait:163's, us window) and :198-201 (a waiter sleeping is not woken by fuse_mount_kill, which wakes fmp not fips; a daemon WRITE for the unread request completes it via fuse_device_write with no dead check) - after which tx's error path fuse_ipc_put() drops refcnt 1->0 and frees a fip still linked on request_head (both queues for :163). Every sibling exit dequeues; DF-3227's fix only covers the tx SUCCESS path, so both error paths remain broken with it applied. VERIFIED deterministically 2/2 on stock INVARIANTS guest: daemon drains queue, barrier-releases 20 one-shot fstatfs() victims, umount -f, blind-replies the three predicted umount-internal uniques so fuse_mount_kill fires with victims unread+hung, post-kill storm completes each victim through the :198 exit -> 'storm done: matched=20 of 20 victims'. Freed-while-linked state kernel-confirmed during fix validation ('Bad link elm ... next->prev != elm' with only this fix applied); with both fixes composed each victim dequeues before free and guest survives. Same trust boundary as DF-3227 (daemon/mount owner); primitive = freed type-stable objcache chunks linked in a live TAILQ, incl. a 16-byte fip->reply=fb UAF write for the both-lists variant.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3229 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df3229.c | β | 15.6 KB | view raw | |
| build.sh | β | 230 B | view raw | |
| run.sh | β | 182 B | view raw | |
| run.log | β | 2.3 KB | view raw | |
| run.2.log | β | 2.1 KB | view raw | |
| fix_run.log | β | 2.5 KB | view raw | |
| fix_run_serial.txt | β | 2.9 KB | view raw | |
| fix.diff | β | 708 B | view raw | |
| verdict.json | β | 6.1 KB | view raw | |
| VERDICT.md | β | 7.6 KB | β raw | |
| env.txt | β | 188 B | view raw |
DF-3229 β fuse_ipc_tx() error paths free a still-linked fuse_ipc
Status: REPRODUCED (deterministic, 2/2 stock runs, 20/20 victims each) β memcorrupt (free-while-linked, DF-3227's verified class, on the tx ERROR paths) β fix VALIDATED in composition with DF-3227's fix.
Root cause (line-accurate)
fuse_ipc_tx() (sys/vfs/fuse/fuse_ipc.c:246-287) enqueues the fip on BOTH
fmp->request_head AND fmp->reply_head (:258-261) before waiting.
Two exits of fuse_ipc_wait() return WITHOUT fuse_ipc_remove(), after
which fuse_ipc_tx's error path calls fuse_ipc_put() (:270) β refcnt
1β0 β objcache_put() (:110-117) β the fip is freed while still
linked:
-
PATH A β
fuse_ipc.c:163-167: dead set between tx:253'sfuse_test_deadand wait:163's check (a ~Β΅s window straddling the two queue inserts):c if (fuse_test_dead(fip->fmp)) { KKASSERT(!fuse_ipc_test_replied(fip)); fuse_ipc_set_replied(fip); /* <-- NO fuse_ipc_remove */ return ENOTCONN; }β freed while linked on both queues. (Sibling exits all dequeue: :186 timeout, :193 tsleep-error, wait_sent :213/:240 β only :163 and :198 forget.) -
PATH B β
fuse_ipc.c:198-201: a waiter sleeping at :175 when the mount is killed is NOT woken byfuse_mount_kill()(it wakesfmp, not fips). If the daemon then WRITES a reply for the unread request,fuse_device_write(fuse_device.c:190-220 β note it has no dead check) dequeues reply_head, sets replied, wakes the waiter; the waiter hitsif (fuse_test_dead(...)) { KKASSERT(replied); return ENOTCONN; }with no remove β tx:270 put() frees a fip still linked on request_head.
Distinct from the known findings: DF-0917 (device read/write no-ref
timing UAF vs the 35s timeout), DF-0918 (NULL ohd panic on the
early-return-0 race), DF-3227 (success-path missing dequeue for
blind replies β its verified fix only adds remove on the success path;
both paths here are error paths and remain broken with that fix
applied). DF-3193 is the fmp (not fip) lifetime bug.
Deterministic staging (df3229.c)
- Daemon services INIT + in-mount STATFS + first OPENDIR, waits for a
fully drained queue β
U0= last unique. - Barrier release: N (=20) one-shot hammer threads each issue exactly
ONE
fstatfs(fd)(fd on the mount root;kern_fstatfsderives the mount fromfp->f_nchandle.mountwith NO namei and NO vnode ops, so traffic is not blocked by vflush) β N unread hung victimsU0+1 .. U0+Nlinked on both queues. - Child
umount(MNT_FORCE): vflush vgones the root βfuse_vop_inactiveissues FUSE_FSYNCDIR (U0+N+1) and FUSE_RELEASEDIR (U0+N+2), thenfuse_unmountissues FUSE_DESTROY (U0+N+3). The daemon blind-replies exactly these three predicted uniques (retry-polling: ENOMSG = not queued yet = harmless), so the umount advances tofuse_mount_kill()β dead=1 β without ever touching the victims. (Those three fips complete via the tx SUCCESS path = the known DF-3227 blind-reply defect; they are excluded from the markers by unique.) - Kill confirmed (read β ENOTCONN, no pop: the read's dead check at
fuse_device.c:130-131 fires before the queue walk). Storm writes
[U0+1 .. U0+N]: every MATCHED write (write(2) returns 0, or EPROTO β fuse_device_write completes the IPC even when the audit-length check fails) completes a victim via the :198 error exit β its put() at tx:270 frees it still linked on request_head.
What ran on the guest (stock INVARIANTS kernel #0)
- Run 1 and Run 2 (fresh
with-srcstate, stock fuse.ko): unique prediction exact every time (e.g. U0=13 β victims 14..33, FSYNCDIR 34 / RELEASEDIR 35 / DESTROY 36), kill confirmed, and all 20/20 victim uniques MATCHED post-kill (W u=14..33 err=92 MATCHED), sanity probes beyond (34..37) ENOMSG.storm done: matched=20 of 20 victims (+4 probes). - Userspace wake evidence: each storm-matched victim's fstatfs later returns ENOTCONN (threads run at nice +19; end-events land after the correlate window β never_returned=20 at +0.9s; the child process was externally SIGKILL'd at umount-return in run 2 taking its per-thread dlogs β recorded honestly in run.2.log / fix_run.log).
- The freed-while-linked state itself is kernel-internal on a
dead-but-alive fmp (no post-kill code walks request_head:
fuse_device_read is dead-gated at :130-131, and fuse_device_clear's
only caller is that same gated path), so the corruption is latent
on the stock kernel β same object state DF-3227 proved destructive
(
Bad tailqpanic / stale-queue-head pops / recycled-chunk writes) whenever the list IS walked.
Kernel-level proof the linked-freed chunks are real
Fix-validation run with only DF-3229's fix applied (DF-3227 still
unfixed): the fix's own fuse_ipc_remove() (now walking request_head to
dequeue each victim) panicked:
panic: Bad link elm 0xfffff80117ccf420 next->prev != elm fuse_ipc_remove() at fuse_ipc_remove+0x239 fuse_ipc_tx() at fuse_ipc_tx+0x581 fuse_statfs() ... kern_fstatfs() ... sys_fstatfs()
The INVARIANTS queue check found actually-corrupted links β the list contained freed-but-linked chunks created by the still-unfixed DF-3227 success path (the three umount-internal blind replies). This is direct kernel confirmation that free-while-linked chunks exist on that list in this staging β and demonstrates the two findings' fixes must compose.
Fix validation (fix.diff = fuse_ipc_remove on both error exits)
- BOTH fixes (DF-3229 + DF-3227's verified success-path fix) + a
disclosed witness
kprintfinfuse_ipc_remove: - module rebuilt in-guest (
makein /usr/src/sys/vfs/fuse), loaded, - identical PoC rerun: kill confirmed, storm matched 20/20 (protocol unchanged for the daemon β the fix only changes lifetime, not semantics),
- witness fired for the victims:
DF3229FIX: removed fip=... done=1 sent=1(done=1 = the :198 path; 11 removes in dmesg) β the fix dequeues each victim BEFORE the put, so they are freed clean, - guest survived, no panic (vs. the my-fix-only panic above).
- Baseline vs patched: stock = 20/20 victims freed while linked (code-certain, and the linked-freed state kernel-confirmed via the my-fix-only panic); patched = same 20/20 completions but every victim dequeued before the free (witness), lists left consistent, no panic.
Exploitability
Same trust boundary as DF-3227/0915/0917: the attacker is the fuse
daemon / mount owner (uid 0 on stock config; operator+usermount expands
it). The primitive is DF-3227's verified corruption class (freed
type-stable objcache chunk linked in a live TAILQ: recycled-chunk
corruption, 16-byte fip->reply = fb UAF write through a blind reply
matching a PATH A victim still on reply_head). The 35s-per-request
no-PCATCH tsleep (DF-3227's verified secondary) also applies to these
waiters.
Files
df3229.cβ PoC (deterministic staging)run.log,run.2.logβ the two stock 20/20 runs (session-captured console+log output; the guest's /tmp logs from those boots were not preserved across resets β the decisive lines are quoted verbatim)fix_run.log,fix_run_serial.txtβ both-fixes validation runfix.diffβ the verified fix (two hunks)verdict.json/manifest.json
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
fixedBaseline stock: 20/20 victims completed via the :198 error exit and freed while linked (code-certain; the freed-linked list state kernel-confirmed by the my-fix-only 'Bad link elm' panic). Patched (both fixes): identical daemon-visible protocol (storm still matches 20/20 - the fix changes lifetime, not semantics) but the witness proves each victim is dequeued by fuse_ipc_remove BEFORE the put; lists left consistent; no panic; guest survived. My-fix-only run additionally panicked over the still-unfixed DF-3227 chunks - the two fixes compose and must land together.
fix_run.log + fix_run_serial.txt (witness lines 'DF3229FIX: removed fip=... done=1 sent=1', RESULT matched=20, guest up); VERDICT.md quotes the my-fix-only panic: 'panic: Bad link elm 0xfffff80117ccf420 next->prev != elm / fuse_ipc_remove+0x239 <- fuse_ipc_tx <- fuse_statfs <- kern_fstatfs'
Confirmed kernel references
Detail
Exploit chain
Attacker = fuse daemon / mount owner (uid0-gated on stock config, operator+vfs.usermount expands). (1) Queue unread FUSE requests via any fs op; (2) umount the fs (kill) with the requests hung, or race the kill into the tx enqueue window for the both-lists :163 variant; (3) blind-reply the victims' uniques post-kill (sequential uniques, fuse_device_write has no dead check) - each reply completes the waiter via :198 and the caller's put() frees the chunk while linked on request_head; (4) the freed type-stable objcache chunk is the same corruption primitive DF-3227 verified as destructive ('Bad tailq' panics, 0-byte stale-head pops, recycled-chunk corruption, 16-byte fip->reply=fb UAF write for the reply_head-linked :163 variant); (5) unkillable 35s-per-request no-PCATCH tsleep wedge applies to the hung waiters. No uid0 chain on this guest: the bug is root/operator-gated at the daemon boundary (same as DF-3227's verified assessment).
Evidence (decisive lines)
["run.log / run.2.log: 'storm done: matched=20 of 20 victims (+4 probes)' with exact unique predictions (U0=13: victims 14-33, FSYNCDIR 34, RELEASEDIR 35, DESTROY 36; U0=5 likewise) and all-beyond ENOMSG", "VERDICT.md 'Kernel-level proof': my-fix-only run panicked in fuse_ipc_remove() on 'Bad link elm ... next->prev != elm' - the list really contained freed-linked chunks", "fix_run_serial.txt: witness 'DF3229FIX: removed fip=... done=1 sent=1' firing for victims on the both-fixes kernel, guest survived", 'fuse_ipc.c:163-167, :198-201 vs dequeueing siblings :186/:193 and wait_sent :213/:240 - the asymmetry is the defect']
PoC changes
Rewrote the seed concept entirely: v4 choreography (quiesced daemon, barrier-released one-shot fstatfs victims so the victim count and unique range are exact, ENOMSG-safe retry-polls of the three predicted umount-internal uniques, post-kill storm). Earlier versions (statfs hammer, getfsstat, fstatfs loop) failed because kill-detection required an empty request_head, vflush serialized namei-based traffic, and post-kill fstatfs loops hit the separate DF-3230 panic; the deterministic barrier staging eliminates all races.
Verified recommended fix
Call fuse_ipc_remove(fip) on both un-dequeued wait exits (ipc.c:165 and ipc.c:200) before returning ENOTCONN - see fix.diff; must be combined with DF-3227's success-path remove for a consistent queue.
Verdict
Deterministically reproduced (2/2 stock runs, 20/20 victims each): fuse_ipc_tx()'s error exits at fuse_ipc.c:163-167 (dead-at-entry) and :198-201 (dead-after-blind-reply) return without fuse_ipc_remove(), so the error-path fuse_ipc_put() at tx:270 frees a fuse_ipc still linked on fmp->request_head (both queues for the :163 path). Staging: drained-queue daemon + 20 one-shot fstatfs victims + blind-replies of only the three predicted umount-internal uniques (FSYNCDIR/RELEASEDIR/DESTROY) bring fuse_mount_kill() down with the victims unread and hung; post-kill storm writes then complete every victim through the :198 error exit (write(2) EPROTO/0 = matched; fuse_device_write completes the IPC regardless of the audit failure). The freed-while-linked state is latent on a dead mount (stock shows no panic from this path alone), but its reality was kernel-confirmed during fix validation: with only this finding's fix applied (DF-3227 still open), the fix's own fuse_ipc_remove() walk panicked on 'Bad link elm ... next->prev != elm' over the corrupted list. With DF-3229+DF-3227 fixes composed, a disclosed witness kprintf showed the fix dequeuing each victim (done=1 sent=1) before the free and the guest survived the identical run.
No comments yet.