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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3003 Β· 10 files
FileTypeDescriptionSize
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

  1. 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 PASSES hammer_crc_test_leaf() (0 == 0).
  2. 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 + xoff is fully attacker-placed.
  3. 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

  1. Build a base image (guest, root): dd 4G sparse, vnconfig, newfs_hammer -f, mount, echo hello > /mnt/f, sync, umount, vnconfig -u.
  2. 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).
  3. 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.

VERDICT.md
↓ download raw

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

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

  2. Stage A (ATIME-only, silent): utimensat(atime=1740000000, mtime=UTIME_OMIT) β†’ only HAMMER_INODE_ATIME dirty β†’ 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 at ondisk+16392. After sync+umount the backing image contains exactly 1740000000*10^6 = 0x00062e8551e8c000 at 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.

  3. Stage B (MTIME-only, panic): utimensat(mtime=1740000001, atime=UTIME_OMIT) β†’ MTIME branch calls hammer_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+0x1c2 On 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).

  4. 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, f resolves 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

fixed
baseline reproduced→ patch + rebuild →patched clean

Patched 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)']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Sat Sep 5 03:05:41 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

craft HAMMER1 image (btree leaf: rec_type=INODE, data_len=32768, data_crc=0, data_offset=+16272; node CRC32C recomputed) -> victim mounts image -> file lookup loads inode (struct copy reads past 16KB block, in-bounds of the 32KB fetched buffer) -> utimensat(atime=chosen, mtime=UTIME_OMIT) dirties only HAMMER_INODE_ATIME -> backend flush -> hammer_update_itimes ATIME branch -> hammer_modify_buffer_noundo (no assert, no undo) + in-place 8-byte store at ondisk+16392 -> buffer flushed to media: chosen 8 bytes persisted into the 16KB block following the crafted record (verified exact value match in the image). MTIME variant panics INVARIANTS kernels at hammer_io.c:931 with rel_offset=16384.

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.