# 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**:

```c
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:

```c
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`.
