# DF-2237 VERDICT — verified reproduced (panic), fix validated

Finding: `sys/libiconv/iconv_xlat16.c` `iconv_xlat16_conv()` dereferences
`dp->d_table[c1][c2]` at line 192 without re-checking `dp->d_table[c1]`
after case-conversion mutated `c1`/`c2` at lines 155-158 (double-byte,
`towlower()`/`towupper()` via `dp->f_ctp`) or lines 186-189 (single-byte,
`ctmp = code >> 16` via `XLAT16_HAS_FROM_LOWER_CASE/_UPPER_CASE` flags).

Guest: DragonFly 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 UTC 2026,
X86_64_GENERIC, amd64, securelevel -1, stock INVARIANTS kernel.
Audited-tree hash match: `/usr/src/sys/libiconv/iconv_xlat16.c`
sha256 298b070928e768b521e2af5debb072eae64740f5e0f3808f13aca6b3f9e477d0
identical to repo `sys/libiconv/iconv_xlat16.c`.

## Reproduction

Arming (root, userland, REAL wire format):
`armtbl` registers 3 charset pairs through `sysctl kern.iconv.add`
(`iconv_sysctl_add`, sys/libiconv/iconv.c:397-443) using the exact
serialized format lib/libkiconv uses (`xlat16_sysctl.c`) and that
`iconv_xlat16_open` (iconv_xlat16.c:59-78) parses: 0x200 caddr_t block
pointers (NULL = block absent) followed by 0x80-entry uint32_t blocks.
Sparse tables with NULL blocks are the NORMAL output of the real builder
(`lib/libkiconv/xlat16_iconv.c` allocates a block only when an entry is
non-zero), so nothing about the crafted tables is structurally exotic:

- `UTF-16BE <- DF2237CS`: only block 0x000 present. Entry `A` (0x41)
  = `0x0041 | XLAT16_HAS_FROM_LOWER_CASE | (0xC2 << 16)`. No `_wctype`
  pair for DF2237CS exists, so `f_ctp == NULL` (iconv_xlat16.c:81-82),
  which activates the single-byte flag path.
- `UTF-16BE <- DF2237W2`: only block 0x1CD present, entry [0x2B]=0x1234.
- `_wctype <- DF2237W2`: only block 0x1CD present, entry [0x2B]
  = `0x8356 | XLAT16_HAS_LOWER_CASE`, i.e. `towlower(0xABCD) = 0x8356`
  via the real `iconv_xlat16_tolower` (iconv_xlat16.c:299-322).

Trigger (KLD through the REAL exported KPI, same dispatch msdosfs uses
at sys/vfs/msdosfs/msdosfs_conv.c:903/988): `iconv_open()` +
`iconv_conv_case(..., KICONV_FROM_LOWER)`.

### T1 — single-byte flag path (lines 186-189 -> 192)

Input `"AB"`. Pre-check at line 142 fails (`d_table[0x42]` NULL) so the
single-byte path runs; `c1 &= 0xff00` -> 0; `d_table[0]` present (line 163
check passes); `code = d_table[0][0x41]` has `XLAT16_HAS_FROM_LOWER_CASE`
with `(code >> 16) = 0xC2`; `ctmp = 0xC2` mutates
`c1 = C2I1(0xC200) = 0x100`, `c2 = 0x42`. Line 192 loads
`d_table[0x100][0x42]` with `d_table[0x100] == NULL`.

Result (t1_boot.log:210, panic.txt):

    Fatal user address access from kernel mode from kldload at ffffffff82602079
    Fatal trap 12: page fault while in kernel mode
    fault virtual address = 0x108        (= NULL + 0x42*4, as predicted)
    fault code = supervisor read data, page not present
    Stopped at iconv_xlat16_conv+0x259: movl (%rdi,%rdx,1),%edi

### T2 — double-byte wctype path (lines 155-158 -> 192)

Input `"\xab\xcd"`. Line 142 passes (`d_table[0x1CD][0x2B] = 0x1234`,
inlen=2); `towlower(0xABCD, f_ctp) = 0x8356` mutates
`c1 = C2I1(0x8356) = 0x156`, `c2 = 0x03`. Line 192 loads
`d_table[0x156][0x03]` with `d_table[0x156] == NULL` in the MAIN table.

Result (t2_boot.log:208, panic2.txt): Fatal trap 12, fault VA **0xc**
(= NULL + 0x03*4), supervisor read. The plain-conversion control
(`\xab\xcd -> 12 34`) ran immediately before on the same table pair.

### Controls (stock kernel, no panic)

- plain `conv("AB")` -> `41 42`, ret=0 (table is an otherwise-legit
  conversion table);
- `conv_case('C', FROM_LOWER)` -> `63` (case-conversion path works when
  the converted index lands in a PRESENT block);
- both controls bit-identical before and after the fix.

## Reachability honesty (as directed)

