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

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).

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2658 Β· 37 files
FileTypeDescriptionSize
VERDICT.md β€” 8.0 KB ↓ raw
README.md β€” 2.0 KB ↓ raw
trigger_df2658.sh β€” 7.6 KB view raw
scanbin.c β€” 5.5 KB view raw
build.sh β€” 115 B view raw
run.sh β€” 155 B view raw
build.log β€” 63 B view raw
run.log β€” 10.2 KB view raw
run_fixed.log β€” 9.9 KB view raw
fix_run.log β€” 9.9 KB view raw
F_block_on_C.bin β€” 64.0 KB ↓ download
F_block_on_B.bin β€” 64.0 KB ↓ download
F_tail_on_C.bin β€” 63.0 KB ↓ download
F_tail_on_B.bin β€” 63.0 KB ↓ download
f_ab.bin β€” 1.0 KB ↓ download
f_c.bin β€” 64.0 KB ↓ download
d1.bin β€” 4.0 KB ↓ download
d2.bin β€” 4.0 KB ↓ download
find_B_fab.txt β€” 13 B view raw
find_C_fab.txt β€” 13 B view raw
find_B_d1.txt β€” 13 B view raw
find_B_d2.txt β€” 13 B view raw
findfix_C_fab.txt β€” 13 B view raw
findfix_B_fab.txt β€” 13 B view raw
findfix_C_fc.txt β€” 26 B view raw
cand_29372416.bin β€” 64.0 KB ↓ download
cand_29425664.bin β€” 64.0 KB ↓ download
cand_29491200.bin β€” 64.0 KB ↓ download
Btail.bin β€” 63.0 KB ↓ download
panic2.nomount-sync.txt β€” 16.5 KB view raw
panic3.backtrace.txt β€” 13.2 KB view raw
panic_fixed_kernel_siblingcrash.txt β€” 13.1 KB view raw
fix.diff β€” 2.5 KB view raw
fix_build.log β€” 5.2 MB ↓ download
env.txt β€” 1.2 KB view raw
manifest.json β€” 2.0 KB view raw
verdict.json β€” 6.0 KB view raw

DF-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").

VERDICT.md
↓ download raw

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 computes nradix = hammer2_getradix(chain->bytes) (the OLD size), so hammer2_chain_resize() sees nbytes == obytes and early-returns a guaranteed no-op (hammer2_chain.c:1363-1366), and
  • sys/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 a HAMMER2_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)

  1. newfs_hammer2 -L tv58 on a 256MB image; clone.
  2. 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).
  3. Column C (solo rw): same creation order (same inums/dirent keys/data-chain key), but F is 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").
  4. Mount A rw; merge B and C (EBUSY mounts). vfs.hammer2.debug=1 traces: 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).
  5. sync fired the moment the replace appeared (dmesg poll, DATA_REPLACE_SEEN poll=1); F's poisoned block was flushed and extracted from C.img with scanbin (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.

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

fixed
baseline reproduced→ patch + rebuild →patched clean

Kernel #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
↓ fix.diffper-fix-DF-2658

Confirmed kernel references

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.