DragonFlyBSD Kernel Audit
← triage · dashboard
DF-0917

Use-after-free on fuse_ipc during tx completion (daemon read/write drops lock before fip access)

Summary

fuse_ipc.c:91 fuse_ipc_get refcnt=1 single owner. fuse_device_write:190-197 removes fip under ipc_lock then :197 mtx_unlock. :205-219 dereferences fip WITHOUT refcount_acquire (fip->reply=fb fuse_in(fip) fuse_ipc_test_and_set_replied). Concurrent timed-out waiter fuse_ipc_wait:186-188 returns ETIMEDOUT. fuse_ipc_tx:270 fuse_ipc_put drops refcnt 1->0 frees fip. Device write path writes through freed fip. 16B controlled write (fuse_buf) + 4B atomic on done into recycled slab objcache. fuse_device_read same pattern. Fix: refcount_acquire under ipc_lock before unlock.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0917 · 14 files
FileTypeDescriptionSize
harness.c trigger-source deterministic pthread model of the UAF pattern (UNFIXED/-DFIXED dual-mode) 9.1 KB view raw
fused0917.c trigger-source live FUSE daemon delaying GETATTR replies ~35s to attempt the real race 9.7 KB view raw
build.sh build-script builds harness + harness_fixed + fused0917 597 B view raw
run.sh run-script runs both models + optional live race 862 B view raw
build.log build-log full build output (final successful build) 308 B view raw
run.log run-log 3x determinism runs: UNFIXED UAF vs FIXED no-UAF 1.3 KB view raw
fix_run.log run-log live FUSE run on patched fuse.ko (no regression) 2.3 KB view raw
fix_build.log build-log patched fuse.ko module-only build output 883 B view raw
fix.diff suggested-fix refcount_acquire under ipc_lock + fuse_ipc_put after access (read & write paths, 4 hunks) 780 B view raw
env.txt environment uname, cc version, module state 386 B view raw
VERDICT.md verdict full narrative analysis 9.0 KB ↓ raw
README.md readme human-facing summary + reproduce steps 3.0 KB ↓ 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 human-facing summary + reproduce steps
↓ download raw

DF-0917 — Use-after-free on fuse_ipc during tx completion

Use-after-free on fuse_ipc (fip) in the FUSE IPC transaction-completion path. The /dev/fuse read/write completion handlers remove fip from the reply/request TAILQ under ipc_lock, drop the lock, and then dereference fip (fip->reply, fip->request.buf, fip->done) with no reference held. If the tx originator (blocked in fuse_ipc_wait) times out (~35s), fuse_ipc_tx:270 fuse_ipc_put(fip) drops the last ref and frees fip while the device path is still mid-access → UAF (16-byte fuse_buf write + pointer read + 4-byte atomic into a freed slab slot).

  • Severity: High (memory corruption; root→kernel on default GENERIC).
  • Impact: panic / corruption (DoS) when the race is won.
  • Reachability: root-only on default GENERIC (/dev/fuse root:operator 0660, mount("fuse") needs uid==0). Same root-only-FUSE threat model as DF-0915. NOT an unprivileged→root escalation.
  • Files: sys/vfs/fuse/fuse_device.c:118-223 (the bug), sys/vfs/fuse/fuse_ipc.c:112-271 (the ref/free mechanics).

Files in this evidence pack

file purpose
harness.c deterministic pthread model of the UAF pattern (UNFIXED → UAF CONFIRMED; -DFIXEDNO UAF)
fused0917.c live FUSE daemon that delays GETATTR replies ~35s to attempt the real race
build.sh / run.sh exact build/run commands
build.log full build output (both models + daemon)
run.log 3x determinism runs of UNFIXED vs FIXED models
fix_run.log live FUSE run on the patched fuse.ko (no regression)
fix_build.log patched fuse.ko module build output
fix.diff git-apply-able fix: refcount_acquire under lock + fuse_ipc_put after access (both read & write paths)
env.txt guest uname / cc / module state
VERDICT.md full narrative analysis
manifest.json machine-readable catalog

Reproduce

# unprivileged deterministic proof (primary):
ssh dfbsd-maxx 'cd poc/DF-0917 && sh build.sh && ./harness && ./harness_fixed'
#   ./harness        -> "UAF CONFIRMED"
#   ./harness_fixed  -> "NO UAF (ref held across window)"

# live race attempt on the real kernel (root only, each iter ~35s):
ssh dfbsd 'kldload fuse && mkdir -p /mnt/fuse && cd /home/maxx/poc/DF-0917 && ./fused0917 2'

