β¬’ 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 Β· 2 files
FileTypeDescriptionSize
README.md file 4.1 KB ↓ raw
verdict.json file 2.5 KB view raw
README.md file
↓ download raw

DF-2658 β€” hammer2_sync_replace() resize is a no-op (wrong variable): OOB

read from the focus chain's data buffer / silent truncation / NULL-source

bcopy for embedded dirents

sys/vfs/hammer2/hammer2_synchro.c:939-957 and 1041-1055 (also the insert path's parallel structure at 790-823 is size-safe because it creates the chain with focus->bytes).

Medium / hammer2 bucket. Code defect: certain. Trigger: likely (requires same-key chains with differing bytes+modify_tid across cluster columns β€” forged/mismatched cluster images, or naturally diverged columns after partial write failures). NOT verified on the guest (status: untested) β€” forging valid same-key DATA chains with differing radix/mtid pairs and valid CRCs+freemap is a multi-hour forger extension of h2common.py; the code-level proof below is line-accurate.

Root cause (path:line)

sys/vfs/hammer2/hammer2_synchro.c:939-944:
    if (chain->bytes != focus->bytes) {
        /* XXX what if compressed? */
        nradix = hammer2_getradix(chain->bytes);      /* WRONG VAR */
        error = hammer2_chain_resize(chain, mtid, 0, nradix, 0);

* `hammer2_getradix(chain->bytes)` derives the radix of the OLD size,
  so `hammer2_chain_resize()` computes `nbytes == obytes` and returns
  immediately (hammer2_chain.c:1363-1366): the branch is a guaranteed
  no-op.  The intent clearly was `hammer2_getradix(focus->bytes)`.
* The subsequent copies then run with mismatched sizes:
    sys/vfs/hammer2/hammer2_synchro.c:1041  bcopy(data, chain->data, chain->bytes);   /* DATA */
    sys/vfs/hammer2/hammer2_synchro.c:1049  bcopy(data, chain->data, chain->bytes);   /* DIRENT */
  where `data = hammer2_xop_gdata(xop)` points at the FOCUS chain's
  buffer of focus->bytes.
  - focus->bytes < chain->bytes  -> OOB READ of (chain->bytes -
    focus->bytes) bytes past the focus buffer, copied into chain->data,
    integrity-checked (hammer2_chain_setcheck) and flushed to media:
    kernel heap/buffer-adjacent bytes persisted into file content,
    readable by any reader of the synced file (info leak).
  - focus->bytes > chain->bytes  -> silent truncation + inconsistent
    metadata (chain->bref.vradix is set to focus's at line 954 while
    the buffer stays smaller).
  - DIRENT with focus embedded (focus->bytes == 0): gdata returns
    focus->data == NULL -> `bcopy(NULL, chain->data, 1024)` -> panic.
* Reachability of the size-mismatch state: `hammer2_chain_cmp()`
  compares ONLY key+keybits (hammer2_chain.c:97-118), so an n==0
  match with differing bytes reaches sync_replace whenever
  modify_tid differs (synchro.c:525); compressed DATA chains can
  legitimately have non-power-of-2 bytes (hammer2_chain_resize
  comment, chain.c:1332-1335), and forged cluster images (the
  DF-2616..2654 family tooling) make the mismatch fully
  attacker-chosen at mount/sync time.

Fix

--- a/sys/vfs/hammer2/hammer2_synchro.c
+++ b/sys/vfs/hammer2/hammer2_synchro.c
    if (chain->bytes != focus->bytes) {
 -      /* XXX what if compressed? */
 -      nradix = hammer2_getradix(chain->bytes);
 +      /*
 +       * Resize to the FOCUS's physical size (may be a
 +       * compressed, non-power-of-2 size rounding down is
 +       * not acceptable: allocate at least focus->bytes).
 +       */
 +      nradix = hammer2_getradix(focus->bytes);
 +      if ((size_t)1 << nradix < focus->bytes)
 +          ++nradix;
        error = hammer2_chain_resize(chain, mtid, 0, nradix, 0);

plus bound the copies by min(chain->bytes, focus->bytes) after the
resize and refuse (error) when focus->bytes == 0 && chain->bytes != 0
for DIRENT (embedded transition must be handled explicitly, as
hammer2_sync_insert() already does at synchro.c:810-823).

PoC status

untested β€” see verdict.json. Sketch for a future run: forge a 2-column cluster where column A (focus) holds a DATA chain at key K with mtid T2 and 512-byte compressed allocation, column B holds the same key with mtid T1 and a 64KB allocation; merge; let the sync thread replace B's chain from A; read the file on B: bytes 512..65535 are kernel OOB read output.

Fix verification

not_testable
per-fix-DF-2658

Confirmed kernel references

Detail

Evidence (decisive lines)

findings/poc/DF-2658/README.md (root-cause with path:line, fix sketch, PoC plan)

PoC changes

n/a (no seed PoC existed; audit-time discovery)

Verified recommended fix

nradix = hammer2_getradix(focus->bytes) (rounded up to hold focus->bytes), bound the copies by min(chain->bytes, focus->bytes), and explicitly handle the embedded-dirent (bytes==0) transition.

Verdict

Not verified on the guest (status untested): triggering requires a forged/mismatched same-key chain pair with differing bytes AND modify_tid across cluster columns, which needs a block-level DATA-chain forger (radix/mtid/CRC/freemap surgery) beyond the current h2common.py tooling; the multi-hour build-out was deprioritized in favor of the DF-2657 root-cause fix validation. The code defect itself is certain from line-accurate reading: hammer2_sync_resize uses hammer2_getradix(chain->bytes) (the OLD size) making hammer2_chain_resize a guaranteed no-op (chain.c:1363-1366 early-returns when nbytes==obytes), after which bcopy(data, chain->data, chain->bytes) at synchro.c:1041/1049 runs with data = gdata(focus) of focus->bytes - focus smaller => OOB read persisted to media (info leak into synced file content); focus larger => silent truncation with inconsistent vradix metadata (synchro.c:954); focus embedded dirent (bytes==0) => NULL-source bcopy panic. hammer2_chain_cmp compares only key+keybits (chain.c:97-118) so size-mismatched same-key chains with differing mtid reach the path.