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.
PoC verification
Evidence pack
findings/poc/DF-0763 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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 athammer2_io.c:126(KKASSERT: pbase != 0 && ((lbase + lsize - 1) & pmask) == pbase), guest enters DDB, ssh dies. Serial log excerpt inpanic.txt. - Patched kernel (
#1): mount returnsEINVAL, dmesg showshammer2_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=1with 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.
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):
vnconfigis built from/usr/src/usr.sbin/vnconfig/(not installed by default;make installadds it).- A clean 64MB image is made with
newfs_hammer2 -L testvol. craft_radix_img.pypatchessroot_blockset[0].data_off(the blockref pointing at the super-root inode) radix bits from0x0a(10) to0x11(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_crc32insys/libkern/icrc32.c).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
fixedVALIDATED. 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.
Confirmed kernel references
- sys/vfs/hammer2/hammer2_chain.c:189
- sys/vfs/hammer2/hammer2_chain.c:190
- sys/vfs/hammer2/hammer2_chain.c:920
- sys/vfs/hammer2/hammer2_chain.c:938
- sys/vfs/hammer2/hammer2_chain.c:1470
- sys/vfs/hammer2/hammer2_chain.c:1827
- sys/vfs/hammer2/hammer2_io.c:115
- sys/vfs/hammer2/hammer2_io.c:122
- sys/vfs/hammer2/hammer2_io.c:126
- sys/vfs/hammer2/hammer2_disk.h:87
- sys/vfs/hammer2/hammer2_disk.h:89
- sys/vfs/hammer2/hammer2_disk.h:106
- sys/vfs/hammer2/hammer2_disk.h:461
- sys/vfs/hammer2/hammer2_disk.h:630
- sys/vfs/hammer2/hammer2_disk.h:1237
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.
Discussion (1)