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

kern.iconv.add accepts unvalidated converter table sizes: ia_datalen=0 yields a deterministic NULL-deref panic in iconv_xlat16_open, and short tables yield attacker-directed kernel heap OOB reads disclosed to userspace through converted filenames

Summary

Only the upper bound of the table blob is validated: datalen=0 is accepted leaving cp_data==NULL (first iconv_open during mount derefs NULL - Fatal trap 12 VA 0x0, reproduced), and short tables are attached and trusted unconditionally by the xlat16/xlat converters - d_table[c1][c2] reads up to ~256KB past an attacker-sized M_ICONVDATA allocation with the bytes returned AS THE CONVERTED FILENAME (marker-proven 4/4: readdir returned '???????KMKMKM...' decoding markers stored in neighbouring kernel allocations). Root-gated registration (SYSCAP_NOSYSCTL_WR) + mount(2); the kernel cannot rely on the userland kiconv helper (direct mount callers pass arbitrary names). Read primitive only (writes bounded by outbytesleft). cp_datalen-recording + converter-side validation fix validated on the guest (both triggers EINVAL at mount, benign table byte-identical). Also out-of-file lead: iconv_ucs_conv reads a 2nd input byte before the ir<inlen check - 1-byte overread on odd-length UTF-16, error-branch-only, no in-tree odd-length caller.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5746 Β· 23 files
FileTypeDescriptionSize
README.md β€” 5.0 KB ↓ raw
VERDICT.md β€” 6.8 KB ↓ raw
common_kiconv.h β€” 2.1 KB view raw
trigger_null.c β€” 1.3 KB view raw
trigger_leak.c β€” 4.4 KB view raw
trigger_benign.c β€” 2.5 KB view raw
trigger_neg.c β€” 1.2 KB view raw
prep_img.sh β€” 449 B view raw
build.sh β€” 78 B view raw
run_leak.sh β€” 400 B view raw
run_null.sh β€” 374 B view raw
build.log β€” 51 B view raw
module_build.log β€” 9.5 KB view raw
run.log β€” 6.6 KB view raw
run.1.log β€” 2.0 KB view raw
run.patched.log β€” 3.0 KB view raw
panic.txt β€” 1.5 KB view raw
leak_sample.txt β€” 1.3 KB view raw
env.txt β€” 549 B view raw
fix.diff β€” 2.2 KB view raw
hashes.txt β€” 162 B view raw
verdict.json β€” 5.8 KB view raw
manifest.json β€” 1.6 KB view raw

DF-5746 β€” kern.iconv.add accepts unvalidated converter tables β†’ NULL-deref panic + kernel heap OOB read disclosed via converted filenames

File audited (pass 2): sys/libiconv/iconv.c Reserved ID: DF-5746 Guest: DragonFly 6.5-DEVELOPMENT x86_64 (see env.txt)

Root cause (one line)

iconv_sysctl_add() (sys/libiconv/iconv.c:410) validates only the upper bound of the caller-supplied table length (ia_datalen <= ICONV_CSMAXDATALEN), never that the blob is large enough (or non-NULL) for the layout the converter will later trust β€” iconv_xlat16_open() (sys/libiconv/iconv_xlat16.c:67-78) unconditionally parses a 4096-byte index array and points d_table[i] at cp_data + 4096 + k*512 for every nonzero index entry, and iconv_xlat_open() (sys/libiconv/iconv_xlat.c:61) uses cp_data as a flat 256-byte table.

Two manifestations

  1. ia_datalen == 0 (explicitly allowed): cp_data stays NULL (iconv.c:426 skips the kmalloc) β†’ first iconv_open() on the pair dereferences *idxp at VA 0 β†’ deterministic kernel panic (trigger_null.c + panic.txt).
  2. 0 < ia_datalen < required: the pair registers fine; the mount succeeds; every converted character then reads a uint32 at victim_alloc + 4096 + 512*rank(X) β€” up to ~256 KB past the end of the attacker-sized allocation β€” and those bytes are written into the output filename returned by readdir() β†’ kernel heap info leak (trigger_leak.c + run.log).

