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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5719 Β· 18 files
FileTypeDescriptionSize
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) where args is the user's CDIOCPLAYTRACKS ioctl struct (struct ioc_play_track, all u_char, sys/sys/cdio.h:112-118), gated only by the CD_Q_BCD_TRACKS drive quirk.
  • scsi_cd.c:2160-2161 β€” args->track = bin2bcd(args->track) on CDIOCREADSUBCHANNEL (_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_TRACKS quirk β€” set at probe only for the 1980s CHINON CDS-535 (scsi_cd.c:222-225). Root can force it via kern.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 gets EPERM from open() 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 from bcd.c + verbatim macros from libkern.h:
  • MODE=1 prints the real object sizes (100/154/37).
  • MODE=2 places each object ending exactly at a PROT_NONE guard 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=3 exhaustive semantics differential (table ≑ BCD arithmetic over the whole valid domain; fix-wraps are the identity there).
  • MODE=4 fix.diff macros under the same guard placement β†’ always in-bounds.
  • bcd_trigger.c β€” in-kernel CDIOCREADSUBCHANNEL sweep, 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 -p1 from 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)
VERDICT.md
↓ download raw

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) β†’ OK
  • bin2bcd(100) (first OOB), bin2bcd(200) (the CDIOCPLAYTRACKS / CDIOCREADSUBCHANNEL u_char), bin2bcd(255) (max u_char) β†’ SIGSEGV
  • bcd2bin(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):

  • bin2bcd table ≑ (i/10)<<4 | i%10 for all 0..99, and the fix wrap % 100 is the identity on that domain (0 errors);
  • bcd2bin table ≑ nibble arithmetic for every valid BCD byte 0x00..0x99, and the fix wrap % 154 is the identity there (0 errors);
  • hex2ascii over 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 is CHINON CD-ROM CDS-535 β€” the exact pattern in the quirk table (scsi_cd.c:222-225). devlist.txt shows <CHINON CD-ROM CDS-535 2.5+> at scbus2 β†’ the kernel assigned CD_Q_BCD_TRACKS at 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) sweeps CDIOCREADSUBCHANNEL track=0..255. Every call returns EIO. Trace: the only EIO source in this branch is cdreadsubchannel() (scsi_cd.c:2163β†’3363 cdrunccb), which the call reached β€” i.e. it passed args->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 of hw/scsi/scsi-disk.c and hw/scsi/scsi-bus.c; also confirmed with passrep.c, which replays the kernel's exact CCB via CAMIOCOMMAND and gets CAM_DATA_RUN_ERR with all-zero sense for every 0x42 variant, and camcontrol cmd probes). So the one-byte copyback disclosure cannot complete on this emulator; on a real drive that answers 0x42 (as the CHINON does), the _IOWR copyback returns the OOB byte.
  • The unprivileged angle is closed by DragonFly itself: cdopen() refuses non-SYSCAP_RESTRICTEDROOT openers (scsi_cd.c:1006-1007); trigger_as_user.log shows open(/dev/cd0): Operation not permitted for 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 -p1 passes) 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_testable at 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 .rodata tables, 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_RESTRICTEDROOT open 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:134 is a plain prototype; no static __inline, no .S definition anywhere in sys/). Byte loop at strlen.c:40-41 matches libc semantics exactly (34-case randomized differential, shl in 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: reads s[0..maxlen-1], never s[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_testable

fix.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
↓ fix.diffper-fix-DF-5719

Confirmed kernel references

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 in bcd_rig.c/str_harness.c; (2) first fix mask '& 0x99' wrong - bit-mask is not a nibble clamp, caught by the MODE=3 differential, replaced with % 154/% 100 wraps matching fix.diff; (3) passrep.c needed DragonFly include paths (), cam_errbuf, no CCB_CLEAR_ALL_EXCEPT_HDR, and must NOT memset the ccb header (wipes cam_getccb's path_id/target/lun); (4) bcd_trigger sizeof(struct cd_sub_channel_info)=24 on the guest bounds data_len to 4..24.

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.