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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0918 Β· 14 files
FileTypeDescriptionSize
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
README.md readme human-readable reproduction instructions
↓ download 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 daemon
  • fix.diff β€” goto done + done: label (dead-recheck-after-replied)
  • build.sh / run.sh β€” exact build/run commands
  • VERDICT.md β€” full narrative + line-by-line trace
  • manifest.json β€” machine-readable catalog
  • *.log β€” full untrimmed build/run/fix logs
VERDICT.md verdict full narrative: reproduced? how/why? exploit chain? fix validation
↓ download raw

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:

  1. passed the line-163 dead check (dead was 0 then β€” the kill hasn't happened yet), and
  2. 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/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 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=0 is derivable from a NULL-deref panic.
  • Conditional reachability (NOT default config): IF an admin set vfs.usermount=1 AND added the user to operator, 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.ko produced, -Werror, no warnings (fix_build.log). sha256 = 4d03e5093cfb9c78fb43350b745f6fa6d801ecc7ea06a8f1371ad01defee64ae.
  • Loads clean: kldload fuse β†’ RC=0, /dev/fuse appears, kldstat -v shows fuse registered.
  • No regression (live): on the patched module, fused0918 2 1 mounts FUSE, handles INIT/STATFS/LOOKUP/GETATTR/OPEN/DESTROY, drives 3.6M+ stat() calls, survives 2 force-unmount teardowns (each runs fuse_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 to ENOTCONN). 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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED 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.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (kernel unchanged; fix validated at the fuse.ko MODULE level -- sha256 fuse.ko = 4d03e5093cfb9c78fb43350b745f6fa6d801ecc7ea06a8f1371ad01defee64ae)

Confirmed kernel references

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.