Both are reached through the documented surface: sysctl kern.iconv.add (writes require SYSCAP_NOSYSCTL_WR, i.e. root β€” sys/kern/kern_sysctl.c:1447) followed by mount(2) of msdosfs with MSDOSFSMNT_KICONV + cs_local/cs_dos naming the malicious pair (sys/vfs/msdosfs/msdosfs_vfsops.c:115-131). No setuid helper or userland kiconv library is involved β€” a direct mount(2) caller controls the charset names, so the kernel-side iconv core is the only validation point. (The userland helper merely happens to build well-formed tables; the kernel cannot rely on that.)

Files

file what
common_kiconv.h register-pair (kern.iconv.add) + mount("msdos", …, KICONV) helpers
trigger_null.c datalen=0 pair β†’ NULL-deref panic
trigger_leak.c index-only table + 16 heap-marker pairs β†’ OOB read disclosed in readdir output
trigger_benign.c full well-formed table (13312 bytes) β€” no-regression control
trigger_neg.c negative-length probe (killed: rejected by the unsigned compare at iconv.c:410)
prep_img.sh 64 MB FAT16 image containing LFN file ABCDEFGHIJKLMNOP.k
build.sh cc -O0 -g -I/usr/src/sys …
run_leak.sh / run_null.sh exact run commands
run.log, run.1.log leak runs (3 suffixes in run.log + first discovery run)
panic.txt serial console capture of the Fatal trap 12
env.txt guest uname / modules / sysctl state
fix.diff the validated fix (cp_datalen + converter-side validation)
verdict.json / manifest.json machine verdict + catalog

Build

sh build.sh     # cc -O0 -g -I/usr/src/sys -o trigger_null/leak/benign …

Run

kldload /boot/kernel/msdos_iconv.ko     # pulls libiconv.ko
sh prep_img.sh                          # FAT16 image with one LFN file
vnconfig -c vn0 /root/fat.img
./trigger_leak  R1 /dev/vn0 /mnt        # leak   (repeat R2, R3)
vnconfig -u vn0
vnconfig -c vn0 /root/fat.img
./trigger_null /dev/vn0 /mnt            # panic (guest dies; see panic.txt)

Expected output (leak, baseline kernel)

The file on disk is named ABCDEFGHIJKLMNOP.k; the directory listing comes back as e.g. (run R2):

--- dirent namlen=29: ???????KMKMKMKMKMKMKMKMKM\x02\x01KM
    hex: 3f 3f 3f 3f 3f 3f 3f 4b 4d 4b 4d ... 4b 4d

4b 4d ('K','M') is the little-endian decode of the marker strings "MK…" that were placed in neighboring 4096-byte kernel allocations (the 16 marker charset pairs registered right after the victim). The kernel read those bytes past the end of the victim's 4096-byte allocation through the unvalidated d_table pointers and returned them to userspace in the dirent. 3f ('?') marks slots whose OOB read happened to hit zero bytes (conversion error path). Across runs R1–R4 the pattern shifts with heap layout (R4 hit markers on every slot: 36/36 bytes) β€” proving live heap contents, not any table the victim itself owns.

Expected output (NULL-deref, baseline kernel)

Fatal user address access from kernel mode from trigger_null at …
Fatal trap 12: page fault while in kernel mode
fault virtual address = 0x0
supervisor read data, page not present
Stopped at iconv_xlat16_open+0x34: movq (%rsi,%rax,1),%rdx

On the patched kernel (fix.diff)

  • trigger_null β†’ mount: Invalid argument, guest stays up (no panic)
  • trigger_leak β†’ mount: Invalid argument, no directory listing (pair rejected at open)
  • trigger_benignβ†’ mounts and converts exactly as on the baseline kernel (B\x01B\x02…B\x10B), proving well-formed tables keep working
