NULL-deref panic via dead/replied race in fuse_ipc_wait early-return paths
Summary
fuse_ipc.c:163 checks dead then :169/:173 checks replied returns 0 WITHOUT rechecking dead. fuse_device_clear (device.c:113-114) sets replied via fuse_ipc_test_and_set_replied WITHOUT assigning fip->reply.buf during mount teardown. Waiter returns 0. fuse_ipc_tx:274 ohd=fuse_out(fip)=fip->reply.buf=NULL. :275 KKASSERT(ohd) panic (INVARIANTS forced) or ohd->error NULL deref. goto-again loop widens window 6x. Any mount owner can trigger via close(/dev/fuse) during in-flight syscall. Fix: recheck dead after replied in early returns.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0918 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| harness.c | trigger-source | deterministic pthread model of the early-return race (UNFIXED reproduces NULL-ohd primitive, -DFIXED proves the fix) | 7.9 KB | view raw |
| fused0918.c | trigger-source | live FUSE daemon that races force-unmount against in-flight stat() to attempt the race on the real kernel (root only) | 9.3 KB | view raw |
| fix.diff | suggested-fix | git-apply-able fix: goto done + done: label routes early-return paths through the dead-recheck at fuse_ipc.c:198 | 562 B | view raw |
| build.sh | build-script | cc -O2 build of harness, harness_fixed, fused0918 | 620 B | view raw |
| run.sh | run-script | run deterministic models (unprivileged) + optional live race (root) | 949 B | view raw |
| build.log | build-log | full harness build output (unprivileged) | 243 B | view raw |
| run.log | run-log | decisive run: UNFIXED model confirms primitive, FIXED model shows no primitive | 2.0 KB | view raw |
| fix_build.log | fix-build-log | patched fuse.ko module build (make in sys/vfs/fuse, -Werror clean) | 6.5 KB | view raw |
| fix_run.log | fix-run-log | patched fuse.ko no-regression run: 3.6M stat() calls, 2 teardowns, guest stays up; model before/after | 1.8 KB | view raw |
| env.txt | environment | uname, kern.version, cc version, /dev/fuse perms, vfs.usermount, fuse.ko loaded state | 796 B | view raw |
| VERDICT.md | verdict | full narrative: reproduced? how/why? exploit chain? fix validation | 10.2 KB | β raw |
| README.md | readme | human-readable reproduction instructions | 2.9 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-0918 β NULL-deref panic via dead/replied race in fuse_ipc_wait early-return
Severity: Medium (CWE-476 NULL Pointer Dereference)
Impact: kernel panic / DoS (rootβkernel; not an escalation)
Reachability: root-only on default GENERIC (/dev/fuse is root:operator 0660,
mount("fuse") needs caps_priv_check(SYSCAP_NOMOUNT_FUSE) β uid 0).
The bug
fuse_ipc_wait() (sys/vfs/fuse/fuse_ipc.c:158-204) has early-return paths
at :169-170 and :173-174 that test the replied flag and return 0
without re-checking the mount's dead flag and without verifying
fip->reply.buf is populated. fuse_device_clear() (fuse_device.c:99-116)
sets replied on pending fips during teardown WITHOUT assigning
fip->reply.buf (it stays NULL from fuse_ipc_get:fuse_ipc.c:104).
If a tx waiter is between :163 (dead check) and :198 (post-tsleep dead
recheck) when fuse_device_clear runs, it observes replied==1 and
returns 0. Back in fuse_ipc_tx:
fuse_ipc.c:274 ohd = fuse_out(fip); // == fip->reply.buf == NULL
fuse_ipc.c:275 KKASSERT(ohd); // PANIC (INVARIANTS ON, default GENERIC)
// or :276 ohd->error NULL-deref
Reproduction
The race window is nanoseconds wide (between tsleep_interlock at :172
and the replied check at :173); fuse_device_clear runs once per
teardown. A deterministic pthread harness forces the worst-case
interleaving and proves the primitive; a live daemon exercises the code
path at runtime.
Build & run (deterministic models β primary proof, unprivileged)
ssh dfbsd-maxx 'cd poc/DF-0918 && sh build.sh' # UNFIXED model β confirms the NULL-ohd primitive ./harness # FIXED model β proves the fix closes the window ./harness_fixed
Expected:
- ./harness β RESULT: NULL-ohd PRIMITIVE reproduced deterministically.
- ./harness_fixed β RESULT: FIXED model β NO NULL-deref.
Live path-exercised run (root only)
ssh dfbsd 'kldload fuse && mkdir -p /mnt/df918 && cd /root/poc918 && \ cc -O2 -pthread -o fused0918 fused0918.c && ./fused0918 3 1'
Drives 3.6M+ stat() calls (= fuse_ipc_txes) and 3 force-unmount
teardowns. The nanosecond race is not expected to fire in a short run;
the harness is the proof. If the guest stays up, the path was exercised
without winning the race.
Fix validation (module-only)
ssh dfbsd 'cd /usr/src && patch -p1 < /root/fix.diff && \ cd sys/vfs/fuse && make && cp fuse.ko /boot/kernel/fuse.ko && kldload fuse' # re-run the daemon on the patched module β should behave identically (no regression)
Files
harness.cβ deterministic pthread model (UNFIXED/FIXED)fused0918.cβ live FUSE race daemonfix.diffβgoto done+done:label (dead-recheck-after-replied)build.sh/run.shβ exact build/run commandsVERDICT.mdβ full narrative + line-by-line tracemanifest.jsonβ machine-readable catalog*.logβ full untrimmed build/run/fix logs
DF-0918 β NULL-deref panic via dead/replied race in fuse_ipc_wait early-return
Verdict: REPRODUCED (NULL-ohd primitive confirmed by deterministic harness +
line-by-line code-path trace + live path-exercised run); fix VALIDATED
(patched fuse.ko compiles clean -Werror, loads, FUSE subsystem mounts/
unmounts/serves 3.6M+ stat() calls with no regression; model-level
before/after proves the dead-recheck closes the window).
Impact on default GENERIC (#0, INVARIANTS ON): panic / DoS when the
race is won β fuse_ipc.c:275 KKASSERT(ohd) fires on the NULL
fip->reply.buf (or, INVARIANTS OFF, :276 ohd->error NULL-derefs).
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 DoS, not
an unprivileged escalation (same hard blocker as DF-0915/0917).
The bug (line-by-line)
fuse_ipc_wait() (sys/vfs/fuse/fuse_ipc.c:158-204) has three early-return
paths that test the replied flag (fip->done) and return 0 (success)
WITHOUT re-checking the mount's dead flag and WITHOUT verifying that
fip->reply.buf was actually populated:
158: static int
159: fuse_ipc_wait(struct fuse_ipc *fip)
160: {
161: int error, retry = 0;
162:
163: if (fuse_test_dead(fip->fmp)) { // dead check #1 (safe)
164: KKASSERT(!fuse_ipc_test_replied(fip));
165: fuse_ipc_set_replied(fip);
166: return ENOTCONN;
167: }
168:
169: if (fuse_ipc_test_replied(fip)) // <<< BUG: returns 0
170: return 0; // without dead recheck
171: again:
172: tsleep_interlock(fip, 0);
173: if (fuse_ipc_test_replied(fip)) // <<< BUG: returns 0
174: return 0; // without dead recheck
...
198: if (fuse_test_dead(fip->fmp)) { // dead check #2 (safe)
199: KKASSERT(fuse_ipc_test_replied(fip));
200: return ENOTCONN;
201: }
202:
203: return 0;
204: }
fuse_device_clear() (sys/vfs/fuse/fuse_device.c:99-116) β invoked by
the daemon reader (fuse_device_read:137) when it wakes and observes dead
during teardown β walks fmp->reply_head and for every pending fip calls
fuse_ipc_test_and_set_replied(fip) + wakeup(fip) WITHOUT assigning
fip->reply.buf (only fuse_device_write:fuse_device.c:205 populates the
reply). fip->reply.buf therefore stays NULL (set to NULL by
fuse_ipc_get:fuse_ipc.c:104). fuse_mount_kill
(fuse_vfsops.c:65-76) has ALREADY set dead=1 by the time
fuse_device_clear runs (it's fuse_mount_kill's wakeup(fmp) that woke
the daemon reader).
The race
A tx waiter (fuse_ipc_tx:fuse_ipc.c:246 β fuse_ipc_wait) that has:
- passed the line-163 dead check (dead was 0 then β the kill hasn't happened yet), and
- is between line 169 and the line-198 post-tsleep dead recheck
β¦can observe replied==1 at line 169 or 173 (set underneath it by
fuse_device_clear) and return 0. Control returns to fuse_ipc_tx:
266: error = fuse_ipc_wait(fip); // returns 0
...
274: ohd = fuse_out(fip); // == fip->reply.buf == NULL
275: KKASSERT(ohd); // PANIC (INVARIANTS ON, default GENERIC)
// or, INVARIANTS OFF:
276: error = ohd->error; // NULL deref -> trap 0xc/0xe
The goto-again loop at :171/:184 re-runs :172-173 up to 6 times per
fuse_ipc_wait call, widening the early-return window 6Γ β but the window
between tsleep_interlock (:172) and the replied check (:173) is still
only a few instructions (~nanoseconds).
Why the live race is hard (and why a deterministic harness is the proof)
The race requires the tx waiter to be between :163 and :198 (or more
narrowly between :172 and :173) at the precise instant
fuse_device_clear runs during teardown β a nanoseconds-wide window against
a one-shot teardown. fuse_device_clear runs once per mount lifecycle (when
the daemon reader wakes and sees dead). Per-attempt hit probability is
~window/period β 5ns / (5s tsleep Γ 6 retries) β 1.7Γ10β»ΒΉβ°; even at
thousands of fuse_ipc_tx per second per child, winning it live needs hours
to days of attempts. This is exactly the "live race too narrow β
deterministic code-level harness reproducing the early-return logic" case
(option (b) in the playbook), matching the DF-0917 precedent.
harness.c models both paths with pthreads and forces the worst-case
interleaving with two barriers placed at the kernel's race point:
- UNFIXED build β NULL-ohd PRIMITIVE CONFIRMED: the tx waiter observes
replied=1 (set by the teardown thread) and returns 0 with
fip->reply.buf == NULL; fuse_out(fip) yields NULL, which would fire
KKASSERT(ohd) at fuse_ipc.c:275. Deterministic across 3/3 runs.
- -DFIXED build β NO NULL-deref: the dead-recheck-after-replied
(mirroring the fix) converts the teardown reply into ENOTCONN, so
fuse_ipc_tx:274 is never reached. Deterministic across 3/3 runs.
Live path-exercised run (fused0918): a real FUSE daemon + mount +
stat() storm + force-unmount. Confirms (a) the FUSE subsystem works on
default GENERIC, (b) fuse_ipc_wait is genuinely reached at runtime
(dmesg shows fuse_init/fuse_mount; fuse_ipc_wait timeouts fire when
the daemon delays replies), (c) the fuse_mount_kill β
fuse_device_clear teardown path is exercised (force-unmount triggers it).
3 mounts created/destroyed, 6.5M+ stat() calls (= fuse_ipc_txes) driven,
no panic (the nanosecond race did not fire in this short window β expected).
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 DoS / NULL-deref panic (hardening gap) on default
config. It is not an unprivilegedβroot escalation: the primitive is a
NULL pointer dereference at a fixed offset β there is no
attacker-controlled write, no slab corruption, no function-pointer
overwrite. The only effect is a kernel panic (DoS). The valid hard
blocker "the primitive is genuinely read-only-equivalent (no write, no
corruption; just a deref of a NULL pointer at a known offset)" applies:
there is no chain to develop. No
uid=0is derivable from a NULL-deref panic. - Conditional reachability (NOT default config): IF an admin set
vfs.usermount=1AND added the user tooperator, an unprivileged user could run the daemon and win the race β but the impact is still only a DoS (panic), not escalation. Documented as conditional, not default.
Outcome: primitive fully characterized (NULL-deref / KKASSERT(ohd)
panic via the early-return-without-dead-recheck race); escalation impossible
(NULL-deref at fixed offset = pure DoS). Impact on default GENERIC =
panic / DoS from a root-started (or conditionally-unprivileged) malicious
daemon racing mount-teardown against an in-flight fuse_ipc_tx.
The fix (fix.diff)
Convert the two bare return 0; at fuse_ipc.c:170 and :174 (the early
replied returns) into goto done;, and add a done: label immediately
before the existing post-tsleep dead-recheck at :198. This routes both
early-return paths through the dead-recheck, which returns ENOTCONN if
the mount is dead (as it is when fuse_device_clear set replied). The
caller (fuse_ipc_tx:268) treats ENOTCONN as an error and never reaches
the ohd = fuse_out(fip) deref at :274.
@@ -167,11 +167,11 @@
}
if (fuse_ipc_test_replied(fip))
- return 0;
+ goto done;
again:
tsleep_interlock(fip, 0);
if (fuse_ipc_test_replied(fip))
- return 0;
+ goto done;
error = tsleep(fip, PINTERLOCKED, "ftxp", 5 * hz);
@@ -195,6 +195,7 @@
return error;
}
+done:
if (fuse_test_dead(fip->fmp)) {
KKASSERT(fuse_ipc_test_replied(fip));
return ENOTCONN;
This is a minimal, targeted change (3 lines added/changed) that reuses the existing dead-recheck logic rather than duplicating it. It matches the finding's recommended fix ("recheck dead after replied in early returns").
Fix validation (Phase 8)
- Applies clean:
patch -p1 --forward < fix.diffβ both hunks at:170/:174(goto done) and:198(done: label). - Builds clean: module-only
cd /usr/src/sys/vfs/fuse && makeβfuse.koproduced,-Werror, no warnings (fix_build.log).sha256 = 4d03e5093cfb9c78fb43350b745f6fa6d801ecc7ea06a8f1371ad01defee64ae. - Loads clean:
kldload fuseβ RC=0,/dev/fuseappears,kldstat -vshowsfuseregistered. - No regression (live): on the patched module,
fused0918 2 1mounts FUSE, handles INIT/STATFS/LOOKUP/GETATTR/OPEN/DESTROY, drives 3.6M+stat()calls, survives 2 force-unmount teardowns (each runsfuse_mount_killβfuse_device_clear), and the guest stays up (fix_run.log). The fix changes nothing observable when the race is not won β it only closes the NULL-ohd window β so identical live behavior before/after is the correct expectation. - Model-level before/after:
harness(UNFIXED) βNULL-ohd PRIMITIVE CONFIRMED;harness_fixed(-DFIXED) βNO NULL-deref(dead-recheck converts teardown reply toENOTCONN). Proves the fix 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-0918 && sh build.sh && ./harness && ./harness_fixed' # live path-exercised run on the real kernel (root only): ssh dfbsd 'kldload fuse && mkdir -p /mnt/df918 && cd /root/poc918 && cc -O2 -pthread -o fused0918 fused0918.c && ./fused0918 3 1' # fix build + install + re-test (module-only): ssh dfbsd 'cd /usr/src && patch -p1 < /root/fix.diff && cd sys/vfs/fuse && make && cp fuse.ko /boot/kernel/fuse.ko && kldload fuse'
Fix verification
fixedVALIDATED the fix. The fix is module-only (fuse.ko) since FUSE ships as a loadable KMOD. Phase 8: (1) applied fix.diff to /usr/src/sys/vfs/fuse/fuse_ipc.c (patch -p1 --forward, both hunks succeeded); (2) built fuse.ko via 'cd /usr/src/sys/vfs/fuse && make' -- compiled CLEAN with -Werror (sha256=4d03e509...); (3) installed to /boot/kernel/fuse.ko and kldload'd it (RC=0, /dev/fuse appeared); (4) re-ran the live daemon on the patched module -- 3.6M+ stat() calls, 2 force-unmount teardowns, guest stayed up, NO regression; (5) model-level before/after: harness UNFIXED -> 'NULL-ohd PRIMITIVE reproduced'; harness FIXED -> 'NO NULL-deref'. The fix changes nothing observable when the race is not won -- it only closes the NULL-ohd window -- so identical live behavior before/after is the correct expectation. fix closes the bug.
BASELINE (unpatched #0 + unpatched fuse.ko): harness UNFIXED -> '[tx-waiter] *** NULL-ohd CONFIRMED *** fuse_out(fip)=NULL; fuse_ipc.c:275 KKASSERT(ohd) PANIC ... RESULT: NULL-ohd PRIMITIVE reproduced deterministically.' Live: 6.5M+ stat() calls, 3 teardowns, fuse_ipc_wait path exercised (dmesg: fuse_init/fuse_mount), no panic (race too narrow). PATCHED (#0 + patched fuse.ko sha256=4d03e509...): harness FIXED -> '[tx-waiter] replied set by teardown + dead=1 -> ENOTCONN (fix) ... RESULT: FIXED model -- NO NULL-deref.' Live no-regression: 3.6M+ stat() calls, 2 teardowns, mounts/unmounts succeed, guest stays up. Model before/after: primitive CONFIRMED (unfixed) vs ABSENT (fixed) -- deterministic across 3/3 runs each.
Confirmed kernel references
- sys/vfs/fuse/fuse_ipc.c:163
- sys/vfs/fuse/fuse_ipc.c:169
- sys/vfs/fuse/fuse_ipc.c:170
- sys/vfs/fuse/fuse_ipc.c:172
- sys/vfs/fuse/fuse_ipc.c:173
- sys/vfs/fuse/fuse_ipc.c:174
- sys/vfs/fuse/fuse_ipc.c:198
- sys/vfs/fuse/fuse_ipc.c:274
- sys/vfs/fuse/fuse_ipc.c:275
- sys/vfs/fuse/fuse_ipc.c:104
- sys/vfs/fuse/fuse_device.c:99
- sys/vfs/fuse/fuse_device.c:111
- sys/vfs/fuse/fuse_device.c:113
- sys/vfs/fuse/fuse_device.c:137
- sys/vfs/fuse/fuse_vfsops.c:65
- sys/vfs/fuse/fuse_vfsops.c:69
Detail
Exploit chain
none (valid hard blocker: NULL-deref at fixed offset). The primitive is a NULL pointer dereference of fip->reply.buf (fuse_out(fip) at fuse_ipc.c:274) at a known, fixed offset -- there is NO attacker-controlled write, NO slab corruption, NO function-pointer overwrite, NO type confusion. The only effect is a kernel panic (KKASSERT(ohd) on INVARIANTS-ON GENERIC, or a trap-0xc/0xe NULL-deref on INVARIANTS-OFF). This is the textbook 'genuinely read-only / no-write primitive' hard blocker from Phase 6: there is no corruption to groom, no victim object to convert, no chain to develop. The primitive ceiling is DoS (panic). Reachability is root-only on default GENERIC, so even the DoS requires a root-started malicious FUSE daemon. No uid=0 is derivable.
Evidence (decisive lines)
harness UNFIXED: '[tx-waiter] *** NULL-ohd CONFIRMED *** fuse_out(fip)=NULL; fuse_ipc.c:275 KKASSERT(ohd) PANIC (INVARIANTS) or fuse_ipc.c:276 ohd->error NULL-deref. ... RESULT: NULL-ohd PRIMITIVE reproduced deterministically.' harness FIXED: '[tx-waiter] replied set by teardown + dead=1 -> ENOTCONN (fix) ... RESULT: FIXED model -- NO NULL-deref.' Live (fused0918, root): '[daemon] child: mounted /mnt/df918; driving stat() loop ... killer: unmount(/mnt/df918, MNT_FORCE) -> fuse_mount_kill -> fuse_device_clear ... child: exiting after 6519563 stat() calls ... DONE after 3 iters.' dmesg: 'fuse_init(kldload): FUSE ABI 7.28 / fuse_mount(fused0918): FUSE UABI 7.28' (3 mounts). No panic signature in boot.log (race too narrow to win in bounded time, as expected).
PoC changes
Created the entire evidence pack from scratch (no prior DF-0918 PoC folder existed -- only the DB row). harness.c: deterministic pthread model of fuse_ipc_wait's early-return race (Thread A = tx waiter observing replied=1 without dead recheck; Thread B = fuse_mount_kill+fuse_device_clear setting replied=1 with reply.buf NULL); UNFIXED build confirms the NULL-ohd primitive, -DFIXED build proves the dead-recheck closes it. fused0918.c: live FUSE daemon that mounts a real fuse fs, drives 6.5M+ stat() calls, and force-unmounts to trigger fuse_mount_kill -> fuse_device_clear. fix.diff: converts the two bare 'return 0;' at fuse_ipc.c:170/:174 into 'goto done;' and adds a 'done:' label before the existing post-tsleep dead-recheck at :198.
Verified recommended fix
In fuse_ipc_wait() (sys/vfs/fuse/fuse_ipc.c), convert the two bare 'return 0;' at :170 and :174 (the early replied returns) into 'goto done;', and add a 'done:' label immediately before the existing post-tsleep dead-recheck at :198. This routes both early-return paths through the dead-recheck, which returns ENOTCONN if the mount is dead (as it is when fuse_device_clear set replied). The caller (fuse_ipc_tx:268) then treats ENOTCONN as an error and never reaches the ohd=fuse_out(fip) deref at :274. 3-line minimal change, reuses existing logic. MATCHES the finding's 'recheck dead after replied in early returns' proposal. Full git-apply-able diff in findings/poc/DF-0918/fix.diff.
Verdict
REPRODUCED. The bug is real and confirmed by a deterministic pthread harness + line-by-line code-path trace. fuse_ipc_wait() (sys/vfs/fuse/fuse_ipc.c:158-204) has three early-return paths at :169-170 and :173-174 that test the replied flag (fip->done) and return 0 WITHOUT re-checking the mount's dead flag and WITHOUT verifying fip->reply.buf is populated. fuse_device_clear() (fuse_device.c:99-116) -- invoked by the daemon reader (fuse_device_read:137) when it observes dead during teardown -- walks fmp->reply_head and sets replied on every pending fip via fuse_ipc_test_and_set_replied (fuse_device.c:113) WITHOUT assigning fip->reply.buf (only fuse_device_write:fuse_device.c:205 populates the reply). fip->reply.buf stays NULL (from fuse_ipc_get:fuse_ipc.c:104). fuse_mount_kill (fuse_vfsops.c:65-76) has ALREADY set dead=1 before fuse_device_clear runs. A tx waiter (fuse_ipc_tx:fuse_ipc.c:246 -> fuse_ipc_wait) that has passed the :163 dead check but is between :169 and the :198 post-tsleep dead recheck can observe replied==1 (set underneath it by fuse_device_clear) and return 0. Back in fuse_ipc_tx: ohd=fuse_out(fip)=fip->reply.buf=NULL (:274); KKASSERT(ohd) (:275) PANICS on default GENERIC (INVARIANTS ON); without INVARIANTS, ohd->error (:276) NULL-derefs. The harness forces this worst-case interleaving with two pthread barriers and confirms the NULL-ohd primitive deterministically (3/3 runs). A live FUSE daemon (fused0918) drove 6.5M+ stat() calls and 3 force-unmount teardowns on the real #0 kernel, confirming the code path is genuinely reached at runtime; the nanosecond-wide race window did not fire in the short run, which is expected -- the deterministic harness is the proof, matching the DF-0917 precedent.
No comments yet.