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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2237 Β· 32 files
FileTypeDescriptionSize
armtbl.c β€” 4.1 KB view raw
df2237_mod.c β€” 4.0 KB view raw
mmap0.c β€” 828 B view raw
build.sh β€” 130 B view raw
p0_controls.sh β€” 1.3 KB view raw
p1_trigger_single.sh β€” 777 B view raw
p2_trigger_double.sh β€” 777 B view raw
pf_fix.sh β€” 2.0 KB view raw
build.log β€” 2.9 KB view raw
run.log β€” 174 B view raw
dmesg.txt β€” 261 B view raw
run_t1.log β€” 250 B view raw
t1_boot.log β€” 21.0 KB view raw
panic.txt β€” 868 B view raw
run_t2.log β€” 250 B view raw
t2_boot.log β€” 14.0 KB view raw
panic2.txt β€” 866 B view raw
fix.diff β€” 509 B view raw
fixbuild.log β€” 1.5 KB view raw
fix_build.log β€” 1.5 KB view raw
fix_controls.log β€” 464 B view raw
fix_dmesg.txt β€” 1.1 KB view raw
fix_run.log β€” 1.7 KB view raw
fix_boot.log β€” 14.0 KB view raw
fix_t1.log β€” 0 B ↓ download
env.txt β€” 795 B view raw
mmap0_user.log β€” 65 B view raw
mmap0_root.log β€” 70 B view raw
README.md β€” 3.0 KB ↓ raw
VERDICT.md β€” 8.3 KB ↓ raw
manifest.json β€” 1.6 KB view raw
verdict.json β€” 4.3 KB view raw

DF-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

  1. Both cited mutation sites reach the unguarded line-192 load: - single-byte path (lines 186-189, ctmp = code >> 16 via XLAT16_HAS_FROM_LOWER_CASE): fault VA 0x108 = NULL + 0x42*4 (panic.txt); - double-byte path (lines 155-158, towlower() via an armed _wctype pair): fault VA 0xc = NULL + 0x03*4 (panic2.txt). Both are supervisor read data, page not present β€” a READ, not a write.
  2. The trigger tables are registered through the real kern.iconv.add sysctl byte format (identical wire format to lib/libkiconv kiconv_add_xlat16_table), i.e. the exact path a mount_msdosfs -C style helper uses.
  3. The conv call is driven through the real exported KPI (iconv_open + iconv_conv_case) β€” the same dispatch msdosfs uses at sys/vfs/msdosfs/msdosfs_conv.c:903,988.
  4. fix.diff (guard on dp->d_table[c1] after mutation) β€” controls stay bit-identical, both triggers degrade to ret=-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
VERDICT.md
↓ download raw

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. Entry A (0x41) = 0x0041 | XLAT16_HAS_FROM_LOWER_CASE | (0xC2 << 16). No _wctype pair for DF2237CS exists, so f_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) = 0x8356 via the real iconv_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, locale tolower()) 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 _wctype pair entirely when chklocale() mismatches β€” leaving f_ctp == NULL and 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 to 0x0..0x1fc; base NULL, offset table-chosen but bounded to the first page.
  • On this guest, page 0 IS user-mappable: unprivileged maxx succeeded mmap(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, and code (line 192) becomes an attacker-chosen uint32 read from the attackers OWN page.
  • Every sink of code is benign: nullin flag, u/l output bytes and code>>16 written (<= 3 bytes, honoring or accounting) into the caller-provided conversion output buffer (msdosfs passes 2-3 byte stack buffers), and (uint16_t)code into towlower/towupper whose 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 from code to 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_MODE must 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 -1 matched 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

fixed
baseline reproduced→ patch + rebuild →patched clean

Single-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
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 + patched libiconv.ko sha256 30bfdffe7430d3524e832faaa0cdc13622194b0e20022373658982a65f119642

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.