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 Β· 2 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | file | 4.1 KB | β raw | |
| verdict.json | file | 2.5 KB | view 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_testableConfirmed 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.
No comments yet.