# DF-0805 — VERDICT

**Verdict:** REPRODUCED. In-kernel KKASSERT panic confirmed on stock
GENERIC (INVARIANTS ON) via a crafted HAMMER2 image; LZ4 OOB-read
primitive confirmed at the unit level via a verbatim copy of the
in-tree `LZ4_decompress_safe`. Fix validated on a single-fix kernel:
panic is replaced by a clean `EIO` return.

## Mechanism (confirmed)

1. An attacker-controlled `compressed_size` field is read from on-media
   data at `sys/vfs/hammer2/hammer2_strategy.c:198`:
   ```c
   compressed_size = *(const int *)data;
   ```
2. The only bound is the KKASSERT at `hammer2_strategy.c:199`:
   ```c
   KKASSERT((uint32_t)compressed_size <= bytes - sizeof(int));
   ```
   On a kernel without `INVARIANTS`, `KKASSERT` expands to
   `do { } while (0)` (`sys/sys/systm.h:117-118`) — the check is gone.
3. With `compressed_size` unchecked, the call at `:202-205`:
   ```c
   LZ4_decompress_safe(__DECONST(char *, &data[sizeof(int)]),
                       compressed_buffer,
                       compressed_size,     /* <-- attacker value as inputSize */
                       bp->b_bufsize);
   ```
   passes the attacker value as `inputSize` to LZ4.
4. Inside `LZ4_decompress_generic` (`sys/vfs/hammer2/hammer2_lz4.c:391`):
   ```c
   BYTE* iend = ip + inputSize;
   ```
   The decoder uses `iend` as the source bound (`ip < iend` in the
   literal-run-extension loop at `:422` and the match-length-extension
   loop at `:467`). An oversized `inputSize` reads past the chain dio
   buffer (`HAMMER2_PBUFSIZE` = 65536 bytes; the dio buffer is allocated
   at `hammer2_io.c:113`) into adjacent kernel memory.
5. The decoded bytes — including the OOB-read ones emitted as LZ4
   literals — land in `compressed_buffer`, then `bcopy`d into
   `bp->b_data` at `:214` and ultimately to the user's read buffer.

## Proof points

### A. Unit-level harness (`lz4_oob_harness.c`) — definitive primitive proof

Maps a 3-page region `[guard-LO (PROT_NONE) | data (RW) | guard-HI (PROT_NONE)]`,
fills `data` with an LZ4 stream of `0xF0 0xFF 0xFF …`, sets the on-media
`compressed_size` int to `0x10000`, and calls the verbatim in-tree
`LZ4_decompress_safe()`. The decoder walks off the data page into the
high guard page and SIGSEGVs:

```
[harness] trigger: compressed_size=0x10000 with bytes=4096 (valid bound 4092); calling LZ4_decompress_safe...
[harness] SIGSEGV at 0x800475000 while inside LZ4_decompress_safe()
[harness]   -> out-of-bounds read past the source buffer
```

This proves the primitive independent of any kernel-config or
check-code interaction. (Full log in `run.log`.)

### B. In-kernel trigger (`hammer2_trigger.sh`) — KKASSERT panic on stock GENERIC

The trigger builds a fresh HAMMER2 image, writes a 64 KiB file with a
unique literal signature, uses `HAMMER2IOC_INODE_SET` (via the
`setcheck` helper) to disable the per-file block check, unmounts,
finds the on-disk LZ4 block by its signature, overwrites the 4-byte
`compressed_size` header with `0x7FFFFFFF`, re-mounts, and reads the
file as the unprivileged `maxx` user.

On the unpatched `#0` GENERIC kernel (INVARIANTS ON), the KKASSERT at
`hammer2_strategy.c:199` fires and the kernel panics (full trace in
`panic.txt`):

```
panic: assertion "(uint32_t)compressed_size <= bytes - sizeof(int)" failed in hammer2_decompress_LZ4_callback at /usr/src/sys/vfs/hammer2/hammer2_strategy.c:199
cpuid = 4
Trace beginning at frame 0xfffff801187f9950
hammer2_xop_strategy_read() at hammer2_xop_strategy_read+0x9a6 0xffffffff80984c76 
hammer2_xop_strategy_read() at hammer2_xop_strategy_read+0x9a6 0xffffffff80984c76 
hammer2_primary_xops_thread() at hammer2_primary_xops_thread+0x280 0xffffffff8095da30 
Debugger("panic")
```

