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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2583 Β· 14 files
FileTypeDescriptionSize
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
README.md readme Human-facing build/run/expected-behavior doc
↓ download 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).

VERDICT.md verdict Full narrative: mechanism, threat model, fix before/after
↓ download raw

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:

  1. Attacker input. A hammer2 image has an INDIRECT blockref whose bref.data_off low 6 bits (the "radix") are forged to 17 (or any value

    HAMMER2_RADIX_MAX = 16). parent->bytes (a.k.a. chain->bytes) becomes 1U << 17 = 131072 bytes.

  2. 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_MAX is defined (hammer2_disk.h:89) but never enforced here.

  3. 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 calls hammer2_io_bread(hmp, type, bref->data_off=0x1c01011, chain->bytes=131072, ...) which dispatches to _hammer2_io_getblk -> hammer2_io_alloc.

  4. On INVARIANTS-ON GENERIC (default guest): the KKASSERT c /* hammer2_io.c:126 */ KKASSERT(pbase != 0 && ((lbase + lsize - 1) & pmask) == pbase); fails because lsize (131072) > HAMMER2_PBUFSIZE (65536) so the request crosses a 64KB page boundary. The kernel prints Illegal: 0000000001c00000 0000000001c01000+00020000 / ffffffffffff0000 then panics with the assertion failure. The guest drops to db> 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

  5. On non-INVARIANTS kernels the KKASSERT is skipped and the request proceeds with dio->psize = HAMMER2_PBUFSIZE (always 64K), but chain->bytes = 131072. Later, at hammer2_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; count becomes 131072/128 = 1024, but npdata only 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)

  1. setup_image.sh creates 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).
  2. forge.c walks the on-disk bref tree, finds the first INDIRECT blockref, patches its data_off radix from 12 to 17, and recomputes the entire hammer2 CRC chain (XXH64 with seed 0x4d617474446c6c6e for inode/indirect blocks; CRC32C for the volume header's 3 regions) so the kernel mounts the image without complaint.
  3. mount_hammer2 mounts the forged image.
  4. poc.c calls getdents(2) on /mnt/h2test/testdir as unprivileged user maxx. 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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. 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.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sat Aug 8 11:07:49 UTC 2026

Confirmed kernel references

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.