bcd.c lookup tables indexed without bounds: bcd2bin()/bin2bcd()/hex2ascii() macros permit out-of-bounds kernel .rodata reads with device/ioctl-supplied indices
Summary
Domain-sized tables paired with unbounded indexing macros and zero validation at every consumer: scsi_cd passes user u_char ioctl fields straight through (CDIOCPLAYTRACKS start/end track, CDIOCREADSUBCHANNEL track - on a compliant drive the OOB byte is copied back = a one-byte kernel-.rodata oracle at user-chosen offsets 100..255) and drive-returned TOC bytes; clock.c passes raw CMOS. Guard-page rig with the verbatim tables/macros: bin2bcd(100..255) and bcd2bin(0x9a/0xff) all SIGSEGV. In-kernel: a CHINON-CDS-535-masquerading virtio-scsi CD makes the kernel itself set CD_Q_BCD_TRACKS at probe and the trigger path is live to the emulator boundary (QEMU 7.2 lacks opcode 0x42 - documented hard blocker). On DFly the user paths sit behind the quirk + the cdopen SYSCAP_RESTRICTEDROOT gate (unpriv open of 0666 /dev/cd0 -> EPERM, verified) - Low: one-byte .rodata oracle behind hardware/privilege gates. wrap-the-index fix validated exhaustively (identity on the whole valid domain). strlen.c/strnlen.c CLEAN (live, libc-identical, exact-bounds guard rigs).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5719 Β· 18 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 4.4 KB | β raw | |
| VERDICT.md | β | 6.8 KB | β raw | |
| bcd_rig.c | β | 8.7 KB | view raw | |
| bcd_trigger.c | β | 4.1 KB | view raw | |
| subq_test.c | β | 1.3 KB | view raw | |
| passrep.c | β | 2.9 KB | view raw | |
| dbg.c | β | 976 B | view raw | |
| str_harness.c | β | 5.0 KB | view raw | |
| build.sh | β | 66 B | view raw | |
| run.sh | β | 90 B | view raw | |
| run.log | β | 2.0 KB | view raw | |
| trigger_as_root.log | β | 4.9 KB | view raw | |
| trigger_as_user.log | β | 53 B | view raw | |
| dmesg.txt | β | 8.6 KB | view raw | |
| devlist.txt | β | 76 B | view raw | |
| env.txt | β | 371 B | view raw | |
| fix.diff | β | 1.3 KB | view raw | |
| verdict.json | β | 6.6 KB | view raw |
DF-5719 β libkern BCD lookup tables indexed without bounds (bcd2bin/bin2bcd/hex2ascii macros)
File: sys/libkern/bcd.c (+ macros in sys/sys/libkern.h:59-61)
Severity: Low Β· Class: CWE-125 out-of-bounds read Β· Status: primitive reproduced (guard-page rig, verbatim kernel algorithm); in-kernel completion gated (see VERDICT.md)
What the bug is
sys/libkern/bcd.c exports three .rodata lookup tables sized exactly
for their valid domains:
| table | entries | valid index domain |
|---|---|---|
bcd2bin_data (bcd.c:9-20) |
154 | BCD bytes 0x00..0x99 |
bin2bcd_data (bcd.c:22-33) |
100 | 0..99 |
hex2ascii_data (bcd.c:36) |
37 | 0..35 + NUL |
The macros in sys/sys/libkern.h:59-61 index them raw:
#define bcd2bin(bcd) (bcd2bin_data[bcd])
#define bin2bcd(bin) (bin2bcd_data[bin])
#define hex2ascii(hex) (hex2ascii_data[hex])
Consumers feed these macros unvalidated device- and ioctl-supplied bytes:
sys/bus/cam/scsi/scsi_cd.c:2044-2047βbin2bcd(args->start_track)/bin2bcd(args->end_track)whereargsis the user'sCDIOCPLAYTRACKSioctl struct (struct ioc_play_track, allu_char,sys/sys/cdio.h:112-118), gated only by theCD_Q_BCD_TRACKSdrive quirk.scsi_cd.c:2160-2161βargs->track = bin2bcd(args->track)onCDIOCREADSUBCHANNEL(_IOWR: the struct is copied back, so on a compliant drive the OOB byte would be disclosed to the caller).scsi_cd.c:2208/2262/2320/2338/2386/2414/2942/2964/2989βbcd2bin()over drive-returned TOC/subchannel bytes.sys/platform/pc64/isa/clock.c:558βbcd2bin(rtcin(port))over raw CMOS RTC bytes (corrupt CMOS β OOB read at boot; wrong time only).
Any index β₯ 100 (bin2bcd) / β₯ 0x9a (bcd2bin) reads past the end of
the 100/154-byte .rodata object: an out-of-bounds kernel read with a
fully attacker-influenced index (on the quirk-gated paths), value used as
a SCSI CDB field and, on CDIOCREADSUBCHANNEL, copied back as a one-byte
kernel-.rodata oracle.
Reachability on DragonFly (why Low, not higher)
- The BCD conversions only run when the drive has the
CD_Q_BCD_TRACKSquirk β set at probe only for the 1980s CHINON CDS-535 (scsi_cd.c:222-225). Root can force it viakern.cam.cd.N.quirks. - DragonFly gates all cd(4) opens on
caps_priv_check_self( SYSCAP_RESTRICTEDROOT)(scsi_cd.c:1006-1007) β an unprivileged user getsEPERMfromopen()even with mode 0666 (verified on the guest). (On FreeBSD the same code is reachable by operator-group users; DragonFly's caps check closes that door.) - Remaining real-world triggers: legacy CHINON hardware, hostile/weird
drive firmware, corrupt CMOS at boot, or a privileged caller. No
write primitive, no crash (read stays inside
.rodata), no privilege boundary crossed. Hence Low (matches "requires specific hardware or already-privileged user").
Evidence in this pack
bcd_rig.cβ verbatim tables frombcd.c+ verbatim macros fromlibkern.h:MODE=1prints the real object sizes (100/154/37).MODE=2places each object ending exactly at aPROT_NONEguard page and probes with the ioctl byte values (bin2bcd(100/200/255),bcd2bin(0x9a/0xff)) β SIGSEGV on every OOB probe, OK on the last in-bounds probe β formal proof the shipped macros index past the object.MODE=3exhaustive semantics differential (table β‘ BCD arithmetic over the whole valid domain; fix-wraps are the identity there).MODE=4fix.diffmacros under the same guard placement β always in-bounds.bcd_trigger.cβ in-kernelCDIOCREADSUBCHANNELsweep, track 0..255.subq_test.c,passrep.cβ the investigation tools that established the QEMU limitation (see VERDICT.md).run.logβ full on-guest suite output;trigger_as_{root,user}.log,dmesg.txt,devlist.txt,env.txtβ guest state.fix.diffβ bounds-wrapping macros (git apply -p1from repo root).
Reproduce
On the DragonFly guest (with the CHINON drive configured via
dfbsd-qemu/up-chinon.sh):
sh build.sh
sh run.sh # rig results + differentials + trigger sweep
Expected decisive lines (run.log):
bin2bcd(200) (ioctl byte) : SIGSEGV bcd2bin(0xff) (garbage CMOS) : SIGSEGV bcd2bin vs arithmetic + fix-mask identity over valid BCD 0x00..0x99: MATCH bin2bcd_fixed(255) : in-bounds (value=85)
DF-5719 β Verification narrative (pass 2, GLM 5.3)
Files audited: sys/libkern/strlen.c, sys/libkern/strnlen.c,
sys/libkern/bcd.c (batch, IDs DF-5719..DF-5731 reserved; sole finding
of the batch is DF-5719 against bcd.c + the libkern.h macros).
What was verified, and how
1. The OOB primitive β reproduced (on-guest, verbatim algorithm)
bcd_rig.c embeds byte-for-byte the three tables from
sys/libkern/bcd.c:9-36 and the macro text from
sys/sys/libkern.h:59-61. MODE=2 copies each table into a mapping so
the object ends exactly at a PROT_NONE guard page, then evaluates
the verbatim macros with exactly the byte values the consumers pass:
bin2bcd(99)(last in-bounds) β OKbin2bcd(100)(first OOB),bin2bcd(200)(the CDIOCPLAYTRACKS / CDIOCREADSUBCHANNELu_char),bin2bcd(255)(maxu_char) β SIGSEGVbcd2bin(0x99)β OK;bcd2bin(0x9a),bcd2bin(0xff)(garbage CMOS byte, clock.c:558) β SIGSEGV
Decisive lines in run.log: bin2bcd(200) (ioctl byte) : SIGSEGV,
bcd2bin(0xff) (garbage CMOS) : SIGSEGV. This proves the shipped
macros leave their objects for every out-of-domain index β the
CWE-125 primitive. (In the linked kernel the tables sit mid-.rodata,
so the read does not fault there; it returns adjacent .rodata bytes β
that is why the kernel never crashes, which is also why impact is Low.)
2. Semantics and the fix β reproduced (differential)
MODE=3 proves exhaustively (rig3 in run.log):
bin2bcdtable β‘(i/10)<<4 | i%10for all 0..99, and the fix wrap% 100is the identity on that domain (0 errors);bcd2bintable β‘ nibble arithmetic for every valid BCD byte 0x00..0x99, and the fix wrap% 154is the identity there (0 errors);hex2asciiover its radix-36 domain (0 errors).
Note: my first fix candidate masked bcd2bin with & 0x99 β the
differential caught it (0x06 & 0x99 == 0x00): a bit-mask is not a
nibble clamp. The corrected wraps (% 154, % 100) are identity on
the valid domain and in-bounds for every input; MODE=4 re-runs the
guard-page probes with the fixed macros β always in-bounds.
3. In-kernel execution path β live, traced, and demonstrated up to the drive
- The guest was booted (host helper
dfbsd-qemu/up-chinon.sh) with a virtio-scsi CD whose INQUIRY isCHINON CD-ROM CDS-535β the exact pattern in the quirk table (scsi_cd.c:222-225).devlist.txtshows<CHINON CD-ROM CDS-535 2.5+>at scbus2 β the kernel assignedCD_Q_BCD_TRACKSat probe (scsi_cd.c:701-707, unconditional on the inquiry match). No kernel patching: this is faithful simulation of the real 1980s hardware. bcd_trigger.c(root) sweepsCDIOCREADSUBCHANNELtrack=0..255. Every call returns EIO. Trace: the only EIO source in this branch iscdreadsubchannel()(scsi_cd.c:2163β3363cdrunccb), which the call reached β i.e. it passedargs->track = bin2bcd(args->track)(scsi_cd.c:2160-2161) first. The EIO itself comes from the emulated drive: QEMU 7.2 implements no READ SUB-CHANNEL (0x42) at all (verified against the v7.2.0 sources ofhw/scsi/scsi-disk.candhw/scsi/scsi-bus.c; also confirmed withpassrep.c, which replays the kernel's exact CCB via CAMIOCOMMAND and getsCAM_DATA_RUN_ERRwith all-zero sense for every 0x42 variant, andcamcontrol cmdprobes). So the one-byte copyback disclosure cannot complete on this emulator; on a real drive that answers 0x42 (as the CHINON does), the_IOWRcopyback returns the OOB byte.- The unprivileged angle is closed by DragonFly itself:
cdopen()refuses non-SYSCAP_RESTRICTEDROOTopeners (scsi_cd.c:1006-1007);trigger_as_user.logshowsopen(/dev/cd0): Operation not permittedfor uid 1001 even with the node chmod'd 0666. (FreeBSD, which lacks this gate, exposes the same code to operator-group users.)
4. Fix validation
fix.diff(git-apply-able,git apply --check -p1passes) wraps the three macro indices into their objects; identity on the valid domain proven by rig3, in-bounds under guard placement proven by rig4.- A patched-kernel build/reboot validation is not possible on this
guest: baseline and patched kernels behave identically (EIO from
the emulator's missing opcode), so there is no in-kernel observable
to A/B. Recorded as
not_testableat the kernel level, validated at the algorithm level β same treatment as DF-5718 for an emulator/hardware-gated finding.
Honest bottom line
- Primitive (OOB read past
.rodatatables, user/device-influenced index): certain, reproduced with the verbatim kernel algorithm under guard pages on the DragonFly guest. - In-kernel path: live (quirk matched, ioctl reaches the conversion; the byte's return trip needs a 0x42-capable drive, which QEMU 7.2 lacks).
- Security impact: Low β all user-reachable conversions are behind
both the legacy-hardware quirk and DragonFly's
SYSCAP_RESTRICTEDROOTopen gate; no crash (mid-.rodata), no write primitive, no unprivβpriv boundary. The bug is a latent kernel OOB read worth fixing defensively (and it is genuinely reachable by hostile drive firmware, corrupt CMOS, or β on FreeBSD-derived stacks without the open gate β operator-group users).
Negative results for the other two files in the batch
sys/libkern/strlen.cβ clean. Compiled (sys/conf/files:2226), no inline/asm shadow (sys/sys/libkern.h:134is a plain prototype; nostatic __inline, no.Sdefinition anywhere insys/). Byte loop at strlen.c:40-41 matches libc semantics exactly (34-case randomized differential,shlin run.log: IDENTICAL); reads exactly through the NUL (guard-page probe with NUL as the final byte: no fault). The unbounded read is the C contract; over-read risk lives in callers, not here.sys/libkern/strnlen.cβ clean. Bounded exactly: readss[0..maxlen-1], nevers[maxlen](guard rig:strnlen(buf,300)on a 300-byte non-NUL buffer ending at a guard page β 300, no fault;strnlen(buf,301)β SIGSEGV, i.e. the bound is exactly maxlen;strnlen(buf,0)β 0, no deref). 268-case differential vs libc including boundary maxlens: IDENTICAL.
Artifacts
run.log (decisive suite), trigger_as_root.log / trigger_as_user.log
(in-kernel sweep + open gate), dmesg.txt, devlist.txt (CHINON probe),
env.txt, build.sh, run.sh, fix.diff, sources
(bcd_rig.c, bcd_trigger.c, subq_test.c, passrep.c,
str_harness.c, dbg.c), host-side up-chinon.sh in dfbsd-qemu/.
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
not_testablefix.diff authored (git apply --check -p1 passes) and validated at the algorithm level: rig3 proves the wraps are the identity over the entire valid domain (bin 0..99 and BCD 0x00..0x99, 0 errors each) and rig4 proves the fixed macros never leave the objects under the same guard-page placement that SIGSEGVs the verbatim macros. A patched-kernel build/reboot A/B is not testable on this guest: baseline and patched kernels behave identically (both EIO from QEMU 7.2's missing READ SUB-CHANNEL opcode), leaving no in-kernel observable; recorded as not_testable at the kernel level per the DF-5718 precedent for emulator/hardware-gated findings.
findings/poc/DF-5719/run.log: 'bcd2bin vs arithmetic + fix-mask identity over valid BCD 0x00..0x99: MATCH (0 errors)', 'bin2bcd_fixed(255) : in-bounds (value=85)', 'bcd2bin_fixed(0xff): in-bounds'; findings/poc/DF-5719/fix.diff
Confirmed kernel references
- sys/libkern/bcd.c:9
- sys/libkern/bcd.c:22
- sys/libkern/bcd.c:36
- sys/sys/libkern.h:59
- sys/sys/libkern.h:60
- sys/sys/libkern.h:61
- sys/bus/cam/scsi/scsi_cd.c:2044
- sys/bus/cam/scsi/scsi_cd.c:2160
- sys/bus/cam/scsi/scsi_cd.c:2208
- sys/bus/cam/scsi/scsi_cd.c:2942
- sys/bus/cam/scsi/scsi_cd.c:2989
- sys/bus/cam/scsi/scsi_cd.c:222
- sys/bus/cam/scsi/scsi_cd.c:701
- sys/bus/cam/scsi/scsi_cd.c:1006
- sys/platform/pc64/isa/clock.c:558
- sys/sys/cdio.h:112
Detail
Evidence (decisive lines)
findings/poc/DF-5719/run.log lines 'bin2bcd(200) (ioctl byte) : SIGSEGV', 'bcd2bin(0xff) (garbage CMOS) : SIGSEGV', 'bcd2bin vs arithmetic + fix-mask identity over valid BCD 0x00..0x99: MATCH (0 errors)', 'bin2bcd_fixed(255) : in-bounds'; trigger_as_user.log 'open(/dev/cd0): Operation not permitted'; devlist.txt '<CHINON CD-ROM CDS-535 2.5+>'; VERDICT.md for the full narrative incl. the QEMU-7.2-lacks-0x42 investigation (passrep.c/subq_test.c)
PoC changes
No seed existed; authored from scratch. Iterations: (1) missing
Verified recommended fix
Wrap the macro indices into the objects in sys/sys/libkern.h: bcd2bin(bcd) -> bcd2bin_data[(u_int)(bcd) % 154], bin2bcd(bin) -> bin2bcd_data[(u_char)(bin) % 100], hex2ascii(hex) -> hex2ascii_data[(u_char)(hex) % 37]; identity on the valid domain, single evaluation (fix.diff).
Verdict
Pass-2 audit of sys/libkern/{strlen,strnlen,bcd}.c; sole finding: the bcd.c lookup tables are sized exactly for their valid domains (bcd2bin_data[154] == BCD 0x00..0x99, bin2bcd_data[100] == 0..99, hex2ascii_data[37]) but the macros in sys/sys/libkern.h:59-61 index them with no bounds, and consumers pass unvalidated u_char ioctl fields and raw device bytes (scsi_cd.c:2044-2047 CDIOCPLAYTRACKS, scsi_cd.c:2160-2161 CDIOCREADSUBCHANNEL with _IOWR copyback, drive-returned TOC bytes at 2208/2262/2320/2338/2386/2414/2942/2964/2989, and clock.c:558 bcd2bin(rtcin())). Primitive REPRODUCED on the DragonFly guest with the verbatim kernel algorithm: guard-page placement of the verbatim tables + verbatim macros SIGSEGVs for every out-of-domain index (bin2bcd(100/200/255), bcd2bin(0x9a/0xff)) while the last in-bounds probes are clean (run.log rig2). In-kernel path live: the guest ran a virtio-scsi CD with INQUIRY 'CHINON CD-ROM CDS-535' (faithful simulation of the hardware that sets CD_Q_BCD_TRACKS at probe, scsi_cd.c:222-225/701-707; devlist.txt) and the CDIOCREADSUBCHANNEL sweep reached cdreadsubchannel (EIO sourced from the drive call at scsi_cd.c:2163, which is strictly after the bin2bcd conversion at 2160-2161); the one-byte copyback cannot complete on this emulator because QEMU 7.2 implements no READ SUB-CHANNEL (0x42) opcode at all - established with passrep.c exact-CBB replication (CAM_DATA_RUN_ERR, zero sense), camcontrol probes, and the QEMU v7.2.0 sources. Reachability ceiling: Low - CD_Q_BCD_TRACKS is set only for the 1980s CHINON CDS-535 or by root via sysctl, and DragonFly's cdopen() gates ALL cd(4) opens on caps_priv_check_self(SYSCAP_RESTRICTEDROOT) (scsi_cd.c:1006-1007; verified: unprivileged open() of a chmod-0666 /dev/cd0 returns EPERM, trigger_as_user.log), so no unprivileged path exists on DragonFly (FreeBSD-derived stacks without that gate expose operator-group users). No crash possible in the linked kernel (tables sit mid-.rodata), no write primitive. Fix (fix.diff, git apply -p1) wraps the macro indices into the objects; differential proves identity on the whole valid domain and the fixed macros stay in-bounds under guard placement (rig3/rig4). My initial fix candidate '& 0x99' was WRONG (bit-mask, not nibble clamp) and the differential caught it - kept as a documented lesson.
No comments yet.