VERDICT.md
↓ download raw

VERDICT β€” DF-5746

Finding: kern.iconv.add accepts converter tables of unvalidated size; table-driven converters trust the layout β†’ NULL-deref panic (datalen=0) and attacker-directed kernel heap OOB read disclosed to userspace (datalen < layout).

Status: reproduced (impact: leak; deterministic panic variant also reproduced) β€” confidence: certain. Fix authored and validated on the guest (fix_status: fixed).

What was run (guest: DragonFly 6.5-DEVELOPMENT x86_64, env.txt)

All triggers talk to the kernel exactly the way the documented surface does: sysctl(kern.iconv.add) to register charset pairs (write requires SYSCAP_NOSYSCTL_WR β€” root, sys/kern/kern_sysctl.c:1447) and mount(2) of msdosfs with MSDOSFSMNT_KICONV|MSDOSMNT_LONGNAME + cs_local/cs_dos (sys/vfs/msdosfs/msdosfs_vfsops.c:115-131 update_mp() opens 4 pairs by name). The LFN file ABCDEFGHIJKLMNOP.k on a 64 MB FAT16 image drives the per-character lookups (win2unixchr() β†’ convchr(pm_w2u,…) at sys/vfs/msdosfs/msdosfs_conv.c:946).

Manifestation 1 β€” deterministic kernel panic (datalen = 0)

trigger_null.c: pair xlat16:PWNX2:UTF-16BE with ia_datalen=0 (iconv.c:410 accepts it β€” only > CSMAXDATALEN is checked; iconv.c:426 if (din.ia_datalen) skips the kmalloc so cp_data == NULL). Mounting dereferences *idxp with idxp == NULL (iconv_xlat16.c:71). Serial console (panic.txt):

local: PWNX2 dos: PWND2
Fatal trap 12: page fault while in kernel mode
fault virtual address = 0x0
supervisor read data, page not present
Stopped at iconv_xlat16_open+0x34: movq (%rsi,%rax,1),%rdx

Guest went down (vm status: down). Reproduced 1/1.

Manifestation 2 β€” kernel heap OOB read disclosed via readdir (datalen=4096, index-only)

