# DF-0805 — LZ4 read-path OOB primitive (HAMMER2)

## Summary

The HAMMER2 LZ4 decompression callback at
`sys/vfs/hammer2/hammer2_strategy.c:198-205` reads the on-media
`compressed_size` field straight from disk as a signed `int` and uses it
as the `inputSize` argument to `LZ4_decompress_safe()`. The only bound
is a `KKASSERT` at line 199:

```c
compressed_size = *(const int *)data;
KKASSERT((uint32_t)compressed_size <= bytes - sizeof(int));
```

`KKASSERT` is a *real* guard only on kernels built with `INVARIANTS`.
On a non-INVARIANTS kernel (`sys/sys/systm.h:117-118`) the macro
expands to `do { } while (0)` — a no-op — and `LZ4_decompress_safe()`
is called with an attacker-controlled `inputSize`. LZ4 trusts
`inputSize` as the source-buffer bound (`iend = ip + inputSize` at
`hammer2_lz4.c:391`); an oversized value causes the decoder to read
past the chain dio buffer into adjacent kernel memory.

- **Default `X86_64_GENERIC` (INVARIANTS ON):** the KKASSERT fires first,
  panicking the kernel. **Local DoS** when reading a malicious HAMMER2
  file.
- **Non-INVARIANTS kernel (custom/embedded/perf builds):** the KKASSERT is
  gone, so the oversized `inputSize` flows through. The decoder reads
  attacker-controlled counts of bytes past the chain dio buffer —
  **kernel heap disclosure** (the OOB bytes are emitted as LZ4 literals
  into `bp->b_data`, which is then `bcopy`d into the user's read buffer
  at line 214), or a page-fault panic depending on adjacent VA layout.

Same KKASSERT-as-bounds pattern at `:197`, `:213`, `:486`, `:613`.

## PoC components

| File | What it does |
|---|---|
| `lz4_oob_harness.c` | **Unit-level proof of the LZ4 OOB primitive.** Compiles a verbatim copy of the in-tree `LZ4_decompress_safe` decompressor (the function under test is byte-equivalent to `sys/vfs/hammer2/hammer2_lz4.c`), mmaps a 3-page region `[guard-LO | data | guard-HI]`, fills `data` with an LZ4 stream, then calls `LZ4_decompress_safe(data+4, dst, 0x10000, 65536)` — exactly mirroring the HAMMER2 read path with an attacker-inflated `compressed_size` of 64 KiB but only a 4096-byte source buffer. The decoder walks off the data page into `guard-HI` and SIGSEGVs, proving the OOB read definitively. |
| `hammer2_trigger.sh` | **In-kernel trigger via a crafted HAMMER2 image.** Run as root. Builds a fresh HAMMER2 image, writes a 64 KiB file with a unique literal signature, disables the per-file block check via `HAMMER2IOC_INODE_SET` (so a corrupted block reaches the LZ4 path instead of being rejected at `hammer2_chain.c:1071`), unmounts, surgically overwrites the on-disk 4-byte `compressed_size` header with `0x7FFFFFFF`, re-mounts, and reads the file as the unprivileged user. |
| `setcheck.c` | Helper that uses `HAMMER2IOC_INODE_GET/SET` to flip a file's `check_algo` to `HAMMER2_CHECK_NONE` so the corrupted block reaches the LZ4 path on next read. |
| `build.sh` / `run.sh` | Build and run the unit-level harness. |
| `fix.diff` | Standalone `git apply`-able unified diff against `sys/vfs/hammer2/hammer2_strategy.c` that replaces the KKASSERT-only bound with a real guard returning `EIO`. |

## Build & run

### Unit-level harness (proves the primitive)
```sh
./build.sh
./lz4_oob_harness
```
Expected output ends with `SIGSEGV at <addr> while inside LZ4_decompress_safe()`
— definitive proof that the algorithm under test reads past the source
buffer when `inputSize` exceeds the backing allocation.

### In-kernel trigger (root on the guest)
```sh
cc -O2 -Wall -o setcheck setcheck.c    # helper, built as root
./hammer2_trigger.sh                   # root; reads as maxx (uid 1001)
```
On the unpatched `#0` GENERIC kernel (INVARIANTS ON), the read panics
with the KKASSERT at `hammer2_strategy.c:199`. After applying `fix.diff`
and rebuilding the kernel, the same trigger returns EIO and the kernel
logs `HAMMER2 LZ4: bad compressed_size 2147483647 (bytes=1024)` — clean
rejection, no panic.

## How to reproduce the panic signature

1. Confirm the running kernel is the unpatched baseline:
   `ssh dfbsd 'sysctl -n kern.version | head -1'` → `#0`.
2. Run `./hammer2_trigger.sh` as root.
3. Watch the serial log: `tail -F dfbsd-qemu/boot.log`.
4. You will see:
   ```
   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
   ```

## How to validate the fix

```sh
scp findings/poc/DF-0805/fix.diff dfbsd:/root/fix.diff
ssh dfbsd '/bin/sh -c "cd /usr/src && patch -p1 --forward < /root/fix.diff"'
ssh dfbsd '/bin/sh -c "cd /usr/src && make -j6 nativekernel KERNCONF=X86_64_GENERIC"'
ssh dfbsd '/bin/sh -c "cp /usr/obj/usr/src/sys/X86_64_GENERIC/kernel.stripped /boot/kernel/kernel && cp /usr/obj/usr/src/sys/X86_64_GENERIC/kernel.debug /boot/kernel/kernel.debug"'
./dfbsd-qemu/vm.sh down && ./dfbsd-qemu/vm.sh up 180
ssh dfbsd '/root/hammer2_trigger.sh'   # now returns EIO, no panic
ssh dfbsd 'dmesg | tail'               # logs "HAMMER2 LZ4: bad compressed_size 2147483647 (bytes=1024)"
```

## Realistic impact

The trigger requires a corrupted/malicious HAMMER2 file block. On the
default HAMMER2 configuration (XXHASH64 check), the kernel rejects
corrupted blocks at `hammer2_chain.c:1071` BEFORE reaching the LZ4
decompression — so the bug is reachable in practice only when (a) the
admin has disabled the per-file check (`HAMMER2_CHECK_NONE`), or (b)
the attacker supplies a malicious image with validly-computed XXHASH64
check codes for their LZ4 payloads, or (c) the kernel is built without
INVARIANTS and a bit-flip / fault corrupts the `compressed_size` field.

Severity **Low** is correct: on stock GENERIC + XXHASH64 the bug is
defense-in-depth; on non-INVARIANTS builds it becomes a real heap
disclosure / DoS via a malicious image.

This is a pure OOB **read** primitive — no escalation chain applies.
