# DF-0821 — Verdict

**REPRODUCED** — Signed-comparison gap in hammer2 freemap linear-iterator guard
allows a negative on-disk `bmap->linear` to pass the guard and drive an OOB
array index.  On the default GENERIC kernel (INVARIANTS ON) the KKASSERT at
`hammer2_freemap.c:633` catches it and **panics** (reliable DoS from a crafted
filesystem image).  On an INVARIANTS-OFF (production) kernel the KKASSERT is
compiled out and the same primitive becomes a **silent heap OOB read+write**
(`bmap->bitmapq[-4095]`, 32760 bytes before `bitmapq[0]`), characterized below
by the deterministic harness.

**Impact: panic (DoS) on default GENERIC; corruption (OOB heap R/W) on
production/no-inv kernels.**  Mount precondition is root-only on this guest
(`vfs.usermount=0`); realistic threat model is an admin (or auto-mounter /
`vfs.usermount=1` user) who mounts a crafted hammer2 image.

## The bug, confirmed

`hammer2_bmap_alloc` (`sys/vfs/hammer2/hammer2_freemap.c:616-619`) gates the
linear-iterator fast path with three sub-conditions:

```c
616:  if (((uint32_t)bmap->linear & HAMMER2_FREEMAP_BLOCK_MASK) + size <=
617:      HAMMER2_FREEMAP_BLOCK_SIZE &&
618:      (bmap->linear & HAMMER2_FREEMAP_BLOCK_MASK) &&
619:      bmap->linear < HAMMER2_SEGSIZE) {
```

`bmap->linear` is `int32_t` from the on-disk freemap leaf
(`hammer2_disk.h:872` `struct hammer2_bmap_data.linear`), loaded raw into
kernel memory at `hammer2_chain.c:1100` (`chain->data = (void *)bdata`) with no
range validation of the `linear` field.  The third sub-condition (`:619`) is a
**signed** `int < int` comparison: `HAMMER2_SEGSIZE` is `1<<22 = 4194304`
(`hammer2_disk.h:102,341`).  A negative `linear` is always `< 4194304`, so it
passes.

For `linear = 0x80001000` (int32 = -2147479552):
- `:616` `(uint32_t)0x80001000 & 0x3FFF` = `0x1000` = 4096; `4096 + 1024 = 5120 <= 16384` ✓
- `:618` `0x80001000 & 0x3FFF` = `0x1000` (nonzero, int promoted to unsigned for `&`) ✓
- `:619` `-2147479552 < 4194304` ✓ (signed)

All three pass.  The linear-iterator path is taken.  Then:

```c
631:  KKASSERT(bmap->linear >= 0 &&
632:           bmap->linear + size <= HAMMER2_SEGSIZE &&
633:           (bmap->linear & (HAMMER2_ALLOC_MIN - 1)) == 0);
634:  offset = bmap->linear;
635:  i = offset / (HAMMER2_SEGSIZE / HAMMER2_BMAP_ELEMENTS);
```

`KKASSERT` is gated by `INVARIANTS` (`sys/sys/systm.h:94-118`).  On default
`X86_64_GENERIC` (`options INVARIANTS`, `sys/config/X86_64_GENERIC:56`) the
first sub-condition `bmap->linear >= 0` is false → **panic at `:633`**.

On an INVARIANTS-OFF kernel `KKASSERT` compiles to `do { } while (0)`
(`systm.h:118`), the negative `offset` flows into the array index:
`i = -2147479552 / 524288 = -4095` (C truncates toward zero).  Then
`:727`, `:749`, `:785` read `bmap->bitmapq[i]` = `bmap->bitmapq[-4095]` =
**32760 bytes before `bitmapq[0]`** in kernel heap, and `:803` writes it
(`bmap->bitmapq[i] |= bmmask`).  The OOB extent is attacker-tunable by
choosing different negative `linear` values.

## Reproduction (live, on the real kernel)

HAMMER2 is the root filesystem on this guest, so the VFS code is compiled in
and live.  Steps (automated by `run.sh live`, root required for
`vnconfig`+`mount_hammer2`):

1. `truncate -s 64M clean.img && newfs_hammer2 -L testvol clean.img`
2. Mount + write 5 small (10KB) files + unmount → freemap leaves with real
   `bmap_data` entries (non-zero `avail`) now exist on disk.
3. `craft_img.py` parses the volume header (`freemap_blockset[0]` →
   `FREEMAP_LEAF` at disk offset `0x10000`, 32KB), picks the 10 `bmap_data`
   entries with `avail > 0`, and sets each `linear` field to `0x80001000`.
   It recomputes:
   - the leaf chain CRC (`HAMMER2_CHECK_FREEMAP` = `iscsi_crc32` over the
     32KB leaf buffer, `hammer2_chain.c:5414-5416`), stored in
     `freemap_blockset[0].check.freemap.icrc32` (vol-header offset `0x840`);
   - `icrc_volheader` (`iscsi_crc32` over 65532 bytes, stored at `0xFFFC`).
   `icrc_sects[7]` (sect0) and `icrc_sects[6]` (sect1) are untouched (the
   freemap_blockset lives in sector 4, which is only covered by
   `icrc_volheader`, not by sect0/sect1 — verified against
   `hammer2_ondisk.c:528-558` which validates only sect0, sect1, volheader).
4. `vnconfig -c vn1 crafted.img && mount -t hammer2 /dev/vn1@testvol /mnt/h2821`
   then `echo trigger > /mnt/h2821/poison_file`.

