# DF-0876 — ext2_gd_csum OOB heap read via unvalidated on-disk group descriptor size

## Verdict

**REPRODUCED.** The bug is real and the OOB read of 65503 bytes past the
64-byte `struct ext2_gd` is provable both statically and dynamically. The
fix.diff validates `e3fs_desc_size` at mount and clamps the csum read length,
and is **VALIDATED** by hot-swapping a patched `ext2fs.ko` and confirming
the bad image is now rejected at mount with `EINVAL` (no OOB read, no csum
side-channel leak).

## Mechanism (trigger → primitive → effect)

The `ext2_gd_csum()` function computes a CRC32C over a group descriptor:

```c
/* sys/vfs/ext2fs/ext2_csum.c:666-704 */
static uint16_t
ext2_gd_csum(struct m_ext2fs *fs, uint32_t block_group, struct ext2_gd *gd)
{
	size_t offset;
	uint32_t csum32;
	uint16_t crc, dummy_csum;

	offset = offsetof(struct ext2_gd, ext4bgd_csum);   /* = 30 */
	block_group = htole32(block_group);

	if (EXT2_HAS_RO_COMPAT_FEATURE(fs, EXT2F_ROCOMPAT_METADATA_CKSUM)) {
		csum32 = calculate_crc32c(fs->e2fs_csum_seed,
		    (uint8_t *)&block_group, sizeof(block_group));
		csum32 = calculate_crc32c(csum32, (uint8_t *)gd, offset);
		dummy_csum = 0;
		csum32 = calculate_crc32c(csum32, (uint8_t *)&dummy_csum,
		    sizeof(dummy_csum));
		offset += sizeof(dummy_csum);                    /* = 32 */
		if (offset < le16toh(fs->e2fs->e3fs_desc_size))
			csum32 = calculate_crc32c(csum32, (uint8_t *)gd + offset,
			    le16toh(fs->e2fs->e3fs_desc_size) - offset);   /* <-- BUG */
		...
```

`fs->e2fs->e3fs_desc_size` is the on-disk `s_desc_size` u16, read straight
from the superblock of the to-be-mounted image. With
`offset = 32` and `e3fs_desc_size = 0xFFFF = 65535`, the `calculate_crc32c`
call reads `65535 - 32 = 65503` bytes starting at `gd + 32`.

The in-memory `gd` is one element of the `fs->e2fs_gd[]` array
(`sys/vfs/ext2fs/ext2fs.h:181`). `struct ext2_gd` is exactly **64 bytes**
(verified: `sizeof(struct ext2_gd) = 64`, `offsetof(ext4bgd_csum) = 30`).
The 65503-byte read walks past the 64-byte struct into adjacent heap.

### Mount-time reachability

`ext2_gd_csum()` is reached from `ext2_gd_csum_verify()`
(`sys/vfs/ext2fs/ext2_csum.c:708`), which is called by
`ext2_compute_sb_data()` (`sys/vfs/ext2fs/ext2_vfsops.c:688`) for any
filesystem with `EXT2F_ROCOMPAT_METADATA_CKSUM` set. So any crafted image
with `metadata_csum` reaches the bug at mount.

### Why the size is unvalidated

The only `e3fs_desc_size` validation in `ext2_compute_sb_data` is at
`sys/vfs/ext2fs/ext2_vfsops.c:549-554`:

```c
if (EXT2_HAS_INCOMPAT_FEATURE(fs, EXT2F_INCOMPAT_64BIT) &&
    le16toh(es->e3fs_desc_size) != E2FS_64BIT_GD_SIZE) {
    SDT_PROBE1(ext2fs, , vfsops, ext2_compute_sb_data_error,
        "unsupported 64bit descriptor size");
    return (EINVAL);
}
```

This only fires when `INCOMPAT_64BIT` is set. For filesystems with
`METADATA_CKSUM` but **not** `INCOMPAT_64BIT`, `e3fs_desc_size` is taken
directly from disk with no bounds check — and `ext2_gd_csum`'s METADATA_CKSUM
branch reads it unconditionally.

The `ext2_gd_csum()` GDT_CSUM branch immediately below is symmetric but
correctly gated behind `INCOMPAT_64BIT`:

```c
} else if (EXT2_HAS_RO_COMPAT_FEATURE(fs, EXT2F_ROCOMPAT_GDT_CSUM)) {
    ...
    if (EXT2_HAS_INCOMPAT_FEATURE(fs, EXT2F_INCOMPAT_64BIT) &&    /* <-- gated */
        offset < le16toh(fs->e2fs->e3fs_desc_size))
        crc = ext2_crc16(crc, ...);
```

The METADATA_CKSUM branch lacks that gate. That is the bug.

## Image-crafting attacker model

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

