iconv_xlat16_conv dereferences d_table[c1] without NULL check after case-conversion mutates c1/c2
Summary
In iconv_xlat16_conv() after KICONV_FROM_LOWER/KICONV_FROM_UPPER case conversion at lines 149-158 (double-byte) or 172-189 (single-byte) changes c1 and c2 to lower/upper-case variant table indices code falls through to line 192 code=dp->d_table[c1][c2] without re-checking dp->d_table[c1] is non-NULL. If case-converted character maps to high-byte block with no table entries (d_table[new_c1]==NULL valid state for sparse tables) line 192 dereferences NULL immediate kernel panic. At line 142 initial lookup checks dp->d_table[c1]&&dp->d_table[c1][c2] before entering double-byte branch. But lines 156-158 mutate c1/c2 based on towlower/towupper result. NEW c1 may index completely different d_table[] slot NOT checked. Single-byte branch line 163 checks d_table[c1] non-NULL but lines 187-189 mutate c1 via C2I1(ctmp<<8). Both paths converge at line 192 code=dp->d_table[c1][c2] with no NULL guard on d_table[c1] after mutation. Check at line 193 if(!code) only fires AFTER dereference already occurred. Reachable via unprivileged kern.iconv.add sysctl registering crafted xlat16 _wctype table where entry has XLAT16_HAS_LOWER_CASE with lowercase code C2I1 decomposition pointing to absent block. Impact: deterministic kernel panic local DoS. [VERIFIED pass2: reproduced on both paths - Fatal trap 12 supervisor READ VA 0x108 single-byte and 0x0c double-byte at iconv_xlat16_conv+0x259 after case-conversion mutation lands in NULL sparse block; root-armed unpriv-triggered per-lookup panic on -C mounts; escalation rigorously closed - first-page READ only, page 0 user-mappable on this guest but all sinks bounded benign; fix-validated - post-mutation NULL guard, triggers degrade to ret -1, controls bit-identical]
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2237 Β· 32 filesDF-2237 β iconv_xlat16_conv NULL-deref after case-conversion index mutation
sys/libiconv/iconv_xlat16.c lines 155-193 β verified 2026-10-17 on the
QEMU/KVM DragonFly 6.5-DEVELOPMENT guest (X86_64_GENERIC, stock kernel).
What this pack proves
- Both cited mutation sites reach the unguarded line-192 load:
- single-byte path (lines 186-189,
ctmp = code >> 16viaXLAT16_HAS_FROM_LOWER_CASE): fault VA 0x108 =NULL + 0x42*4(panic.txt); - double-byte path (lines 155-158,towlower()via an armed_wctypepair): fault VA 0xc =NULL + 0x03*4(panic2.txt). Both aresupervisor read data, page not presentβ a READ, not a write. - The trigger tables are registered through the real
kern.iconv.addsysctl byte format (identical wire format tolib/libkiconvkiconv_add_xlat16_table), i.e. the exact path amount_msdosfs -Cstyle helper uses. - The conv call is driven through the real exported KPI
(
iconv_open+iconv_conv_case) β the same dispatch msdosfs uses atsys/vfs/msdosfs/msdosfs_conv.c:903,988. fix.diff(guard ondp->d_table[c1]after mutation) β controls stay bit-identical, both triggers degrade toret=-1(normal miss), no panic.
Reproduce
Guest with /usr/src present (see dfbsd-qemu/vm.sh reset with-src), then
from the host, in order:
sh p0_controls.sh # fresh boot, build, controls only (no panic)
# create snapshot df2237-built after p0 (see VERDICT.md "repro trail")
sh p1_trigger_single.sh # single-byte trigger -> Fatal trap 12, VA=0x108
sh p2_trigger_double.sh # double-byte trigger -> Fatal trap 12, VA=0xc
sh pf_fix.sh # apply fix.diff in guest /usr/src, rebuild
# libiconv.ko, re-run controls + triggers: no panic
Guest-side build only: sh /root/df2237/build.sh (userland armtbl /
mmap0, KLD harness modes 0/1/2). Note KCFLAGS+= is required β
sys/conf/kmod.mk:98 resets CFLAGS with a plain assignment.
Key artifacts
armtbl.cβ crafts the sparse xlat16 tables (serialized 0x200-entry caddr_t index + 0x80-entry uint32 blocks) and registers 3 charset pairs.df2237_mod.cβ KLD harness; mode 0 controls, mode 1/2 triggers.panic.txt/panic2.txtβ decisive Fatal trap 12 dumps (serial log).t1_boot.log/t2_boot.logβ full serial logs of the trigger boots.fix.diff,fix_build.log,fix_run.logβ validated fix.mmap0_user.logβ unprivileged user CAN map page 0 on this guest (relevant to the impact ceiling; see VERDICT.md).
Expected results
| run | stock kernel | patched module |
|---|---|---|
| controls (mode 0) | 41 42, 63, 12 34, ret=0 |
identical |
| trigger T1 (mode 1) | Fatal trap 12, fault VA 0x108 | SURVIVED ret=-1, no panic |
| trigger T2 (mode 2) | Fatal trap 12, fault VA 0xc | SURVIVED ret=-1, no panic |
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. EntryA(0x41) =0x0041 | XLAT16_HAS_FROM_LOWER_CASE | (0xC2 << 16). No_wctypepair for DF2237CS exists, sof_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) = 0x8356via the realiconv_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, localetolower()) 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_wctypepair entirely whenchklocale()mismatches β leavingf_ctp == NULLand 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 to0x0..0x1fc; base NULL, offset table-chosen but bounded to the first page. - On this guest, page 0 IS user-mappable: unprivileged
maxxsucceededmmap(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, andcode(line 192) becomes an attacker-chosen uint32 read from the attackers OWN page. - Every sink of
codeis benign: nullin flag,u/loutput bytes andcode>>16written (<= 3 bytes, honoringoraccounting) into the caller-provided conversion output buffer (msdosfs passes 2-3 byte stack buffers), and(uint16_t)codeintotowlower/towupperwhose 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 fromcodeto 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_MODEmust 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 -1matched 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.
Fix verification
fixedSingle-guard fix.diff applied to guest /usr/src, libiconv.ko rebuilt and hash-verified, disassembly shows test %rdi,%rdi je break before the previously unguarded load. Same armed tables and same harness: control conversions bit-identical (41 42, 63, 12 34), single-byte and double-byte triggers both return ret=-1 with 0 bytes consumed/produced (identical to a normal unconvertible char), zero Fatal traps in the patched boot.
fix_run.log (sha256 line + armed pairs + km0/km1/km2 all load, df2237 dmesg showing SURVIVED ret=-1 for both triggers), fix_build.log (patched module compile+link), fix_boot.log (whole boot, no Fatal trap), fix.diff
Confirmed kernel references
Detail
Evidence (decisive lines)
panic.txt and t1_boot.log:210 (Fatal trap 12, fault VA 0x108, supervisor read, stopped at iconv_xlat16_conv+0x259 movl (%rdi,%rdx,1),%edi); panic2.txt and t2_boot.log:208 (fault VA 0xc); fix_run.log / fix_dmesg.txt (controls 41 42 / 63 / 12 34 identical pre and post fix, triggers SURVIVED ret=-1); fix.diff (guard before line 192); mmap0_user.log (unpriv user can map page 0 - read hits attacker-owned page, all code sinks are bounded <= 3-byte conversion outputs, so no escalation)
PoC changes
authored from scratch (no seed pack): armtbl.c registers 3 crafted sparse xlat16 pairs via the real sysctl wire format; df2237_mod.c KLD harness drives the real exported iconv KPI in 3 compile-time modes; KCFLAGS required because sys/conf/kmod.mk:98 resets CFLAGS; first fix-validation attempt hit a stale /usr/obj module picked by find|head -1 plus a Hammer2 rollback after a DDB kill - redone with explicit paths, syncs and hash verification
Verified recommended fix
guard dp->d_table[c1] == NULL with ret=-1 break immediately before the line 192 load, mirroring the existing line 163 check (see fix.diff)
Verdict
Verified on stock DFly 6.5-DEVELOPMENT guest: iconv_xlat16_conv line 192 dereferences d_table[c1] without a NULL recheck after case conversion mutates c1/c2 at lines 155-158 (double-byte towlower via armed _wctype pair, fault VA 0xc) and lines 186-189 (single-byte XLAT16_HAS_FROM_LOWER_CASE flag path, fault VA 0x108). Both faults are supervisor READs of NULL plus a 0..0x1fc table-chosen offset. Tables were armed via the real kern.iconv.add sysctl byte format and the conv driven through the exported iconv KPI that msdosfs uses per filename char. Fix guard validated A/B: controls bit-identical, both triggers degrade to ret=-1, no panic.
No comments yet.