**Baseline result (`#0` unpatched, INVARIANTS ON):** the file write panics
immediately — guest enters DDB, ssh dies, `vm.sh status` ⇒ down.  Serial-log
panic signature (`panic.txt`):

```
panic: assertion "bmap->linear >= 0 && bmap->linear + size <= HAMMER2_SEGSIZE
       && (bmap->linear & (HAMMER2_ALLOC_MIN - 1)) == 0" failed
       in hammer2_bmap_alloc at /usr/src/sys/vfs/hammer2/hammer2_freemap.c:633
hammer2_bmap_alloc.constprop.2() at hammer2_bmap_alloc.constprop.2+0x3a5
hammer2_freemap_alloc() at hammer2_freemap_alloc+0x45c
hammer2_chain_modify() at hammer2_chain_modify+0x60c
hammer2_chain_create() at hammer2_chain_create+0xe24
hammer2_xop_inode_create_det() at hammer2_xop_inode_create_det+0x125
```

Reproduced deterministically on 2 independent fresh-`reset` runs on `#0`.

## Deterministic harness (noinv / production characterization)

`harness.c` transcribes the guard (`:616-619`) + index computation (`:634-636`)
+ OOB read (`:727`) + OOB write (`:803`) verbatim, with the KKASSERT disabled
to simulate INVARIANTS-OFF.  A poisoned allocator places `bitmapq[]` at the
end of a canary-filled buffer so the OOB read into `bitmapq[-4095]` visibly
returns canary bytes and the OOB write visibly mutates them.  Output for
`linear=0x80001000`:

```
guard1 PASS  guard2 PASS  guard3 PASS  => linear-iterator path TAKEN: YES (BUG)
[GENERIC] KKASSERT(bmap->linear >= 0) would PANIC here (line 631-633)
[noinv]   continuing to OOB array index...
offset = -2147479552   i = -4095
*** OOB ARRAY INDEX: bitmapq[-4095] is 32760 bytes BEFORE bitmapq[0] ***
bitmapq[-4095] @ bitmapq[0] + -32760 bytes = 0xcdcdcdcdcdcdcdcd
wrote 0x0000000000000003 into bitmapq[-4095]
canary region BEFORE bitmapq[0] modified: YES (OOB write confirmed)
```

This confirms the OOB extent and that both the read (`:727/:749/:785`) and the
write (`:803`) sinks are reachable on a noinv kernel.

## Impact ceiling & why escalation stops at panic on GENERIC

On the **default `X86_64_GENERIC` (INVARIANTS ON)** the KKASSERT at `:631-633`
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: the write primitive the chain would need
is gated behind INVARIANTS, which catches the negative `linear` at the
assertion before `i` is even computed.  The realistic impact is therefore
**panic / DoS from a crafted filesystem image** (mount + first allocation in
the poisoned segment).

On a hypothetical **INVARIANTS-OFF** kernel the same trigger yields a
genuine heap OOB read+write primitive (32KB-class freemap leaf buffer,
attacker-chosen negative offset into the preceding kernel heap).  That is in
principle groomable for privilege escalation, but only on a **non-default**
kernel build.  Per the bright-line rule this is reported as `panic` for the
default GENERIC target with the non-default primitive characterized by the
harness, not escalated.

**Threat model.**  Realistic precondition: an attacker supplies a crafted
hammer2 filesystem image and a victim mounts it (USB stick, downloaded image,
auto-mounter, or `vfs.usermount=1` with a user-owned device).  On default
DragonFlyBSD this yields immediate kernel panic / DoS.  No privilege beyond
image supply is required.

## The fix (validated)

`fix.diff` adds `bmap->linear >= 0` as the FIRST sub-condition of the guard at
`:616-619`, short-circuiting the linear-iterator path before any other
evaluation when `linear` is negative (or, via the existing `bmap->linear <
HAMMER2_SEGSIZE`, when it is `>= SEGSIZE`).  When the guard fails the
allocator falls through to the bitmap-scan path (`:642-714`), which does not
use `bmap->linear` for array indexing and handles the allocation correctly.
The fix is minimal, targeted at the root cause, and does not change any
on-disk format.

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

| Kernel                         | Crafted `linear=0x80001000` image         | Clean image          |
|--------------------------------|-------------------------------------------|----------------------|
| `#0` unpatched (INVARIANTS ON) | **PANIC** `hammer2_freemap.c:633` KKASSERT | mounts, works        |
| `#1` patched (INVARIANTS ON)   | **mount + file write succeed, no panic**   | mounts, works        |

No regression: on `#1` the crafted image mounts, files are written and read
correctly, unmount succeeds, guest stays up.  Verified on 2 consecutive runs.

## PoC artifacts

- `craft_img.py` — host-side image patcher: poisons `bmap->linear` to
  `0x80001000` in up to 10 freemap leaf entries, recomputes the leaf chain
  CRC and `icrc_volheader` (CRC32C / `iscsi_crc32`, self-test verified).
- `harness.c` — deterministic userspace transcription of the guard + OOB
  logic; shows the OOB extent on a noinv kernel without needing a mount.
- `h2_821.img` (generated by `run.sh`) — clean populated image (intermediate).
- `h2_craft_821.img` — crafted image with poisoned freemap.
- `build.sh` / `run.sh` — reproducible build & trigger (harness + live).
- `panic.txt` — kernel panic signature from `boot.log` (crash proof).
- `harness_run.log` — full harness output.
- `fix.diff` — the validated one-line guard fix.
- `fix_build.log` / `fix_run.log` — Phase 8 build + validation logs.
