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

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