# DF-0877 — ext2_dx_csum OOB heap read via unvalidated htree entry count

## Verdict

**REPRODUCED.** The bug is real: `ext2_dx_csum_verify` reads `count`
(h_entries_num) from disk and validates `limit` (h_entries_max) against the
block size but NEVER checks `count <= limit`. The unvalidated `count` drives
a CRC32C read of `count_offset + count*8` bytes from a `bsize`-byte directory
block buffer. With `count=65535`, that is a **524312-byte read — 523288 bytes
(511 KB) past a 1024-byte buffer**. The fix.diff adds `if (count > limit)
return EIO;` and is **VALIDATED** by hot-swapping a patched `ext2fs.ko` and
confirming the poisoned directory is rejected cleanly (EIO) without the OOB
read, with no regression on legitimate images.

## Mechanism (trigger → primitive → effect)

### The vulnerable code

`ext2_dx_csum_verify` (`sys/vfs/ext2fs/ext2_csum.c:268-293`):

```c
limit = le16toh(cp->h_entries_max);          /* :280 — from disk */
count = le16toh(cp->h_entries_num);          /* :281 — from disk, NOT validated */

/* :282-284 — limit IS validated against block size */
if (count_offset + (limit * sizeof(struct ext2fs_htree_entry)) >
    ip->i_e2fs->e2fs_bsize - sizeof(struct ext2fs_htree_tail))
    return (EIO);
/* *** NO count <= limit CHECK HERE *** */

tp = (struct ext2fs_htree_tail *)(((struct ext2fs_htree_entry *)cp) + limit);
calculated = ext2_dx_csum(ip, ep, count_offset, count, tp);  /* :287 */
```

`ext2_dx_csum` (`ext2_csum.c:240-266`):

```c
size = count_offset + (count * sizeof(struct ext2fs_htree_entry));  /* :253 */
...
crc = calculate_crc32c(crc, (uint8_t *)buf, size);  /* :261 — OOB READ */
```

`sizeof(struct ext2fs_htree_entry) = 8` (`sys/vfs/ext2fs/htree.h:58-61`).
With `count=65535` and `count_offset=32`:

```
size = 32 + 65535 * 8 = 524312 bytes
```

The buffer `buf = (char *)ep = bp->b_data` is a single directory block of
`bsize` (1024) bytes, allocated by `bread()` in the buffer cache. The
`calculate_crc32c` call reads `524312` bytes starting at `buf`, walking
**523288 bytes (511 KB) past the 1024-byte buffer** into adjacent kernel
memory.

### Post-mount reachability (UNPRIVILEGED)

The caller chain from readdir/lookup/stat:

```
ext2_readdir (ext2_lookup.c:185)
  → ext2_blkatoff (ext2_subr.c:78)
    → bread(vp, ..., bsize, &bp)              /* reads bsize bytes from disk */
    → ext2_dir_blk_csum_verify(ip, bp)        /* ext2_subr.c:95 */
      → ext2_get_dx_count(ip, ep, NULL)       /* ext2_csum.c:311 — is this an htree block? */
      → ext2_dx_csum_verify(ip, ep)           /* ext2_csum.c:312 */
        → ext2_dx_csum(ip, ep, count_offset, count, tp)  /* ext2_csum.c:287 */
          → calculate_crc32c(crc, buf, 524312)          /* ext2_csum.c:261 — OOB */
```

**Any user with read permission on the mountpoint** triggers this via
`ls`, `stat`, or `readdir` on a directory whose first block is forged to
look like an htree root. The OOB read happens during the checksum
computation at `ext2_csum.c:261`, BEFORE the checksum comparison at `:289`
— so even though the csum mismatches (causing EIO), the 520 KB over-read
already occurred.

### Image-crafting attacker model

Attacker controls an ext2 filesystem image. To trigger the OOB read they:

1. `mke2fs -t ext2 -b 1024 -O metadata_csum,dir_index,^64bit` (enables the
   dx_csum path and skips 64-bit desc_size validation).
2. Create a directory (the root or a subdirectory).
3. Patch the directory's first data block to look like an htree root:
   - `.` dirent (reclen=12) + `..` dirent (reclen=bsize-12, stretched to
     kill the dirent tail so the dx_csum path runs, not the dirent_csum path)
   - htree_root_info at offset 24 (reserved1=0, info_len=8)
   - htree_count at offset 32: h_entries_max=1 (limit), h_entries_num=65535 (count)
