# DF-5746 — kern.iconv.add accepts unvalidated converter tables → NULL-deref panic + kernel heap OOB read disclosed via converted filenames

**File audited (pass 2):** `sys/libiconv/iconv.c`
**Reserved ID:** DF-5746
**Guest:** DragonFly 6.5-DEVELOPMENT x86_64 (see `env.txt`)

## Root cause (one line)

`iconv_sysctl_add()` (`sys/libiconv/iconv.c:410`) validates only the *upper*
bound of the caller-supplied table length (`ia_datalen <= ICONV_CSMAXDATALEN`),
never that the blob is large enough (or non-NULL) for the layout the converter
will later trust — `iconv_xlat16_open()` (`sys/libiconv/iconv_xlat16.c:67-78`)
unconditionally parses a 4096-byte index array and points `d_table[i]` at
`cp_data + 4096 + k*512` for every nonzero index entry, and `iconv_xlat_open()`
(`sys/libiconv/iconv_xlat.c:61`) uses `cp_data` as a flat 256-byte table.

## Two manifestations

1. **`ia_datalen == 0`** (explicitly allowed): `cp_data` stays NULL
   (`iconv.c:426` skips the kmalloc) → first `iconv_open()` on the pair
   dereferences `*idxp` at VA 0 → **deterministic kernel panic**
   (`trigger_null.c` + `panic.txt`).
2. **`0 < ia_datalen < required`**: the pair registers fine; the mount
   succeeds; every converted character then reads a `uint32` at
   `victim_alloc + 4096 + 512*rank(X)` — up to ~256 KB past the end of the
   attacker-sized allocation — and those bytes are written into the output
   filename returned by `readdir()` → **kernel heap info leak**
   (`trigger_leak.c` + `run.log`).

Both are reached through the documented surface: `sysctl kern.iconv.add`
(writes require `SYSCAP_NOSYSCTL_WR`, i.e. root — `sys/kern/kern_sysctl.c:1447`)
followed by `mount(2)` of msdosfs with `MSDOSFSMNT_KICONV` + `cs_local`/`cs_dos`
naming the malicious pair (`sys/vfs/msdosfs/msdosfs_vfsops.c:115-131`).
No setuid helper or userland kiconv library is involved — a direct `mount(2)`
caller controls the charset names, so the kernel-side iconv core is the only
validation point. (The userland helper merely *happens* to build well-formed
tables; the kernel cannot rely on that.)

## Files

| file | what |
|---|---|
| `common_kiconv.h` | register-pair (`kern.iconv.add`) + `mount("msdos", …, KICONV)` helpers |
| `trigger_null.c` | datalen=0 pair → NULL-deref panic |
| `trigger_leak.c` | index-only table + 16 heap-marker pairs → OOB read disclosed in readdir output |
| `trigger_benign.c` | full well-formed table (13312 bytes) — no-regression control |
| `trigger_neg.c` | negative-length probe (killed: rejected by the unsigned compare at iconv.c:410) |
| `prep_img.sh` | 64 MB FAT16 image containing LFN file `ABCDEFGHIJKLMNOP.k` |
| `build.sh` | `cc -O0 -g -I/usr/src/sys …` |
| `run_leak.sh` / `run_null.sh` | exact run commands |
| `run.log`, `run.1.log` | leak runs (3 suffixes in run.log + first discovery run) |
| `panic.txt` | serial console capture of the Fatal trap 12 |
| `env.txt` | guest uname / modules / sysctl state |
| `fix.diff` | the validated fix (cp_datalen + converter-side validation) |
| `verdict.json` / `manifest.json` | machine verdict + catalog |

## Build

```
sh build.sh     # cc -O0 -g -I/usr/src/sys -o trigger_null/leak/benign …
```

## Run

```
kldload /boot/kernel/msdos_iconv.ko     # pulls libiconv.ko
sh prep_img.sh                          # FAT16 image with one LFN file
vnconfig -c vn0 /root/fat.img
./trigger_leak  R1 /dev/vn0 /mnt        # leak   (repeat R2, R3)
vnconfig -u vn0
vnconfig -c vn0 /root/fat.img
./trigger_null /dev/vn0 /mnt            # panic (guest dies; see panic.txt)
```

## Expected output (leak, baseline kernel)

The file on disk is named `ABCDEFGHIJKLMNOP.k`; the directory listing comes
back as e.g. (run R2):

```
--- dirent namlen=29: ???????KMKMKMKMKMKMKMKMKM\x02\x01KM
    hex: 3f 3f 3f 3f 3f 3f 3f 4b 4d 4b 4d ... 4b 4d
```

`4b 4d` (`'K','M'`) is the little-endian decode of the marker strings
`"MK…"` that were placed in *neighboring* 4096-byte kernel allocations (the
16 marker charset pairs registered right after the victim). The kernel read
those bytes **past the end of the victim's 4096-byte allocation** through the
unvalidated `d_table` pointers and returned them to userspace in the dirent.
`3f` (`'?'`) marks slots whose OOB read happened to hit zero bytes (conversion
error path). Across runs R1–R4 the pattern shifts with heap layout (R4 hit
markers on *every* slot: 36/36 bytes) — proving live heap contents, not any
table the victim itself owns.

## Expected output (NULL-deref, baseline kernel)

```
Fatal user address access from kernel mode from trigger_null at …
Fatal trap 12: page fault while in kernel mode
fault virtual address = 0x0
supervisor read data, page not present
Stopped at iconv_xlat16_open+0x34: movq (%rsi,%rax,1),%rdx
```

## On the patched kernel (fix.diff)

* `trigger_null`  → `mount: Invalid argument`, guest stays up (no panic)
* `trigger_leak`  → `mount: Invalid argument`, no directory listing (pair rejected at open)
* `trigger_benign`→ mounts and converts exactly as on the baseline kernel
  (`B\x01B\x02…B\x10B`), proving well-formed tables keep working
