Lockless list traversal in iconv_open/iconv_lookupcs/iconv_lookupconv allows use-after-free via concurrent module unload
Summary
Every reader of global iconv_cslist and iconv_converters TAILQ lists operates WITHOUT acquiring iconv_lock while every writer acquires LK_EXCLUSIVE on same lock. Because readers take no lock at all writers exclusive lock provides zero mutual exclusion. Thread in iconv_open can obtain pointer to cspair or converter class that concurrent module-unload or sysctl_add error-path thread is freeing then dereference freed memory UAF. iconv_lookupconv (lines 146-160) traverses iconv_converters bare TAILQ_FOREACH. iconv_lookupcs (lines 163-176) traverses iconv_cslist bare TAILQ_FOREACH. iconv_open calls both then dereferences returned pointers line 245 ICONV_CONVERTER_OPEN(csp->cp_dcp) line 264 dereferences dcp. Freeing paths: iconv_mod_unload:93-94 TAILQ_REMOVE+kfree(csp) line 224 iconv_unregister_converter:140-141 TAILQ_REMOVE+kobj_class_uninstantiate(dcp). iconv_sysctl_add:418 calls iconv_lookupconv BEFORE acquiring lock at 420 TOCTOU window. cp_refcount incremented/decremented without any lock in all 3 converters refcount-based safety check at iconv_mod_unload:87 itself racy. Freeing requires root kldunload. Reading side reachable from mount(2) on filesystems using iconv (msdosfs cd9660 smbfs ntfs udf). With vfs.usermount=1 unprivileged user can mount driving iconv_open. Impact: freed slab reclaimed with attacker data UAF into controlled kernel code execution. [VERIFIED pass2: reproduced twice on independent boots - Fatal trap 12 at iconv_lookupconv+0x30 fault 0x800 from lockless walk racing kldunload libiconv; root-gated on both sides so DoS ceiling; fix-validated - locked traversal plus in-flight drain guard, baseline signature gone, 316M-walk soaks clean; residual nanosecond kldunload-drain window documented as needing linker-level drain]
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2233 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 2.2 KB | β raw | |
| VERDICT.md | β | 5.2 KB | β raw | |
| Makefile | β | 681 B | β download | |
| race2233_harness.c | β | 2.7 KB | view raw | |
| churnadd.c | β | 2.6 KB | view raw | |
| build.sh | β | 184 B | view raw | |
| run.sh | β | 180 B | view raw | |
| build.log | β | 9.3 KB | view raw | |
| run.log | β | 5.7 KB | view raw | |
| panic.txt | β | 1.0 KB | view raw | |
| panic.2.txt | β | 818 B | view raw | |
| env.txt | β | 455 B | view raw | |
| fix.diff | β | 5.1 KB | view raw | |
| fixvalidation.log | β | 3.8 KB | view raw | |
| manifest.json | β | 1.3 KB | view raw | |
| verdict.json | β | 4.4 KB | view raw |
DF-2233 β PoC evidence pack
Finding: Lockless list traversal in iconv_open/iconv_lookupcs/
iconv_lookupconv allows use-after-free via concurrent module unload.
File: sys/libiconv/iconv.c (walks at :150, :167, :243, :251-258;
lockless sysctl_add lookup at :418; frees at :133-143, :218-225, :80-98).
Verdict: REPRODUCED β kernel panic (UAF read on freed converter class),
twice, stock modules, < 45 s per run. See VERDICT.md, panic.txt,
panic.2.txt, run.log.
Build
On the host, from this directory (guest booted via dfbsd-qemu/vm.sh):
../../../../findings/poc/pushrig.sh # or: scp -F ... the 3 files + guest make
On the guest (root):
sh /root/rig2233/../build.sh # i.e.: cd /root/rig2233 && make; cc churnadd.c
race2233.ko = pristine /usr/src/sys/libiconv sources + race2233_harness.c
(walk hammer). churnadd = userland walk/free driver.
Run (baseline β expect panic)
sh run.sh # on the guest, as root # host: dfbsd-qemu/vm.sh log # capture: Stopped at iconv_lookupconv+0x30
Expected decisive output (see panic.txt):
Fatal trap 12: page fault while in kernel mode fault virtual address = 0x800 Stopped at iconv_lookupconv+0x30: movq 0x800(%rsi),%rdi
Fix validation
cd /usr/src && patch -p1 < fix.diff (rebuild race2233.ko / a libiconv variant from the patched tree) rerun the soaks -- see fixvalidation.log (baseline signature never reappears; residual kldunload-drain micro-race documented there).
Files
Makefile kmod makefile (bundles pristine libiconv + harness) race2233_harness.c in-kernel iconv_open walk hammer (sysctl-driven) churnadd.c userland: walk (EINVAL probe) / bad (register+free) build.sh, run.sh exact commands build.log full module build output run.log full run narrative + outputs panic.txt, panic.2.txt serial console panic captures (two boots) env.txt guest environment fix.diff validated fix (traversal lock + drain guard) fixvalidation.log A/B fix validation outputs manifest.json machine catalog verdict.json machine verdict
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()walksiconv_converterswith no lock:sys/libiconv/iconv.c:145-160(TAILQ_FOREACH at :150).iconv_lookupcs()walksiconv_cslistwith 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 dispatchesICONV_CONVERTER_OPEN(csp->cp_dcp, ...)on the raw result.iconv_sysctl_add()calls the locklessiconv_lookupconv()at:418before takingLK_EXCLUSIVE(also a TOCTOU: thedcpused 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, thenlockuninit).
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.addwrites require root: generic sysctl-write privilege gatecaps_priv_check(SYSCAP_NOSYSCTL_WR)atsys/kern/kern_sysctl.c:1444-1448.kldunloadrequires root.- The lockless walkers reachable without a custom KLD: (a) any root
kern.iconv.addcaller (kiconv mount helpers, e.g.mount_cd9660 -C,mount_msdosfs -Lbefore mounting); (b) filesystem mount paths (msdosfs_vfsops.c:119-130etc. through theVFS_DECLARE_ICONVbridge); withvfs.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-pathiconv_opencall in a tight in-kernel loop (debug.race2233_run, CTLFLAG_NOLOCK).churnadd.cβ userland:walkmode = stockkern.iconv.addEINVAL probe (pure lockless converter walk, no state change);badmode = register+free churn through thebad:path (sameiconv_unregister_cspairthe unload path uses).run.logβ full narrative + outputs;panic.txt,panic.2.txtβ serial captures of both panics.
Fix (fix.diff) β validated A/B
iconv_lookupcs/iconv_lookupconvbecome_lockedvariants; all walkers (iconv_open,iconv_add,iconv_sysctl_add) run underLK_SHARED/LK_EXCLUSIVEacross traversal and dispatch.- In-flight refcount (
iconv_refs) drain guard on every external entry point;iconv_mod_unloadrefuses (EBUSY) while callers are in flight, preventinglockuninit/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.
Fix verification
fixedConfirmed kernel references
Detail
Evidence (decisive lines)
panic.txt and panic.2.txt: serial captures of both panics showing Stopped at iconv_lookupconv+0x30 with fault virtual address 0x800 from process churnadd. run.log: full rig narrative including the 300M-walk churn run (no fault, stale-read analysis), the refcount-leak side observation (pair ref=12685 never drains) and the silent hard death of the bundled-module rig. fixvalidation.log: fixed-build soaks A, B2 (316798072 walks, clean), C3 (240 s unload storm, clean) and the documented C4 residual.
PoC changes
No seed pack existed; built from scratch. Rig 1 needed no custom module at all (stock libiconv.ko + stock kern.iconv.add sysctl path driven by churnadd walk mode). The bundled race2233.ko compiles the pristine libiconv sources plus an in-kernel walk harness replicating the mount-path iconv_open call. CTLFLAG_NOLOCK was required on the harness sysctl so several hammer processes run concurrently.
Verified recommended fix
Hold iconv_lock across all list traversals and result dispatch; add an in-flight refcount drain guard refusing unload (EBUSY) while callers are inside the module.
Verdict
The lockless traversal UAF is real and reproduces with stock modules: two independent boots, each panicking in under 45 seconds with Stopped at iconv_lookupconv+0x30 movq 0x800(%rsi),%rdi (fault VA 0x800) while a plain kern.iconv.add caller walked the converter list and kldunload libiconv concurrently freed and NULLed the class ops tables. A third rig (in-kernel iconv_open walkers vs unload) killed the guest outright with no trap text. The cslist-walk variant against register/free churn performs stale reads of freed but still-mapped chunks and does not fault. Reachability is root-on-both-sides on default config: sysctl writes are gated by SYSCAP_NOSYSCTL_WR and kldunload needs root, with an unprivileged mount-side angle only under vfs.usermount=1. Impact is therefore local DoS / kernel UAF read, severity Medium as filed. The fix (lock held across traversal and dispatch, plus an in-flight refcount drain guard on unload) was validated on rebuilt modules: the baseline panic signature never reappears, 316M-walk soaks and 240 s unload storms are clean; a nanosecond-scale kldunload-vs-inflight residual remains and is documented (needs linker-level drain support).
No comments yet.