### C. Reachability note (defense-in-depth)

On the *default* HAMMER2 configuration, the per-block XXHASH64 check
(`hammer2_chain.c:1071`) rejects corrupted blocks before they reach
the LZ4 path. The trigger therefore explicitly disables the check via
the `HAMMER2IOC_INODE_SET` ioctl (`setcheck` helper) to expose the
vulnerable code path. This is consistent with the realistic threat
model — a malicious downloaded HAMMER2 image can ship with
`HAMMER2_CHECK_NONE` inodes or with validly-computed XXHASH64 codes
for malicious LZ4 payloads.

## Exploit chain

Not applicable. The primitive is a pure OOB **read** — there is no
write, no UAF, no type confusion, no corruption of a victim object.
On stock GENERIC + XXHASH64, the check code prevents the LZ4 path
from being reached with corrupted data, so the bug is defense-in-depth
under default config. The realistic impact is:

- **GENERIC (INVARIANTS ON) + check disabled:** local DoS via KKASSERT
  panic.
- **Non-INVARIANTS + check disabled:** kernel heap disclosure (the OOB
  bytes flow through to userspace via the read buffer) or page-fault
  panic depending on adjacent VA layout.
- **Non-INVARIANTS + check enabled:** latent — only a bit-flip or
  attacker-controlled image with matching XXHASH64 reaches the LZ4 path.

No `uid=0` chain derivable from this primitive alone. Severity `Low` is
correct.

## Fix

`fix.diff` replaces the KKASSERT-only bound with a real guard that
returns `EIO` (and zeroes the user buffer) when the on-media
`compressed_size` is negative or exceeds `bytes - sizeof(int)`. The
guard is compiled in unconditionally — it does not depend on
`INVARIANTS`, so the path is safe on non-INVARIANTS kernels too.

```diff
+	if (compressed_size < 0 ||
+	    (u_int)compressed_size > bytes - sizeof(int)) {
+		kprintf("HAMMER2 LZ4: bad compressed_size %d (bytes=%u)\n",
+			compressed_size, bytes);
+		bp->b_error = EIO;
+		bp->b_flags |= B_ERROR;
+		bp->b_resid = bp->b_bufsize;
+		bzero(bp->b_data, bp->b_bufsize);
+		return;
+	}
```

## Fix validation (Phase 8)

- **Baseline (`#0`, unpatched):** trigger panics with the assertion at
  `hammer2_strategy.c:199` (panic.txt). ✅ reproduced.
- **Patched (`#1`, single-fix):** same trigger returns `EIO` cleanly,
  no panic, guest stays up. The fix's `kprintf` lands in `dmesg`:
  ```
  HAMMER2 LZ4: bad compressed_size 2147483647 (bytes=1024)
  ```
  ✅ fix validated.

## PoC changes

The finding folder was seeded empty (no prior PoC). I authored:
- `lz4_oob_harness.c` — unit-level primitive proof with verbatim in-tree LZ4
- `hammer2_trigger.sh` — in-kernel trigger via crafted HAMMER2 image
- `setcheck.c` — ioctl helper to disable per-file check
- `build.sh` / `run.sh` — driver scripts
- `fix.diff` — the fix
- `VERDICT.md`, `README.md`, `manifest.json` — evidence pack

## Kernel refs (confirmed)

- `sys/vfs/hammer2/hammer2_strategy.c:198` — `compressed_size = *(const int *)data`
- `sys/vfs/hammer2/hammer2_strategy.c:199` — `KKASSERT(...)` (no-op without INVARIANTS)
- `sys/vfs/hammer2/hammer2_strategy.c:202-205` — `LZ4_decompress_safe(..., compressed_size, ...)`
- `sys/vfs/hammer2/hammer2_lz4.c:391` — `iend = ip + inputSize` (oversized inputSize reads OOB)
- `sys/sys/systm.h:117-118` — `KKASSERT` no-op without INVARIANTS
- `sys/vfs/hammer2/hammer2_io.c:113` — dio buffer size = `HAMMER2_PBUFSIZE` (65536)
- `sys/vfs/hammer2/hammer2_chain.c:1071` — XXHASH64 check rejection (defense-in-depth)
- `sys/vfs/hammer2/hammer2_strategy.c:929-933` — all-zeros writes are stored as holes (not LZ4)