1. Build an ext2 image with `metadata_csum` set and `64bit` clear
   (`mke2fs -O metadata_csum,^64bit`). This activates the METADATA_CKSUM
   branch and skips the desc_size validation.
2. Binary-patch the on-disk `s_desc_size` field (superblock offset 254)
   to `0xFFFF`.
3. Recompute the superblock CRC32C (Castagnoli, no inversion, over
   `[0 .. 1020)` of the superblock) so `ext2_sb_csum_verify`
   (`ext2_csum.c:87`) accepts the patched superblock.

`craft_img.py` performs all three steps. The resulting `ext2_bad.img` mounts
just far enough to reach `ext2_gd_csum_verify` and trigger the OOB read.

## Reproduction

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

`harness.c` transcribes `ext2_gd_csum` verbatim and runs it against a
64-byte `struct ext2_gd` placed at the end of a page, with the next page
`PROT_NONE`. Three cases:

| Case | desc_size | Result |
|------|-----------|--------|
| A    | 0         | read length 0 (legitimate rev0) |
| B    | 64        | read length 32 (legitimate 64bit, 32..64 within GD) |
| C    | 0xFFFF    | **read length 65503 — 65471 bytes past the 64-byte struct** |
| D    | 0xFFFF    | full read faults at predicted page boundary (SIGSEGV) |

Output (`run.log`):
```
[*] sizeof(struct ext2_gd)        = 64 bytes
[*] offsetof(ext4bgd_csum)        = 30 bytes

[A] desc_size=0     (legitimate rev0 GD):
    csum=0x8cb0   OOB read length = 0 bytes (expected 0)

[B] desc_size=64    (legitimate 64bit GD):
    csum=0x2c9a   read length    = 32 bytes (32..64 within GD)

[C] desc_size=0xFFFF (attacker-controlled, METADATA_CKSUM only):
    csum=0x2c9a   read length    = 65503 bytes
    OOB read: gd+32 .. gd+65535 (length 65503)
    struct ext2_gd ends at gd+64
    -> read extends 65471 bytes PAST the 64-byte struct ext2_gd.

[D] Invoking ext2_gd_csum_faulting (full ext2_csum.c:684-686 read):
    GD at 0x800473fc0 (end of page 1); next page PROT_NONE at 0x800474000
    Expecting SIGSEGV at 0x800474000 (= gd+64 = start of PROT_NONE page).

[!] SIGSEGV caught during csum read at addr 0x0000000800474000
HARNESS_RC=133
```

### B) In-kernel manifestation (root only — see privilege note)

Mount the crafted image on the default `6.5-DEVELOPMENT #0` GENERIC kernel:

```
# kldload ext2fs
# vnconfig -c vn0 /root/poc/DF-0876/ext2_bad.img
# mount_ext2fs /dev/vn0 /mnt/t1
mount_ext2fs: /dev/vn0: Input/output error                # EIO from csum verify
# dmesg | tail -2
vn0: MBR magic not found; ...
WARNING: mount of vn0 denied due bad gd=0 csum=0x8300, expected=0x4e72 - run fsck
```

The `expected=0x????` value is the kernel-computed csum incorporating the
65503 bytes that were read (32 from the GD + 65471 from adjacent heap). It
**varies across mounts** as heap layout changes
(0xcc5e / 0x3a24 / 0xb94 / 0x3d5b / 0x4e72 / 0x72a observed), proving the
read incorporates varying adjacent heap content. This is the info-leak
manifestation: 16 bits of noisy heap-state summary per group descriptor.

No kernel panic was observed on the test slab layout (the read walks through
adjacent *mapped* slab pages). On a different heap layout — or with a larger
read — the read would cross an unmapped page and page-fault (panic). Both
outcomes are valid manifestations; the bug class is heap over-read / OOB.

## Impact ceiling

* **Class**: OOB heap read (CWE-125). Read-only primitive — there is no
  write, no UAF, no type confusion. **No escalation chain is possible.**
* **Info leak**: the 16-bit csum diff in `dmesg` is a noisy summary of
  adjacent heap state. Practically hard to weaponize for KASLR-defeat on
  this kernel (KASLR is already OFF in the audit guest anyway), but a real
  disclosure channel.
* **Possible panic**: heap-layout-dependent; on production kernels with
  adjacent unmapped pages, the OOB read page-faults and DoSes.
* **Privilege boundary**: ext2 mount requires `SYSCAP_RESTRICTEDROOT`
  (`sys/kern/vfs_syscalls.c:318` and the per-fs `mount(2)` path), i.e.
  **root only**. An unprivileged user cannot mount a crafted ext2 image,
  so this is not a local-privesc vector. The realistic threat is root
  mounting attacker-supplied media (USB, downloaded image, mount-on-connect
  appliance), where the bug yields DoS / info leak.
* **No escalation attempted**: read-only primitive — no `uid=0` chain to
  develop. This is a valid hard blocker per the procedure's Phase 6 list
  ("the primitive is genuinely read-only").

