# DF-0784 — `ext2_readlink` integer-truncation unbounded kernel heap disclosure

## Verdict: REPRODUCED (info leak / DoS via panic on large buffers)

`ext2_readlink` narrows a `uint64_t i_size` into a signed `int`, then passes
that `int` to `uiomove()` where it is sign-extended back to a huge `size_t`.
A crafted ext2 image with a symlink inode whose `i_size = 0x80000000` (bit 31
set) drives an unbounded `copyout()` of kernel heap to an unprivileged
`readlink(2)` caller, bounded only by the caller's buffer size — and panics
the kernel once `copyout` walks past the slab into an unmapped page.

## Root cause (path:line)

`sys/vfs/ext2fs/ext2_vnops.c` — `ext2_readlink`:

```c
1345: static int
1346: ext2_readlink(struct vop_readlink_args *ap)
1347: {
1348:     struct vnode *vp = ap->a_vp;
1349:     struct inode *ip = VTOI(vp);
1350:     int isize;                                       /* signed 32-bit */
1351:
1352:     isize = ip->i_size;                              /* uint64 -> int TRUNCATE */
1353:     if (isize < vp->v_mount->mnt_maxsymlinklen) {    /* signed compare */
1354:         uiomove((char *)ip->i_shortlink, isize, ap->a_uio);
1355:         return (0);                                  /* isize promoted to size_t */
1356:     }
1357:     return (VOP_READ(vp, ap->a_uio, 0, ap->a_cred));
1358: }
```

* `ip->i_size` is `uint64_t` (`sys/vfs/ext2fs/inode.h:102`).
* `isize = ip->i_size` with `i_size = 0x80000000` yields `isize = -2147483648`.
* `-2147483648 < 60` (`EXT2_MAXSYMLINKLEN`, `ext2_dinode.h:102` → 15 × 4 = 60) → TRUE.
* `uiomove(i_shortlink, isize, uio)` — `isize` (int) is implicitly promoted to
  `size_t` (`sys/kern/kern_subr.c:96`: `uiomove(caddr_t cp, size_t n, ...)`),
  sign-extending to `0xFFFFFFFF80000000`.
* `kern_subr.c:117` `while (n > 0 && uio->uio_resid)` then loops `copyout(cp,
  iov_base, cnt)` from `i_shortlink` (= `i_db[12]`, `inode.h:133`) into the
  user buffer, advancing `cp` past the inode struct into adjacent kernel heap,
  until `uio_resid` is exhausted or `copyout` faults on an unmapped page.

There is **no** `i_blocks != 0` guard in `ext2_readlink` (the UFS analog at
`sys/vfs/ufs/ufs_vnops.c:1740-1745` does check `ip->i_din.di_blocks == 0`,
but via an OR that does not actually close this bug). No KKASSERT fires
before the leak on GENERIC — `uiomove`'s KASSERTs only check `uio_rw` /
`uio_segflg`, not the magnitude of `n`.

## Reproduction

### Image crafting

`mke2fs -t ext2 -b 1024 -O ^metadata_csum,^64bit,^resize_inode,^dir_index`
(256 KB image, no metadata checksum to invalidate). A symlink
`slink_test -> target_foobar_link` (18 bytes, inode 12) is created inside the
image while mounted in the guest, then `craft_img.py` parses the ext2
superblock + group descriptor, locates the on-disk inode, and rewrites
`e2di_size` (offset +4 in the on-disk inode, `ext2_dinode.h:110`) from 18
(`0x00000012`) to `0x80000000`. The patched image is `vnconfig`'d and
`mount -t ext2fs`'d; any local user can then `readlink /mnt/slink_test`.

### Live trigger (unprivileged, GENERIC #0, INVARIANTS ON)

`readlink_poc /mnt/slink_test 4` (4 KB buffer) returns **4096 bytes** from an
18-byte symlink — **4078 bytes of kernel heap** past the legit target. The
leaked bytes on this quiet test slab are zero (fresh slab), but the
over-read IS unbounded by the symlink content. With a 1 MB buffer the read
walks past the slab and **panics** the kernel:

```
panic: vm_fault: fault on stack guard, addr: 0xfffff8011838c000
Trace:
vm_fault() at vm_fault+0x12eb
trap_pfault() at trap_pfault+0x9a
trap() at trap+0x17c
calltrap() at calltrap+0x9
--- trap 000000000000000c, rip = ffffffff80bcaeaa ---
std_copyout() at std_copyout+0x15a
ext2_readlink() at ext2_readlink+0x48
```

