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

Missing radix validation in hammer2_chain_alloc causes OOB read/write from crafted filesystem image

Summary

hammer2_chain_alloc derives chain->bytes from 6-bit radix in bref->data_off without range validation. bytes=1U<<radix(:190) UB for radix>=32 shift>=type width. radix 17-31 well-defined but bytes(128KB-2GB) exceeds HAMMER2_PBUFSIZE(64KB). I/O layer allocates fixed 64KB buffer KKASSERT compiled out production. chain->bytes used unvalidated as: CRC hash size (hammer2_icrc32 :5538 reads chain->bytes from 64KB buffer), blockref array count divisor (count=parent->bytes/128 :2532 count=8192 for radix20 base[8191] 960KB past), COW bcopy length(:1827 OOB read+write), base_find scan iteration. Attacker sets methods=CHECK_NONE(:5531 return 1 no data read) bypasses CRC leaving countbrefs/array-iteration OOB exploitable. Crafted HAMMER2 image mount triggers.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2605 Β· 16 files
FileTypeDescriptionSize
forge.c trigger-source image forger: patches INDIRECT bref radix + recomputes XXH64/CRC32C chain 15.7 KB view raw
poc.c trigger-source unprivileged getdents trigger for the forged INDIRECT chain load 2.2 KB view raw
crc32ctab.h trigger-source CRC32C (Castagnoli) table for the volume-header icrc 3.2 KB view raw
setup_image.sh trigger-source root: builds a base hammer2 image whose dir spills into an INDIRECT block 836 B view raw
build.sh build-script cc -O2 -o forge forge.c; cc -O2 -o poc poc.c 247 B view raw
run.sh run-script forge + mount + readdir-as-maxx chain (radix arg, default 17) 1.5 KB view raw
fix.diff suggested-fix radix validation in hammer2_chain_alloc + early reject in hammer2_chain_load_data 2.7 KB view raw
VERDICT.md verdict full narrative: mechanism, impact, before/after, why-not-uid0 7.9 KB ↓ raw
README.md readme human repro guide 2.5 KB ↓ raw
panic.txt panic-signature baseline #0 panic: hammer2_io_alloc:126 KKASSERT from radix=17 readdir 629 B view raw
run.log run-log baseline radix=17 panic (ssh died at Phase 4) + serial panic sig 1.1 KB view raw
run.2.log run-log baseline radix=32 UB path -> EDOM (bogus bytes=1), no panic 663 B view raw
fix_build.log build-log single-fix kernel build (rc=0, full output) 5.6 MB ↓ download
fix_run.log run-log patched #1: radix=17 -> EDOM, radix=32 -> EDOM, clean image -> 13 files; no panic 1.5 KB view raw
dmesg.txt dmesg warnonce kprintf from the new alloc-site guard (radix 17 and 32) 396 B view raw
env.txt environment uname, kern.version (#0 + #1), cc 8.3, vfs.usermount=0, kernel sha256 444 B view raw
README.md readme human repro guide
↓ download raw

DF-2605 β€” hammer2 hammer2_chain_alloc unvalidated radix β†’ panic / DoS

Reproduction + fix-validation pack for DF-2605 (sys/vfs/hammer2/hammer2_chain.c:189-190). Same unvalidated-radix root-cause class as the DF-2583 sibling, located at the upstream allocation site.

Reproduce

# in the guest, as the unprivileged user maxx (build only):
cd poc/DF-2605 && sh build.sh

# as root: create a base hammer2 image with an INDIRECT block
sh poc/DF-2605/setup_image.sh

# as root: forge radix=17 on the image, mount, readdir as maxx -> PANIC on #0
sh poc/DF-2605/run.sh 17

Expected (bug present, unpatched #0 GENERIC)

The su maxx ./poc /mnt/h2test/testdir step triggers panic: assertion "pbase != 0 && ((lbase + lsize - 1) & pmask) == pbase" failed in hammer2_io_alloc at /usr/src/sys/vfs/hammer2/hammer2_io.c:126 β€” guest drops to db>, ssh dies. (radix=32 takes the UB path: gcc evaluates 1U<<32β†’1, so bytes is bogus; readdir returns EDOM, no panic, but the chain is mis-sized β€” the UB the finding flags.)

Expected (fixed #1 kernel)

getdents returns errno=33 (EDOM); no panic; guest stays up; dmesg shows hammer2_chain_alloc: ...: forged radix=17 (max=16); capping and marking chain errored. A clean (unforged) image still readdirs normally.

Files

file purpose
forge.c image forger: patches an INDIRECT bref's data_off radix and recomputes the full XXH64/CRC32C chain so the kernel accepts it
crc32ctab.h CRC32C table (Castagnoli) for the volume-header icrc
poc.c unprivileged trigger: getdents on the forged dir β†’ INDIRECT chain load
setup_image.sh root: builds a base 64 MB hammer2 image with a directory that spills into an INDIRECT block
build.sh / run.sh exact build / run commands
fix.diff git-apply-able fix: radix validation in hammer2_chain_alloc + early reject in hammer2_chain_load_data
VERDICT.md full narrative (mechanism, impact, before/after, why-not-uid0)
panic.txt baseline panic signature from the serial console
run.log / run.2.log baseline run (radix=17 panic; radix=32 UB→EDOM)
fix_build.log single-fix kernel build output (rc=0)
fix_run.log patched-kernel run output (EDOM, no panic, no regression)
env.txt guest uname / cc / sysctl / kernel sha256
manifest.json machine-readable artifact catalog

Build / run commands (exact)

  • build: cc -O2 -o forge forge.c && cc -O2 -o poc poc.c
  • run (as root, after setup_image.sh): sh run.sh 17
VERDICT.md verdict full narrative: mechanism, impact, before/after, why-not-uid0
↓ download raw

DF-2605 β€” hammer2 hammer2_chain_alloc unvalidated on-disk radix β†’ kernel panic / OOB (DoS on default GENERIC; OOB-write primitive on noinv)

Verdict: REPRODUCED (panic / DoS on default GENERIC; write-primitive characterised on noinv), FIX VALIDATED

The bug is real and confirmed. hammer2_chain_alloc() at sys/vfs/hammer2/hammer2_chain.c:189-190 derives chain->bytes from the low 6 bits of bref->data_off (the "radix") with no range check:

if ((int)(bref->data_off & HAMMER2_OFF_MASK_RADIX))
    bytes = 1U << (int)(bref->data_off & HAMMER2_OFF_MASK_RADIX);
else
    bytes = 0;

A malicious hammer2 filesystem image can set that radix to any of 0..63. HAMMER2_RADIX_MAX is 16 (HAMMER2_PBUFSIZE = 64 KB) but is never enforced at this derivation site. Two failure classes:

  1. radix 17..31 β€” bytes is well-defined (128 KB..2 GB) but exceeds HAMMER2_PBUFSIZE. The unprivileged readdir path drives the kernel into hammer2_io_alloc() (hammer2_io.c:126) where the KKASSERT(pbase != 0 && ((lbase + lsize - 1) & pmask) == pbase) trips because the oversized request crosses a 64 KB page. Panic on default GENERIC (INVARIANTS ON) β€” reproduced.

  2. radix >= 32 β€” 1U << radix is undefined behaviour (shift count >= type width). On gcc 8.3 x86-64 -O2 the shl masks the count to 5 bits, so e.g. 1U << 32 evaluates to 1, producing a bogus bytes and a mis-sized 1-byte I/O. Confirmed: no panic, but chain->bytes is wrong (implementation-defined) β€” exactly the latent UB the finding flags.

On a non-INVARIANTS kernel the hammer2_io.c:126 KKASSERT is skipped and the oversized chain->bytes later drives the OOB blockref-array walk in hammer2_flush_core() (hammer2_flush.c:1094, count = parent->bytes / sizeof(hammer2_blockref_t)) β€” the same write-capable primitive characterised in the DF-2583 sibling (same root-cause class). On the default GENERIC kernel that primitive is masked by the earlier KKASSERT and manifests purely as a DoS panic; per the bright-line rule this is reported as panic on GENERIC (the realistic target), with the noinv OOB-write noted as a non-default-kernel characterisation.

The fix is validated on a built-and-booted single-fix kernel (#1): the same forged image (radix 17 β€” which panicked the baseline #0) now mounts and readdir returns EDOM (errno 33) instead of panicking; the guest stays up; the warnonce kprintf from the new alloc-site guard fires. radix 32 (UB path) is also cleanly rejected. A clean (unforged) image still readdirs all 13 files β€” no regression.

Mechanism (trigger β†’ primitive β†’ effect)

  1. Attacker input. A hammer2 image has an INDIRECT blockref whose bref.data_off low 6 bits are forged to 17 (data_off = 0x1c01011). The full hammer2 CRC chain (XXH64 over inode/indirect blocks with seed 0x4d617474446c6c6e, CRC32C over the volume header) is recomputed by forge.c so the kernel accepts the forged blockref.

  2. Unvalidated derivation at hammer2_chain.c:189-190 (the DF-2605 root cause): bytes = 1U << 17 = 131072, no bound check. HAMMER2_RADIX_MAX (= 16) is defined in hammer2_disk.h:89 but never enforced here.

  3. Reachable from unprivileged readdir. getdents(2) on the directory whose inode blockset holds the forged INDIRECT bref descends: hammer2_chain_get β†’ hammer2_chain_lock β†’ hammer2_chain_load_data (hammer2_chain.c:940) β†’ hammer2_io_bread(data_off, bytes=131072) β†’ _hammer2_io_getblk β†’ hammer2_io_alloc.

  4. Default GENERIC (INVARIANTS ON): the KKASSERT at hammer2_io.c:126 fails (lsize=131072 > HAMMER2_PBUFSIZE=65536 crosses a page boundary). Illegal: 0000000001c00000 0000000001c01000+00020000 / ffffffffffff0000 then panic: assertion ... failed in hammer2_io_alloc. Guest β†’ db>, ssh dies. Reproduced (see panic.txt).

  5. radix >= 32 (UB): 1U << radix is UB; on this gcc bytes wraps to a tiny value, so the I/O KKASSERT does not fire, but the chain is mis-sized and readdir returns EDOM. The behaviour is implementation-defined β€” a different compiler/flags could produce anything (including a huge bytes that does panic). The finding's UB concern is real.

  6. noinv (non-default): KKASSERT skipped β†’ the oversized parent->bytes drives count = parent->bytes / 128 at hammer2_flush.c:1094, iterating the blockref array (base[]) far past its actual size β†’ OOB read/write of kernel heap with attacker-influenced blockref data on sync. This is the DF-2583 write primitive (same root cause). Not escalated to uid=0 here: on the realistic target (default GENERIC) the KKASSERT converts this to a pure DoS panic before any OOB write lands, which is the valid hard blocker for a default-kernel escalation.

Exploit chain / impact

  • Impact class: memory-corruption-primitive (oversized/UB radix β†’ oversized chain->bytes) surfaced as a DoS panic on the default GENERIC kernel.
  • Bucket / victim object: N/A for the default-kernel demonstration β€” the KKASSERT at hammer2_io.c:126 fires before any heap victim is touched. The write primitive (noinv only) targets the INDIRECT block's npdata[] blockref array at hammer2_flush.c:1094; characterised in DF-2583.
  • Unprivileged reachability: yes β€” maxx (uid 1001, not in wheel) reads the mounted directory; the forged INDIRECT chain is loaded during getdents. Realistic precondition: an admin mounts (or makes mountable via vfs.usermount) a hammer2 image and lets the user read it.
  • Why not uid=0: on the default GENERIC kernel (INVARIANTS ON β€” the realistic target) the hammer2_io.c:126 KKASSERT catches the oversized I/O before any OOB write can land, so the manifestation is a DoS panic, not an escalation. The write-capable OOB only exists on the non-default noinv kernel (characterised in DF-2583; per the bright-line rule a noinv-only escalation is not a default-GENERIC uid0). This is a valid hard blocker (the primitive is masked by INVARIANTS on the default kernel).

The fix (validated)

fix.diff makes two minimal, targeted changes in sys/vfs/hammer2/hammer2_chain.c:

  1. hammer2_chain_alloc (root cause, DF-2605): compute radix once; if radix > HAMMER2_RADIX_MAX, do not execute the UB/oversized 1U << radix β€” instead cap bytes = 1U << HAMMER2_RADIX_MAX and mark the chain HAMMER2_ERROR_CHECK (with a warnonce kprintf). This closes both the radix-17..31 oversized-I/O path and the radix>=32 UB at the derivation site.

  2. hammer2_chain_load_data (companion, = the DF-2583 guard): reject a chain already marked errored (or with bytes > HAMMER2_PBUFSIZE) before calling hammer2_io_bread(). This is what actually prevents the I/O-layer KKASSERT: the errored chain short-circuits, callers return EDOM/EIO, no panic.

The pair fully closes DF-2605 (and subsumes DF-2583's downstream guard).

Before / after (decisive)

kernel PoC (radix=17 readdir) result
#0 unpatched baseline su maxx ./poc /mnt/h2test/testdir panic hammer2_io_alloc:126 KKASSERT β†’ db>, ssh dies
#1 single-fix same EDOM (errno 33), guest UP, warnonce kprintf fires
#1 single-fix radix=32 (UB path) EDOM, guest UP, no UB executed
#1 single-fix clean (unforged) image readdirs 13 files normally β€” no regression

PoC changes

Authored from scratch (the finding shipped no PoC). Reused the CRC-chain forger pattern proven on the DF-2583 sibling (XXH64 seed 0x4d617474446c6c6e, CRC32C volume header) β€” DF-2605 is the same unvalidated-radix root-cause class, re-targeted at the upstream hammer2_chain_alloc site. Files: forge.c (image forger), poc.c (getdents trigger), setup_image.sh (creates a base hammer2 image with enough entries to force an INDIRECT block), build.sh / run.sh, fix.diff, full logs.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. On unpatched baseline #0, same PoC (radix=17 readdir as maxx) panics in hammer2_io_alloc:126 KKASSERT (db>, ssh dies). On single-fix kernel #1 (only fix.diff, make -j6 nativekernel rc=0), same PoC returns EDOM (errno 33) with NO panic, guest stays up, dmesg shows new alloc-site guard firing ('hammer2_chain_alloc: ...: forged radix=17 (max=16); capping and marking chain errored'). radix=32 (UB path) also cleanly rejected (EDOM, no UB executed). Clean unforged image still readdirs all 13 files -> no regression. fix.diff git-apply clean.

baseline #0 radix=17: panic 'assertion ... failed in hammer2_io_alloc at hammer2_io.c:126' -> db>, ssh dies. patched #1 radix=17: getdents returned -1 (errno=33 EDOM); guest UP; dmesg 'hammer2_chain_alloc: 0000000001c01011: forged radix=17 (max=16); capping and marking chain errored'. patched #1 radix=32: getdents returned -1 (errno=33 EDOM); guest UP. patched #1 clean image: readdirs 13 files normally (no regression).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sat Aug 8 17:19:42 UTC 2026 (sha256 b55c935365f725118c0266353e59370b32d3ea589f9f87d364467e97b5a915ca)

Confirmed kernel references

Detail

Exploit chain

DoS panic on default GENERIC (INVARIANTS ON). Bucket/victim-object grooming NOT reached: KKASSERT at hammer2_io.c:126 catches oversized I/O (lsize=1<<17=131072 crosses 64KB page) before any heap victim touched, so clean DoS panic not escalation. The write-capable OOB primitive (count=parent->bytes/sizeof(hammer2_blockref_t) at hammer2_flush.c:1094, iterating base[] past 64KB INDIRECT buffer) only manifests on NON-default INVARIANTS-OFF (noinv) kernel, same primitive characterized in DF-2583 sibling (identical root-cause class). Per bright-line rule, noinv-only escalation is not default-GENERIC uid0; default-kernel impact honestly reported as panic with noinv write primitive noted non-default. Valid hard blocker: on realistic target (default GENERIC) INVARIANTS masks write primitive into DoS panic. Unprivileged reachability confirmed (maxx readdir); realistic precondition = admin mounts hammer2 image (vfs.usermount=0). No exploit.c written (demonstrated primitive is masked DoS panic, not escalation-capable write on default kernel).

Evidence (decisive lines)

baseline #0 radix=17 (unprivileged readdir): Illegal: 0000000001c00000 0000000001c01000+00020000 / ffffffffffff0000 / panic: assertion 'pbase != 0 && ((lbase + lsize - 1) & pmask) == pbase' failed in hammer2_io_alloc at hammer2_io.c:126 / _hammer2_io_getblk() / _hammer2_io_bread() / hammer2_chain_load_data() / Stopped at Debugger+0x7c / db> (ssh died; guest down). radix=32 (UB shift) on baseline: getdents returned -1 (errno=33 EDOM); no panic; bytes bogus=1 (gcc masks shift).

PoC changes

Authored from scratch (finding shipped no PoC). forge.c reuses CRC-chain approach proven on DF-2583 sibling (XXH64 seed 0x4d617474446c6c6e, CRC32C volume header) re-targeted at upstream hammer2_chain_alloc site; poc.c unprivileged getdents trigger; setup_image.sh builds base hammer2 image whose dir spills into INDIRECT block; build.sh/run.sh; fix.diff validated fix.

Verified recommended fix

fix.diff makes two minimal changes in sys/vfs/hammer2/hammer2_chain.c. (1) hammer2_chain_alloc (DF-2605 root cause): compute radix once; if radix > HAMMER2_RADIX_MAX, do NOT execute the UB/oversized 1U<<radix β€” instead cap bytes = 1U<error = HAMMER2_ERROR_CHECK (warnonce kprintf). (2) hammer2_chain_load_data (companion, = DF-2583 guard): reject a chain already errored (or with bytes > HAMMER2_PBUFSIZE) before calling hammer2_io_bread(). Pair closes both radix 17..31 oversized-I/O path and radix>=32 UB at derivation site, subsumes DF-2583's downstream guard.

Verdict

REPRODUCED. The bug is real: hammer2_chain_alloc() at sys/vfs/hammer2/hammer2_chain.c:189-190 derives chain->bytes = 1U << (data_off radix) with NO range check; HAMMER2_RADIX_MAX (16, hammer2_disk.h:89) is never enforced. A forged hammer2 image with an INDIRECT bref radix of 17 (full XXH64/CRC32C chain recomputed by forge.c) survives mount and, on the first unprivileged getdents (maxx, uid 1001) of the directory, drives hammer2_chain_load_data -> hammer2_io_bread -> hammer2_io_alloc where lsize=131072 > HAMMER2_PBUFSIZE=65536 trips the KKASSERT at hammer2_io.c:126 -> panic, guest to db>, ssh dies. Confirmed twice on unpatched #0 GENERIC (INVARIANTS ON). The radix>=32 path also characterized: 1U<<32 is UB; gcc 8.3 evaluates to 1 (shift masked), so chain->bytes bogus and readdir returns EDOM (no panic, but real mis-sizing/UB).