- The kernel code defect is real and reachable through the documented
  KPI/sysctl byte format. Root must arm it (kern.iconv.add has no
  explicit priv_check inside iconv.c, but sysctl writing requires root;
  vfs.usermount=0 on this guest, so mount-arming is also root).
- After arming, EVERY caller of `iconv_conv_case(.., KICONV_FROM_LOWER |
  FROM_UPPER)` walks the vulnerable path — msdosfs does exactly that per
  filename character once mounted with `-C` (msdosfs_conv.c:903,988).
- Can the REAL builder produce a self-triggering table? With a
  locale/charset that agree, generally no: the from-lowercase targets
  embedded at build time (`xlat16_iconv.c:258-266`, locale `tolower()`)
  are convertible code points whose own entries make their blocks
  non-NULL. But the builder mixes two INDEPENDENT sources (userland
  iconv occupancy vs locale wctype tables) and skips registering the
  `_wctype` pair entirely when `chklocale()` mismatches — leaving
  `f_ctp == NULL` and the flag path active with locale-derived targets.
  So a locale/charset divergence at mount time is the plausible organic
  trigger; the deterministic trigger is root-crafted tables. This is a
  latent-KPI robustness bug with a root-armed DoS manifestation — Medium
  is the right severity; not a self-propagating or unprivileged-armed
  primitive.

## Escalation analysis (team directive)

- The fault is a kernel **READ** of `NULL + c2*4`, `c2 in 0..0x7f` ->
  fault VA confined to `0x0..0x1fc`; base NULL, offset table-chosen but
  bounded to the first page.
- On this guest, page 0 IS user-mappable: unprivileged `maxx` succeeded
  `mmap(0, 4096, MAP_FIXED|MAP_ANON)` (mmap0_user.log; DragonFly does not
  enforce a non-zero VM_MIN_USER_ADDRESS here). So the panic can be
  suppressed by the attacker, and `code` (line 192) becomes an
  attacker-chosen uint32 read from the attackers OWN page.
- Every sink of `code` is benign: nullin flag, `u`/`l` output bytes and
  `code>>16` written (<= 3 bytes, honoring `or` accounting) into the
  caller-provided conversion output buffer (msdosfs passes 2-3 byte stack
  buffers), and `(uint16_t)code` into `towlower/towupper` whose xlat16
  implementations re-check their own tables. There is NO write-what-where,
  no info leak (the attacker reads their own memory), and no path from
  `code` to a pointer, length, or refcount.
- Conclusion: no uid=0 chain. Ceiling = kernel panic (default) or, with
  page 0 mapped, silently attacker-chosen conversion output (garbage
  filenames). Panic-only impact on hardened guests (page 0 unmappable).

## Fix validation

`fix.diff` adds a single guard before line 192 mirroring the existing
line 163 check, covering both mutation sites:

     	/*
     	 * Case conversion above may have moved c1/c2 into a high
     	 * byte block that is absent (NULL) in a sparse table.
     	 */
     	if (dp->d_table[c1] == NULL) {
     		ret = -1;
     		break;
     	}
     	code = dp->d_table[c1][c2];

Applied to the guest /usr/src, `libiconv.ko` rebuilt
(sha256 30bfdffe7430d3524e832faaa0cdc13622194b0e20022373658982a65f119642,
disassembly shows `test %rdi,%rdi; je <break>` immediately before the
previously-unguarded `mov (%rdi,%r9,4),%edi`), loaded, same tables armed:

- controls: `41 42` / `63` / `12 34` — bit-identical to baseline;
- T1: `SURVIVED ret=-1 consumed=0 produced=0`;
- T2: `SURVIVED ret=-1 consumed=0 produced=0`;
- zero Fatal traps in the patched boot (fix_boot.log, fix_run.log).

ret=-1 is the same semantic as a normal unconvertible character
(line 193-196), so callers already handle it (msdosfs falls back to
its cannot-convert magic numbers).

## Repro trail / deviations

- bsd.kmod.mk resets CFLAGS (sys/conf/kmod.mk:98 `CFLAGS=`), so
  `-DKMOD_MODE` must go in KCFLAGS; first build silently produced three
  mode-0 modules (caught because trigger did not fire).
- First fix-validation attempt loaded a STALE unpatched libiconv.ko
  (`find /usr/obj | head -1` matched a Jul 2 X86_64_GENERIC build) and
  the DDB-kill rolled back unsynced Hammer2 writes (patch + build
  vanished). Redone with explicit paths + sync + hash-verified module.
- QEMU serial log accumulates across guest reboots; each phase log was
  copied out before the next reset.

Bottom line: reproduced (panic, both code paths), root-armed local DoS /
latent-KPI bug, no escalation path (read-only, first-page-only, benign
sinks), fix validated A/B.

## Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest `make -j6 nativekernel KERNCONF=X86_64_GENERIC` completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
