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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3229 Β· 11 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download 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's fuse_test_dead and 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 by fuse_mount_kill() (it wakes fmp, 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 hits if (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)

  1. Daemon services INIT + in-mount STATFS + first OPENDIR, waits for a fully drained queue β†’ U0 = last unique.
  2. Barrier release: N (=20) one-shot hammer threads each issue exactly ONE fstatfs(fd) (fd on the mount root; kern_fstatfs derives the mount from fp->f_nchandle.mount with NO namei and NO vnode ops, so traffic is not blocked by vflush) β†’ N unread hung victims U0+1 .. U0+N linked on both queues.
  3. Child umount(MNT_FORCE): vflush vgones the root β†’ fuse_vop_inactive issues FUSE_FSYNCDIR (U0+N+1) and FUSE_RELEASEDIR (U0+N+2), then fuse_unmount issues 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 to fuse_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.)
  4. 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-src state, 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 tailq panic / 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 kprintf in fuse_ipc_remove:
  • module rebuilt in-guest (make in /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 run
  • fix.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

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline 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'
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0 + in-guest rebuilt fuse.ko (fix.diff + DF-3227 fix.diff + disclosed witness kprintf)

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.