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

Unsynchronized cspair lifetime handoff: iconv_xlat_open/iconv_xlat_close manipulate cp_refcount lockless against under-lock cspair teardown, and iconv_sysctl_add's error path frees referenced pairs unconditionally β€” UAF writes into freed cspairs, dangling d_table, kernel heap corruption (reproduced panic:BADFREE2)

Summary

The refcount that protects cp_data/csp is claimed and dropped OUTSIDE iconv_lock while the frees it must exclude run UNDER it, and the sysctl-add error path frees pairs unconditionally (never checking the refcount it exists to protect): a colliding open claims a reference on freed memory and keeps a dangling d_table that every later conv reads and close decrements. Rig on verbatim production sources: 3/3 panics ('BADFREE2' in iconv_sysctl_add's kfree) plus live dangling-handle probes (refcount=-2046); two-part fix (serialize the handoff + copyin-before-publish + refcount-checked reap) validated over 21.3M opens, DANGLING=0, positive control passes. Both race sides privilege-gated (sysctl write caps + mount-only open path) - Low, root-triggered DoS/heap-corruption class, no unpriv->root chain.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5765 Β· 20 files
FileTypeDescriptionSize
README.md β€” 3.5 KB ↓ raw
VERDICT.md β€” 8.1 KB ↓ raw
harness.c β€” 7.4 KB view raw
hammer.c β€” 4.1 KB view raw
Makefile β€” 571 B ↓ download
build.sh β€” 87 B view raw
run.sh β€” 123 B view raw
xfer.sh β€” 801 B view raw
setup.log β€” 396 B view raw
build.log β€” 9.2 KB view raw
run.log β€” 2.4 KB view raw
run.2.log β€” 2.0 KB view raw
panic.txt β€” 1.7 KB view raw
panic.2.txt β€” 1.2 KB view raw
panic.3-stale-build.txt β€” 2.1 KB view raw
run_fixed.log β€” 1.9 KB view raw
fix_build.log β€” 8.8 KB view raw
env.txt β€” 393 B view raw
fix.diff β€” 5.1 KB view raw
verdict.json β€” 6.2 KB view raw

DF-5765 β€” iconv_xlat cspair lifetime handoff race β†’ kernel heap corruption (panic: BADFREE2)

What

sys/libiconv/iconv_xlat.c claims and drops its cspair lifetime reference (csp->cp_refcount++ at iconv_xlat.c:63 inside iconv_xlat_open, csp->cp_refcount-- at iconv_xlat.c:73 inside iconv_xlat_close) with no synchronization, while the cspair teardown paths (iconv_sysctl_add error path, iconv.c:439-442; iconv_mod_unload, iconv.c:85-95) free cspairs and their tables under iconv_lock β€” a lock that iconv_open()/iconv_close() (iconv.c:233-273) never take. A racing open can find a pair, sleep in kobj_create(M_WAITOK) (iconv_xlat.c:60), and then write cp_refcount++ into freed memory and cache a dangling d_table; the add error path frees the pair unconditionally, even when a counted reference is held.

Reproduce (inside the DragonFly guest, as root)

cd /root/xlatrace   # == findings/poc/DF-5765/ transferred into the guest
sh build.sh         # copies VERBATIM production iconv.c/iconv_xlat.c
                    # (md5s recorded in setup.log), builds
                    # xlatrace_harness.ko + userland hammer
kldload ./xlatrace_harness.ko
sh run.sh           # baseline, then 2 x 8,000,000-iteration races

run.sh starts kernel racer threads (one per CPU except CPU0) that call the production iconv_open/iconv_convstr/iconv_close on the pair zz9to:zz9from in a loop, while the userland hammer drives the production kern.iconv.add sysctl with ia_datalen=256, ia_data=0x1000 (unmapped β‡’ copyin EFAULT β‡’ production error path registers the pair, allocates the 256-byte table, then unregisters and kfrees both, in a tight loop).

Expected / observed

  • Unpatched: within seconds-to-minutes of churn:
  • console: [xlatrace] PROOF: live handle d_csp=... refcount=-2046 -- pair freed/reused under the open (d_table=...)
  • then panic: BADFREE2 at _kfree+0x593 called from iconv_sysctl_add+0x172 (the production error path freeing a chunk whose freelist the racers' writes corrupted); ssh dies, vm.sh status β‡’ down.
  • 3/3 race sessions panicked with the identical signature (run.log, run.2.log, panic.3-stale-build.txt).
  • With fix.diff applied to the rig's verbatim production iconv.c: full run.sh completes (FIX_RUN_RC=0), 21.3M cumulative opens, DANGLING=0, kern.iconv.cslist shows no refcount anomalies, no panic (run_fixed.log). Positive control: a valid add still works (csid=1) and racers perform balanced open/conv/close cycles on it.

Files

  • harness.c β€” KLD rig (production sources compiled in verbatim)
  • hammer.c β€” userland sysctl-churn driver (-l dumps cslist/drvlist)
  • Makefile, build.sh, run.sh, xfer.sh
  • run.log, run.2.log β€” decisive unpatched runs (console captures)
  • panic.txt, panic.2.txt, panic.3-stale-build.txt β€” panic captures
  • run_fixed.log, fix_build.log β€” fix-validation run
  • setup.log β€” verbatim-source md5s; env.txt β€” guest environment
  • VERDICT.md, manifest.json, verdict.json, fix.diff

Impact

Privilege analysis: BOTH sides of the race are root-gated on a stock system β€” writing kern.iconv.add requires SYSCAP_NOSYSCTL_WR (kern_sysctl.c:1446-1447) and the iconv_open side is only reachable from mount(2)-time VFS charset setup (root; vfs.usermount=0 default). So: privileged-user-triggerable kernel heap corruption / DoS β€” severity Low per the audit rubric ("already-privileged user"), CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:N/I:H/A:H (3.3).

VERDICT.md
↓ download raw

DF-5765 β€” VERDICT

Status: reproduced (panic). Fix validated (fixed).

The finding (pass 2 on sys/libiconv/iconv_xlat.c, beyond DF-2247/2248/2249/5746)

The known trio covers sizing bugs of the flat converter (table length unvalidated at open; min() truncation; convchar==1 unchecked). What remained in the 125 lines was lifetime: iconv_xlat_open/iconv_xlat_close are the only code that claims and releases a cspair's existence guarantee, and they do it unsynchronized:

  • iconv_xlat.c:60-64 β€” kobj_create(M_WAITOK) (sleeps), then dp->d_table = csp->cp_data (:61), then csp->cp_refcount++ (:63).
  • iconv_xlat.c:73 β€” dp->d_csp->cp_refcount--.
  • Callers never hold iconv_lock: iconv_open walks iconv_cslist unlocked (iconv.c:243 via iconv_lookupcs :163-176) and dispatches the open method at iconv.c:245/:264; iconv_close at iconv.c:269-273.
  • The freeing paths hold iconv_lock: iconv_sysctl_add error path iconv.c:439-442 β†’ iconv_unregister_cspair (:218-225: TAILQ_REMOVE + kfree(cp_data) + kfree(csp)), and iconv_mod_unload (:85-95, same free via refcount==0 check that the unlocked ++ can hide from).
  • Aggravator: the add error path frees unconditionally β€” it never checks cp_refcount β€” so even a correctly counted reference does not protect the pair (this is why the fix needed two parts; see below).

Verification (guest-live, production-code KLD rig)

Rig: xlatrace_harness.ko compiles the verbatim production iconv.c + iconv_xlat.c (md5s in setup.log) and drives them exactly as production does:

  • racer kthreads = the VFS-mount role: production iconv_open() β†’ iconv_convstr() β†’ iconv_close() on pair zz9to:zz9from;
  • userland hammer (root) = the sysctl role: production kern.iconv.add with ia_datalen=256, ia_data=0x1000 (unmapped) β‡’ production copyin EFAULT β‡’ production bad: path registers, allocates a full-size table, then unconditionally unregisters/frees, in an 8M-iteration loop per round.

Observed (unpatched) β€” 3/3 race sessions:

  1. Dangling handoff probe (harness-side read-only check right after a successful production open: the handle's cspair refcount must be β‰₯1): fired with refcount=-2046 / -2047 / -204 (cleanest capture in panic.3-stale-build.txt): [xlatrace] PROOF: live handle d_csp=0xfffff80116c2bae0 refcount=-2046 -- pair freed/reused under the open (d_table=0xfffff8008da91600) β€” i.e. the pair was freed under the live handle and ~2Γ—10Β³ subsequent decrements landed on the reused chunk.
  2. panic: BADFREE2 at _kfree+0x593 ← iconv_sysctl_add+0x172 (production kfree of allocator-state-corrupted memory), ssh dies, vm.sh status β‡’ down. Identical signature across runs (cpuid 2, 5, 2, 3).

The unpatched runs never completed run.sh (panic mid-round), so the in-guest run.log was lost each time; the serial console captures (run.log, run.2.log, panic*.txt) are the runtime evidence.

Primitive characterization / uid=0?

The corruption primitive is a kernel-heap freelist corruption (small, partially controlled Β±1 writes at a predictable offset in a freed 64-112B chunk, repeatable millions of times, leading to BADFREE2). But the escalation chain is closed by construction: both sides of the race are root-gated on a stock system β€”

  • kern.iconv.add writes require caps_priv_check(SYSCAP_NOSYSCTL_WR) (kern_sysctl.c:1446-1447);
  • iconv_open() is only reachable from mount(2)-time charset setup (cd9660/msdosfs/smbfs), root-only with vfs.usermount=0 (default).

An unprivileged user can drive neither side; a hard-blocked prerequisite (privilege on both fronts) β€” so impact ceiling = privileged-user-triggered kernel memory corruption / DoS. Severity Low per rubric ("already-privileged user"), impact=panic.

Fix validation (mandatory for memcorrupt) β€” fixed

fix.diff (git-apply-able vs sys/libiconv/iconv.c, authored after verification, never applied to the read-only tree) has two parts:

  1. serialize the handoff: iconv_open()/iconv_close() hold iconv_lock LK_EXCLUSIVE across lookup+converter-open / converter-close (both direct and composite open paths);
  2. stop freeing referenced pairs: iconv_sysctl_add copies the table in before publishing the cspair, and its bad: path only reaps the pair when cp_refcount == 0.

Honest detour: fix iteration 1 contained only part 1; the first validation attempt still panicked. Investigation showed that session (and one other) had loaded a stale build due to a MAKEOBJDIRPREFIX objdir mixup between build.sh and the rebuild (proven behaviorally: under part 2's copyin-before-publish, successful opens during pure-EFAULT churn are impossible β€” and a verified-clean rebuild shows exactly opens=0 β€” yet those runs showed successful opens). With the objdir fixed and the source patch verified (grep ndata iconv.c, fresh single-objdir build), the complete fix was validated:

  • short race: 4M hammer iterations + racers β‡’ opens=0 DANGLING=0, no panic, cslist: 0 bytes (nothing published, nothing orphaned);
  • positive control: a valid kern.iconv.add (real 256-byte table) still succeeds (csid=1); racers performed 774k/257k balanced open/conv/close cycles on it, DANGLING=0, final refcount 0;
  • full run.sh (baseline + 2Γ—8M race rounds): FIX_RUN_RC=0, cumulative opens=21335040 convs=21335040 closes=21335040 DANGLING=0, final cslist 1 pairs, all refcounts 0, no panic (run_fixed.log).

Baseline-vs-patched table:

build race runs PROOF/dangling panic
stock 3 sessions refcount βˆ’204β€¦βˆ’2047 probes BADFREE2 Γ—3 (identical trace)
fixed 2Γ—8M iters + short + positive 0 none

Negative results (what I checked and killed in this file)

  • iconv_xlat_conv byte-index and length math beyond the known trio: with a full-size table the loop is exactly 1:1, *inbytesleft -= n cannot underflow for the min() path (n ≀ both), casetype != 0 returns βˆ’1 cleanly (xlat has no case support; callers use xlat16 for that), NULL inbuf/outbuf handled (:88-89).
  • kobj_create(M_WAITOK) cannot fail-with-NULL here (M_WAITOK).
  • d_table aliasing csp->cp_data is sound if the lifetime claim were synchronized β€” which is exactly the finding.
  • No unprivileged entry: sysctl write privilege gate + mount-only open path (see above), so no uid=0 chain exists for this bug.

bt_input.c / hda_acer_c720_patch.c (same batch)

  • sys/netbt/bt_input.c: current tree is still the pre-DF-5752-fix state β€” producers netisr_queue(NETISR_BLUETOOTH, m) at hci_unit.c:437/459/481/543, btintr (bt_input.c:32-40) ignores the netmsg and drains all units via hci_intr. DF-5752 owns the queue contract, DF-5754 owns the walk-vs-detach UAF (hci_unit.c:126-129). Beyond those two: netisr_register(NULL hashfn) is safe (netisr.c:543-544 defaults it), netbt's MOD_UNLOAD returns EBUSY (bt_proto.c:48-49) so no dangling netisr handler, and the mplock wrap matches netbt's lock discipline β€” nothing new in 41 lines.
  • sys/gnu/dev/sound/pci/hda/hda_acer_c720_patch.c: clean. The patch is gated on codec id ALC283 and Acer C720 subvendor ids (hdaa_patches.c:786-790, values from hardware config space), applied only at codec configure/reinit (hdaa.c:6226, :6563) β€” the attacker is malicious/buggy physical hardware. hdaa_widget_get is bounds-checked (hdaa.c:1673-1678) and NULL-checked (:195); the wclass.pin.config read (:196) is a union of scalars only (hdaa.h:122-134 β€” no pointer disclosure); all coefficient writes are fixed constants through hda_command whose return codes the codec can NAK without kernel-state consequence; the tsleep blocks are #if 0. No software-reachable input, no memory-safety defect.

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
baseline reproduced→ patch + rebuild →patched clean

Fixed: with fix.diff applied to the rig's verbatim production iconv.c and rebuilt clean (single objdir, source patch verified), the identical race rig that panicked 3/3 times stock survives baseline + 2x8M churn rounds with zero dangling handoffs (opens=21335040, DANGLING=0), kern.iconv.cslist shows no refcount anomalies, and a valid (positive-control) kern.iconv.add still registers a working pair that racers open/convert/close 257k+ times balanced. The previously-observed panic:BADFREE2 and PROOF lines are gone.

run_fixed.log (full fixed run: FIX_RUN_RC=0, DANGLING=0, cslist '1 pairs, all refcounts 0'); fix_build.log; panic.txt/panic.2.txt/panic.3-stale-build.txt (baseline BADFREE2 x3); fix.diff
↓ fix.diffper-fix-DF-5765

Confirmed kernel references

Detail

Exploit chain

root: loop sysctl kern.iconv.add (ia_datalen=256, ia_data=unmapped -> EFAULT error path) to publish+unconditionally-free the zz9to:zz9from cspair and table while root-driven mount-role threads (production iconv_open->iconv_convstr->iconv_close) collide -> open lands on the pair, sleeps in kobj_create(M_WAITOK), pair is freed under it -> cp_refcount++ writes into freed chunk, d_table dangles -> conv/close write/read freed memory repeatedly (observed refcount=-2046 on a reused chunk) -> allocator freelist corruption -> panic:BADFREE2 in iconv_sysctl_add's kfree. Unprivileged escalation: not possible (both entry paths privilege-gated).

Evidence (decisive lines)

run.log + panic.txt (run1 console: BADFREE2, cpuid=2); run.2.log + panic.2.txt (run2: interleaved PROOF refcount=-204 + BADFREE2 cpuid=5); panic.3-stale-build.txt (cleanest PROOF line: d_csp=0xfffff80116c2bae0 refcount=-2046 d_table=0xfffff8008da91600); run_fixed.log + fix_build.log (validated fix: FIX_RUN_RC=0, opens=21335040 DANGLING=0, cslist all-zero); setup.log (verbatim production-source md5s)

PoC changes

Self-authored pass-2 rig (no prior seed): KLD compiles verbatim production iconv.c+iconv_xlat.c (Makefile mirrors production module build); harness adds per-CPU racer kthreads (CPU0 left free; 1-tick sleep every 1024 attempts after the first attempt livelocked userland -- pure-spin kthreads starve user LWPs), a read-only post-open refcount probe via a mirror struct, and sysctls hw.xlatrace.{ctl,stats}; userland hammer needed errno.h added; run.sh uses dotted sysctl names.

Verified recommended fix

Hold iconv_lock across iconv_open/iconv_close converter dispatch, copy the table in before publishing the cspair in kern.iconv.add, and only reap the pair on the error path when cp_refcount==0 (fix.diff, sys/libiconv/iconv.c).

Verdict

REPRODUCED as panic:BADFREE2 (3/3 unpatched race sessions, identical trace _kfree+0x593 <- iconv_sysctl_add+0x172) plus an on-console dangling-handoff probe (live handle refcount=-2046). iconv_xlat_open/iconv_xlat_close claim and drop the cspair lifetime reference (iconv_xlat.c:63/:73) with no lock, while iconv_sysctl_add's error path frees the cspair and its table under iconv_lock (iconv.c:439-442 -> 218-225) unconditionally (no cp_refcount check) and iconv_open/iconv_close never take that lock (iconv.c:233-273); a racing open sleeps in kobj_create (M_WAITOK) between the unlocked list-walk hit and the refcount claim, then writes into freed memory and caches a dangling d_table, corrupting the kernel heap until the production kfree catches it. Impact ceiling is a root-triggered race on a stock system: kern.iconv.add writes need SYSCAP_NOSYSCTL_WR (kern_sysctl.c:1446-1447) and iconv_open is mount(2)-time-only (vfs.usermount=0 default), so no unprivileged->uid0 chain exists; classified Low severity / DoS-class panic. fix.diff (serialize open/close under iconv_lock + copyin-before-publish + refcount-checked reap) validated on the identical rig: 2x8M churn rounds + 21.3M balanced opens with DANGLING=0, positive-control add still works, no panic.