# DF-0803 — Integer-truncation in `e2fs_gcount` (ext2_vfsops.c:620)

## Verdict

**REPRODUCED + FIX VALIDATED.** The integer-truncation bug is real,
deterministically demonstrable in a userspace harness, and causes an
unconditional kernel panic on the default GENERIC kernel when a 1 MB
crafted ext2 image is mounted. The fix (compute `gcount` as `uint64_t`
*before* assigning to the `uint32_t` field, plus an `ext2_vget` bounds
check) rejects the malicious images cleanly with `EINVAL` and exhibits
no regression on a normal ext2 filesystem.

## Mechanism (every hop cited `path:line`)

1. **Attacker-controlled 64-bit block count.**
   `ext2_vfsops.c:594-598`:
   ```c
   if (EXT2_HAS_INCOMPAT_FEATURE(fs, EXT2F_INCOMPAT_64BIT)) {
       fs->e2fs_bcount |= (uint64_t)(le32toh(es->e4fs_bcount_hi)) << 32;
       ...
   }
   ```
   `e2fs_bcount` is `uint64_t` (`ext2fs.h:159`); the 64bit feature is
   in `EXT2F_INCOMPAT_SUPP` (`ext2fs.h:335`) so any image with the
   `0x80` incompat bit is accepted by `ext2_check_sb_compat`.

2. **The truncating assignment.**
   `ext2_vfsops.c:620-621`:
   ```c
   fs->e2fs_gcount = howmany(fs->e2fs_bcount -
       le32toh(es->e2fs_first_dblock), EXT2_BLOCKS_PER_GROUP(fs));
   ```
   `e2fs_gcount` is **`uint32_t`** (`ext2fs.h:175`). `howmany` (`sys/param.h:397`)
   returns `uint64_t` when fed a `uint64_t` dividend; the implicit
   narrowing to `uint32_t` discards the high 32 bits *before* the
   range check on the next line.

3. **The post-truncation check that doesn't catch truncation.**
   `ext2_vfsops.c:622-626`:
   ```c
   if (fs->e2fs_gcount > ((uint64_t)1 << 32) - EXT2_DESCS_PER_BLOCK(fs)) {
       ...
       return (EINVAL);
   }
   ```
   LHS is the already-truncated `uint32_t`, promoted back to `uint64_t`
   for the compare. Max value is `2^32 - 1`. RHS is `2^32 - 64` (with
   4 KB block / 64-byte 64bit GD). So the check only fires for the 64
   values `[2^32 - 64, 2^32 - 1]` — and *never* for `gcount == 0` (the
   `2^32`-wrap case) or any other wrap.

4. **Downstream effect for `gcount == 0`.**
   `ext2_vfsops.c:638-650`: `e2fs_descpb = 64`,
   `e2fs_gdbcount_alloc = howmany(0, 64) = 0`, `e2fs_gdbcount = 0`,
   `fs->e2fs_gd = malloc(0 * 4096, ...)`. DragonFly's `malloc(0)`
   returns `ZERO_LENGTH_PTR = (void *)-8` (`kern_slaballoc.c:193,888-890`).
   The for-loop at `:652-678` runs 0 iterations (no GD blocks read).
   `ext2_cg_validate` (`:366-451`) iterates `i < 0` times — zero
   validations. Mount returns 0 (success).

5. **The panic on first inode access.**
   The mount-VFS_ROOT call chain reaches `ext2_vget(EXT2_ROOTINO=2)`
   (`ext2_vfsops.c:1337-`). `ino_to_cg(fs, 2) = (2-1)/ipg = 0`
   (`fs.h:108`). `ino_to_fsba` (`fs.h:111-112`) calls
   `e2fs_gd_get_i_tables(&fs->e2fs_gd[0])`, which dereferences address
   `(-8) + offsetof(ext2_gd, ext4bgd_i_tables_hi)*… = 0xFFFFFFFFFFFFFFF8 + 0x28`,
   i.e. `0x20`. The kernel page-faults:

   ```
   Fatal user address access from kernel mode from ls at ffffffff82601960
   Fatal trap 12: page fault while in kernel mode
   fault virtual address	= 0x20
   instruction pointer	= 0x8:0xffffffff82601960
   Stopped at      e2fs_gd_get_i_tables:   movl    0x28(%rdi),%eax
   ```

   (Fault address `0x20` = `ZERO_LENGTH_PTR + 0x28`. Deterministic.)

## Variants characterized

