β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-2233

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2233 Β· 16 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download raw

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.

Fix verification

fixed
↓ fix.diffper-fix-DF-2233

Confirmed 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).