4. Mount the image (root), then any unprivileged user reads the directory.

`craft_img.py` performs all steps using `mke2fs` + `debugfs` + binary patching.

## Reproduction

### A) Deterministic C harness (no kernel required)

`harness.c` transcribes `ext2_dx_csum` and `ext2_dx_csum_verify` verbatim
and runs against a 1024-byte buffer placed at the end of a page with a
PROT_NONE guard page. Three cases:

| Case | count | limit | Result |
|------|-------|-------|--------|
| A    | 1     | 1     | size=40, OOB=0 (legitimate) |
| B    | 65535 | 1     | **size=524312, OOB=523288 bytes (511 KB)** |
| C    | 65535 | 1     | full read faults at predicted page boundary (SIGSEGV) |

Output (`run.log`):
```
[A] count=1 limit=1 (legitimate):
    size=40  oob=0  (expected size=40, oob=0)

[B] count=65535 limit=1 (attacker-controlled):
    limit check PASSED (limit=1: 32+8=40 <= 1016)
    ext2_dx_csum size = 524312 bytes
    buffer size       = 1024 bytes
    OOB read          = 523288 bytes (511 KB) past the buffer

[C] Invoking ext2_dx_csum_faulting (full ext2_csum.c:261 read):
    buf at 0x800473c00 (end of page 1); PROT_NONE guard at 0x800474000
    Expecting SIGSEGV near 0x800474000 (start of guard page).

[!] SIGSEGV during dx_csum OOB read at addr 0x0000000800474000
```

### B) In-kernel manifestation (unprivileged, post-mount)

On the default `6.5-DEVELOPMENT #0` GENERIC kernel (INVARIANTS ON):

```
# Root mounts the crafted image:
vnconfig -c vn0 /root/df0877.ext2
mount_ext2fs -o ro /dev/vn0 /mnt/t1

# Unprivileged maxx (uid=1001) triggers the OOB read:
$ ls /mnt/t1/testdir/
ls: /mnt/t1/testdir/: Input/output error      # EIO from dx_csum_verify
$ echo $?
1
```

The mount succeeds (the image is structurally valid — all metadata checksums
are correct; only the directory data block is patched). The `ls` triggers
`ext2_blkatoff → ext2_dir_blk_csum_verify → ext2_dx_csum_verify → ext2_dx_csum`
which reads 524312 bytes from the 1024-byte buffer. The csum mismatches → EIO.
The OOB read already happened.

**No kernel panic was observed on this heap layout** — the 520 KB read walked
through adjacent MAPPED buffer-cache pages without hitting an unmapped page.
This is the **info-leak manifestation** (same class as DF-0876): the read is
silent, and the leaked bytes are fed into a CRC32C and discarded (blind leak).
On a production kernel with different heap layout (or a smaller buffer cache),
the read crosses an unmapped page and page-faults (panic/DoS). Both outcomes
are valid manifestations of the OOB read.

## Impact ceiling

* **Class**: OOB heap read (CWE-125). Read-only primitive — **no write, no
  UAF, no type confusion. No escalation chain is possible.**
* **Info leak**: the 520 KB read is "blind" — the result feeds a csum and is
  compared/discarded. The attacker cannot directly observe the leaked bytes.
  On this heap layout (GENERIC, INVARIANTS ON), the read is silent. The
  realistic impact is primarily a potential **panic/DoS** on heap layouts
  where the read hits unmapped memory.
* **Potential panic**: heap-layout-dependent; the 520 KB read will eventually
  cross an unmapped page on many kernel configurations, DoSing the system.
* **Privilege boundary**: the mount requires root (`SYSCAP_RESTRICTEDROOT`),
  but the **trigger is unprivileged** — any local user with read permission
  on the mountpoint can trigger the OOB read via `ls`/`stat`/`readdir`. The
  realistic threat: admin mounts attacker-supplied ext2 media (USB, downloaded
  image), any local user triggers a ~520 KB heap disclosure or DoS.
