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)
PoC verification
Evidence pack
findings/poc/DF-5746 Β· 23 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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
ia_datalen == 0(explicitly allowed):cp_datastays NULL (iconv.c:426skips the kmalloc) β firsticonv_open()on the pair dereferences*idxpat VA 0 β deterministic kernel panic (trigger_null.c+panic.txt).0 < ia_datalen < required: the pair registers fine; the mount succeeds; every converted character then reads auint32atvictim_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 byreaddir()β 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 β 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:410comparesintagainst thesize_texpressionICONV_CSMAXDATALEN, so negatives promote to huge unsigned and are rejected (empiricallyEINVAL,trigger_neg.c). - cslist sysctl beyond DF-2234's strcpy: fixed-size
SYSCTL_OUTrecords;csifullybzero()ed incl. padding (iconv.c:360); counts/id reads are underLK_SHARED.cp_base(iconv.c:367) is never assigned anywhere in the tree β dead read, always 0. iconv_sysctl_addfield strlens:SYSCTL_INcopies the whole struct; non-terminated 31-byte names terminate inside the struct (ia_datalen/ pointer bytes guarantee a NUL) β no stack overread.iconv_xlat16_convindex math:C2I1/C2I2boundc1 β€ 0x1ff,c2 β€ 0x7fon 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 byoutbytesleft; the*p=0aticonv.c:521lands withindst+strlen(src).- (out-of-file observation for a future audit:
iconv_ucs_convsys/libiconv/iconv_ucs.c:247-254reads a 2nd byte before their < inlencheck 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
fixedfix.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.
Confirmed kernel references
- sys/libiconv/iconv.c:410
- sys/libiconv/iconv.c:421
- sys/libiconv/iconv.c:426
- sys/libiconv/iconv_xlat16.c:67
- sys/libiconv/iconv_xlat16.c:71
- sys/libiconv/iconv_xlat16.c:192
- sys/libiconv/iconv_xlat16.c:243
- sys/libiconv/iconv_xlat.c:61
- sys/libiconv/iconv_xlat.c:99
- sys/vfs/msdosfs/msdosfs_vfsops.c:115
- sys/vfs/msdosfs/msdosfs_conv.c:946
- sys/kern/kern_sysctl.c:1447
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
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.
No comments yet.