hammer_update_itimes() in-place atime/mtime update writes past the inode record's 16KB block on crafted HAMMER1 images β no data_len validation at extraction and hammer_crc_test_leaf() accepts any wrong-length INODE record via the 0-return CRC bypass
| Field | Value |
|---|---|
| ID | DF-3003 |
| Status | new |
| Severity | High |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H |
| CWE | CWE-787 / CWE-345 |
| File | sys/vfs/hammer/hammer_inode.c |
| Lines | 1446, 1462 (root: hammer_btree.c:728-738; hammer_crc.h:281-303) |
| Area | vfs/hammer |
| Confidence | certain |
| Discovered | 2026-09-02 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
hammer_update_itimes() is the only in-place record-data writer in the HAMMER1 inode engine: for a timestamp-only flush it locates the on-disk INODE record and stores mtime/atime directly through cursor->data at fixed struct offsets (+112/+120). cursor->data is derived entirely from the on-disk leaf (data_offset/data_len) by hammer_btree_extract(), which validates neither the INODE record's data_len (must be sizeof(struct hammer_inode_data)) nor that the record's data range stays inside its 16KB block; and the CRC gate that should make wrong-length inode records unrepresentable is bypassable because hammer_crc_get_leaf() returns 0 for them, which hammer_crc_test_leaf() happily matches against a crafted data_crc of 0. A crafted image therefore places the record's data at the tail of a 16KB buffer (xoff=16272) and the backend flush writes 8..136 bytes past the end of the record's block. VERIFIED on the guest: the atime-only branch (hammer_modify_buffer_noundo β no KKASSERT, no UNDO) silently persisted the attacker-chosen value 1740000000Γ10βΆ exactly at buffer+16392 through the buffer cache into the following on-disk block, undetected by every CRC; the mtime branch panicked the stock INVARIANTS kernel with the out-of-block arithmetic in the assertion message; on production kernels it proceeds as a silent 16-byte write plus a misdirected UNDO entry whose undo_offset crosses into the next 16KB block (corrupting unrelated metadata during crash recovery). Honest ceiling, established on-guest: every DFly buffer-cache buffer owns a private MAXBSIZE=64KB kva reservation, so the store can never leave the buffer's own reservation β no cross-allocation heap overflow and no uid0 route claimed; impact is a chosen-value persistent on-disk metadata write in the block following the crafted record, misdirected-undo recovery corruption, and kernel panic, from mounting attacker-supplied media (utimensat with only atime changed, or an atime read update).
Proof of contest
VERIFIED (findings/poc/DF-3003/): craft_image.py repoints the INODE
leaf to data_offset=buffer+16272, data_len=32768, data_crc=0, recompute
node CRC32C; mount + utimensat(atime, UTIME_OMIT) + sync + umount β
image contains exactly 0x00062e8551e8c000 at target+16392
(byte-exact); mtime variant panics INVARIANTS kernels. Fix validated
(extraction choke-point data_len + buffer-span validation + CRC-gate
harden): patched kernel rejects the crafted record with EIO, no write,
no panic, legit mounts unaffected.
Timeline
- 2026-09-02 Discovered during pass-2 audit of hammer_inode.c (GLM 5.3); silent chosen-value on-disk write + panic reproduced + fix validated.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3003 Β· 10 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 4.7 KB | β raw | |
| VERDICT.md | β | 5.2 KB | β raw | |
| craft_image.py | β | 5.6 KB | view raw | |
| trig.c | β | 1.4 KB | view raw | |
| run.sh | β | 643 B | view raw | |
| build.sh | β | 999 B | view raw | |
| panic_getinode_run1.txt | β | 14.0 KB | view raw | |
| panic_updatetimeimes_runB3.txt | β | 16.0 KB | view raw | |
| fix.diff | β | 1.7 KB | view raw | |
| verdict.json | β | 7.1 KB | view raw |
DF-3003 β hammer_update_itimes() in-place atime/mtime update writes past the record's 16KB block on crafted HAMMER1 inode records (no data_len validation + CRC-gate 0-return bypass)
Kernel: DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC, INVARIANTS), sys/vfs/hammer File under audit: sys/vfs/hammer/hammer_inode.c (sink: hammer_update_itimes, lines 1410-1474)
What was verified (all on the QEMU guest, stock INVARIANTS kernel)
Two independent reproductions, both from a crafted HAMMER1 image mounted with
vnconfig + mount_hammer (root in the PoC; same image reaches any victim
that mounts attacker-supplied media, incl. vfs.usermount=1 setups):
Stage A β silent, attacker-valued, persistent write past the record's 16KB block (reproduced)
Craft the file's INODE btree record with data_len = 32768 (any value != 128
works; see CRC bypass below) and data_offset = <free 16KB buffer> + 16272
(so xoff + 128 > 16384). utimensat(atime=1740000000s, mtime=UTIME_OMIT)
dirties ONLY HAMMER_INODE_ATIME (hammer_vnops.c setattr) so the backend
flush routes to hammer_update_itimes()'s ATIME branch
(hammer_inode.c:1454-1463): hammer_modify_buffer_noundo() (no UNDO, no
KKASSERT) followed by the in-place store
cursor->data->inode.atime = ip->sync_ino_data.atime at
buffer->ondisk + 16272 + 120 = ondisk + 16392 β 8..136 bytes PAST the end
of the 16KB block the record's data_offset nominally occupies.
Observed: after sync + umount, the disk image contains exactly
1740000000 * 10^6 = 0x00062e8551e8c000 at image offset target+16392
(was marker 0x4242424242424242 before). Chosen value, chosen block,
silent on an INVARIANTS kernel (no panic, no UNDO record; atime is excluded
from the record CRC so nothing detects it).
Stage B β mtime branch panic with the OOB arithmetic printed (reproduced)
utimensat(mtime=1740000001s, atime=UTIME_OMIT) dirties only
HAMMER_INODE_MTIME β the MTIME branch calls
hammer_modify_buffer(trans, data_buffer, &data->inode.mtime, 16)
(hammer_inode.c:1446) whose KKASSERT((rel_offset & ~HAMMER_BUFMASK) == 0)
computes rel_offset = 16384 β panic:
panic: assertion "(rel_offset & ~(intptr_t)HAMMER_BUFMASK) == 0" failed
in hammer_modify_buffer at /usr/src/sys/vfs/hammer/hammer_io.c:931
hammer_modify_buffer() at hammer_modify_buffer+0xc9
hammer_sync_inode() at hammer_sync_inode+0x778
hammer_flusher_slave_thread() at hammer_flusher_slave_thread+0x1c2
On non-INVARIANTS kernels the assert is compiled out: the write proceeds and the UNDO entry is misdirected (undo_offset = zone2 base + 16384, i.e. into the NEXT 16KB block β recovery would apply the rollback to the wrong block).
Corollary (small data_len variant)
With data_len = 1 (16KB buffer fetched), the past-end page is unmapped by
construction on DFly (every buffer-cache buffer has its own MAXBSIZE=64KB kva
reservation, vfs_bio.c:638) β the struct copy in hammer_get_inode
(hammer_inode.c:525, DF-0771) page-faults at the buffer-end boundary:
Fatal trap 12 ... Stopped at hammer_get_inode+0x3f5: movq 0x70(%rax),%rdx.
The same fault geometry applies to the update_itimes store on the flusher.
Root cause chain
hammer_crc_get_leaf()(hammer_crc.h) returns CRC 0 for INODE records whose data_len != sizeof(struct hammer_inode_data); a crafted leaf with data_crc=0 therefore PASSEShammer_crc_test_leaf()(0 == 0).hammer_btree_extract()(hammer_btree.c:728-738) never validates data_len for the record type nor that (xoff + data_len) stays within the record's 16KB block;cursor->data = buffer->ondisk + xoffis fully attacker-placed.hammer_update_itimes()(hammer_inode.c:1410-1474) writes atime/mtime IN PLACE at fixed struct offsets (+112/+120) through that pointer with no length validation β the only in-place record-data writer in the inode engine.
Reproduce
- Build a base image (guest, root): dd 4G sparse, vnconfig, newfs_hammer -f,
mount,
echo hello > /mnt/f, sync, umount, vnconfig -u. - On the host:
python3 craft_image.py h1.img h1_patched2.img(repoints the file's INODE record; data_len=32768, data_crc=0, recomputes the btree node CRC32C; prints all offsets; writes a .json). - Push to guest; compile
cc -o trig trig.c; Stage A: vnconfig+mount_hammer -o atime,./trig a /mnt/f, sync, umount; pull image; verify 8 bytes at target+16392 == 0x00062e8551e8c000. Stage B: fresh mount,./trig b /mnt/f, sync β panic (serial log).
Fix
fix.diff (validated on guest, see VERDICT.md): reject INODE records whose
data_len != sizeof(struct hammer_inode_data) and any record whose data range
crosses out of its aligned buffer block in hammer_btree_extract(); make
hammer_crc_test_leaf() fail such INODE records instead of letting the 0
CRC "match" a stored 0.
DF-3003 VERDICT
Status: reproduced (both stages, stock INVARIANTS guest kernel
DragonFly dfbsd 6.5-DEVELOPMENT #0 ... X86_64_GENERIC).
Finding: hammer_update_itimes() (sys/vfs/hammer/hammer_inode.c:1410-1474)
updates atime/mtime in place through cursor->data β a pointer derived
entirely from the on-disk record's data_offset/data_len with no
validation:
* hammer_btree_extract() (hammer_btree.c:728-738) computes
cursor->data = buffer->ondisk + (data_offset & 0x3FFF); it never checks
that the record's data range stays inside its 16KB block, and never
checks INODE records' data_len == sizeof(struct hammer_inode_data).
* hammer_crc_get_leaf() (hammer_crc.h) returns 0 for INODE records with a
wrong data_len, and hammer_crc_test_leaf() compares it against the
stored data_crc β so a crafted leaf with data_crc = 0 passes CRC for
ANY data_len (this is the gate that was supposed to make wrong-length
inode records unrepresentable).
* With a crafted data_offset at the tail of a 16KB buffer
(xoff = 16272), the in-place stores at data+112/data+120
(mtime/atime) land 8..136 bytes past the end of the record's 16KB block.
What was demonstrated on the guest
-
Crafted image accepted: btree node CRC recomputed offline (CRC32C β implementation verified against the original node), leaf data_len=32768, data_crc=0, data_offset β free-buffer tail. Mount clean, file resolvable.
-
Stage A (ATIME-only, silent):
utimensat(atime=1740000000, mtime=UTIME_OMIT)β onlyHAMMER_INODE_ATIMEdirty β backend flush takes the ATIME branch (hammer_inode.c:1454-1463):hammer_modify_buffer_noundo()(no UNDO record, no KKASSERT) then the 8-byte in-place store atondisk+16392. Aftersync+umountthe backing image contains exactly1740000000*10^6 = 0x00062e8551e8c000at image offset target+16392 (was 0x4242... marker). Attacker-chosen value, attacker-chosen 16KB block (any block in the volume's data zone), persisted to media, invisible to every CRC (atime is excluded from inode record CRC by design) β on an INVARIANTS kernel with zero console output. -
Stage B (MTIME-only, panic):
utimensat(mtime=1740000001, atime=UTIME_OMIT)β MTIME branch callshammer_modify_buffer(trans, bp, &data->inode.mtime, 16);KKASSERT((rel_offset & ~HAMMER_BUFMASK) == 0)at hammer_io.c:931 fails with rel_offset = 16384 β the panic arithmetic proving the update targeted bytes past the record's 16KB block:panic: assertion "(rel_offset & ~(intptr_t)HAMMER_BUFMASK) == 0" failed in hammer_modify_buffer at /usr/src/sys/vfs/hammer/hammer_io.c:931 hammer_modify_buffer() at hammer_modify_buffer+0xc9 hammer_sync_inode() at hammer_sync_inode+0x778 hammer_flusher_slave_thread() at hammer_flusher_slave_thread+0x1c2On production (non-INVARIANTS) kernels the assert is compiled out: the 16-byte write proceeds and the generated UNDO is misdirected (undo_offset = zone2_base + 16384β crash recovery would apply the rollback to the next 16KB block, i.e. corrupt unrelated metadata). -
Small-data_len corollary: with
data_len = 1(16KB buffer fetched), the page after the buffer's last page is unmapped by construction on DFly (each buffer-cache buffer owns a private MAXBSIZE=64KB kva reservation, sys/kern/vfs_bio.c:638-640) βhammer_get_inode's struct copy (hammer_inode.c:525 β DF-0771) page-faults at the boundary (observed twice:Fatal trap 12 ... hammer_get_inode+0x3f5: movq 0x70(%rax),%rdx); the update_itimes store would fault identically.
Honest impact ceiling
- The store can never leave the buffer's own 64KB kva reservation on DFly β this is NOT a cross-allocation kernel-heap overflow and no uid0 route is claimed. The demonstrated primitives are:
- chosen-value 8/16-byte write at a chosen offset inside the 16KB block following the crafted record's data block, written through the buffer cache and persisted to the medium (silent on production kernels, silent even on INVARIANTS for the atime variant);
- kernel panic in the backend flusher (INVARIANTS kernels, mtime variant);
- misdirected UNDO entries corrupting unrelated blocks during crash recovery (production kernels);
- page-fault panic (small data_len variant).
- Precondition: mounting attacker-supplied HAMMER media (root mount or
vfs.usermount=1).
Fix validation
fix.diff rejects INODE records with data_len !=
sizeof(struct hammer_inode_data) and any record whose data range crosses
out of its aligned buffer block at hammer_btree_extract(), and makes
hammer_crc_test_leaf() fail wrong-length INODE records instead of letting
the helper's 0 return "match" a stored 0. Built via make nativekernel in
the guest and rebooted:
- Baseline (stock kernel): stage A writes 0x00062e8551e8c000 into the image; stage B panics in hammer_modify_buffer.
- Patched kernel: same crafted image, same commands β the crafted record is
rejected (EIO) at extraction,
fresolves to ENOENT, no bytes change, no panic, guest stays up.
(fix log excerpt in fix_run.log; baseline logs in run_stageA.log / panic_updatetimeimes_runB3.txt.)
Fix verification
fixedPatched kernel (fix.diff applied in guest /usr/src, make nativekernel + installkernel, reboot) rejects the crafted record at extraction: mount OK, /mnt/f and utimensat return EIO, console prints 'HAMMER: bad inode record data_len 32768', the target image bytes are byte-identical before/after (no write), no panic, and a legitimate HAMMER mount (h1.img: ls/cat/touch/sync/umount) works unchanged. Baseline reproduced both the silent write (exact chosen value in image) and the hammer_modify_buffer panic.
['findings/poc/DF-3003/fix_build.log (make nativekernel output)', 'findings/poc/DF-3003/fix_run.log (patched-kernel stage A/B + regression run)', 'image byte comparison: [target+16384,+16400) == (0x4141414141414141, 0x4242424242424242) before AND after the patched run (baseline run had overwritten atime to 0x00062e8551e8c000)']
Confirmed kernel references
Detail
Exploit chain
craft HAMMER1 image (btree leaf: rec_type=INODE, data_len=32768, data_crc=0, data_offset=
Evidence (decisive lines)
['findings/poc/DF-3003/panic_updatetimeimes_runB3.txt: panic assertion in hammer_modify_buffer via hammer_sync_inode/hammer_flusher_slave_thread', 'findings/poc/DF-3003/README.md + VERDICT.md: stage A exact-value image verification (0x00062e8551e8c000 at target+16392, was 0x4242424242424242)', 'findings/poc/DF-3003/panic_getinode_run1.txt: small-data_len variant page-fault at hammer_get_inode+0x3f5 (buffer-end kva boundary)', 'findings/poc/DF-3003/fix_run.log: patched kernel rejects record with EIO, image bytes unchanged, no panic, legit FS regression-free', "guest console: 'HAMMER: bad inode record data_len 32768' from the fix"]
PoC changes
Replaced the seeded sketch with: (1) host-side image crafter (craft_image.py) that walks the B-Tree from vol0_btree_root, repoints the file's INODE record data_offset to a free 16KB buffer tail (xoff=16272), sets data_len=32768/data_crc=0 and recomputes the btree node CRC32C (verified against the original node); (2) trig.c using utimensat with UTIME_OMIT (utimes with tv_sec=-1 returns EINVAL on DFly, and touch -a still sets the MTIME dirty flag); (3) discovered that data_len must exceed 16KB so hammer_bread_ext fetches a >=32KB buffer whose kva covers the past-block store - with small data_len the past-end page is unmapped by construction (per-buffer MAXBSIZE reservations) and the load faults first.
Verified recommended fix
Reject INODE records whose data_len != sizeof(struct hammer_inode_data) and any record whose data range crosses out of its aligned buffer block in hammer_btree_extract(); make hammer_crc_test_leaf() fail wrong-length INODE records (see fix.diff, built and validated on the guest).
Verdict
Reproduced on the stock INVARIANTS guest. A crafted HAMMER1 inode record (data_offset at the tail of a 16KB buffer, xoff=16272; data_len=32768; data_crc=0 which hammer_crc_test_leaf() accepts because hammer_crc_get_leaf() returns 0 for wrong-length INODE records) drives hammer_update_itimes()'s in-place atime/mtime stores at cursor->data+112/+120, i.e. 8..136 bytes past the record's 16KB block. Stage A (atime-only via utimensat+UTIME_OMIT): silent 8-byte store of the attacker-chosen value 1740000000*10^6=0x00062e8551e8c000 observed exactly at image offset target+16392 after sync+umount (no panic, no UNDO, no CRC change, INVARIANTS kernel). Stage B (mtime-only): panic 'assertion (rel_offset & ~(intptr_t)HAMMER_BUFMASK) == 0 failed in hammer_modify_buffer at hammer_io.c:931' on the flusher via hammer_sync_inode, rel_offset=16384 proving the out-of-block target; on production kernels that assert is compiled out leaving a silent 16-byte write plus a misdirected UNDO entry (undo_offset crosses into the next 16KB block, corrupting unrelated metadata at crash recovery). Small-data_len variant page-faults at the buffer-end kva boundary (observed in hammer_get_inode, the DF-0771 read). Honest ceiling: on DFly every buffer-cache buffer owns a private MAXBSIZE=64KB kva reservation (vfs_bio.c:638) so the store can never leave the buffer's own reservation - no cross-allocation heap overflow and no uid0 route is claimed; impact = chosen-value persistent on-disk metadata corruption in the block following the crafted record's data + kernel panic, from mounting attacker-supplied media.
No comments yet.