# DF-2233 — Lockless iconv list traversal vs concurrent module unload (UAF)

**File:** `sys/libiconv/iconv.c`
**Claim under verification:** `iconv_open` / `iconv_lookupcs` / `iconv_lookupconv`
walk the `iconv_cslist` / `iconv_converters` TAILQs without holding `iconv_lock`
(or having dropped it), while module unload frees the walked entries → UAF.

## TL;DR — VERDICT: reproduced (impact: panic / kernel UAF-read; DoS)

Two independent panics on separate boots, both stopped at
`iconv_lookupconv+0x30: movq 0x800(%rsi),%rdi` — the walker dereferencing a
converter class whose kobj ops table was freed/NULLed by a concurrent
`kldunload` of `libiconv.ko`. Plus one silent hard guest death under the
maximum-aggression rig. Race reproduced with **stock** modules and the stock
`kern.iconv.add` sysctl path in < 45 s.

## Root cause (line-accurate)

* `iconv_lookupconv()` walks `iconv_converters` with **no lock**:
  `sys/libiconv/iconv.c:145-160` (TAILQ_FOREACH at :150).
* `iconv_lookupcs()` walks `iconv_cslist` with **no lock**: `:162-176`
  (TAILQ_FOREACH at :167).
* `iconv_open()` walks both lists with **no lock**: `:232-266`
  (lookupcs at :243, converters TAILQ_FOREACH at :251, lookups :255/:258),
  then dispatches `ICONV_CONVERTER_OPEN(csp->cp_dcp, ...)` on the raw result.
* `iconv_sysctl_add()` calls the lockless `iconv_lookupconv()` at `:418`
  **before** taking `LK_EXCLUSIVE` (also a TOCTOU: the `dcp` used after).
* Free paths, all under `LK_EXCLUSIVE`:
  `iconv_unregister_converter` `:133-143` (TAILQ_REMOVE +
  `kobj_class_uninstantiate` → `kfree(cls->ops); cls->ops = NULL`,
  sys/kern/subr_kobj.c:225-243), `iconv_unregister_cspair` `:218-225`
  (TAILQ_REMOVE + kfree), `iconv_mod_unload` `:80-98` (frees **all** pairs,
  then `lockuninit`).

Interleaving proven on the guest: walker loads `dcp` from the list → kldunload
removes class, `dcp->ops = NULL` → walker calls `ICONV_CONVERTER_NAME(dcp)` →
kobj dispatch loads from NULL ops → `movq 0x800(%rsi)` fault at VA 0x800.

## Reachability / threat model (honest)

* `kern.iconv.add` **writes** require root: generic sysctl-write privilege
  gate `caps_priv_check(SYSCAP_NOSYSCTL_WR)` at `sys/kern/kern_sysctl.c:1444-1448`.
* `kldunload` requires root.
* The lockless walkers reachable without a custom KLD: (a) any root
  `kern.iconv.add` caller (kiconv mount helpers, e.g. `mount_cd9660 -C`,
  `mount_msdosfs -L` before mounting); (b) filesystem mount paths
  (`msdosfs_vfsops.c:119-130` etc. through the `VFS_DECLARE_ICONV` bridge);
  with `vfs.usermount=1` (non-default) the mount-side walk is reachable by an
  unprivileged user on a device they own. The *unload* side is always root.
* Net: a root-triggered kldunload racing in-flight kiconv users (mounts,
  kiconv(3) library users) reliably corrupts/panics the kernel. Both panic
  reproductions used only stock modules + stock sysctl path. Not an
  unprivileged-only primitive on default config; severity Medium (as filed)
  is right — local DoS/UAF with admin-triggered free side.

## PoC rig

* `race2233_harness.c` — compiled INTO the same KLD as the pristine
  libiconv sources (`Makefile`); replicates the mount-path `iconv_open` call
  in a tight in-kernel loop (`debug.race2233_run`, CTLFLAG_NOLOCK).
* `churnadd.c` — userland: `walk` mode = stock `kern.iconv.add` EINVAL probe
  (pure lockless converter walk, no state change); `bad` mode =
  register+free churn through the `bad:` path (same
  `iconv_unregister_cspair` the unload path uses).
* `run.log` — full narrative + outputs; `panic.txt`, `panic.2.txt` — serial
  captures of both panics.

## Fix (fix.diff) — validated A/B

1. `iconv_lookupcs`/`iconv_lookupconv` become `_locked` variants; all
   walkers (`iconv_open`, `iconv_add`, `iconv_sysctl_add`) run under
   `LK_SHARED`/`LK_EXCLUSIVE` across traversal **and** dispatch.
2. In-flight refcount (`iconv_refs`) drain guard on every external entry
   point; `iconv_mod_unload` refuses (EBUSY) while callers are in flight,
   preventing `lockuninit`/unmap under them.

Validation on rebuilt modules: walk+churn soak 316M walks clean; 240 s
unload storm clean; baseline signature never reappears. Residual (documented):
a nanosecond-scale window remains where a caller ref lands after the
unloader's final check — under a 180 s supervised restart-storm this produced
one silent reboot after the storm had finished. Airtight closure needs a
linker-level drain/ref mechanism for kldunload (generic DragonFly kmod
limitation), out of scope of this diff.

## Side observation

After heavy successful open/close churn, `cp_refcount` of the
UTF-16BE↔UTF-8 pair leaks monotonically (observed ref=12685, never drains)
making the module permanently unloadable. Worth a separate upstream look.

## Reproduce

```
./pushrig.sh                      # push + build rigs on the guest
vm.sh run_root '/root/rig2233/rig1.sh &'    # stock-module race (<60 s to panic)
vm.sh log                                  # capture trap from serial console
```

## 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.
