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).
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2763 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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) readsmp->mnt_vn_journal_opslock-free and callsjournal_attach()/journal_detach()on that basis.journal_attach()(vfs_jops.c:235-243) allocatesmnt_jbitmapand 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 fromdounmount()holdingmp->mnt_token(vfs_syscalls.c:814,1037), butjournal_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).
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
fixedexact 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']
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.
No comments yet.