| Variant    | true howmany | stored gcount | Mount result on #0 GENERIC         |
|------------|--------------|---------------|------------------------------------|
| `gcount=0` | `2^32`       | 0             | mount OK, panic on first `ls`      |
| `gcount=1` | `2^32 + 1`   | 1             | mount OK, `ls` returns 0 entries; only GD[0] validated; GD[1..63] populated from disk GD block but never validated |
| `gcount=64`| `2^32 + 64`  | 64            | mount OK; exactly fills 4096-byte alloc; all entries validated by cg_validate |
| `gcount=65`| `2^32 + 65`  | 65            | mount OK; needs 8192-byte alloc (gdbcount_alloc=2), 65 entries validated |

The harness (`harness.c`) transcribes all of these deterministically.

## Impact ceiling (honest)

- **Primary demonstrated impact: kernel panic / local DoS.** Any user
  able to cause the kernel to mount (or auto-mount, fsck, or otherwise
  evaluate the superblock of) a 1 MB crafted ext2 image can panic the
  system deterministically. On the audit guest `vfs.usermount=0`, so
  mounting requires root; the realistic threat is a malicious image
  presented to root (USB stick, downloaded VM image, mount-on-insert,
  backup-restore, fsck-on-boot), which is the standard "crafted
  filesystem" threat model.

- **Heap-OOB-write claim (gcount ≥ 65):** *Not* confirmed. The GD
  allocation is always `howmany(gcount, descpb) * bsize`, and the
  `for (i = 0; i < gcount; i++)` write loop in both the load
  (`ext2_vfsops.c:665-675`) and `ext2_cg_validate`
  (`ext2_vfsops.c:379-448`) is bounded by the same `gcount`, so the
  in-bounds relationship holds. There is no slab-corruption primitive
  on the default GENERIC kernel from this bug.

- **Arbitrary-disk-block read/write (gcount = 1):** *Characterized but
  not escalated.* With `gcount=1`, `malloc(4096)` allocates 64 GD
  entries, all populated from the on-disk GD block, but only GD[0] is
  validated. To exercise GD[1..63] an inode with `ino_to_cg ≥ 1` must
  be looked up, which requires a crafted root directory entry pointing
  into group 1. The kernel would then `bread(devvp, attacker_block, …)`
  on the mounted *device* — i.e. read/write inside the same disk image
  the attacker already controls. **This is not a privilege-boundary
  crossing** (the attacker already owns the image bytes), so it does
  not yield `uid=0`. There is no path from this bug to kernel-memory
  corruption on GENERIC.

- **Valid Phase-6 hard blocker (read-only / disk-only primitive):**
  The bug's only write capability is the `ZERO_LENGTH_PTR` deref at a
  fixed kernel address (`0xFFFFFFFFFFFFFFF8 + offset`), which traps
  immediately — it is a *read*-class fault, not a write primitive.
  There is no surviving corrupted state after the panic, so there is
  no escalation chain to develop. This is the explicit Phase-6
  "valid hard blocker: read-only primitive / panic-only" case.

## PoC & harness

- `craft_img.py` — produces three crafted 1 MB images (`gcount=0`,
  `gcount=1`, `gcount=64`) by binary-patching the superblock of a
  `mke2fs`-created base image: sets `e4fs_bcount_hi`, the
  `EXT2F_INCOMPAT_64BIT` feature, `e3fs_desc_size=64`, and disables
  `metadata_csum` / `gdt_csum` so no checksum gets in the way.
- `harness.c` — deterministic userspace transcription of the kernel's
  exact arithmetic for `e2fs_gcount` and the downstream
  malloc/validate/deref flow. Proves the truncation bug without
  needing the kernel; built and run on the guest.
- `run.sh` — runs the harness, then (as root) `vnconfig` + `mount_ext2fs`
  each crafted image.

## Fix (`fix.diff`)

Two minimal changes:

1. **`ext2_compute_sb_data`** (`ext2_vfsops.c:620-626`): compute
   `gcount64` as `uint64_t`, validate `gcount64 == 0 || gcount64 > 2^32
   - DESC_PER_BLOCK` *before* the narrowing assignment. This catches
   the wrap-to-0 case (and the gcount=1 wrap case where true howmany =
   `2^32 + 1 > 2^32 - 64`).

2. **`ext2_vget`** (`ext2_vfsops.c:1338-`): bounds-check
   `ino_to_cg(fs, ino) >= fs->e2fs_gcount` (and `ino < EXT2_ROOTINO`)
   before any `e2fs_gd[…]` deref. Defense in depth against future
   field-size mismatches and against corrupted images that survive
   mount-time validation.

Both changes apply cleanly with `git apply` and compile under the
GENERIC KERNCONF. The patched kernel + module rejects every variant
of the malicious image with `EINVAL` while still mounting normal ext2
filesystems without regression.

The fix **supersedes** the finding markdown's proposal (which suggested
only the pre-assignment check); the runner additionally adds the
`ext2_vget` bounds check as defense-in-depth.
