# DF-2583 — hammer2 unvalidated on-disk radix -> kernel panic / OOB blockref array access during flush

## Verdict: REPRODUCED (panic / DoS, with OOB-potential on noinv), FIX VALIDATED

The bug is **real and confirmed**. A malicious hammer2 filesystem image causes
a kernel panic on the default INVARIANTS-ON GENERIC kernel whenever a process
reads (readdir) a directory whose INDIRECT block has been forged with an
inflated `bref.data_off` radix. On a non-INVARIANTS kernel the same forged
radix drives an out-of-bounds read/write of the blockref array at
`hammer2_flush.c:1094` (`count = parent->bytes / sizeof(hammer2_blockref_t)`).

The fix (`fix.diff`) adds a single guard in `hammer2_chain_load_data` that
rejects any chain whose computed `bytes` exceeds `HAMMER2_PBUFSIZE` (= the
maximum legal radix, `HAMMER2_RADIX_MAX`). Validated on a built-and-booted
single-fix kernel (`#1`): the same forged image mounts and readdir returns
`EDOM` instead of panicking; no OOB; guest stays up.

## Mechanism (trigger -> primitive -> effect)

The chain of failures:

1. **Attacker input.** A hammer2 image has an INDIRECT blockref whose
   `bref.data_off` low 6 bits (the "radix") are forged to 17 (or any value
   > `HAMMER2_RADIX_MAX` = 16). `parent->bytes` (a.k.a. `chain->bytes`)
   becomes `1U << 17` = 131072 bytes.

2. **Unvalidated derivation** at `sys/vfs/hammer2/hammer2_chain.c:189-190`:
   ```c
   if ((int)(bref->data_off & HAMMER2_OFF_MASK_RADIX))
       bytes = 1U << (int)(bref->data_off & HAMMER2_OFF_MASK_RADIX);
   ```
   No bound check. `HAMMER2_RADIX_MAX` is defined (`hammer2_disk.h:89`) but
   never enforced here.

3. **Reachable from unprivileged readdir.** When any process calls
   `getdents(2)` on the directory containing the forged INDIRECT bref, the
   kernel descends: `hammer2_chain_get` -> `hammer2_chain_lock` ->
   `hammer2_chain_load_data` (`hammer2_chain.c:940`). It then calls
   `hammer2_io_bread(hmp, type, bref->data_off=0x1c01011, chain->bytes=131072, ...)`
   which dispatches to `_hammer2_io_getblk` -> `hammer2_io_alloc`.

4. **On INVARIANTS-ON GENERIC (default guest):** the KKASSERT
   ```c
   /* hammer2_io.c:126 */
   KKASSERT(pbase != 0 && ((lbase + lsize - 1) & pmask) == pbase);
   ```
   fails because `lsize` (131072) > `HAMMER2_PBUFSIZE` (65536) so the request
   crosses a 64KB page boundary. The kernel prints
   `Illegal: 0000000001c00000 0000000001c01000+00020000 / ffffffffffff0000`
   then panics with the assertion failure. The guest drops to `db>` and ssh
   dies. Reproduced:
   ```
   panic: assertion "pbase != 0 && ((lbase + lsize - 1) & pmask) == pbase" failed
       in hammer2_io_alloc at /usr/src/sys/vfs/hammer2/hammer2_io.c:126
   Trace:
     _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
   ```

5. **On non-INVARIANTS kernels** the KKASSERT is skipped and the request
   proceeds with `dio->psize = HAMMER2_PBUFSIZE` (always 64K), but
   `chain->bytes = 131072`. Later, at `hammer2_flush.c:1094`:
   ```c
   case HAMMER2_BREF_TYPE_INDIRECT:
   case HAMMER2_BREF_TYPE_FREEMAP_NODE:
       if (parent->data)
           base = &parent->data->npdata[0];
       else
           base = NULL;
       count = parent->bytes / sizeof(hammer2_blockref_t);  /* <-- OOB */
       break;
   ```
   `count` becomes 131072/128 = 1024, but `npdata` only holds the on-disk
   INDIRECT block (≤ HAMMER2_PBUFSIZE = 512 blockrefs). The flush loop then
   iterates OOB into adjacent kernel heap, writing attacker-influenced
   blockref data to disk. This is a write-capable primitive on noinv
   builds; on default GENERIC it is masked by the earlier KKASSERT and
   manifests only as a panic.

## Reproduction (unpatched `#0`)

