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)
PoC verification
Evidence pack
findings/poc/DF-0917 · 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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/fuseroot:operator0660,mount("fuse")needsuid==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; -DFIXED → NO 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
#0kernel +harness:UAF CONFIRMED(deterministic). - UNFIXED
#0kernel +fused0917:stat()=ETIMEDOUT(tx waiter freed fip), daemonwrite()=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).
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:
- take
ipc_lock, findfipinreply_head/request_head,TAILQ_REMOVEit, and releaseipc_lock—fuse_device.c:197(write),fuse_device.c:149(read); - then dereference
fipwith no reference held: - write path:fip->reply = fb(:205),fuse_in(fip)=fip->request.buf(:206),fuse_ipc_test_and_set_replied(fip)= atomic onfip->done(:219); - read path:fuse_in_size(fip)=fip->request.len(:153),fuse_in(fip)(:156),fip->sentatomic (: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/fuseiscrw-rw---- root:operator;mount("fuse",...)requirescaps_priv_check(SYSCAP_NOMOUNT_FUSE)→uid==0(fuse_vfsops.c:155,kern_caps.c:311).vfs.usermount=0on this guest andmaxx(uid 1001) is not inoperator. 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 astat()/read()on a root-mounted FUSE fs, butmaxxcannot 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=1AND added the user tooperator, an unprivileged user could run the daemon, win the race, and reclaim the freedfuse_ipcslot with controlled content.fuse_ipcis a dedicatedobjcache(M_FUSE_IPC,fuse_ipc.c:34,316-318) backed bykmalloc; on GENERIC theWEIRD_ADDR/chunk_mark_*INVARIANTS checks inkern_slaballoc.cwould very likely catch the cross-free corruption and panic before a cleanuid0. On a non-default INVARIANTS-OFF build the primitive (16-byte controlled write of afuse_bufinto the recycled slot + a ptr read + 4-byte atomic) would be directly weaponizable against a same-bucket victim (e.g. anotherfuse_ipc, or akmalloc-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 afterTAILQ_REMOVEfromrequest_head;fuse_device.c:162— read path:fuse_ipc_put(fip)beforereturn error;fuse_device.c:196— write path: acquire afterTAILQ_REMOVEfromreply_head;fuse_device.c:225— write path:fuse_ipc_put(fip)beforereturn 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 && make→fuse.koproduced,-Werror, no warnings (fix_build.log). - Loads clean:
kldload fuse→RC=0,/dev/fuseappears (sha256 fuse.ko = 2eff54d3…). - No regression (live): on the patched module,
fused0917 2mounts FUSE, handles INIT/STATFS/LOOKUP/GETATTR/OPEN, the delayed GETATTRs time out (stat()=ETIMEDOUT, daemonwrite()=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
fixedVALIDATED. 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).
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).
No comments yet.