hammer2_sync_replace() resize is a guaranteed no-op (radix of chain->bytes instead of focus->bytes): heap OOB read persisted to media / silent truncation / NULL-source bcopy for embedded dirents
| Field | Value |
|---|---|
| ID | DF-2658 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:H/I:L/A:H |
| CWE | CWE-125 OOB Read (CWE-681 root; CWE-824 variant) |
| File | sys/vfs/hammer2/hammer2_synchro.c |
| Lines | 939-942 (no-op resize), 1041/1049 (copies) |
| Area | vfs |
| Confidence | likely |
| Discovered | 2026-08-29 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | hammer2 |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
When a same-key chain pair with differing modify_tid has differing
bytes (hammer2_chain_cmp compares only key+keybits, chain.c:97-118), the
resize branch computes nradix = hammer2_getradix(chain->bytes) β the
OLD size β so hammer2_chain_resize() sees nbytes == obytes and
early-returns (chain.c:1363-1366): no resize ever happens. The
subsequent bcopy(data, chain->data, chain->bytes) with
data = gdata(focus) of focus->bytes then (a) reads
(chain->bytes - focus->bytes) bytes past the focus buffer when the
focus is smaller β the leaked kernel/buffer-adjacent bytes are copied
into chain->data, setcheck'd, and flushed to media, i.e. persisted into
the synced file's content and readable by any reader; (b) silently
truncates and leaves inconsistent metadata (bref.vradix from focus at
:954 while the buffer stays smaller) when the focus is larger; (c) for
an embedded focus dirent (focus->bytes == 0), gdata returns NULL and
bcopy(NULL, chain->data, 1024) panics.
Threat model & preconditions
Forged/mismatched cluster images mounted by root (the DF-2616..2654 forging family) or naturally diverged columns after partial write failures. Impact: kernel heap OOB read persisted into file data (info leak up to ~63KB per chain), kernel panic, or silent on-media corruption β at sync time.
Proof of concept
Code-level proof is line-accurate (findings/poc/DF-2658/README.md); live execution needs a DATA/DIRENT chain forger with radix/mtid/CRC/ freemap surgery beyond the current tooling (plan in the pack).
Recommended fix
Resize to the focus's size (rounding up for compressed sizes) and bound each bcopy by min(chain->bytes, focus->bytes); explicitly reject/handle the embedded-dirent transition as hammer2_sync_insert() does at :810-823:
--- a/sys/vfs/hammer2/hammer2_synchro.c
+++ b/sys/vfs/hammer2/hammer2_synchro.c
@@ -938,9 +938,17 @@
if (chain->bytes != focus->bytes) {
- /* XXX what if compressed? */
- nradix = hammer2_getradix(chain->bytes);
+ /*
+ * Resize to the FOCUS's physical size, rounding up
+ * (compressed sizes may be non-power-of-2).
+ */
+ nradix = hammer2_getradix(focus->bytes);
+ if ((size_t)1 << nradix < focus->bytes)
+ ++nradix;
error = hammer2_chain_resize(chain, mtid, 0, nradix, 0);
Timeline
- 2026-08-29 Discovered during pass-2 audit of hammer2_synchro.c (GLM 5.3).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2658 Β· 37 filesDF-2658 β hammer2_sync_replace() no-op resize: kernel OOB read persisted
to cluster media (cross-file disclosure + kernel-memory bytes) / silent
truncation / NULL-source bcopy for embedded dirents
sys/vfs/hammer2/hammer2_synchro.c:939-945 (resize no-op) and :1041/:1049 (unbounded bcopy). Medium / hammer2 / CWE-125.
VERIFIED (reproduced) on the stock INVARIANTS guest β see VERDICT.md.
What it is
The resize branch in hammer2_sync_replace() computes
nradix = hammer2_getradix(chain->bytes) β the OLD size β so
hammer2_chain_resize() early-returns (chain.c:1363-1366) and the chain
keeps its old allocation size; the subsequent
bcopy(data, chain->data, chain->bytes) copies chain->bytes out of the
focus's focus->bytes-sized buffer:
- focus smaller -> OOB read past the focus's logical allocation: rest of the 64KB dio media region (other files' block content) plus bytes past the end of the 64KB kernel dio buffer (kernel memory), CRC'd and flushed into the synced chain (info leak persisted to media).
- focus larger -> silent truncation + inconsistent vradix metadata.
- embedded focus (DIRENT, bytes==0) ->
bcopy(NULL, chain->data, 1024)panic (code-proven variant).
Reproduce
# guest (root): /root/poc/df2658/build.sh-equivalent: cc -O2 -o scanbin scanbin.c sh trigger_df2658.sh # ~3 min; everything lands in /root/poc/df2658/
Expected (stock kernel): the run log shows
DATA_REPLACE_SEEN ... poll=N, and F_block_on_C.bin decomposes as
[1024B focus content][d2's 4096B file content][B-media-matching zeros]
[4096B of kernel memory past the dio buffer end β an ELF64 header is
visible in this run]; ls -l F collapses 65536 -> 1024.
Fix
fix.diff β resize to the focus's radix (rounded up for non-pow2 compressed sizes), bound both copies by min(focus->bytes, chain->bytes) with the remainder zeroed, and bzero for the embedded-focus dirent transition. Validated on a rebuilt kernel (see VERDICT.md "Fix validation").
DF-2658 VERDICT β REPRODUCED (kernel OOB read persisted to cluster media)
Bottom line
On a stock DragonFly 6.5-DEVELOPMENT X86_64_GENERIC (INVARIANTS) kernel, a
3-master hammer2 cluster (same-pfs_clid dd clones, merged via EBUSY mounts)
whose columns A/B (quorum-agreeing) hold a file F with a 1024-byte
data-chain allocation while the diverged column C holds the same key
(same dirent name, same inum, same lbase-0 data-chain key) with a 65536-byte
allocation, drives C's sync thread into hammer2_sync_replace() where:
sys/vfs/hammer2/hammer2_synchro.c:939-945β the resize branch computesnradix = hammer2_getradix(chain->bytes)(the OLD size), sohammer2_chain_resize()seesnbytes == obytesand early-returns a guaranteed no-op (hammer2_chain.c:1363-1366), andsys/vfs/hammer2/hammer2_synchro.c:1041βbcopy(data, chain->data, chain->bytes)copies 65536 bytes out of the focus's 1024-byte allocation (data = hammer2_xop_gdata(xop)=focus->data, a pointer into aHAMMER2_PBUFSIZE(64KB) dio buffer,hammer2_io.c:113).
The overwritten 64KB chain data is then CRC'd by hammer2_chain_setcheck()
and flushed to C's media. Decoded on-media evidence (F_block_on_C.bin):
| region | content | proven |
|---|---|---|
[0..1024) |
byte-identical to A/B's F content (f_ab.bin), ndiff=0 |
focus's 1024 bytes copied |
[1024..5120) |
d2's full 4096-byte file content (HIT at offset 1024) |
cross-file disclosure: another file's bytes read out of the same 64KB media region and persisted into F's data block |
[5120..60416) |
zeros, byte-identical to B's media at the same relative position (equal_prefix=60416, exactly 61440 β 1024 as computed from OFF_B % 65536 = 4096) |
in-buffer overread of the focus's dio buffer (B's media region) |
[60416..64512) |
3423/4096 nonzero bytes that do NOT match B's media, starting 43 16 00 00 84 7f 45 4c 46 02 01 01 00 01 00 40 ... β a literal \x7fELF ELF64 ET_REL x86-64 header |
read past the end of the 64KB kernel dio buffer β kernel memory disclosed into the synced file's data block |
The OFF_B % 65536 = 4096 geometry is the smoking gun: the copy starts
4096 bytes into a 65536-byte kernel buffer with length 65536, so it runs
exactly 4096 bytes past the buffer end β and those 4096 bytes (3423
nonzero, ELF-header-led) are on C's disk inside F's block, having never
been written by anyone.
Also observed: F's logical size collapses 65536 -> 1024 (the focus inode
wins) β the "silent truncation" half of the finding.
Guest: DragonFly 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 2026, X86_64_GENERIC (INVARIANTS), QEMU/KVM, stock kernel.
Reproduction (trigger_df2658.sh, run.log)
newfs_hammer2 -L tv58on a 256MB image; clone.- Column A (solo rw): create
d1(4KB),F(1KB),d2(4KB) in this order; unmount;B = dd(A)(bit-identical -> A/B form the 2-of-3 quorum). - Column C (solo rw): same creation order (same inums/dirent keys/data-chain
key), but
Fis written in two stages β 1 byte,sync, then the full 64KB β which shifts F's inode-chain and data-chain modify_tids past A/B's without changing the inum order (run-2 lesson: identical single-shot sequences on cloned allocators make every mtid equal and the scan is skipped as "100% match"). - Mount A rw; merge B and C (EBUSY mounts).
vfs.hammer2.debug=1traces:replace rec ... slave 2 3.0000000000000000 mod=0x13β the size-diverged DATA chain "replace" on column 2 (C) = the bug executing (synchro.c:1041 bcopy ran with chain->bytes=65536, focus->bytes=1024). syncfired the moment the replace appeared (dmesg poll,DATA_REPLACE_SEEN poll=1); F's poisoned block was flushed and extracted fromC.imgwithscanbin(see run.log; artifacts F_block_on_C.bin etc.).
Precondition reality check (reachability)
The size-mismatched same-key state arises whenever a column's data-chain
allocation differs from the quorum's: forged/mismatched cluster images, or
naturally diverged columns after partial write failures / offline writes.
The rig manufactures it with unprivileged-equivalent file writes (a solo
mount is required for the divergence step β i.e. an operator action or a
forged image, matching the filed PR:H / "forged or naturally diverged
cluster images" threat model). Remote forging of the same state over the
dmsg cluster protocol is not needed for the primitive itself: the bug is in
the local sync thread's copy loop.
Related crash observed during verification (NOT this finding)
Within seconds after any successful catch-up pass on the stock kernel, the
guest panics at hammer2_inode_chain_and_parent.cold.9+0x15
(hammer2_inode.c:446-453: chain == NULL -> chain->parent, fault va
0x118) via hammer2_chain_inode_find() (hammer2_chain.c:5678), reached
from xop unlink/lookup paths (hammer2_xops.c:96/298/472) racing the sync
thread's partially-populated in-core inodes. This is a separate pre-existing
defect (same family as DF-2620/DF-2657's multi-chain lifecycle bugs); it cut
every verification run short after the DF-2658 primitive executed and was
raced successfully by the DATA_REPLACE_SEEN + immediate sync poll.
Full backtraces in panic3.backtrace.txt (and attempts_fghi.console.txt in
the DF-2660 pack).
Fix validation
fix.diff (authored after reproduction, applied inside the guest's /usr/src
copy only; never to the audit tree):
1. resize branch uses hammer2_getradix(focus->bytes), rounded up to
hold non-power-of-2 (compressed) foci;
2. the DATA/DIRENT copies are bounded by min(focus->bytes, chain->bytes)
with the remainder zeroed;
3. an embedded focus (bytes == 0) into a non-embedded local dirent clears
the local allocation instead of bcopy(NULL, ...) (the panic variant).
(Validation run results appended below after the rebuilt kernel.)
Fix validation (kernel #1, Thu Oct 15 07:30:39 UTC 2026, both fixes applied)
Build: cd /usr/src && make nativekernel && make installkernel on the guest
with fix2658.diff + fix2660.diff applied (fix_build.log, 17087 lines,
BUILD_INSTALL_DONE_RC0; hammer2_synchro.c compiled clean under -Werror).
Baseline (stock kernel #0, same images/flow): F_block_on_C.bin = [1024B f_ab][4096B d2 file content][zeros matching B media for exactly 60416 bytes][4096B kernel memory past the dio buffer end (3423 nonzero, ELF64 header visible)] <- THE BUG
Patched (kernel #1, identical trigger):
* the catch-up pass runs to completion through the patched path
(run_fixed.log / serial: replace rec ... 3.0000000000000000 mod=0x13,
deferred finals 0x15/0x11, root final 0x19);
* f_ab content found on C.img at 3 offsets (findfix_C_fab.txt:
29372416, 29425664, 29491200) and none carries the stock signature:
tail-vs-B-media comparison gives equal_prefix=0 for every candidate
(stock: 60416), no d2 content adjacent at +1024, no kernel-byte window;
F's data chain is a 1024-byte allocation holding exactly f_ab;
* the original 64KB C allocation is gone/reclaimed (findfix_C_fc.txt: 0
hits for the old f_c content) and F's logical size follows the focus
(1024) with consistent vradix metadata.
=> the persisted OOB read is GONE on the patched kernel; fix_status=fixed (baseline_reproduced=1, patched_reproduced=0).
Note: the pre-existing sibling NULL-deref (hammer2_inode_chain_and_parent, fault 0x118, via hammer2_chain_inode_find) still terminates the session a few seconds after the pass on the patched kernel β unchanged by this fix, out of scope, captured in panic_fixed_kernel_siblingcrash.txt. It did not affect the extraction (the trigger's DATA_REPLACE_SEEN poll syncs and the images persist on disk across the crash+reboot).
Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
Fix verification
fixedKernel #1 (2026-10-15 07:30:39, fix2658+fix2660 applied, BUILD_INSTALL_DONE_RC0): identical trigger runs the full catch-up pass through the patched resize/copy path (replace rec 3.0 mod=0x13 through root final 0x19 in run_fixed.log); on C.img none of the three f_ab blocks carries the stock signature (tail-vs-B-media equal_prefix=0 vs stock 60416, no adjacent d2 content, no kernel-byte window) - the persisted OOB read is gone; F's chain is a 1024-byte allocation of exactly the focus content. The sibling chain_inode_find NULL-deref still fires post-pass on the fixed kernel (pre-existing, out of scope, unchanged signature).
findings/poc/DF-2658/fix_run.log (fixed-kernel run), findfix_C_fab.txt/findfix_B_fab.txt, cand_29372416.bin/cand_29425664.bin/cand_29491200.bin + Btail.bin (cmp equal_prefix=0 on all), panic_fixed_kernel_siblingcrash.txt, fix_build.log
Confirmed kernel references
- sys/vfs/hammer2/hammer2_synchro.c:939
- sys/vfs/hammer2/hammer2_synchro.c:941
- sys/vfs/hammer2/hammer2_synchro.c:954
- sys/vfs/hammer2/hammer2_synchro.c:1041
- sys/vfs/hammer2/hammer2_synchro.c:1049
- sys/vfs/hammer2/hammer2_chain.c:97
- sys/vfs/hammer2/hammer2_chain.c:1363
- sys/vfs/hammer2/hammer2_io.c:113
- sys/vfs/hammer2/hammer2.h:1963
Detail
Exploit chain
Unprivileged-readable trigger surface is media persistence: the OOB tail (other files' media blocks + kernel buffer-adjacent memory) is written into the synced file's data block and CRC'd/flushed to the column's image; reading the tail back through the VFS is size-gated (focus inode wins), so direct exfiltration requires raw-device access (root) or a subsequent consumer of the stale block - realistic impact is cross-file/kernel-memory disclosure persisted to cluster media plus metadata inconsistency (vradix vs allocation), not a direct unpriv readout. Escalation to uid=0 does not apply (CWE-125 read-only primitive).
Evidence (decisive lines)
VERDICT.md (full narrative + fix validation); F_block_on_C.bin [1024 f_ab][4096 d2][zeros==B-media 60416][4096 kernel bytes incl 7fELF]; scanbin cmp output equal_prefix=60416 (run.log); find_B_fab.txt OFF_B=29364224 (OFF_B%65536=4096); panic3.backtrace.txt + panic_fixed_kernel_siblingcrash.txt (sibling defect, out of scope); fix validation: findfix_*.txt + cand_*.bin (equal_prefix=0 everywhere on kernel #1) and run_fixed.log (pass completes through patched code).
PoC changes
No seed PoC existed (audit-time finding, status was untested). Built the full rig: 3-master same-clid clone cluster via vnconfig+newfs_hammer2+EBUSY merges; size-diverged same-key F (1024B on A/B vs 64KB on C) with inum/key alignment preserved by identical creation order, mtid divergence by a two-stage F write on C (run-2 lesson: identical single-shot sequences on cloned allocators make every mtid equal and the scan is skipped); scanbin.c analysis tool (find/stat/dump/cmp with range args); dmesg-poll + immediate sync to beat the sibling crash to the flush.
Verified recommended fix
nradix from hammer2_getradix(focus->bytes) rounded up to hold focus->bytes; bound both DATA/DIRENT copies by min(focus->bytes, chain->bytes) with the remainder zeroed; bzero for the embedded-focus dirent transition (fix.diff, validated).
Verdict
REPRODUCED on the stock INVARIANTS kernel with a 3-master same-clid hammer2 cluster (dd clones, EBUSY merges): column A/B (quorum) hold F with a 1024-byte data-chain allocation, diverged column C holds the same key with 65536 bytes; C's sync thread runs hammer2_sync_replace() where nradix=hammer2_getradix(chain->bytes) (the OLD size) makes hammer2_chain_resize() a guaranteed no-op (chain.c:1363-1366), after which bcopy(data, chain->data, 65536) at synchro.c:1041 copies 64KB out of the focus's 1024-byte allocation. On-media proof (F_block_on_C.bin): [1024B focus content][4096B d2 file content = cross-file disclosure][zeros byte-identical to B's media for exactly 60416 tail bytes, matching OFF_B%65536=4096 geometry][4096 bytes past the end of the 64KB kernel dio buffer - 3423 nonzero, led by a literal ELF64 ET_REL x86-64 header = kernel memory]. setcheck+flush persisted the OOB read to C's media; F's logical size silently collapsed 65536->1024. The embedded-dirent NULL-bcopy panic variant remains code-proven only (same-name embed divergence requires forged images). A separate pre-existing NULL-deref (hammer2_inode_chain_and_parent, hammer2_inode.c:446-453, via hammer2_chain_inode_find from xop unlink/lookup) kills the guest within seconds after any successful catch-up pass on stock - it cut every run AFTER the bug executed and was outrun by a dmesg-poll + immediate sync.
No comments yet.