## PoC changes

Built the entire evidence pack from scratch:

* `harness.c` — deterministic C harness transcribing `ext2_gd_csum`
  verbatim. Three cases (legitimate rev0, legitimate 64bit, attacker
  0xFFFF) plus a faulting variant that proves the read crosses a page
  boundary at the predicted address. Built with `cc -O2`.
* `craft_img.py` — host-side image crafter. `mke2fs -O metadata_csum,^64bit`
  followed by binary-patching `s_desc_size=0xFFFF` at SB offset 254 and
  recomputing the superblock CRC32C. Produces `ext2_bad.img`.
* `build.sh` / `run.sh` — exact reproduce commands.
* `fix.diff` — git-apply-able two-hunk fix (see below).
* `ext2_bad.img`, `ext2_bad_tiny.img` — crafted images (different
  geometries, same exploit).

## Recommended fix (fix.diff)

Two complementary changes:

1. **Mount-time validation** (`sys/vfs/ext2fs/ext2_vfsops.c`, the existing
   "Check group descriptors" block): extend the existing INCOMPAT_64BIT
   desc_size check with a non-64bit branch that rejects anything other than
   `0` or `E2FS_REV0_GD_SIZE` (= 32). Returns `EINVAL` for crafted images.
2. **Defense-in-depth clamp** (`sys/vfs/ext2fs/ext2_csum.c:684-686`): clamp
   the `calculate_crc32c` length to `sizeof(struct ext2_gd) - offset` so
   any caller that does reach the loop with an unchecked desc_size cannot
   read past the in-memory struct.

This **supersedes** the finding markdown's initial proposal (which suggested
either approach in isolation): doing both is correct because (1) is the
user-visible behavior fix (reject bad images cleanly with `EINVAL`) and (2)
guarantees that no future code path that re-introduces an unchecked
desc_size can resurrect the OOB read.

## Fix validation (Phase 8)

Built a single-fix `ext2fs.ko` module by applying `fix.diff` to the
in-guest `/usr/src` and running `make` in `sys/vfs/ext2fs/` (~12 s, gcc 8.3).
Hot-swapped the patched module via `kldunload ext2fs && kldload <patched.ko>`.

| Test | Unpatched `ext2fs.ko` | Patched `ext2fs.ko` |
|------|------------------------|----------------------|
| Bad image (`s_desc_size=0xFFFF`) | `Input/output error` (EIO) + dmesg: `WARNING: mount ... csum=0x8300, expected=0x4e72` (OOB read happened) | `Invalid argument` (EINVAL) + **no** csum verify message (rejected at mount) |
| Legitimate image (`s_desc_size=0`) | mounts OK (RW writes have a pre-existing unrelated EIO) | mounts OK (same behavior — no regression) |

Patch determinism: 3/3 patched runs return EINVAL with no csum verify
message. The pre-existing `ext2_mountfs: trying to free NULL pointer`
warnings on the EINVAL cleanup path are **not** introduced by the fix —
they fire on any early `ext2_compute_sb_data` failure (including the
pre-existing INCOMPAT_64BIT+bad-desc_size case) because the `out:` cleanup
unconditionally calls `free(... e2fs_gd ...)`.

Patched module SHA256: `6dd7daa1eb89dc4e11b40a726d834a8c5b636907f687732e45cf33e5c5694c15`
Unpatched module SHA256: `497134c238ec6f4b42bd04f9c49d658e3698dc6f4ba7948bc68a1f48eedf29fb`

`fix_status: fixed` (bad behavior gone on patched module, present on baseline,
legitimate-image regression test clean).

## Kernel references

* `sys/vfs/ext2fs/ext2_csum.c:666-704` — `ext2_gd_csum` (the bug)
* `sys/vfs/ext2fs/ext2_csum.c:684-686` — the unchecked read
* `sys/vfs/ext2fs/ext2_csum.c:698-700` — the symmetric but correctly-gated
  GDT_CSUM branch
* `sys/vfs/ext2fs/ext2_csum.c:708-726` — `ext2_gd_csum_verify` (the
  mount-time caller)
* `sys/vfs/ext2fs/ext2_vfsops.c:548-554` — the existing (insufficient)
  desc_size validation
* `sys/vfs/ext2fs/ext2_vfsops.c:688` — `ext2_gd_csum_verify` call site
* `sys/vfs/ext2fs/ext2_vfsops.c:647` — `e2fs_gd` allocation
* `sys/vfs/ext2fs/ext2fs.h:372-396` — `struct ext2_gd` definition
* `sys/vfs/ext2fs/ext2fs.h:94` — `e3fs_desc_size` field
* `sys/kern/vfs_syscalls.c:318` — `SYSCAP_RESTRICTEDROOT` mount check
  (root-only boundary)
