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

journal_mountctl journal lifecycle is lock-free: concurrent install/remove double-detach panics on INVARIANTS and double-kfrees/UAF-writes mnt_jbitmap on release kernels

Field Value
ID DF-2763
Status new
Severity Low
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:N/I:L/A:H
CWE CWE-362 / CWE-416
File sys/kern/vfs_jops.c
Lines 162/185 (dispatch), 245-253 (free), 547/556/613 (bitmap RMW)
Area kern
Confidence certain
Discovered 2026-08-30
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

journal_mountctl() decides attach/detach from a lock-free read of mnt_vn_journal_ops, and the detach decision (TAILQ_EMPTY re-check) can execute long after the list state changed, straddling tsleeps inside journal_destroy. Two concurrent mountctl ops interleave so journal_detach() runs twice: KKASSERT(mp->mnt_jbitmap != NULL) fires (panic 3/3, ≀90s); on release kernels the both-see-non-NULL interleave double-kfrees the 1024-byte M_JOURNAL bitmap, and detach-vs- in-flight-jreclist variants give a UAF bit-RMW at jreclist_init / jreclist_done. Same unsynchronized family as DF-2704/2747/2748 but distinct victim and endpoints. Root triggers; unpriv VOPs on the journaled mount are the racing victims of the UAF variants.

Proof of concept

VERIFIED 3/3 on the stock INVARIANTS guest (findings/poc/DF-2763/): two concurrent mountctl install/remove churn loops β†’ panic assertion mp->mnt_jbitmap != NULL failed in journal_detach at vfs_jops.c:248 (two runs with unpriv writers, one minimal). Fix (mnt_token serialization + idempotent detach + tokened bitmap RMW) validated: 85+85 cycles clean, M_JOURNAL accounting returns to 0. Fix v1 (token only) honestly recorded as FAILED β€” tokens drop across tsleep (fixrun_v1_console.log).

Four hunks in findings/poc/DF-2763/fix.diff (validated).

Timeline

  • 2026-08-30 Discovered during pass-2 audit of vfs_jops.c (GLM 5.3); 3/3 panic reproduced + fix validated same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2763 Β· 16 files
FileTypeDescriptionSize
README.md β€” 3.1 KB ↓ raw
VERDICT.md β€” 2.9 KB ↓ raw
panic.txt β€” 1.6 KB view raw
console_run1_full.log β€” 2.2 KB view raw
console_run3_full.log β€” 2.3 KB view raw
console_run4_full.log β€” 2.2 KB view raw
fixrun_v1_console.log β€” 2.2 KB view raw
df2763.c β€” 1.3 KB view raw
df2763_root.sh β€” 466 B view raw
df2763_run2.sh β€” 742 B view raw
df2763_run3.sh β€” 496 B view raw
df2763_run4.sh β€” 455 B view raw
build.sh β€” 105 B view raw
run.sh β€” 726 B view raw
fix.diff β€” 3.6 KB view raw
verdict.json β€” 2.7 KB view raw

DF-2763 β€” journal_mountctl attach/detach lifecycle is unsynchronized β†’ panic on INVARIANTS, double-kfree/UAF of mnt_jbitmap on release kernels

What

sys/kern/vfs_jops.c manages the per-mount journal lifecycle state machine (mnt_vn_journal_ops installed ⇔ mnt_jbitmap allocated ⇔ mnt_jlist entries) with zero locking:

  • journal_mountctl() (vfs_jops.c:153-231) reads mp->mnt_vn_journal_ops lock-free and calls journal_attach()/journal_detach() on that basis.
  • journal_attach() (vfs_jops.c:235-243) allocates mnt_jbitmap and installs the journal vop vector (KKASSERT(mp->mnt_jbitmap == NULL)).
  • journal_detach() (vfs_jops.c:245-253) frees both (KKASSERT(mp->mnt_jbitmap != NULL), kfree(mp->mnt_jbitmap)).
  • journal_remove_all_journals() (vfs_jops.c:422-430) is called from dounmount() holding mp->mnt_token (vfs_syscalls.c:814,1037), but journal_mountctl() itself takes no token at all.

Two concurrent mountctl operations (or mountctl racing unmount) interleave the state machine. Reproduced interleave: thread A runs journal_attach() (installs ops + bitmap) while thread B's MOUNTCTL_REMOVE sees a stale non-NULL mnt_vn_journal_ops, empties mnt_jlist, and calls journal_detach() (frees/NULLs the bitmap A just installed). The next remove then calls journal_detach() on an already-NULL bitmap:

panic: assertion "mp->mnt_jbitmap != NULL" failed in journal_detach at vfs_jops.c:248
journal_detach() <- journal_mountctl() <- vop_mountctl() <- kern_mountctl() <- sys_mountctl()

On a release kernel (no INVARIANTS) the same interleaving where both threads observe a non-NULL bitmap is a double kfree() of the 1024-byte M_JOURNAL block, and the detach-vs-in-flight-VOP variants give a UAF bit-RMW in jreclist_init() (:547 read of a freed/NULL mnt_jbitmap, :556 bit-set) and jreclist_done() (:613 bit-clear) β€” the bitmap is freed while a streamid is still owned. Related family: DF-2704 (vop-vector dispatch UAF), DF-2747 (memfifo RMW), DF-2748 (jo/fifo free under sleepers).

Who can trigger

mountctl is SYSCAP_RESTRICTEDROOT-gated (vfs_syscalls.c:1281-1285), so the trigger is root-only; the racing victim side can be any unprivileged VOP on the journaled mount. Severity Low accordingly.

PoC (reproduced 3/3, root-only minimal config)

# two concurrent install/remove churn loops on /tmp (tmpfs), ~90 s
sh df2763_run4.sh        # two loops: mountctl -a -w ... /tmp:cN ; mountctl -d /tmp:cN