* **No escalation attempted**: read-only primitive — no `uid=0` chain to
  develop. Valid hard blocker per Phase 6 ("the primitive is genuinely
  read-only").

## PoC changes

Built the entire evidence pack from scratch:

* `harness.c` — deterministic C harness transcribing `ext2_dx_csum` and
  `ext2_dx_csum_verify` verbatim. Three cases (legitimate count=1, attacker
  count=65535, faulting full read) proving the 524312-byte OOB read.
* `craft_img.py` — host-side image crafter. `mke2fs -O metadata_csum,dir_index,^64bit`
  + `debugfs` to find the testdir block + binary patch the htree count to
  65535 (with ".." reclen stretched to kill the dirent tail so the dx_csum
  path runs). Produces `df0877.ext2`.
* `df0877.ext2` — 4MB crafted ext2 image with the poisoned htree count.
* `build.sh` / `run.sh` — exact reproduce commands.
* `fix.diff` — git-apply-able two-hunk fix (see below).

## Recommended fix (fix.diff)

Add `if (count > limit) return EIO;` (and `return;` in the `_set` variant)
in BOTH `ext2_dx_csum_verify` and `ext2_dx_csum_set`, after the existing
limit validation and before calling `ext2_dx_csum`. This rejects any
filesystem with `h_entries_num > h_entries_max` — an impossible state for a
legitimate htree — and prevents the unvalidated count from driving the
CRC32C read length.

```c
/* ext2_csum.c:284-285 (verify) / :362-363 (set) */
if (count > limit)
    return (EIO);   /* or return; in ext2_dx_csum_set */
```

This **matches** the finding markdown's initial proposal (clamp count to
limit / reject count > limit). It is the minimal targeted fix at the root
cause.

## Fix validation (Phase 8)

Built a single-fix `ext2fs.ko` module by applying `fix.diff` to the
in-guest `/usr/src/sys/vfs/ext2fs/ext2_csum.c` and running `make` in
`sys/vfs/ext2fs/` (~15 s, gcc 8.3, MODULE_BUILD_RC=0). Hot-swapped via
`kldunload ext2fs && cp .../ext2fs.ko /boot/kernel/ext2fs.ko && kldload ext2fs`.

| Test | Unpatched `ext2fs.ko` | Patched `ext2fs.ko` |
|------|------------------------|----------------------|
| Poisoned image (count=65535) | EIO — `ext2_dx_csum` called, **524312-byte OOB read happens** (silent on this heap layout) | EIO — `count > limit` early return at :285, **ext2_dx_csum NEVER called, no OOB read** |
| Legitimate image (no htree) | mounts + reads OK | mounts + reads OK (no regression) |
| Root dir (legitimate dirents) | reads OK | reads OK |

Both return EIO for the poisoned image, but the **internal behavior is
fundamentally different**: the fix's `count > limit` check at line 285
returns BEFORE the `ext2_dx_csum` call at line 289, so the 520 KB OOB read
cannot occur. The harness proves what the OOB read WOULD do without the fix;
the fix prevents it entirely.

Patched module SHA256: `152aa381ba010ca9563ce0dea1622070ec0847c21ef585043899e2f3c257a1ab`
Unpatched module SHA256: `497134c238ec6f4b42bd04f9c49d658e3698dc6f4ba7948bc68a1f48eedf29fb`

`fix_status: fixed` (OOB read prevented on patched module; legitimate-image
regression test clean).

## Kernel references

* `sys/vfs/ext2fs/ext2_csum.c:240-266` — `ext2_dx_csum` (the OOB read)
* `sys/vfs/ext2fs/ext2_csum.c:253` — `size = count_offset + count*8` (unvalidated count)
* `sys/vfs/ext2fs/ext2_csum.c:261` — `calculate_crc32c(crc, buf, size)` (the OOB read)
* `sys/vfs/ext2fs/ext2_csum.c:268-293` — `ext2_dx_csum_verify` (missing count check)
* `sys/vfs/ext2fs/ext2_csum.c:280-284` — limit validated, count NOT
* `sys/vfs/ext2fs/ext2_csum.c:286-287` — passes unvalidated count to ext2_dx_csum
* `sys/vfs/ext2fs/ext2_csum.c:296-318` — `ext2_dir_blk_csum_verify` (caller)
* `sys/vfs/ext2fs/ext2_csum.c:339-364` — `ext2_dx_csum_set` (symmetric bug, also fixed)
* `sys/vfs/ext2fs/ext2_subr.c:78-106` — `ext2_blkatoff` (the post-mount entry point)
* `sys/vfs/ext2fs/ext2_lookup.c:185` — `ext2_readdir` calls `ext2_blkatoff`
* `sys/vfs/ext2fs/htree.h:53-61` — `struct ext2fs_htree_count` / `ext2fs_htree_entry` (count=4B, entry=8B)
