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

Missing on-disk radix validation in chain allocation β€” malicious filesystem image radix 17-31 causes heap OOB read/write via bcopy/hash/CRC/array iteration

Summary

hammer2_chain_alloc :189-190 bytes=1U<<(int)(bref->data_off & HAMMER2_OFF_MASK_RADIX). HAMMER2_OFF_MASK_RADIX=0x3F 6-bit radix 0-63. NO validation radix<=HAMMER2_RADIX_MAX(16) max valid 64KB. bref from on-disk media malicious image sets radix=17 bytes=128KB. DIO buffer always HAMMER2_PBUFSIZE=64KB. chain_load_data XXH64/XXH32 (setcheck :5391/:5395/:5538/:5547) reads 128KB from 64KB buffer OOB heap read. chain_modify :1827 bcopy(chain->data,bdata,128KB) OOB heap read AND write 64KB past. indirect count=parent->bytes/sizeof(blockref) inflated entry count base_find iterates past npdata[] array OOB. INVARIANTS: KKASSERT hammer2_io.c:126 panics reliable DoS. Non-INVARIANTS: silent heap corruption exploitable for priv-esc. Trigger: crafted HAMMER2 image mount. Fix: if(radix>HAMMER2_RADIX_MAX) bytes=0 in chain_alloc + chain->error=BADBREF in load_data.

Discussion (1)

jewbird2026-08-28 07:45
grouping live; dillon to confirm the radix-validation family

PoC verification

Evidence pack

findings/poc/DF-0763 Β· 15 files
FileTypeDescriptionSize
craft_radix_img.py trigger-source host-side image patcher: sets sroot_blockset[0] radix 10->17 and recomputes the 3 volume-header CRC32Cs 8.2 KB view raw
build.sh build-script builds guest vnconfig, creates clean image, crafts bad-radix variant 1.5 KB view raw
run.sh run-script mounts the crafted image (the trigger) 1.1 KB view raw
h2_clean.img test-fixture clean 64MB hammer2 image for regression check 64.0 MB ↓ download
h2_craft_radix17.img test-fixture crafted image: sroot_blockset[0].data_off radix=17 -> bytes=128KB > PBUFSIZE 64.0 MB ↓ download
run.log run-log baseline panic transcript (serial-log excerpt) on #0 2.2 KB view raw
panic.txt panic-signature hammer2_io.c:126 KKASSERT panic with stack trace 702 B view raw
fix.diff suggested-fix validated fix: chain_alloc radix check + chain_load_data BADBREF guard 2.0 KB view raw
fix_build.log build-log single-fix kernel #1 nativekernel build output (rc=0) 5.6 MB ↓ download
fix_run.log run-log patched-kernel #1 test: EINVAL + 'Bad Blockref Error', guest alive, clean image regression-OK 901 B view raw
env.txt environment uname, cc, mount, hammer2 tools 546 B view raw
VERDICT.md verdict full narrative: mechanism, OOB extent, panic proof, impact ceiling, fix 7.1 KB ↓ raw
README.md readme how to reproduce + file index 3.9 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme how to reproduce + file index
↓ download raw

DF-0763 β€” Missing on-disk radix validation in hammer2 chain allocation

Verdict: REPRODUCED (panic on default GENERIC). Impact: panic / reliable local DoS from a crafted hammer2 filesystem image. On a non-INVARIANTS kernel the same primitive becomes a silent heap OOB read/write (characterized, not escalated on the default target).

See VERDICT.md for the full mechanism and fix.diff for the validated fix.

What the bug is

hammer2_chain_alloc (sys/vfs/hammer2/hammer2_chain.c:189-190) computes chain->bytes = 1U << radix from the low 6 bits of the on-disk bref->data_off without validating radix <= HAMMER2_RADIX_MAX (16). A malicious image setting radix 17..63 makes chain->bytes exceed HAMMER2_PBUFSIZE (64KB), and the subsequent hammer2_io_bread drives hammer2_io_alloc (hammer2_io.c:126) into a KKASSERT panic on INVARIANTS-ON kernels (the default).