1. `setup_image.sh` creates a 64 MB hammer2 image with `/testdir/` containing
   12 small files plus a >64-byte-named file (forces an INDIRECT block in
   testdir's inode blockset).
2. `forge.c` walks the on-disk bref tree, finds the first INDIRECT blockref,
   patches its `data_off` radix from 12 to 17, and recomputes the entire
   hammer2 CRC chain (XXH64 with seed `0x4d617474446c6c6e` for inode/indirect
   blocks; CRC32C for the volume header's 3 regions) so the kernel mounts the
   image without complaint.
3. `mount_hammer2` mounts the forged image.
4. `poc.c` calls `getdents(2)` on `/mnt/h2test/testdir` as unprivileged user
   `maxx`. This forces the kernel to descend into the forged INDIRECT chain,
   triggering the panic.

Panic signature (full text in `panic.txt` and `run.log`):
```
Illegal: 0000000001c00000 0000000001c01000+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
```

## Threat model & preconditions

- **Trigger:** unprivileged user readdir/getdents on a malicious hammer2 mount.
- **Mount precondition:** an admin has mounted (or made mountable, e.g. via
  `vfs.usermount=1` + a root-created image owned by the attacker) a malicious
  hammer2 filesystem image. Realistic: USB media, downloaded images, attached
  backup drives. Thehammer2 mount itself requires root unless the admin
  pre-configures usermount.
- **Effect on default GENERIC:** kernel panic (DoS).
- **Effect on non-INVARIANTS kernel:** kernel heap OOB write during flush,
  potentially exploitable to corruption primitives. The audit guest is
  INVARIANTS-ON GENERIC by default, so we characterize the noinv path
  analytically here and demonstrate the panic-only path empirically.

## Impact classification

- **Class:** unvalidated on-disk integer (radix) -> OOB-prone count
  computation -> kernel panic on default GENERIC; potential heap OOB write
  on noinv. CVSS 3.1: `AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H` (DoS).
- **Impact ceiling (default GENERIC):** reliable kernel panic / DoS, fully
  repeatable from unprivileged readdir. No memory-corruption primitive
  observable because INVARIANTS catches the oversized radix before flush.
- **Impact ceiling (noinv):** kernel heap OOB write of attacker-influenced
  blockref data to disk during flush; escalation-relevant primitive but not
  pursued here because the default/realistic target is INVARIANTS-ON GENERIC.

## Phase 6 — escalation analysis (memory-corruption class)

The **default GENERIC kernel** traps the bad radix at I/O time
(`hammer2_io_alloc` KKASSERT), so the OOB at `hammer2_flush.c:1094` never
executes on the realistic target. The genuine write primitive exists only on
a non-INVARIANTS build (noinv), which the bright-line rule classifies as a
**non-default-kernel result**.

This is therefore a Phase 6 **valid hard blocker** for escalation on the
default GENERIC kernel: the OOB write is masked by an INVARIANTS-class trap
firing earlier on the same forged input. The primitive is fully
characterized analytically (bucket = page-cache 64K DIO buffer; victim =
adjacent slab/heap objects near the INDRECT npdata; conversion would be
slab-groom -> forge `struct ucred` in userspace (no SMAP) -> redirect a
corrupted `ucred *` -> `setresuid(0)`), but it cannot be demonstrated on
the default-kernel target because INVARIANTS terminates the chain first.
Per the bright-line rule we report the demonstrated impact (panic) on
GENERIC and label the noinv-only escalation path as non-default.

## Fix (fix.diff, validated)

The fix is a single, targeted guard in `hammer2_chain_load_data`
(`sys/vfs/hammer2/hammer2_chain.c`) that runs **before** the `hammer2_io_bread`
call. If `chain->bytes > HAMMER2_PBUFSIZE` (= `1<<HAMMER2_RADIX_MAX`, the
maximum legal radix), the chain is rejected (`chain->error =
HAMMER2_ERROR_CHECK`), no buffer is allocated, and callers handle the
errored chain gracefully (lookup/readdir return `EDOM`, flush skips the
chain). The guard is rate-limited to 4 console messages to avoid warning
floods.

Why a load-time guard rather than at `chain_alloc`?
- The KKASSERT that masks the bug on GENERIC fires in `hammer2_io_alloc`,
  which is reached *through* `hammer2_chain_load_data` (not at chain
  creation). Intercepting at load-time is the cleanest single-point fix
  that closes both failure modes (the I/O KKASSERT on INVARIANTS, and the
  flush OOB on noinv).
- Earlier attempts at `chain_alloc` (clamping `bytes` and/or sanitizing
  `chain->bref.data_off`) either tripped a *different* KKASSERT in
  `_hammer2_io_getblk` (because the radix in `data_off` no longer matched
  the bytes), or broke the lookup cache's bref-matching logic and caused
  a 42000-line warning storm.

`fix.diff` is git-apply-able, supersedes any proposal in the finding
markdown (no proposal existed pre-verification — the PoC was authored from
scratch by the verifier).

### Before/after validation

| Kernel              | PoC getdents result                              | Panic? | Guest |
|---------------------|--------------------------------------------------|--------|-------|
| `#0` unpatched      | panic during `hammer2_io_alloc` KKASSERT         | **YES**| down  |
| `#1` patched        | returns `-1 errno=EDOM` ("Numerical argument out of domain") | no   | up    |

Both runs use the identical forged image, identical `forge.c` and `poc.c`.
Patched kernel `kern.version`:
`DragonFly 6.5-DEVELOPMENT #1: Sat Aug  8 11:07:49 UTC 2026` (sha256 of
`/boot/kernel/kernel` = `47813fded593d3d1c2941081867034b39d920a4b2c9562061543fb6c2bf462db`).

Reproduced twice on the patched kernel; deterministic.

## Files

| File | Description |
|------|-------------|
| `forge.c` | Image forger: patches INDIRECT bref radix + fixes XXH64/CRC32C chain |
| `crc32ctab.h` | CRC32C lookup table for volume header CRC (reused from DF-2562) |
| `poc.c` | Unprivileged getdents trigger that forces INDIRECT chain load |
| `setup_image.sh` | Creates the hammer2 image with testdir + 12 files |
| `build.sh` | Builds forge + poc |
| `run.sh` | Full reproduction chain (forge -> mount -> trigger) |
| `fix.diff` | git-apply-able fix for hammer2_chain.c (load-time radix guard) |
| `fix_build.log` | Full kernel build output (single-fix kernel) |
| `fix_run.log` | PoC output on fixed kernel (returns EDOM, no panic) |
| `panic.txt` | Panic signature from boot.log (KKASSERT in hammer2_io_alloc) |
| `run.log` | Decisive baseline run with panic capture |
| `env.txt` | Guest environment info |
| `manifest.json` | Machine-readable artifact catalog |