# build + install the fix (module-only), then re-test:
ssh dfbsd 'cd /usr/src && patch -p1 < /root/fix.diff && cd sys/vfs/fuse && make && cp /usr/obj/usr/src/sys/vfs/fuse/fuse.ko /boot/kernel/fuse.ko && kldload fuse'

Expected results

  • UNFIXED #0 kernel + harness: UAF CONFIRMED (deterministic).
  • UNFIXED #0 kernel + fused0917: stat()=ETIMEDOUT (tx waiter freed fip), daemon write()=ENOMSG (fip already removed); guest stays up (the sub-µs race is not won in a bounded run — see VERDICT.md).
  • Patched fuse.ko + harness_fixed: NO UAF.
  • Patched fuse.ko + fused0917: identical to unpatched (no regression; FUSE mounts/stats/replies correctly; guest stays up).
VERDICT.md verdict full narrative analysis
↓ download raw

DF-0917 — Use-after-free on fuse_ipc during tx completion

Verdict: REPRODUCED (UAF pattern confirmed by deterministic harness + line-by-line code-path trace); fix VALIDATED (patched fuse.ko compiles clean, loads, FUSE subsystem functions with no regression; model-level before/after proves the refcount-hold closes the window).

Impact on default GENERIC (#0, INVARIANTS ON): panic / memory corruption (DoS) when the race is won — a 16-byte write (struct fuse_buf = kernel pointer + length) into a freed fuse_ipc slab slot plus a pointer dereference (fuse_in(fip) reads the freed request.buf) and a 4-byte atomic on fip->done. Reachability is root-only on default config (/dev/fuse is root:operator 0660, mount("fuse") needs caps_priv_check( SYSCAP_NOMOUNT_FUSE)uid==0), so this is a root→kernel UAF, not an unprivileged→root escalation (same hard blocker as DF-0915).

The bug (line-by-line)

The daemon's /dev/fuse write completion (sys/vfs/fuse/fuse_device.c, fuse_device_write) and read completion (fuse_device_read) both:

  1. take ipc_lock, find fip in reply_head/request_head, TAILQ_REMOVE it, and release ipc_lockfuse_device.c:197 (write), fuse_device.c:149 (read);
  2. then dereference fip with no reference held: - write path: fip->reply = fb (:205), fuse_in(fip) = fip->request.buf (:206), fuse_ipc_test_and_set_replied(fip) = atomic on fip->done (:219); - read path: fuse_in_size(fip) = fip->request.len (:153), fuse_in(fip) (:156), fip->sent atomic (:158).

The only reference on fip is the originator's (fuse_ipc_get sets refcnt=1, fuse_ipc.c:98; nobody else refcount_acquires). The originator blocks in fuse_ipc_wait (fuse_ipc.c:158). On timeout (7 × 5*hz = ~35s, fuse_ipc.c:175,183) the waiter runs fuse_ipc_remove + fuse_ipc_set_replied (:186-187) and returns ETIMEDOUT (:188); fuse_ipc_tx then calls fuse_ipc_put(fip) (:270) which drops refcnt 1→0 and frees fip (fuse_buf_free ×2 + objcache_put, fuse_ipc.c:112-116). If that free lands while the device path is still mid-access (between its mtx_unlock and its last fip deref), every one of those derefs is a use-after-free.

The freeing path is real and was exercised live: a daemon that delays its FUSE_GETATTR reply by 35s causes the originator's stat() to return ETIMEDOUT (so fuse_ipc_put ran and freed fip), and the daemon's subsequent write() for that unique returns ENOMSG/w=-1 (fip already removed from reply_head by the timed-out waiter's fuse_ipc_remove). The UAF arm — daemon finds fip in reply_head and then the waiter frees it before :205 — is the same code path with the waiter losing the lock race by a few microseconds rather than winning it.

Why the live race is hard (and why a deterministic harness is the proof)

The UAF requires the originator's final 5*hz tsleep to return EWOULDBLOCK at the precise instant the daemon's write() syscall holds fip between lock-drop (:197) and set_replied (:219) — a sub-microsecond window against a 35-second period. The per-attempt hit probability is ~window/period ≈ 1µs/35s ≈ 3×10⁻⁸; winning it live needs many thousands of 35-second attempts (hours). This is exactly the "live race too narrow → deterministic code-level harness reproducing the drop-lock-then-access-fip logic" case (option (b) in the playbook).

harness.c models both paths with pthreads and forces the worst interleaving with two barriers placed at the kernel's lock-drop point: - UNFIXED build → UAF CONFIRMED: the device thread observes the 0xdeadc0de poison the timeout/free thread wrote into the freed fip (mirrors INVARIANTS WEIRD_ADDR slab poisoning), proving it dereferenced freed memory. Deterministic across 3/3 runs. - -DFIXED build → NO UAF: the device thread's refcount_acquire (mirroring the fix) makes refcnt=2, so the waiter's put (2→1) cannot free fip mid-access. Deterministic across 3/3 runs.

Threat model / reachability / Phase 6 escalation

  • /dev/fuse is crw-rw---- root:operator; mount("fuse",...) requires caps_priv_check(SYSCAP_NOMOUNT_FUSE)uid==0 (fuse_vfsops.c:155, kern_caps.c:311). vfs.usermount=0 on this guest and maxx (uid 1001) is not in operator. So the FUSE daemon — which authors the timing that opens the race — must be started by root on default GENERIC.
  • This is a root→kernel UAF (hardening gap + DoS) on default config. It is not an unprivileged→root escalation: the valid hard blocker "the write is reachable only from an already-root context" applies (the daemon that opens the race window runs as root; root→kernel is game-over by definition; there is no privilege boundary for the daemon to cross for itself). An unprivileged consumer (maxx) can drive a stat()/read() on a root-mounted FUSE fs, but maxx cannot shape the race timing or the freed-slot content, so he cannot escalate himself.
  • Conditional escalation (NOT default config): IF an admin set vfs.usermount=1 AND added the user to operator, an unprivileged user could run the daemon, win the race, and reclaim the freed fuse_ipc slot with controlled content. fuse_ipc is a dedicated objcache (M_FUSE_IPC, fuse_ipc.c:34,316-318) backed by kmalloc; on GENERIC the WEIRD_ADDR/chunk_mark_* INVARIANTS checks in kern_slaballoc.c would very likely catch the cross-free corruption and panic before a clean uid0. On a non-default INVARIANTS-OFF build the primitive (16-byte controlled write of a fuse_buf into the recycled slot + a ptr read + 4-byte atomic) would be directly weaponizable against a same-bucket victim (e.g. another fuse_ipc, or a kmalloc-same-size object holding a function pointer / ucred *). These are documented as conditional / non-default, not default-GENERIC.

Outcome: primitive fully characterized (16-byte write + ptr-read + 4-byte atomic UAF into a freed fuse_ipc slab slot); escalation on default GENERIC blocked by the root-only-FUSE hard blocker (root authors the race; no unpriv boundary to cross). Impact on default GENERIC = panic / corruption (DoS) from a root-started (or conditionally-unprivileged) malicious/slow daemon.

The fix (fix.diff)

Hold a reference on fip across the lock-drop window in both fuse_device_write and fuse_device_read: refcount_acquire(&fip->refcnt) under ipc_lock (before the unlock), and fuse_ipc_put(fip) after the last fip access. Four hunks:

  • fuse_device.c:149 — read path: acquire after TAILQ_REMOVE from request_head;
  • fuse_device.c:162 — read path: fuse_ipc_put(fip) before return error;
  • fuse_device.c:196 — write path: acquire after TAILQ_REMOVE from reply_head;
  • fuse_device.c:225 — write path: fuse_ipc_put(fip) before return error.

The early return ENOMSG (write, fip==NULL) and the read-path mtxsleep-error returns sit before any ref/acccess, so they need no put. With the fix, the originator's timeout fuse_ipc_put drops refcnt 2→1 (not 1→0), so fip cannot be freed while the device path still dereferences it; the device path's own final fuse_ipc_put then performs the last drop. This matches the finding's recommended fix ("refcount_acquire under ipc_lock before unlock").

Fix validation (Phase 8)

  • Applies clean: patch -p1 --forward < fix.diff → all 4 hunks at :146/:159/:193/:222.
  • Builds clean: module-only cd /usr/src/sys/vfs/fuse && makefuse.ko produced, -Werror, no warnings (fix_build.log).
  • Loads clean: kldload fuseRC=0, /dev/fuse appears (sha256 fuse.ko = 2eff54d3…).
  • No regression (live): on the patched module, fused0917 2 mounts FUSE, handles INIT/STATFS/LOOKUP/GETATTR/OPEN, the delayed GETATTRs time out (stat()=ETIMEDOUT, daemon write()=ENOMSG) exactly as on the unpatched module, and the guest stays up (fix_run.log). The fix changes nothing observable when the race is not won — it only closes the UAF window — so identical live behavior before/after is the correct expectation.
  • Model-level before/after: harness (UNFIXED) → UAF CONFIRMED; harness_fixed (-DFIXED) → NO UAF (ref held across window). Proves the refcount-hold closes the window deterministically.

fix_status = fixed: the patched fuse.ko compiles, loads, and the FUSE subsystem functions correctly; the model-level before/after demonstrates the window is closed.

How to reproduce

# unprivileged deterministic models (the primary proof):
ssh dfbsd-maxx 'cd poc/DF-0917 && sh build.sh && ./harness && ./harness_fixed'
# live race attempt on the real kernel (root only):
ssh dfbsd 'kldload fuse && mkdir -p /mnt/fuse && cd /home/maxx/poc/DF-0917 && ./fused0917 2'
# fix build + install + re-test (module-only):
ssh dfbsd 'cd /usr/src && patch -p1 < /root/fix.diff && cd sys/vfs/fuse && make && cp /usr/obj/usr/src/sys/vfs/fuse/fuse.ko /boot/kernel/fuse.ko && kldload fuse'

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. fix.diff applies clean (4/4 hunks at fuse_device.c:146/159/193/222); patched fuse.ko compiles with -Werror (module-only build) and kldloads RC=0. BEFORE (baseline #0 + unfixed fuse.ko): harness UNFIXED -> 'UAF CONFIRMED' (3/3); live fused0917 -> stat()=ETIMEDOUT (tx waiter freed fip) + daemon write=ENOMSG, race mechanics live. AFTER (patched fuse.ko): harness_fixed (-DFIXED) -> 'NO UAF' (ref held across window, 3/3); live fused0917 on patched module -> identical, correct behavior (FUSE mounts/stats/replies, guest stays up, no regression). The fix closes the UAF window by construction (refcount hold prevents the timeout put from freeing fip mid-access); model-level before/after proves it deterministically. The live race is too tight to win in budget so the live observable is identical before/after (correct -- the fix only closes the window).

BEFORE (unfixed #0): harness -> '*** UAF CONFIRMED *** accessed fip AFTER it was freed ... fip->request.buf=0xdeadc0dedeadc0de (poisoned? YES) ... RESULT: USE-AFTER-FREE reproduced deterministically.' (3/3) | AFTER (patched fuse.ko): harness_fixed -> '[device] (FIXED model) accessed fip cleanly: ref held across window (refcnt=1) -> tx-waiter put could not free it. RESULT: FIXED model - NO UAF.' (3/3) | live regression check on patched module: 'stat[0] rc=-1; errno=Operation timed out' + 'reply unique=2 ... w=-1' + 'stat[2] rc=0 (ok)' + guest 'up 7 mins' (FUSE fully functional, no crash).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 (kern.version unchanged -- fix is module-only: patched fuse.ko sha256=2eff54d309a427907bf2624f1d3a09b4967022a4d0b5aba853bd46d0553c8d86, orig=680333bb4bef3b83c26b2197c37915fc43ba82ffc6bf8b67bf5f5c8a1b51b2b3; built from /usr/src/sys/vfs/fuse after patch -p1 < fix.diff, all 4 hunks applied)

Confirmed kernel references

Detail

Exploit chain

Memory-corruption primitive characterized: winning the race yields a 16-byte write of a struct fuse_buf (kernel heap pointer + length) into the freed fuse_ipc slot, a read+dered of the freed request.buf pointer (fuse_in(fip)), and a 4-byte atomic on fip->done. fuse_ipc lives in a dedicated objcache (M_FUSE_IPC, fuse_ipc.c:34,316) backed by kmalloc. ESCALATION BLOCKED by the valid Phase-6 hard blocker 'write reachable only from an already-root context': /dev/fuse is root:operator 0660 and mount("fuse") requires caps_priv_check(SYSCAP_NOMOUNT_FUSE)->uid==0 (fuse_vfsops.c:155, kern_caps.c:311), vfs.usermount=0, and maxx is NOT in operator -- so the daemon that opens the race window MUST be started by root on default GENERIC (root->kernel is game-over; no unpriv boundary for the daemon to cross). An unprivileged consumer (maxx) can drive a stat()/read() on a root-mounted FUSE fs but cannot shape race timing or freed-slot content, so he cannot escalate himself. On GENERIC (INVARIANTS -- note fuse.h:31-33 forces INVARIANTS into the module) the WEIRD_ADDR/chunk_mark slab checks would catch the cross-free and panic before a clean uid0; the primitive would only be directly weaponizable on a non-default INVARIANTS-OFF build (conditional/non-default, documented). Net: primitive fully characterized; escalation blocked by root-only-FUSE on default GENERIC; impact = panic/corruption (DoS) from a root-started (or conditionally-unprivileged) slow/malicious daemon. Chain characterization written into harness.c (dual-mode model).

Evidence (decisive lines)

UNFIXED harness (3/3 deterministic): '[tx-waiter] timed out: removed+replied+put fip=... (NOW FREED)' / '[device] *** UAF CONFIRMED *** accessed fip AFTER it was freed: fip->request.buf=0xdeadc0dedeadc0de (poisoned? YES), fip->done=0xdeadc0de (poisoned? YES) => daemon wrote fip->reply + read fip->request.buf into a FREED fuse_ipc slab slot' / 'RESULT: USE-AFTER-FREE reproduced deterministically.' FIXED harness (3/3): '[device] (FIXED model) accessed fip cleanly: ref held across window (refcnt=1) -> tx-waiter put could not free it' / 'RESULT: FIXED model - NO UAF.' Live unpatched #0 (fused0917 2): 'stat[0] rc=-1 (FAIL); errno=Operation timed out' (tx waiter freed fip) + 'reply unique=2 ... w=-1' (daemon write hit ENOMSG, fip gone) -- race mechanics confirmed; guest stayed up. Patched fuse.ko live: identical behavior, guest up (no regression).

PoC changes

PoC folder did not exist; created the entire evidence pack from scratch. Wrote harness.c (deterministic pthread model of fuse_device_write vs fuse_ipc_wait timeout+put, dual UNFIXED/-DFIXED mode with two-barrier deterministic interleaving), fused0917.c (live FUSE daemon that mounts via fork+mount, handles INIT/STATFS/LOOKUP/GETATTR/OPEN, and delays GETATTR replies ~35s to align the daemon's write with the tx timeout), build.sh/run.sh, VERDICT.md, README.md, manifest.json, and fix.diff. Iterated harness twice (first version had single-barrier scheduler nondeterminism -> device could run before free; added a second barrier to force tx-waiter free before device access, making UAF detection deterministic).

Verified recommended fix

In sys/vfs/fuse/fuse_device.c, hold a reference on fip across the lock-drop window in BOTH fuse_device_write and fuse_device_read: refcount_acquire(&fip->refcnt) under ipc_lock right after the TAILQ_REMOVE (write :196, read :149), and fuse_ipc_put(fip) after the last fip deref before return (write :225, read :162). The early ENOMSG/error returns sit before any ref/access and need no put. With this, the originator's timeout put drops refcnt 2->1 (not 1->0), so fip cannot be freed mid-access. MATCHES the finding proposal ('refcount_acquire under ipc_lock before unlock'). Full git-apply-able 4-hunk diff in findings/poc/DF-0917/fix.diff.

Verdict

REPRODUCED -- UAF pattern confirmed. The /dev/fuse read/write completion handlers (sys/vfs/fuse/fuse_device.c) remove fuse_ipc (fip) from the reply/request TAILQ under ipc_lock then DROP the lock (fuse_device.c:197 write / :149 read) and dereference fip (fip->reply=:205, fuse_in(fip)=:206, fip->done atomic=:219; fip->request.len=:153, fip->request.buf=:156, fip->sent=:158) with NO reference held. The only ref is the originator's (fuse_ipc_get refcnt=1, fuse_ipc.c:98); on tx timeout (~35s, fuse_ipc.c:175,183) fuse_ipc_wait runs fuse_ipc_remove+set_replied (:186-187) returns ETIMEDOUT (:188) and fuse_ipc_tx:270 fuse_ipc_put drops refcnt 1->0 freeing fip while the device path is still mid-access -> UAF (16-byte fuse_buf write + pointer read + 4-byte atomic into a freed fuse_ipc slab slot). A deterministic pthread harness (harness.c) forces the worst interleaving with two barriers and prints 'UAF CONFIRMED' on the UNFIXED build (3/3 runs) and 'NO UAF' on the -DFIXED build (3/3 runs). Live: a daemon delaying FUSE_GETATTR replies 35s makes stat() return ETIMEDOUT (proving fuse_ipc_put freed fip) and the daemon's write() return ENOMSG/w=-1 (fip already removed from reply_head) -- exactly the race mechanics; the sub-microsecond UAF arm is not won in a bounded run (window/period ~= 1us/35s), so the deterministic harness + line-by-line trace is the proof (playbook option b).