trigger_leak.c: victim pair PWNLxx:UTF-16BE with ia_datalen = 4096 (the index array only β€” no data blocks), 18 index slots marked (for the filename's characters). iconv_xlat16_open() computes d_table[X] = cp_data + 4096 + 512*rank(X) for each marked slot β€” all of them past the end of the 4096-byte M_ICONVDATA allocation. 16 "marker" pairs are registered immediately afterwards so the neighbouring heap objects contain a known pattern ("MKxx!NN" + 0xCA 0xFE, 4096 bytes each).

Every readdir then makes the kernel read code = d_table[c1][c2] (iconv_xlat16.c:192) out of bounds and copy (code>>8, code&0xff) into the output filename (iconv_xlat16.c:243-244).

Observed across 4 runs (baseline kernel; run.log, run.1.log):

run returned filename (hex)
R1 3fΓ—7 4b4d Γ—13 0201 4b4d
R2 3fΓ—7 4b4d Γ—13 0201 4b4d
R3 4b4dΓ—7 0201 3fΓ—7 0201 4b4d 3f
R4 4b4d Γ—18 (every slot hit markers β€” 36/36 bytes)

4b 4d = 'K','M' β€” the little-endian decode of the marker strings stored in neighbouring kernel allocations, i.e. bytes the victim allocation does not contain. 3f ('?') marks slots whose OOB read hit zero (error path). The layout-dependence across runs is itself evidence of live heap contents.

Primitive characterisation: attacker chooses the allocation size (any ia_datalen ≀ 266240), the number of "blocks" (0..512, stride 512 B), and the input characters (per-char slot selection) β€” a mouldable ~256 KB read window past a groomable heap object, with contents returned byte-for-byte to unprivileged readers of the directory. Read primitive only: no OOB write exists (all converter writes are bounded by outbytesleft), so no uid0 chain; both triggers are root-gated at the sysctl/mount boundary.

Killed facets (checked, not bugs)

  • negative ia_datalen β†’ kmalloc((size_t)-N) panic hypothesis: dead β€” iconv.c:410 compares int against the size_t expression ICONV_CSMAXDATALEN, so negatives promote to huge unsigned and are rejected (empirically EINVAL, trigger_neg.c).
  • cslist sysctl beyond DF-2234's strcpy: fixed-size SYSCTL_OUT records; csi fully bzero()ed incl. padding (iconv.c:360); counts/id reads are under LK_SHARED. cp_base (iconv.c:367) is never assigned anywhere in the tree β€” dead read, always 0.
  • iconv_sysctl_add field strlens: SYSCTL_IN copies the whole struct; non-terminated 31-byte names terminate inside the struct (ia_datalen / pointer bytes guarantee a NUL) β€” no stack overread.
  • iconv_xlat16_conv index math: C2I1/C2I2 bound c1 ≀ 0x1ff, c2 ≀ 0x7f on every path (checked the towlower/towupper remaps too) β€” in-bounds iff the table is well-formed (hence this finding).
  • iconv_convstr/iconv_convmem: all converter writes bounded by outbytesleft; the *p=0 at iconv.c:521 lands within dst+strlen(src).
  • (out-of-file observation for a future audit: iconv_ucs_conv sys/libiconv/iconv_ucs.c:247-254 reads a 2nd byte before the ir < inlen check at :267 β€” 1-byte overread on odd-length UTF-16 input; value only steers an error branch; no in-tree caller found passing odd lengths. Not filed against iconv.c.)
  • DF-2233 (lockless list/unload race) and DF-2234 (cslist strcpy) β€” known, not re-reported.

Fix validation (fix.diff: cp_datalen + converter-side validation)

Applied to the guest's /usr/src, rebuilt libiconv.ko only (the struct is module-internal β€” no other kernel/module consumer), installed, rebooted. fix_build.log/module_build.log (gcc 8.3, -Werror, clean).

trigger baseline kernel patched kernel
trigger_null (datalen=0) Fatal trap 12, VA 0x0, guest down mount: Invalid argument, guest up
trigger_leak (index-only table) marker bytes leaked in filenames Γ—4 runs mount: Invalid argument, no listing, no leak
trigger_benign (full 13312 B table) mounts; names B\x01B\x02…B\x10B mounts; identical B\x01B\x02…B\x10B

Baseline bad behaviors are gone; legitimate well-formed tables keep working. fix_baseline_reproduced=1, fix_patched_reproduced=0, fix_status=fixed.

Bottom line

One missing kernel-side size check (iconv.c:410) turns a root-reachable sysctl+mount pair into (a) a one-syscall-adjacent deterministic kernel panic and (b) a mouldable kernel-heap info leak returned through ordinary readdir() β€” because the xlat16/xlat converters trust a serialized layout the kernel never validates. Severity: High for a vfs.usermount=1 / unprivileged-mount threat model, Medium on default config (root-gated); the guest's vfs.usermount=0. The 3-line-per-file fix eliminates both manifestations with no behavioural change for well-formed tables.

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

fix.diff applied to the guest /usr/src, libiconv.ko rebuilt (module-only; the cspair struct is internal to libiconv.ko) and installed; rebooted. Baseline: trigger_null panicked the guest (Fatal trap 12 VA 0x0 in iconv_xlat16_open) and trigger_leak leaked marker heap bytes in readdir filenames (4 runs). Patched: trigger_null -> mount fails EINVAL, guest stays up; trigger_leak -> mount fails EINVAL, no directory output, no leak; trigger_benign (full well-formed 13312-byte table) mounts and converts byte-identically to baseline (B\x01B\x02...B\x10B). No regression; both bad behaviors gone.

run.patched.log (all three triggers on the patched module, guest uptime shown); module_build.log (clean -Werror build); VERDICT.md fix-validation table.
↓ fix.diffper-fix-DF-5746

Confirmed kernel references

Detail

Exploit chain

root: sysctl(kern.iconv.add) registers pair to=PWNLxx from=UTF-16BE converter=xlat16 with ia_datalen=4096 (index-only) and 16 marker pairs for heap grooming -> mount(2)('msdos', MSDOSFSMNT_KICONV|LONGNAME, cs_local=PWNLxx) -> update_mp opens the victim pair for pm_w2u -> iconv_xlat16_open maps d_table[X]=cp_data+4096+512*rank(X) past the 4096-byte allocation -> readdir of LFN 'ABCDEFGHIJKLMNOP.k' -> win2unixchr -> iconv_xlat16_conv reads code=d_table[c1][c2] OOB -> (code>>8,code&0xff) written into the dirent name -> marker/neighbour heap bytes disclosed to any user able to list the directory. datalen=0 variant panics in the same open path (VA 0 read).

Evidence (decisive lines)

panic.txt (Fatal trap 12, VA 0x0, Stopped at iconv_xlat16_open+0x34); run.log + run.1.log (4 leak runs, marker bytes in filenames); leak_sample.txt (raw bytes per run); run.patched.log (both malicious triggers -> EINVAL, benign table -> identical output as baseline); module_build.log (patched libiconv.ko build, clean -Werror); VERDICT.md for the full narrative incl. killed facets.

PoC changes

rewrote the seed sketch into standalone triggers: struct msdosfs_args pulled via -I/usr/src/sys like mount_msdos; DragonFly specifics fixed (VFS name is 'msdos' not 'msdosfs', unmount(2) not umount, struct dirent via with _DIRENT_DIRSIZ); 64MB FAT16 (8MB too small for FAT16); heap-marker grooming pairs added to make the OOB read rigorous; added trigger_benign (full 13312-byte table) as the no-regression control and trigger_neg (negative datalen, killed: rejected by the unsigned compare at iconv.c:410).

Verified recommended fix

Record ia_datalen in struct iconv_cspair (cp_datalen) at iconv_sysctl_add and have iconv_xlat16_open/iconv_xlat_open reject pairs whose data is NULL or smaller than the layout they parse (index array + one 512-byte block per nonzero index entry; 256 bytes for xlat).

Verdict

iconv_sysctl_add (sys/libiconv/iconv.c:410) validates only the upper bound of ia_datalen, so kern.iconv.add accepts a table that is empty (datalen=0 -> cp_data==NULL -> deterministic NULL-deref panic 'Fatal trap 12, fault VA 0x0, iconv_xlat16_open+0x34' on the first iconv_open during msdosfs KICONV mount) or index-only (datalen=4096 -> iconv_xlat16_open builds d_table pointers up to ~256KB past the attacker-sized M_ICONVDATA allocation; every readdir then reads uint32s OOB and returns them as the converted filename). The leak was reproduced 4/4 runs on the baseline guest with heap-marker proof: filenames came back as '???????KMKMKMK...' where 4b4d is the little-endian decode of marker strings stored in neighbouring kernel allocations; run R4 hit markers on all 36/36 leaked bytes. Read primitive only (all converter writes stay bounded by outbytesleft) and both triggers are root-gated (sysctl write needs SYSCAP_NOSYSCTL_WR, mount(2) needs root with vfs.usermount=0), so no unpriv->uid0 chain exists for this bug; the ceiling is kernel heap disclosure plus a root-triggerable instant panic. fix.diff (store cp_datalen in the cspair; reject short/empty tables in iconv_xlat16_open/iconv_xlat_open) was rebuilt as libiconv.ko on the guest and validated: panic gone, leak gone (both now fail the mount with EINVAL), while a full well-formed 13312-byte table still mounts and converts byte-identically to baseline.