Crafted on-disk pfs_nmasters drives OOB thread-array access and arbitrary kfree in hammer2 unmount (xop_helper_cleanup)
| Field | Value |
|---|---|
| ID | DF-2620 |
| Status | new |
| Severity | High |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-787 Out-of-bounds Write / CWE-129 Improper Validation of Array Index |
| File | sys/vfs/hammer2/hammer2_vfsops.c |
| Lines | 527-529 |
| Area | vfs |
| Confidence | certain |
| Discovered | 2026-08-28 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | hammer2 |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
hammer2_pfsalloc copies the on-disk meta.pfs_nmasters (fully
attacker-controlled uint8_t, 0..255) into pmp->pfs_nmasters with no clamp
(vfsops.c:527-529). At unmount, hammer2_xop_helper_cleanup (called from
hammer2_vfs_unmount, vfsops.c:1664) iterates cluster indices with
for (i = 0; i < pmp->pfs_nmasters; ++i) over pmp->xop_groups[j].thrs[i],
but thrs[] has only HAMMER2_MAXCLUSTER=8 entries (hammer2.h:1065). A
crafted value >8 reads .td up to ~15KB past the heap array and, when the
OOB .td is non-NULL, calls hammer2_thr_delete() on an out-of-bounds
"thread" β producing wild atomic writes (thr_signal), an 8-byte NULL write
(thr->pmp=NULL), kfree() of an OOB-read pointer (thr->scratch,
arbitrary free), and an OOB KKASSERT. The same wrong bound in the other
direction (pfs_nmasters < nchains, e.g. slave elements) strands live xop
threads inside the array that line 468 then kfree()s β a guaranteed UAF.
Root cause
Data flow: crafted image β volume-header validation covers only magic+CRCs
(hammer2_ondisk.c:501-566); the PFS inode content under the super-root is
attacker-authored. hammer2_update_pmps (vfsops.c:1553-1562) β
hammer2_pfsalloc β vfsops.c:527-529 if (ripdata && pmp->pfs_nmasters <
ripdata->meta.pfs_nmasters) pmp->pfs_nmasters =
ripdata->meta.pfs_nmasters; β meta.pfs_nmasters (hammer2_disk.h:972,
uint8_t at inode offset 0x86) is never clamped to HAMMER2_MAXCLUSTER (8).
Sink: unmount β hammer2_xop_helper_cleanup (hammer2_admin.c:461-469) walks
thrs[i] for i < pfs_nmasters; sizeof(hammer2_xop_group_t) = 8Γ64 = 512
bytes; for i>=8 the address base+j*512+i*64 walks past each group's
thrs[8], leaving the allocation for the last group.
hammer2_thr_delete then (a) reads thr->td OOB, (b) hammer2_thr_signal
does atomic_cmpset_int writes on OOB thr->flags, (c) hammer2_thr_wait
can spin 60 s per iteration, (d) writes NULL to OOB thr->pmp, (e)
kfree(thr->scratch) with a heap-content-controlled pointer, (f)
KKASSERT(TAILQ_EMPTY(&thr->xopq)) reads OOB. Down-direction: helper_create
makes threads for ALL i<nchains (admin.c:437-446); cleanup only deletes
i<pfs_nmasters yet unconditionally kfree()s the array β remaining threads
(e.g. slave cluster index, or a slave-only pmp where nmasters stays 0 because
the count-of-masters loop goto dones at vfsops.c:465) keep executing
hammer2_primary_xops_thread with their thr pointer inside freed memory
(admin.c:1159,1218,1244).
Threat model & preconditions
- Attacker position: mounts a crafted HAMMER2 image, then unmounts it.
With
vfs.usermount=1and a user-accessible block device (vn/md node or owned USB disk) this is reachable by an unprivileged local user; otherwise root mounting untrusted media (foreign USB disk, restore-from-image). - Privileges gained or impact: conditional OOB writes up to ~247Γ64
bytes past a 16KB+ allocation and
kfree()of an attacker-influenced heap value (arbitrary free β slab corruption β potential unprivβroot), or stranded-thread UAF crash; at minimum a deterministic panic duringumount(8). - Required config or capabilities: one byte in the image:
meta.pfs_nmasters > 8, or a non-MASTER PFS / cluster with nchains > nmasters. - Reachability: mount + umount.
Proof of concept
Build & run
newfs_hammer2 image; locate the PFS inode under the super-root (scan for its 16-byte pfs_clid); set byte at inode+0x86 to 0xFF; recompute checks; mount -o ro; umount. Down-direction variant: PFS with pfs_type=SLAVE and pfs_nmasters=0 -> mount+umount strands all 64 xop threads inside the kfree'd array -> panic within 30 s.
Expected output
panic during/after umount with hammer2_thr_delete / hammer2_primary_xops_thread in the backtrace; or (groomed heap) silent slab corruption via kfree of a controlled pointer.
Impact
Kernel heap corruption (OOB writes + arbitrary free) at unmount of a crafted image; deterministic DoS otherwise.
Recommended fix
Clamp the on-disk value at import and stop using it as an array bound at cleanup (iterate the fixed-size array instead):
--- a/sys/vfs/hammer2/hammer2_vfsops.c
+++ b/sys/vfs/hammer2/hammer2_vfsops.c
@@ -524,8 +524,11 @@ hammer2_pfsalloc(hammer2_chain_t *chain,
if (ripdata && pmp->pfs_nmasters < ripdata->meta.pfs_nmasters) {
pmp->pfs_nmasters = ripdata->meta.pfs_nmasters;
+ if (pmp->pfs_nmasters > HAMMER2_MAXCLUSTER) {
+ kprintf("hammer2_pfsalloc: clamp pfs_nmasters %d\n",
+ ripdata->meta.pfs_nmasters);
+ pmp->pfs_nmasters = HAMMER2_MAXCLUSTER;
+ }
}
--- a/sys/vfs/hammer2/hammer2_admin.c
+++ b/sys/vfs/hammer2/hammer2_admin.c
@@ -458,8 +458,14 @@ hammer2_xop_helper_cleanup(hammer2_pfs_t *pmp)
return;
}
- for (i = 0; i < pmp->pfs_nmasters; ++i) {
+ /*
+ * Xop helper threads exist for every cluster element index
+ * (0..nchains-1), not just masters. Using pfs_nmasters both
+ * strands threads (nmasters < nchains) and indexes out of
+ * bounds when the on-disk pfs_nmasters is crafted > 8.
+ */
+ for (i = 0; i < HAMMER2_MAXCLUSTER; ++i) {
for (j = 0; j < hammer2_xop_nthreads; ++j) {
if (pmp->xop_groups[j].thrs[i].td)
hammer2_thr_delete(&pmp->xop_groups[j].thrs[i]);
References
- hammer2.h:1065 (thrs[HAMMER2_MAXCLUSTER]), hammer2_admin.c:426-469
- DF-2621 (the companion double-create defect in the same subsystem)
Timeline
- 2026-08-28 Discovered during automated audit (pass 2, GLM 5.3).
Cross-reference (2026-08-29)
The pass-2 audit of hammer2_admin.c independently re-derived this defect and characterized additional facets (cross-group aliasing β cross-mount worker kills / wild kfree of in-use scratch; quorate-merge escalation route blocked by guest vn limits; unquorate-mount kernel-memory exhaustion). That run's evidence pack lives at findings/poc/DF-2654/ (filed as a duplicate of this finding).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2620 Β· 21 files| File | Type | Description | Size | |
|---|---|---|---|---|
| forge_df2620.py | β | 6.7 KB | view raw | |
| df2620_dump.py | β | 2.6 KB | view raw | |
| trigger_A.sh | β | 847 B | view raw | |
| trigger_B.sh | β | 1.8 KB | view raw | |
| trigger_C.sh | β | 1.2 KB | view raw | |
| fixA.sh | β | 633 B | view raw | |
| fixC.sh | β | 1.3 KB | view raw | |
| fix.diff | β | 4.1 KB | view raw | |
| panic.txt | β | 724 B | view raw | |
| panic_C.txt | β | 118 B | view raw | |
| boot_after_A.log | β | 14.7 KB | view raw | |
| boot_after_C.log | β | 13.8 KB | view raw | |
| C_run.log | β | 914 B | view raw | |
| fixA_run.log | β | 412 B | view raw | |
| fixC_run.log | β | 745 B | view raw | |
| fix_build.log | β | 5.7 MB | β download | |
| env.txt | β | 1.5 KB | view raw | |
| code_hashes.txt | β | 303 B | view raw | |
| VERDICT.md | β | 7.5 KB | β raw | |
| manifest.json | β | 1.7 KB | view raw | |
| verdict.json | β | 8.1 KB | view raw |
DF-2620 β hammer2: crafted on-disk pfs_nmasters drives OOB thread-array access and UAF at unmount (hammer2_xop_helper_cleanup) =======================================================================
CLAIM VERIFIED β both directions reproduced as kernel page-fault panics on the stock INVARIANTS kernel; fix validated by rebuild (panics gone).
What was verified
Root cause confirmed in source: * sys/vfs/hammer2/hammer2_vfsops.c:527-528 β pmp->pfs_nmasters is taken verbatim from the on-disk PFS inode field ripdata->meta.pfs_nmasters (uint8_t, sys/vfs/hammer2/hammer2_disk.h:972) with no clamp to HAMMER2_MAXCLUSTER (=8). * sys/vfs/hammer2/hammer2_admin.c:461-467 β hammer2_xop_helper_cleanup() bounds its teardown loop by pmp->pfs_nmasters while indexing pmp->xop_groups[j].thrs[i], and each group is only thrs[HAMMER2_MAXCLUSTER] wide (sys/vfs/hammer2/hammer2.h:1063-1067).
Reproduction A (up-direction, nmasters=0xFF > 8):
* forge: testvol PFS inode meta.pfs_nmasters 0x00 -> 0xFF
(forge_df2620.py variant A; CHECK_NONE brefs; volhdr CRCs recomputed).
* mount -o ro /dev/vn0@testvol /mnt/h2 -> succeeds
* reboot (shutdown -r) -> boot() -> vfs_unmountall -> VFS_UNMOUNT ->
hammer2_xop_helper_cleanup walks thrs[0..254] over a 288-slot
(18432-byte) allocation:
Fatal trap 12: page fault while in kernel mode
fault virtual address = 0xfffff801192f5020
supervisor read data, page not present
current process = 1
Stopped at hammer2_xop_helper_cleanup+0x5a:
cmpq $0,0x20(%rax,%r15,1)
The faulting instruction IS the OOB thrs[i].td check
(offsetof(hammer2_thread, td) == 0x20). panic.txt, boot_after_A.log.
Reproduction C (down-direction, nmasters=1 < nchains=2): * forge: C.img = clone of base with testvol pfs_type MASTER->SLAVE (same pfs_clid). Two devices carrying one pfs_clid merge into a single pmp: mount vn0@testvol (MASTER, clindex 0), then attempt mount vn1@testvol (SLAVE) β hammer2_update_pmps() merges the SLAVE chain at clindex 1 (creating its 36 xop threads), the mount then fails EBUSY ("PFS already mounted!") but the merged chain stays (the failure path's hammer2_unmount_helper() is a no-op because mount_count != 0). Visible masters = 1 -> pmp->pfs_nmasters = 1 (count bump, vfsops.c:536-542) while nchains = 2. * umount -f /mnt/h2 -> cleanup deletes only the thrs[0] column (i < 1) and immediately kfrees xop_groups with the SLAVE column's 36 live kernel threads still inside; the per-chain cleanup in hammer2_pfsdealloc()/hammer2_pfsfree() (vfsops.c:655-661, 683-690) is skipped because pmp->xop_groups is already NULL. * Result (stock kernel): Fatal trap 12 ... (during umount -f) Stopped at hammer2_primary_xops_thread+0x2d9: lock xaddl %edx,0x81558(%rsi) an xop worker faulting on torn-down state. panic_C.txt, boot_after_C.log, C_run.log.
Also observed (same unclamped ingestion, side effect): * With pfs_nmasters >= 2 on a single-chain mount the mount succeeds but the FIRST access to the mount root hangs forever: nquorum = nmasters/2+1 > 1 chain can ever satisfy -> hammer2_vfs_root() loops at vfsops.c:1966-2008 (wchan "h2root"), pinning the namecache lock of the mountpoint (later path lookups pile up in D-state on "ncplk"). Any umount by path also crosses into VFS_ROOT and hangs the same way. Not fixed by fix.diff (separate robustness bug, noted in the fix header).
Memory-corruption primitive characterization
Up-direction: the walk reads .td at a fixed 64-byte stride across ~15.7KB past an 18KB kmalloc(M_HAMMER2) block; for every OOB slot with non-NULL garbage .td the kernel performs, inside hammer2_thr_delete() (admin.c:254-269): (a) an atomic bit-set write (OR 0x10) into an arbitrary adjacent 32-bit heap word via hammer2_thr_signal() (admin.c:77-97), (b) wakeup() on a garbage address, (c) a NULL write through thr->pmp, (d) kfree(thr->scratch) of a wild 64-bit value, and (e) KKASSERT(TAILQ_EMPTY(&thr->xopq)) over OOB memory. In the observed run the walk page-faulted at the first unmapped page before reaching a populated garbage slot; the intermediate hang observed in an earlier run (umount stuck in "h2twait" forever) is exactly case (a)+(b) on a garbage slot whose flags never gain STOPPED. Down-direction: deterministic UAF β 36 live kernel threads keep polling (30s tsleep loop) and executing from an 18KB block after kfree(), with xopq TAILQ manipulation and xop dispatch through freed memory once the block is reused; stock kernel panics inside the worker (reproduced). A uid=0 chain was not developed: the OOB write offset is stride-locked past a specific allocation and the UAF victim objects are kernel thread structs whose reuse would have to be groomed against a 36-thread poller; both directions reliably panic/DoS instead.
Fix validation (build + boot + re-run, same guest)
Baseline (stock #0, INVARIANTS): trigger A -> Fatal trap 12 in hammer2_xop_helper_cleanup+0x5a (panic.txt); trigger C -> Fatal trap 12 in hammer2_primary_xops_thread+0x2d9 (panic_C.txt). Patched (#1, fix.diff applied to guest /usr/src, nativekernel): * trigger A: mount + shutdown -r -> CLEAN reboot, zero Fatal traps (fixA_run.log; boot.log shows normal syncing disks / Uptime / Rebooting). * trigger C: mount + merge + umount -f -> NO panic, NO corruption, cleanup deletes both columns; the umount ultimately blocks in D-state ("h2twait") waiting for a second-chain worker teardown (fixC_run.log). IMPORTANT: that residual hang is PRE-EXISTING and independent of this finding β a plain unmodified clone pair (MASTER+MASTER, nmasters == nchains == 2, no forged bytes, clamp inert, cleanup sequence byte-identical to stock) hangs umount -f identically (verified, wchan "h2twait"). It is a separate multi-chain teardown defect worth its own finding; on the STOCK kernel trigger C did not hang β it corrupted memory and panicked instead.
Files
forge_df2620.py image forger (variants A/B/C) β host python3 df2620_dump.py.in walking dumper used to verify the on-disk layout trigger_A.sh up-direction trigger (mount + reboot) trigger_B.sh lone-SLAVE trigger (wedges VFS_ROOT; kept for record) trigger_C.sh down-direction trigger (2-device merge + umount -f) fixA.sh/fixC.sh fix-validation triggers fix.diff the verified fix (clamp + cluster-width bound) panic.txt stock kernel, variant A trap + ddb stop panic_C.txt stock kernel, variant C trap + ddb stop boot_after_A.log / boot_after_C.log full serial console logs C_run.log variant C run log (stock; ends at the fatal umount) fixA_run.log / fixC_run.log fix-validation run logs (patched) fix_build.log full untrimmed nativekernel build log (37793 lines) env.txt kernels, sysctls, md5s
Exact reproduction (from this repo root)
forge (needs the base image: newfs_hammer2 -L testvol on a 64M file)
python3 findings/poc/DF-2620/forge_df2620.py base.img A A.img python3 findings/poc/DF-2620/forge_df2620.py base.img C C.img
in guest (root): variant A
vnconfig -c vn0 A.img && mkdir -p /mnt/h2 mount -t hammer2 -o ro /dev/vn0@testvol /mnt/h2 shutdown -r now # -> Fatal trap 12, hammer2_xop_helper_cleanup+0x5a
in guest (root): variant C
vnconfig -c vn0 base.img; vnconfig -c vn1 C.img mount -t hammer2 -o ro /dev/vn0@testvol /mnt/h2 mount -t hammer2 -o ro /dev/vn1@testvol /mnt/h2b # EBUSY (chain merged) umount -f /mnt/h2 # -> Fatal trap 12, hammer2_primary_xops_thread+0x2d9
Fix verification
fixedfix.diff applied to guest /usr/src, kernel rebuilt (make nativekernel, full log fix_build.log) and booted as #1. Baseline on stock #0: variant A panics in hammer2_xop_helper_cleanup+0x5a, variant C panics in hammer2_primary_xops_thread+0x2d9 (both captured). Patched: variant A (mount + shutdown -r) reboots cleanly with zero Fatal traps -- panic eliminated; variant C (merge + umount -f) no longer corrupts memory or panics -- cleanup now deletes both thread columns before kfree. Residual: variant C's umount -f ends up waiting forever (D-state, h2twait) on second-chain worker teardown; verified pre-existing and unrelated by running the identical umount on an unmodified MASTER+MASTER clone pair (no forged bytes, clamp inert, stock-identical cleanup sequence) which hangs the same way. Memory-safety defect: fixed.
['fix_build.log (full nativekernel build, BUILD_RC=0)', "fixA_run.log + serial boot.log of the patched reboot: clean shutdown/reboot, grep -c 'Fatal trap' == 0", 'fixC_run.log: no panic on patched kernel; thread count drops as columns are deleted (109 -> 73 -> ...)', 'pre-existing-hang control: unmodified MASTER+MASTER clone pair, umount -f stuck in h2twait on patched kernel where the fix is provably inert']
Confirmed kernel references
- sys/vfs/hammer2/hammer2_vfsops.c:527
- sys/vfs/hammer2/hammer2_vfsops.c:528
- sys/vfs/hammer2/hammer2_vfsops.c:541
- sys/vfs/hammer2/hammer2_vfsops.c:566
- sys/vfs/hammer2/hammer2_vfsops.c:655
- sys/vfs/hammer2/hammer2_vfsops.c:683
- sys/vfs/hammer2/hammer2_admin.c:254
- sys/vfs/hammer2/hammer2_admin.c:261
- sys/vfs/hammer2/hammer2_admin.c:451
- sys/vfs/hammer2/hammer2_admin.c:461
- sys/vfs/hammer2/hammer2_admin.c:463
- sys/vfs/hammer2/hammer2.h:1065
- sys/vfs/hammer2/hammer2_disk.h:972
- sys/vfs/hammer2/hammer2_cluster.c:348
Detail
Exploit chain
mount crafted image (root, or unprivileged with vfs.usermount=1 + owned device) -> pmp->pfs_nmasters = on-disk value, unclamped -> umount (via shutdown, or directly for nmasters<=1 clusters) -> hammer2_xop_helper_cleanup walks/frees xop_groups with wrong bounds -> OOB read (page fault, panic), OOB bit-set write + wild kfree on garbage .td slots (silent heap corruption / unkillable hang), or 36 live kernel threads left executing inside an 18KB freed block (UAF, worker panic). Deterministic kernel memory-safety violation; escalation not demonstrated.
Evidence (decisive lines)
["panic.txt: 'Fatal trap 12 ... Stopped at hammer2_xop_helper_cleanup+0x5a: cmpq $0,0x20(%rax,%r15,1)' (stock kernel, variant A, process 1 during vfs_unmountall)", "panic_C.txt / boot_after_C.log: 'Fatal trap 12 ... Stopped at hammer2_primary_xops_thread+0x2d9: lock xaddl %edx,0x81558(%rsi)' (stock kernel, variant C during umount -f)", 'boot_after_A.log: full serial log of the A run (mount of forged /dev/vn0@testvol then the trap)', 'C_run.log: variant C run log ending at the fatal umount -f; T1=37 -> T2=109 thread counts show the same-clid merge', "fixA_run.log + guest boot.log after it: patched kernel #1, same trigger A, clean reboot, grep -c 'Fatal trap' == 0", 'fixC_run.log: patched kernel #1, trigger C, no panic; umount waits in D-state (h2twait) on second-chain teardown -- verified pre-existing by an unmodified MASTER+MASTER clone pair (fix inert, identical hang)', 'fix_build.log: full untrimmed nativekernel build with fix.diff applied', 'forge_df2620.py + df2620_dump.py: image forger/verifier (pfs_nmasters=0xFF at inode+0x86; pfs_type patch at +0x87; CHECK_NONE brefs; volhdr CRC32C recompute)']
PoC changes
Finding seed had no runnable PoC. Built: (1) newfs_hammer2 base image + host-side python forger (DF-2616 CHECK_NONE technique) patching the testvol PFS inode's meta.pfs_nmasters (0xFF) or meta.pfs_type (SLAVE) with volhdr CRC32C recomputation; (2) discovered the claimed naive trigger (mount; umount) cannot reach cleanup for nmasters>8 because any path-based umount/stats cross VFS_ROOT, which loops forever on the inflated quorum (vfsops.c:1966-2008) -- variant A therefore triggers via shutdown/vfs_unmountall; (3) down-direction needed a usable mount: same-pfs_clid 2-device merge (mount #2 fails EBUSY AFTER merging its chain), giving nmasters=1 < nchains=2 with quorum satisfied; (4) fix validation required make nativekernel (buildkernel demands a missing buildworld).
Verified recommended fix
Clamp ripdata->meta.pfs_nmasters to HAMMER2_MAXCLUSTER at ingestion (hammer2_vfsops.c:527-529) and bound hammer2_xop_helper_cleanup()'s loop by min(iroot->cluster.nchains, HAMMER2_MAXCLUSTER) instead of pfs_nmasters (hammer2_admin.c:461) -- see fix.diff.
Verdict
Verified end-to-end on the stock INVARIANTS kernel. (A) Up-direction: a crafted PFS inode with pfs_nmasters=0xFF is ingested unclamped at hammer2_vfsops.c:527-528; mount succeeds and the next unmount (reached via shutdown -> vfs_unmountall, since path-based umount crosses VFS_ROOT which the inflated quorum blocks forever) makes hammer2_xop_helper_cleanup() walk thrs[0..254] over a 288-slot/18432-byte allocation, page-faulting at hammer2_xop_helper_cleanup+0x5a on the exact OOB thrs[i].td read (cmpq $0,0x20(%rax,%r15,1)); garbage .td slots additionally drive hammer2_thr_delete() on OOB memory (atomic bit-set write to arbitrary adjacent heap words, thr->pmp=NULL write, kfree of a wild thr->scratch, OOB KKASSERT), observed in an earlier run as a permanent umount hang in h2twait. (B) Down-direction: a same-pfs_clid MASTER+SLAVE 2-device cluster gives pmp->pfs_nmasters=1 < nchains=2; umount -f deletes only the thrs[0] column and kfrees xop_groups with the SLAVE column's 36 live kernel threads inside (pfsdealloc/pfsfree cleanup skipped because xop_groups is already NULL) -> Fatal trap 12 in hammer2_primary_xops_thread+0x2d9 (lock xaddl on a wild pointer) during umount. Both panics captured on serial console. Fix (clamp at ingestion + cluster-width-bounded cleanup loop) validated by guest kernel rebuild: variant A reboots cleanly with zero traps; variant C no longer corrupts memory (a residual umount wait on second-chain worker teardown is pre-existing and reproduces identically with an unmodified MASTER+MASTER clone pair where the fix is inert). Root-cause chain mount->panic is deterministic; uid=0 chain not developed (OOB write offset is stride-locked past a specific 18KB allocation; UAF victims are kernel thread structs polled by 36 live threads).
No comments yet.