Result (stock INVARIANTS kernel, run1/run3 with 3 unpriv writers, run4 writers-free): panic at vfs_jops.c:248 within 25-90 s.

Files: df2763.c (unpriv writer stress, used in runs 1/3), df2763_root.sh/df2763_run2.sh/df2763_run3.sh/df2763_run4.sh (churn configs), panic.txt (all three captures), run.log narrative.

Fix (validated)

Serialize the lifecycle: lwkt_gettoken(&mp->mnt_token) around the whole journal_mountctl() body (pairs with dounmount(), which already holds mnt_token across journal_remove_all_journals()). See fix.diff (also carries the DF-2765/DF-2766 hunks; the DF-2763 hunks are the token pair at :168/:234).

VERDICT.md
↓ download raw

DF-2763 β€” VERDICT

Status: reproduced (3/3 baseline runs), fix validated on guest.

Baseline (stock INVARIANTS kernel, DragonFly 6.5-DEVELOPMENT #0, Thu Jul 2 06:02:54 UTC 2026)

Three independent runs panicked with the identical signature:

panic: assertion "mp->mnt_jbitmap != NULL" failed in journal_detach at vfs_jops.c:248
journal_detach() <- journal_mountctl() <- vop_mountctl() <- kern_mountctl() <- sys_mountctl()
  • RUN 1 (two churn loops overlapping + 3 unprivileged writers, <=150 s)
  • RUN 3 (deliberate: two churn loops + 3 unprivileged writers, <=110 s)
  • RUN 4 (MINIMAL: two concurrent root mountctl install/remove churn loops on /tmp, NO other VOP traffic, <=90 s) β€” proves the race is purely the journal_mountctl attach/detach state machine, not VOP load.

Mechanism (path:line): - journal_mountctl() decides attach-vs-journaled on a lock-free read of mnt_vn_journal_ops (sys/kern/vfs_jops.c:162/:185). - journal_attach() :235-243 allocates mnt_jbitmap + installs ops. - journal_detach() :245-253 frees both; the decision to call it is a TAILQ_EMPTY(&mp->mnt_jlist) check that can run long after the journal list state changed (:172-173/:208-209). - Interleave reproduced: thread A's MOUNTCTL_REMOVE is inside journal_destroy (its final jrecord + journal_destroy_threads sleep), thread B runs a full -a/-d pair, drains the list, detaches (frees bitmap, NULLs it); A wakes, sees an empty list, detaches again -> KKASSERT fires. On release kernels the both-see-non-NULL interleaving is a double kfree() of the 1024-byte M_JOURNAL bitmap block; the detach-vs-in-flight-jreclist variants give UAF bit RMW at jreclist_init :547/:556 and jreclist_done :613.

Fix iterations

  • v1 (token around journal_mountctl only): FAILED β€” the panic persisted at the shifted :257 because lwkt tokens are dropped across tsleep, and journal_destroy's destroy_threads tsleeps; the detach decision still straddled a sleep. Honest failure kept: fixrun_v1_console.log.
  • v2 (validated): (a) token pair around journal_mountctl; (b) journal_detach made idempotent (NULL-guard return instead of KKASSERT); (c) token around jreclist_init's streamid bitmap RMW (:545-558 region, released around the exhaustion tsleep); (d) token + NULL-guard around jreclist_done's bit clear. Plus the DF-2765 and DF-2766 hunks (same file; independently validated).

Fix validation (kernel #2, DragonFly 6.5-DEVELOPMENT #1..#2 Mon Aug 31 2026, make nativekernel rc=0)

  • Exact PoC config re-run on the patched kernel: both churn loops completed (85 + 85 cycles = 170 install/remove pairs), no panic, guest stayed up, vmstat -m journal in-use returned to 0.
  • Baseline: panic <=90 s, 3/3.

Impact classification

Root-gated trigger (mountctl is SYSCAP_RESTRICTEDROOT), so uid0 escalation is not the threat model; impact = kernel panic on INVARIANTS / double-free or 1-byte UAF bit write on release kernels (memcorrupt bucket). Severity Low.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

exact PoC config re-run on patched kernel #2: 85+85 churn cycles completed, no panic, guest up, M_JOURNAL in-use back to 0 (baseline: panic <=90 s, 3/3)

['fix.diff', 'fixrun_v1_console.log (failed v1, kept honest)', 'fix_build_v2.log tail in task log: RC=0 INST=0']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #2: Mon Aug 31 23:12:27 UTC 2026

Confirmed kernel references

Detail

Evidence (decisive lines)

['panic.txt (3 captures, all :248 via sys_mountctl)', 'console_run4_full.log (minimal 2-loop config, no VOP traffic)', 'VERDICT.md fix section: baseline panic vs patched clean completion (85+85 cycles)']

PoC changes

no seed existed; authored from scratch: two concurrent 'mountctl -a/-d' churn loops proved sufficient (writers unnecessary)

Verified recommended fix

Serialize journal_mountctl with mnt_token, make journal_detach idempotent (NULL-guard), token-protect jreclist_init/done bitmap RMW (fix.diff)

Verdict

journal_mountctl's attach/detach lifecycle state machine is lock-free; two concurrent mountctl ops interleave it so journal_detach runs twice (or straddles journal_destroy's tsleep), firing KKASSERT(mnt_jbitmap != NULL) at vfs_jops.c:248 (reproduced 3/3, incl. a writers-free minimal config); on release kernels the interleave is a double kfree of the 1024-byte M_JOURNAL bitmap and, vs in-flight jreclists, a UAF bit-RMW at :547/:556/:613. Root-gated (mountctl = SYSCAP_RESTRICTEDROOT) so privesc is not the model; impact is kernel panic / slab corruption.