Unvalidated parent->bytes yields OOB blockref array access during flush
Summary
hammer2_flush_core computes blockref array element count as parent->bytes/sizeof(hammer2_blockref_t) at :1094 no bound check. parent->bytes from on-disk bref.data_off radix (1U<<(data_off&0x3F)) never validated against HAMMER2_IND_COUNT_MAX(1024). Crafted image with INDIRECT blockref inflated radix (e.g radix=20 bytes=1MB count=16384) causes flush to pass huge count into base_delete(:1119) base_insert(:1130) iterating base[i] up to count-1 massive OOB read/write on 64KB parent data buffer. KKASSERT only guard on non-INVARIANTS kernels no-op. npdata[1024+] accessed.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2583 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| forge.c | trigger-source | Image forger: patches INDIRECT bref radix + recomputes full hammer2 CRC chain (XXH64 + CRC32C) | 16.6 KB | view raw |
| poc.c | trigger-source | Unprivileged getdents trigger forcing the kernel to descend into the forged INDIRECT chain | 2.4 KB | view raw |
| crc32ctab.h | trigger-source | CRC32C lookup table for volume header CRC (reused from DF-2562) | 3.2 KB | view raw |
| setup_image.sh | build-script | Creates 64MB hammer2 image with testdir + 12 entries to force an INDIRECT block | 1.0 KB | view raw |
| build.sh | build-script | Builds forge + poc with cc -O2 | 247 B | view raw |
| run.sh | run-script | Full reproduction chain (forge -> mount -> readdir trigger) | 1.2 KB | view raw |
| run.log | run-log | Decisive baseline run output with panic signature | 1.5 KB | view raw |
| panic.txt | panic-signature | KKASSERT panic at hammer2_io.c:126 (hammer2_io_alloc) | 821 B | view raw |
| fix.diff | suggested-fix | git-apply-able fix: load-time radix guard in hammer2_chain_load_data | 1.7 KB | view raw |
| fix_build.log | build-log | Full kernel build output of the single-fix kernel | 5.6 MB | β download |
| fix_run.log | run-log | PoC output on patched kernel (EDOM, no panic) | 1.3 KB | view raw |
| env.txt | environment | uname, cc version, vfs.usermount, vm.randomize_mmap | 250 B | view raw |
| VERDICT.md | verdict | Full narrative: mechanism, threat model, fix before/after | 10.3 KB | β raw |
| README.md | readme | Human-facing build/run/expected-behavior doc | 1.9 KB | β raw |
DF-2583 β hammer2 image-forging PoC
What this is
PoC for DF-2583: a malicious hammer2 filesystem image whose INDIRECT blockref
has an inflated bref.data_off radix triggers a kernel panic on default
DragonFlyBSD GENERIC (INVARIANTS ON) when a process readdirs the affected
directory. On a non-INVARIANTS kernel the same forged radix drives an
out-of-bounds read/write of the blockref array during flush
(hammer2_flush.c:1094).
Build
./build.sh
Builds forge and poc with cc -O2.
Setup (root, once per fresh guest)
sh setup_image.sh # creates /root/h2.img (64 MB hammer2) with testdir
Run (root runs the chain, the readdir itself runs as maxx)
sh run.sh # default radix 17 # or: sh run.sh 20 # radix 20 (1MB)
run.sh does:
1. Forge /root/h2.img β patches the first INDIRECT bref's radix and
recomputes the entire hammer2 CRC chain (XXH64 inode/indirect, CRC32C
volume header).
2. vnconfig + mount_hammer2 the forged image at /mnt/h2test.
3. chmod 755 /mnt/h2test /mnt/h2test/testdir.
4. Run ./poc /mnt/h2test/testdir as unprivileged user maxx β calls
getdents(2), forcing the kernel to descend into the forged INDIRECT
chain.
5. Cleanup (umount, vnconfig -u).
Expected behavior
| Kernel | Result |
|---|---|
unpatched (#0) |
kernel panic at hammer2_io.c:126 (KKASSERT(pbase != 0 && ((lbase + lsize - 1) & pmask) == pbase)); ssh dies, db> prompt in serial console |
patched (#1) |
getdents returns -1 errno=EDOM; dmesg shows hammer2_chain_load_data: ... forged radix ... rejecting chain; guest stays up |
Reproduce
./build.sh && \ [sh setup_image.sh] && \ sh run.sh
The setup is needed only once. run.sh will re-forge the image each time
(the forge is idempotent on an unforged image; if you re-run on an
already-forged image, re-run setup_image.sh first to reset it).
DF-2583 β hammer2 unvalidated on-disk radix -> kernel panic / OOB blockref array access during flush
Verdict: REPRODUCED (panic / DoS, with OOB-potential on noinv), FIX VALIDATED
The bug is real and confirmed. A malicious hammer2 filesystem image causes
a kernel panic on the default INVARIANTS-ON GENERIC kernel whenever a process
reads (readdir) a directory whose INDIRECT block has been forged with an
inflated bref.data_off radix. On a non-INVARIANTS kernel the same forged
radix drives an out-of-bounds read/write of the blockref array at
hammer2_flush.c:1094 (count = parent->bytes / sizeof(hammer2_blockref_t)).
The fix (fix.diff) adds a single guard in hammer2_chain_load_data that
rejects any chain whose computed bytes exceeds HAMMER2_PBUFSIZE (= the
maximum legal radix, HAMMER2_RADIX_MAX). Validated on a built-and-booted
single-fix kernel (#1): the same forged image mounts and readdir returns
EDOM instead of panicking; no OOB; guest stays up.
Mechanism (trigger -> primitive -> effect)
The chain of failures:
-
Attacker input. A hammer2 image has an INDIRECT blockref whose
bref.data_offlow 6 bits (the "radix") are forged to 17 (or any valueHAMMER2_RADIX_MAX= 16).parent->bytes(a.k.a.chain->bytes) becomes1U << 17= 131072 bytes. -
Unvalidated derivation at
sys/vfs/hammer2/hammer2_chain.c:189-190:c if ((int)(bref->data_off & HAMMER2_OFF_MASK_RADIX)) bytes = 1U << (int)(bref->data_off & HAMMER2_OFF_MASK_RADIX);No bound check.HAMMER2_RADIX_MAXis defined (hammer2_disk.h:89) but never enforced here. -
Reachable from unprivileged readdir. When any process calls
getdents(2)on the directory containing the forged INDIRECT bref, the kernel descends:hammer2_chain_get->hammer2_chain_lock->hammer2_chain_load_data(hammer2_chain.c:940). It then callshammer2_io_bread(hmp, type, bref->data_off=0x1c01011, chain->bytes=131072, ...)which dispatches to_hammer2_io_getblk->hammer2_io_alloc. -
On INVARIANTS-ON GENERIC (default guest): the KKASSERT
c /* hammer2_io.c:126 */ KKASSERT(pbase != 0 && ((lbase + lsize - 1) & pmask) == pbase);fails becauselsize(131072) >HAMMER2_PBUFSIZE(65536) so the request crosses a 64KB page boundary. The kernel printsIllegal: 0000000001c00000 0000000001c01000+00020000 / ffffffffffff0000then panics with the assertion failure. The guest drops todb>and ssh dies. Reproduced:panic: assertion "pbase != 0 && ((lbase + lsize - 1) & pmask) == pbase" failed in hammer2_io_alloc at /usr/src/sys/vfs/hammer2/hammer2_io.c:126 Trace: _hammer2_io_getblk() at _hammer2_io_getblk+0xc6 _hammer2_io_bread() at _hammer2_io_bread+0x17 hammer2_chain_load_data() at hammer2_chain_load_data+0x2a5 hammer2_chain_lock() at hammer2_chain_lock+0xde hammer2_chain_get() at hammer2_chain_get+0x45 -
On non-INVARIANTS kernels the KKASSERT is skipped and the request proceeds with
dio->psize = HAMMER2_PBUFSIZE(always 64K), butchain->bytes = 131072. Later, athammer2_flush.c:1094:c case HAMMER2_BREF_TYPE_INDIRECT: case HAMMER2_BREF_TYPE_FREEMAP_NODE: if (parent->data) base = &parent->data->npdata[0]; else base = NULL; count = parent->bytes / sizeof(hammer2_blockref_t); /* <-- OOB */ break;countbecomes 131072/128 = 1024, butnpdataonly holds the on-disk INDIRECT block (β€ HAMMER2_PBUFSIZE = 512 blockrefs). The flush loop then iterates OOB into adjacent kernel heap, writing attacker-influenced blockref data to disk. This is a write-capable primitive on noinv builds; on default GENERIC it is masked by the earlier KKASSERT and manifests only as a panic.
Reproduction (unpatched #0)
setup_image.shcreates a 64 MB hammer2 image with/testdir/containing 12 small files plus a >64-byte-named file (forces an INDIRECT block in testdir's inode blockset).forge.cwalks the on-disk bref tree, finds the first INDIRECT blockref, patches itsdata_offradix from 12 to 17, and recomputes the entire hammer2 CRC chain (XXH64 with seed0x4d617474446c6c6efor inode/indirect blocks; CRC32C for the volume header's 3 regions) so the kernel mounts the image without complaint.mount_hammer2mounts the forged image.poc.ccallsgetdents(2)on/mnt/h2test/testdiras unprivileged usermaxx. This forces the kernel to descend into the forged INDIRECT chain, triggering the panic.
Panic signature (full text in panic.txt and run.log):
Illegal: 0000000001c00000 0000000001c01000+00020000 / ffffffffffff0000
panic: assertion "pbase != 0 && ((lbase + lsize - 1) & pmask) == pbase" failed
in hammer2_io_alloc at /usr/src/sys/vfs/hammer2/hammer2_io.c:126
Threat model & preconditions
- Trigger: unprivileged user readdir/getdents on a malicious hammer2 mount.
- Mount precondition: an admin has mounted (or made mountable, e.g. via
vfs.usermount=1+ a root-created image owned by the attacker) a malicious hammer2 filesystem image. Realistic: USB media, downloaded images, attached backup drives. Thehammer2 mount itself requires root unless the admin pre-configures usermount. - Effect on default GENERIC: kernel panic (DoS).
- Effect on non-INVARIANTS kernel: kernel heap OOB write during flush, potentially exploitable to corruption primitives. The audit guest is INVARIANTS-ON GENERIC by default, so we characterize the noinv path analytically here and demonstrate the panic-only path empirically.
Impact classification
- Class: unvalidated on-disk integer (radix) -> OOB-prone count
computation -> kernel panic on default GENERIC; potential heap OOB write
on noinv. CVSS 3.1:
AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H(DoS). - Impact ceiling (default GENERIC): reliable kernel panic / DoS, fully repeatable from unprivileged readdir. No memory-corruption primitive observable because INVARIANTS catches the oversized radix before flush.
- Impact ceiling (noinv): kernel heap OOB write of attacker-influenced blockref data to disk during flush; escalation-relevant primitive but not pursued here because the default/realistic target is INVARIANTS-ON GENERIC.
Phase 6 β escalation analysis (memory-corruption class)
The default GENERIC kernel traps the bad radix at I/O time
(hammer2_io_alloc KKASSERT), so the OOB at hammer2_flush.c:1094 never
executes on the realistic target. The genuine write primitive exists only on
a non-INVARIANTS build (noinv), which the bright-line rule classifies as a
non-default-kernel result.
This is therefore a Phase 6 valid hard blocker for escalation on the
default GENERIC kernel: the OOB write is masked by an INVARIANTS-class trap
firing earlier on the same forged input. The primitive is fully
characterized analytically (bucket = page-cache 64K DIO buffer; victim =
adjacent slab/heap objects near the INDRECT npdata; conversion would be
slab-groom -> forge struct ucred in userspace (no SMAP) -> redirect a
corrupted ucred * -> setresuid(0)), but it cannot be demonstrated on
the default-kernel target because INVARIANTS terminates the chain first.
Per the bright-line rule we report the demonstrated impact (panic) on
GENERIC and label the noinv-only escalation path as non-default.
Fix (fix.diff, validated)
The fix is a single, targeted guard in hammer2_chain_load_data
(sys/vfs/hammer2/hammer2_chain.c) that runs before the hammer2_io_bread
call. If chain->bytes > HAMMER2_PBUFSIZE (= 1<<HAMMER2_RADIX_MAX, the
maximum legal radix), the chain is rejected (chain->error =
HAMMER2_ERROR_CHECK), no buffer is allocated, and callers handle the
errored chain gracefully (lookup/readdir return EDOM, flush skips the
chain). The guard is rate-limited to 4 console messages to avoid warning
floods.
Why a load-time guard rather than at chain_alloc?
- The KKASSERT that masks the bug on GENERIC fires in hammer2_io_alloc,
which is reached through hammer2_chain_load_data (not at chain
creation). Intercepting at load-time is the cleanest single-point fix
that closes both failure modes (the I/O KKASSERT on INVARIANTS, and the
flush OOB on noinv).
- Earlier attempts at chain_alloc (clamping bytes and/or sanitizing
chain->bref.data_off) either tripped a different KKASSERT in
_hammer2_io_getblk (because the radix in data_off no longer matched
the bytes), or broke the lookup cache's bref-matching logic and caused
a 42000-line warning storm.
fix.diff is git-apply-able, supersedes any proposal in the finding
markdown (no proposal existed pre-verification β the PoC was authored from
scratch by the verifier).
Before/after validation
| Kernel | PoC getdents result | Panic? | Guest |
|---|---|---|---|
#0 unpatched |
panic during hammer2_io_alloc KKASSERT |
YES | down |
#1 patched |
returns -1 errno=EDOM ("Numerical argument out of domain") |
no | up |
Both runs use the identical forged image, identical forge.c and poc.c.
Patched kernel kern.version:
DragonFly 6.5-DEVELOPMENT #1: Sat Aug 8 11:07:49 UTC 2026 (sha256 of
/boot/kernel/kernel = 47813fded593d3d1c2941081867034b39d920a4b2c9562061543fb6c2bf462db).
Reproduced twice on the patched kernel; deterministic.
Files
| File | Description |
|---|---|
forge.c |
Image forger: patches INDIRECT bref radix + fixes XXH64/CRC32C chain |
crc32ctab.h |
CRC32C lookup table for volume header CRC (reused from DF-2562) |
poc.c |
Unprivileged getdents trigger that forces INDIRECT chain load |
setup_image.sh |
Creates the hammer2 image with testdir + 12 files |
build.sh |
Builds forge + poc |
run.sh |
Full reproduction chain (forge -> mount -> trigger) |
fix.diff |
git-apply-able fix for hammer2_chain.c (load-time radix guard) |
fix_build.log |
Full kernel build output (single-fix kernel) |
fix_run.log |
PoC output on fixed kernel (returns EDOM, no panic) |
panic.txt |
Panic signature from boot.log (KKASSERT in hammer2_io_alloc) |
run.log |
Decisive baseline run with panic capture |
env.txt |
Guest environment info |
manifest.json |
Machine-readable artifact catalog |
Fix verification
fixedVALIDATED. Baseline #0 unpatched: kernel panic at hammer2_io.c:126 KKASSERT. Patched #1 single-fix kernel: getdents returns -1 errno=33 (EDOM), dmesg shows 'hammer2_chain_load_data: ... forged radix (bytes=131072 > HAMMER2_PBUFSIZE=65536); rejecting chain', guest stays up, mount/umount succeed. Deterministic across two runs. Fix closes the bug.
baseline #0: panic assertion failed in hammer2_io_alloc at hammer2_io.c:126 (cpuid=3, db> prompt, ssh dies). patched #1: getdents returned -1 (errno=33); dmesg rejecting-chain message; umount_rc=0; guest up.
Confirmed kernel references
- sys/vfs/hammer2/hammer2_chain.c:189
- sys/vfs/hammer2/hammer2_chain.c:190
- sys/vfs/hammer2/hammer2_chain.c:940
- sys/vfs/hammer2/hammer2_io.c:115
- sys/vfs/hammer2/hammer2_io.c:126
- sys/vfs/hammer2/hammer2_io.c:193
- sys/vfs/hammer2/hammer2_flush.c:1094
- sys/vfs/hammer2/hammer2_disk.h:89
- sys/vfs/hammer2/hammer2_disk.h:106
Detail
Exploit chain
Memory-corruption class but blocked from uid0 on the default GENERIC target by a valid Phase-6 hard blocker: the OOB write at hammer2_flush.c:1094 never executes because INVARIANTS catches the oversized radix 17 at I/O time before flush runs. The write primitive is real on noinv-only and is documented analytically but cannot be demonstrated on the realistic INVARIANTS-ON GENERIC target. Per the bright-line rule, reported as panic on default GENERIC with the noinv-only escalation path labeled non-default. No exploit.c written because the default-kernel primitive is panic-only.
Evidence (decisive lines)
panic: assertion "pbase != 0 && ((lbase + lsize - 1) & pmask) == pbase" failed in hammer2_io_alloc at /usr/src/sys/vfs/hammer2/hammer2_io.c:126; cpuid=3; call chain chain_get->chain_lock->chain_load_data->io_bread->io_getblk->io_alloc.
PoC changes
Authored everything from scratch. forge.c: extends DF-2562's image-forger to patch an INDIRECT bref's data_off radix (12 -> 17) and recompute the full hammer2 CRC chain. poc.c: unprivileged getdents(2). setup_image.sh: builds hammer2 image with 12 entries to force an INDIRECT block. run.sh: full forge+mount+trigger. fix.diff: load-time radix guard in hammer2_chain_load_data.
Verified recommended fix
In sys/vfs/hammer2/hammer2_chain.c, hammer2_chain_load_data, immediately after the no-data-reference early-return and before the io_bread call, add: if (chain->bytes > HAMMER2_PBUFSIZE) { rate-limited kprintf(...); chain->error = HAMMER2_ERROR_CHECK; return; }. Rejects chains whose on-disk radix implies a buffer the I/O layer cannot satisfy in a single DIO, closing both the KKASSERT trip on INVARIANTS kernels and the OOB blockref-array iteration on non-INVARIANTS kernels. Full git-apply-able diff in findings/poc/DF-2583/fix.diff.
Verdict
REPRODUCED on default INVARIANTS-ON GENERIC. The bug is real: hammer2_chain_alloc (sys/vfs/hammer2/hammer2_chain.c:189-190) derives chain->bytes from the on-disk radix (bref->data_off & HAMMER2_OFF_MASK_RADIX) with no bound check; a forged hammer2 image with an INDIRECT blockref whose radix > HAMMER2_RADIX_MAX (16) makes chain->bytes huge. When a process getdents/readdir's the affected directory, hammer2_chain_load_data -> hammer2_io_bread -> _hammer2_io_getblk -> hammer2_io_alloc fires the KKASSERT at hammer2_io.c:126 because lsize (131072) > HAMMER2_PBUFSIZE (65536) crosses a 64KB page boundary. Confirmed by panic capture naming hammer2_io_alloc. The claimed OOB at hammer2_flush.c:1094 is masked on default GENERIC by this earlier KKASSERT; it would only execute on a non-INVARIANTS kernel.
No comments yet.