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)
PoC verification
Evidence pack
findings/poc/DF-2605 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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
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:
-
radix 17..31 β
bytesis well-defined (128 KB..2 GB) but exceedsHAMMER2_PBUFSIZE. The unprivileged readdir path drives the kernel intohammer2_io_alloc()(hammer2_io.c:126) where theKKASSERT(pbase != 0 && ((lbase + lsize - 1) & pmask) == pbase)trips because the oversized request crosses a 64 KB page. Panic on default GENERIC (INVARIANTS ON) β reproduced. -
radix >= 32 β
1U << radixis undefined behaviour (shift count >= type width). On gcc 8.3 x86-64-O2theshlmasks the count to 5 bits, so e.g.1U << 32evaluates to1, producing a bogusbytesand a mis-sized 1-byte I/O. Confirmed: no panic, butchain->bytesis 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)
-
Attacker input. A hammer2 image has an INDIRECT blockref whose
bref.data_offlow 6 bits are forged to 17 (data_off = 0x1c01011). The full hammer2 CRC chain (XXH64 over inode/indirect blocks with seed0x4d617474446c6c6e, CRC32C over the volume header) is recomputed byforge.cso the kernel accepts the forged blockref. -
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 inhammer2_disk.h:89but never enforced here. -
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. -
Default GENERIC (INVARIANTS ON): the KKASSERT at
hammer2_io.c:126fails (lsize=131072 > HAMMER2_PBUFSIZE=65536crosses a page boundary).Illegal: 0000000001c00000 0000000001c01000+00020000 / ffffffffffff0000thenpanic: assertion ... failed in hammer2_io_alloc. Guest βdb>, ssh dies. Reproduced (seepanic.txt). -
radix >= 32 (UB):
1U << radixis UB; on this gccbyteswraps to a tiny value, so the I/O KKASSERT does not fire, but the chain is mis-sized and readdir returnsEDOM. The behaviour is implementation-defined β a different compiler/flags could produce anything (including a hugebytesthat does panic). The finding's UB concern is real. -
noinv (non-default): KKASSERT skipped β the oversized
parent->bytesdrivescount = parent->bytes / 128athammer2_flush.c:1094, iterating the blockref array (base[]) far past its actual size β OOB read/write of kernel heap with attacker-influenced blockref data onsync. This is the DF-2583 write primitive (same root cause). Not escalated touid=0here: 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:126fires before any heap victim is touched. The write primitive (noinv only) targets the INDIRECT block'snpdata[]blockref array athammer2_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 duringgetdents. Realistic precondition: an admin mounts (or makes mountable viavfs.usermount) a hammer2 image and lets the user read it. - Why not
uid=0: on the default GENERIC kernel (INVARIANTS ON β the realistic target) thehammer2_io.c:126KKASSERT 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-defaultnoinvkernel (characterised in DF-2583; per the bright-line rule anoinv-only escalation is not a default-GENERICuid0). 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:
-
hammer2_chain_alloc(root cause, DF-2605): computeradixonce; ifradix > HAMMER2_RADIX_MAX, do not execute the UB/oversized1U << radixβ instead capbytes = 1U << HAMMER2_RADIX_MAXand mark the chainHAMMER2_ERROR_CHECK(with a warnoncekprintf). This closes both the radix-17..31 oversized-I/O path and the radix>=32 UB at the derivation site. -
hammer2_chain_load_data(companion, = the DF-2583 guard): reject a chain already marked errored (or withbytes > HAMMER2_PBUFSIZE) before callinghammer2_io_bread(). This is what actually prevents the I/O-layer KKASSERT: the errored chain short-circuits, callers returnEDOM/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
fixedVALIDATED. 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).
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<
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).
No comments yet.