How to reproduce

# On the host (has python3):
./build.sh                    # builds vnconfig (guest), creates+crafts the image
ssh -F dfbsd-qemu/config dfbsd 'cp /tmp/h2_craft_radix17.img /tmp/ && sh /root/run_remote.sh'
# or, after build.sh has pushed the image to the guest:
./run.sh                      # mounts the crafted image -> panic on #0, EINVAL on #1

Expected

  • Unpatched kernel (#0): immediate kernel panic at hammer2_io.c:126 (KKASSERT: pbase != 0 && ((lbase + lsize - 1) & pmask) == pbase), guest enters DDB, ssh dies. Serial log excerpt in panic.txt.
  • Patched kernel (#1): mount returns EINVAL, dmesg shows hammer2_mount: error Bad Blockref Error reading super-root, guest stays up. Clean image still mounts and works (no regression).

Files

file what
craft_radix_img.py host-side image patcher (radix 10β†’17 + CRC32C recompute)
build.sh builds vnconfig, creates clean image, crafts bad image
run.sh mounts the crafted image (the trigger)
h2_clean.img clean 64MB hammer2 image (regression check)
h2_craft_radix17.img crafted image (sroot_blockset[0] radix=17)
fix.diff validated fix (chain_alloc radix check + load_data guard)
run.log baseline panic transcript (from serial log)
panic.txt panic signature excerpt
fix_run.log patched-kernel test transcript
fix_build.log single-fix kernel build log
env.txt guest environment (uname, cc, mount, hammer2 tools)
VERDICT.md full narrative + impact analysis
manifest.json machine-readable artifact catalog

Preconditions (realistic)

  • Attacker can supply a hammer2 filesystem image that a victim mounts (USB media, downloaded image, vfs.usermount=1 with a user-owned device, auto-mount). No special privilege beyond image delivery is required.
  • The vulnerable kernel is the default X86_64_GENERIC (hammer2 compiled in; root fs on this guest is hammer2).

The fix

fix.diff adds: 1. A radix bounds check in hammer2_chain_alloc that flags the chain with HAMMER2_ERROR_BADBREF when radix > HAMMER2_RADIX_MAX. 2. An early-return guard in hammer2_chain_load_data that rejects any chain already flagged HAMMER2_ERROR_BADBREF, preventing the bad radix from reaching hammer2_io_bread/hammer2_io_alloc.

Validated on a single-fix kernel (#1): the panic is gone, the crafted image is cleanly rejected with EINVAL/Bad Blockref Error, and normal hammer2 operation (mount/read/write of a clean image) is unaffected.

VERDICT.md verdict full narrative: mechanism, OOB extent, panic proof, impact ceiling, fix
↓ download raw

DF-0763 β€” Verdict

REPRODUCED β€” Missing on-disk radix validation in hammer2 chain allocation. Impact: panic (reliable DoS on default GENERIC with INVARIANTS ON). On a non-INVARIANTS kernel the same primitive becomes a silent heap OOB read/write (characterized below, not escalated on the default target).

The bug, confirmed

hammer2_chain_alloc (sys/vfs/hammer2/hammer2_chain.c:189-190) extracts the on-disk radix from the low 6 bits of bref->data_off and uses it to compute chain->bytes with no bounds check:

189: if ((int)(bref->data_off & HAMMER2_OFF_MASK_RADIX))
190:     bytes = 1U << (int)(bref->data_off & HAMMER2_OFF_MASK_RADIX);

HAMMER2_OFF_MASK_RADIX is 0x3F (hammer2_disk.h:461) β€” a 6-bit field, so the radix can be 0..63. The valid range is only 0 (no data) and HAMMER2_RADIX_MIN=10 .. HAMMER2_RADIX_MAX=16 (hammer2_disk.h:87,89), i.e. 1KB..64KB. 64KB is HAMMER2_PBUFSIZE (hammer2_disk.h:106), the size of the kernel DIO buffer cache page.

A malicious filesystem image setting radix=17 (or any 17..63) makes bytes = 1<<17 = 128KB. When hammer2_chain_load_data (hammer2_chain.c:920) later calls hammer2_io_bread(... chain->bytes ...), hammer2_io_alloc (hammer2_io.c:115-126) recomputes lsize = 1<<17 = 128KB independently from data_off, then validates:

122: if (pbase == 0 || ((lbase + lsize - 1) & pmask) != pbase) {
123:     kprintf("Illegal: %016jx %016jx+%08x / %016jx\n", ...);
124: }
125: /* falls through to: */
126: KKASSERT(pbase != 0 && ((lbase + lsize - 1) & pmask) == pbase);

Because lsize=128KB spans two 64KB DIO pages, (lbase + lsize - 1) & pmask != pbase β€” the KKASSERT panics on INVARIANTS-ON kernels (the default X86_64_GENERIC).

The downstream OOB sinks the finding cites are real and reachable on a non-INVARIANTS kernel, but are masked by the io_alloc KKASSERT on default GENERIC: - hammer2_chain.c:1827 bcopy(chain->data, bdata, chain->bytes) β€” 128KB COW bcopy into a 64KB DIO buffer (OOB read + write). - hammer2_chain.c:5395,5547 XXH64(bdata, chain->bytes, ...) β€” 128KB hash read from a 64KB buffer (OOB heap read).

Reproduction (live, on the real kernel)

The DragonFly guest's root filesystem is hammer2, so the VFS code is compiled in and fully live. Steps (build.sh automates; run.sh triggers):

  1. vnconfig is built from /usr/src/usr.sbin/vnconfig/ (not installed by default; make install adds it).
  2. A clean 64MB image is made with newfs_hammer2 -L testvol.
  3. craft_radix_img.py patches sroot_blockset[0].data_off (the blockref pointing at the super-root inode) radix bits from 0x0a (10) to 0x11 (17), and recomputes the three volume-header CRCs: - icrc_sects[6] (sector-1 CRC over the sroot_blockset), - icrc_sects[7] (sector-0 CRC, invalidated by changing [6]), - icrc_volheader (whole-header CRC). All CRCs use CRC32C (Castagnoli, iscsi_crc32 in sys/libkern/icrc32.c).
  4. vnconfig -c vn0 image && mount -t hammer2 /dev/vn0@testvol /mnt/h2test.

Baseline result (unpatched #0, INVARIANTS ON): the mount panics immediately β€” the kernel enters DDB, ssh dies, vm.sh status β‡’ down. Serial-log panic signature (panic.txt):

Illegal: 0000000001400000 0000000001400000+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
_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
Stopped at Debugger+0x7c: movb $0,0xbdaf09(%rip)
db>

lsize = 0x20000 (128KB) and pmask = 0xFFFF0000 confirm radix 17 drove the request past the DIO page. Reproduced deterministically on two independent fresh-reset runs.

Impact ceiling & why escalation stops here (GENERIC)

On the default X86_64_GENERIC kernel (INVARIANTS ON) the KKASSERT at hammer2_io.c:126 fires before any OOB access β€” the primitive never materializes as memory corruption, only as a panic. This is a valid hard blocker for uid=0 escalation on the realistic target: there is no write primitive to groom because INVARIANTS traps the oversized request at the IO gatekeeper before chain->data is even set.

On a hypothetical INVARIANTS-OFF kernel the same radix=17 would proceed silently: the bread() would still return a 64KB DIO buffer, but chain->bytes (128KB) would drive a 64KB over-read in XXH64 (testcheck, hammer2_chain.c:1071/:5395) and β€” if the chain is subsequently modified β€” a 64KB over-read+over-write in bcopy(chain->data, bdata, chain->bytes) (hammer2_chain.c:1827). That is a genuine heap OOB read/write primitive in the kmalloc-65536 page-zone that could in principle be groomed for privilege escalation, but only on a non-default kernel. Per the bright-line rule this is reported as panic for the default GENERIC target with the non-default primitive characterized but not escalated.

Realistic threat model: an attacker who can cause a victim to mount (or auto-mount) a crafted hammer2 image achieves immediate kernel panic / DoS on any default DragonFlyBSD configuration. No privilege is required beyond the ability to supply a filesystem image (e.g. a USB stick, a downloaded image, vfs.usermount with a user-owned device). Information disclosure / privilege escalation is theoretically possible but gated on INVARIANTS being off.

The fix (validated)

fix.diff adds a radix bounds check at the first point the on-disk radix is trusted (hammer2_chain_alloc), marking the chain with HAMMER2_ERROR_BADBREF, plus an early-return guard in hammer2_chain_load_data so the bad chain never reaches hammer2_io_bread. hammer2_chain_modify already calls chain_load_data and checks chain->error afterward (hammer2_chain.c:1470-1472), so the bcopy OOB sink is covered too.

Phase 8 validation (single-fix kernel #1, make nativekernel):

Kernel Crafted radix-17 image mount Clean image mount
#0 unpatched (INVARIANTS ON) PANIC hammer2_io.c:126 KKASSERT mounts, works
#1 patched EINVAL + "Bad Blockref Error" dmesg mounts, works

No regression on the clean image (mount, read, write, unmount all succeed on #1).

PoC changes from the initial finding scaffolding

The finding folder was empty; everything here was built from scratch: - craft_radix_img.py β€” host-side image patcher (CRC32C-correct, verified). - build.sh / run.sh β€” reproducible build & trigger. - h2_clean.img / h2_craft_radix17.img β€” clean and crafted images. - fix.diff β€” the validated fix (supersedes the finding's one-liner proposal by adding the chain_load_data guard, which is required because hammer2_io_alloc independently recomputes lsize from data_off). - panic.txt, run.log, fix_run.log, fix_build.log, env.txt.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. Applied fix.diff to /usr/src, built single-fix kernel with 'make -j6 nativekernel KERNCONF=X86_64_GENERIC' (rc=0, fix_build.log), installed kernel.stripped->/boot/kernel/kernel + kernel.debug, rebooted to #1 (kern.version bumped #0->#1, build ts Jul 5 10:25:57). Re-ran the SAME trigger: on the unpatched #0 baseline the crafted radix-17 image panics at hammer2_io.c:126 (baseline reproduced); on the patched #1 kernel the mount returns EINVAL with dmesg 'hammer2_mount: error Bad Blockref Error reading super-root' and the guest stays up (no panic, no OOB). Clean-image regression check on #1: mounts, reads, writes, unmounts normally. Fix closes the bug with no regression.

BEFORE (#0 unpatched): 'Illegal: 0000000001400000 0000000001400000+00020000 / ffffffffffff0000' + 'panic: assertion ... failed in hammer2_io_alloc at hammer2_io.c:126' (guest enters DDB, dies). AFTER (#1 patched): 'mount: Invalid argument' (MOUNT_RC=1) + 'hammer2_mount: error Bad Blockref Error reading super-root' + 'guest still alive: up 2 mins' + clean-image mount/write/unmount all succeed. Full build log: fix_build.log; full patched-kernel run: fix_run.log.
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Sun Jul 5 10:25:57 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

Blocked by a valid hard blocker on the default GENERIC target: the KKASSERT at hammer2_io.c:126 fires BEFORE any OOB memory access occurs, so on INVARIANTS-ON (the default) there is no write primitive to groom - the bug manifests purely as a reliable panic/DoS. On a hypothetical INVARIANTS-OFF kernel the same radix=17 would proceed silently: bread() still returns a 64KB DIO buffer but chain->bytes=128KB drives a 64KB heap over-read in XXH64 (chain_load_data testcheck, hammer2_chain.c:1071/5395) and, on chain_modify, a 64KB over-read+over-write in bcopy(chain->data,bdata,chain->bytes) (hammer2_chain.c:1827) - a genuine kmalloc-65536 page-zone OOB read/write primitive. That primitive is characterized but NOT escalated, and only on a non-default kernel (labeled as such per the bright-line rule). No escalation chain file was written because there is no corruption primitive on the default GENERIC target.

Evidence (decisive lines)

findings/poc/DF-0763/{craft_radix_img.py,build.sh,run.sh,h2_craft_radix17.img,panic.txt,run.log,fix.diff,fix_build.log,fix_run.log,env.txt,VERDICT.md,README.md,manifest.json}. Baseline panic (panic.txt): 'Illegal: 0000000001400000 0000000001400000+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' / stack: _hammer2_io_getblk -> _hammer2_io_bread -> hammer2_chain_load_data+0x2a5 -> hammer2_chain_lock -> hammer2_chain_get.

PoC changes

Folder was empty; built everything from scratch. craft_radix_img.py: host-side Python3 image patcher that sets sroot_blockset[0].data_off low 6 bits from 0x0a(radix 10,1KB) to 0x11(radix 17,128KB) and recomputes the 3 volume-header CRCs (icrc_sects[6] sector-1 CRC over the sroot_blockset, icrc_sects[7] sector-0 CRC which is invalidated by changing [6], and icrc_volheader whole-header CRC) using CRC32C (Castagnoli, matching sys/libkern/icrc32.c iscsi_crc32). Verified all 3 CRCs match after patching. h2_clean.img + h2_craft_radix17.img committed as fixtures. build.sh builds guest vnconfig from source (not installed by default), creates the clean image, runs the crafter. run.sh triggers the mount.

Verified recommended fix

fix.diff adds: (1) in hammer2_chain_alloc (hammer2_chain.c:189) extract the radix, validate radix <= HAMMER2_RADIX_MAX, and set chain->error = HAMMER2_ERROR_BADBREF when out of range; (2) an early-return guard in hammer2_chain_load_data (hammer2_chain.c:~958) that rejects any chain flagged HAMMER2_ERROR_BADBREF before it reaches hammer2_io_bread/hammer2_io_alloc. chain_modify is covered transitively because it calls chain_load_data and checks chain->error at :1471-1472. This supersedes the finding markdown's one-liner proposal (which only suggested bytes=0 in chain_alloc) - that alone is insufficient because hammer2_io_alloc independently recomputes lsize from data_off's radix bits, so the chain_load_data guard is required to prevent the bad radix from reaching the IO layer at all.

Verdict

REPRODUCED. hammer2_chain_alloc (sys/vfs/hammer2/hammer2_chain.c:189-190) computes chain->bytes = 1U<<(bref->data_off & 0x3F) with no bounds check on the 6-bit on-disk radix (valid 0,10..16 = HAMMER2_RADIX_MIN..HAMMER2_RADIX_MAX). A crafted hammer2 image setting radix=17 makes chain->bytes=128KB > HAMMER2_PBUFSIZE(64KB). When hammer2_chain_load_data (:920) calls hammer2_io_bread, hammer2_io_alloc (hammer2_io.c:115-126) recomputes lsize=128KB from data_off and the KKASSERT 'pbase!=0 && ((lbase+lsize-1)&pmask)==pbase' fails because 128KB spans two 64KB DIO pages -> panic on INVARIANTS-ON (default GENERIC). Confirmed deterministically by mounting a CRC32C-correct crafted image (sroot_blockset[0] radix patched 10->17, all 3 volume-header CRCs recomputed) on the #0 kernel: immediate panic 'assertion ... failed in hammer2_io_alloc at hammer2_io.c:126' with lsize=0x20000 in the 'Illegal:' diagnostic line, stack trace chain_get->chain_lock->chain_load_data->_hammer2_io_bread->_hammer2_io_getblk->hammer2_io_alloc. Reproduced on two independent fresh-reset runs.