(The full signature is in `panic.txt`.)

### Deterministic harness (`harness.c`)

Transcribes `ext2_vnops.c:1345-1357` and `kern_subr.c:96-154` verbatim with a
poisoned "inode" allocation. Confirms the arithmetic: `int isize = -2147483648`,
promoted to `size_t = 0xffffffff80000000`, fast-path taken, 4078 bytes over-read
past the 18-byte target. Run output in `harness_run.log`.

## Impact ceiling

* **Read-only primitive** — no write, no corruption. There is **no escalation
  chain**; this is a pure kernel-heap info-leak / DoS, not a memory-corruption
  finding. The Phase 6 escalation chain is `none` (genuinely read-only class).
* **Leak extent on production (INVARIANTS-OFF) kernels**: bounded only by the
  user buffer up to the first unmapped kernel page following the inode slab —
  in practice tens to hundreds of KB of adjacent slab/heap content per call.
* **On default GENERIC (#0, INVARIANTS ON)**: the leak is still live for
  small buffers (4 KB, 64 KB) — INVARIANTS does **not** trip here. Only at
  ~1 MB does `copyout` walk past the slab and panic.
* **DoS**: any unprivileged user can panic the kernel by passing a large
  readlink buffer against a malicious ext2 mount.
* **Realistic threat model**: a malicious ext2 image (USB stick, downloaded
  disk image, mountable by `vfs.usermount` if enabled) loaded by an admin
  gives **any local user** with execute permission on the mountpoint a
  kernel-heap disclosure / panic primitive via `readlink(2)`.

## Fix (`fix.diff`)

Two changes in `ext2_readlink`:

1. `int isize` → `uint64_t isize` (kills the truncation at the source).
2. Tighten the fast-path condition to `isize <= mnt_maxsymlinklen && ip->i_blocks == 0`
   and add a fallback `if (ip->i_blocks == 0) return (EINVAL);` so a corrupted
   inode with `isize > maxsymlinklen` and no backing blocks returns cleanly
   instead of falling through to `VOP_READ` on a vnode with no VM object
   (which would otherwise panic in `getblk`).

The second guard was discovered during fix validation: the naive
"`uint64_t isize` + fall-through to VOP_READ" fix converted the leak into a
*new* panic (`getblk: vnode has no object`) because fast-symlink vnodes never
get a VM object (see `ext2_vnops.c:1688-1693`). The refined fix returns
`EINVAL` for the corrupted case.

## Fix validation (Phase 8)

Built the patched `ext2fs.ko` module (`make` in `/usr/src/sys/vfs/ext2fs`,
sha256 `9f651a77…`), hot-swapped it via `kldunload/kldload`, re-mounted the
*same* crafted image, and re-ran the trigger:

| kernel state               | `readlink /mnt/slink_test 4`     | `readlink … 1024` (1 MB) |
|----------------------------|----------------------------------|--------------------------|
| **before fix** (#0 GENERIC)| 4096 B returned, **4078 B leak** | **panic** `vm_fault` in `ext2_readlink+0x48` |
| **after fix** (patched .ko)| `errno=22 (EINVAL)`, no leak     | `errno=22 (EINVAL)`, no panic, guest up |
| **positive control** (legit 18 B symlink, patched .ko) | 18 B returned | — |

The fix closes both the leak and the panic. Full logs: `run.log`,
`fix_run.log`, `fix_build.log`, `panic.txt`, `leak_sample.txt`.

## Files

| file | purpose |
|------|---------|
| `readlink_poc.c` | live trigger: unprivileged `readlink(2)` with selectable buffer |
| `harness.c` | deterministic arithmetic transcription of the bug |
| `craft_img.py` | ext2 image patcher: rewrites symlink inode `e2di_size` |
| `build.sh` / `run.sh` | reproducible build/run |
| `fix.diff` | git-apply-able one-function fix |
| `run.log` / `harness_run.log` | decisive run outputs (before fix) |
| `fix_run.log` / `fix_build.log` | after-fix module build + re-run |
| `panic.txt` | `vm_fault` panic signature from `boot.log` |
| `leak_sample.txt` | 3× stress-test of the leak |
| `env.txt` | guest environment |
| `manifest.json` | artifact catalog |
