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

cxm_tuner: terminator-less band_codes[3]/assignments[17] walks rely on unenforced min_freq/min_channel data invariants - latent rodata OOB-read hazard (Info hardening)

Summary

Both walks stay in bounds only because cross-field invariants hold in the static data (verified entry-by-entry for all 21 tuners and 9 channel sets in this tree); a future table edit breaking either turns an unpriv TVTUNER_SETCHNL/SETFREQ ioctl into a .rodata OOB read whose bytes are written to the I2C bus. Same latent class as DF-1681. Bound-by-extent + exhaustion-check fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4540 Β· 10 files
FileTypeDescriptionSize
README.md β€” 2.9 KB ↓ raw
VERDICT.md β€” 3.3 KB ↓ raw
cxm_env_probe.sh β€” 585 B view raw
build.sh β€” 92 B view raw
run.sh β€” 118 B view raw
run.log β€” 515 B view raw
env.txt β€” 721 B view raw
fix.diff β€” 1.1 KB view raw
manifest.json β€” 954 B view raw
verdict.json β€” 3.6 KB view raw

DF-4540 β€” cxm_tuner: terminator-less tuner-table walks rely on unenforced cross-field data invariants (latent OOB-read hazard)

What this is (Info / hardening β€” no demonstrated impact with current tables)

Both tuner-table walks in cxm_tuner.c run without a terminator element and without an array-extent bound, stopping only when an element field drops to/below the request:

  1. Band-code walk β€” cxm_tuner_select_frequency(), cxm_tuner.c:725-727: for (band_code = sc->tuner->band_codes; band_code->freq > freq; band_code++); struct cxm_tuner.band_codes is declared [3] (cxm.h:619) and every tuner table in cxm_tuners[] initializes all three slots (cxm_tuner.c:273-498) β€” there is no zero terminator slot at all.
  2. Channel-assignment walk β€” cxm_tuner_select_channel(), cxm_tuner.c:789-791: for (assignments = channels->assignments; assignments->channel > channel; assignments++); assignments is [17] (cxm.h:594) and bg_air_channels fills all 17 slots (cxm_tuner.c:112-128).

The only thing keeping both walks in bounds today are cross-field invariants in the static data, checked at the call sites: * freq >= sc->tuner->min_freq (cxm_tuner.c:706-708) together with band_codes[2].freq == min_freq for all 21 tuner entries (verified: cxm_tuner.c:273-498, e.g. FI1216 48250/48250, Microtune 45750/45750, TCL 55250/55250); * channel >= channels->min_channel (cxm_tuner.c:784-787) together with the last assignment entry having channel == min_channel for all 9 channel tables (cxm_tuner.c:72-251).

Neither invariant is enforced or asserted anywhere. A future table entry that breaks either one (a new tuner whose min_freq is below its last band-code freq, or a channel set whose last assignment channel is above min_channel) makes an unprivileged TVTUNER_SETFREQ / TVTUNER_SETCHNL ioctl (cxm.c:2824, 2864) walk off the end of a .rodata array β€” an OOB read whose fetched bytes are then written to the I2C bus (cxm_tuner.c:750).

This is the same class as the latent DF-1681 (unguarded divide, "latent") β€” a data-invariant dependency with no runtime guard.

Also checked and killed (see VERDICT.md)

msg[5] builders (in-bounds, tuner_msg_len ∈ {4,5}), N scaling (checked N > 32767), afcmap[16] index (masked & 0x0f), system-code lookup (bounded NUM_ELEMENTS(codes[6]) + post-check), tuner-type dispatch (bounds-checked, cxm_tuner.c:557), u32 kHz-conversion wrap in cxm.c RADIO_SETFREQ/TVTUNER_SETFREQ (wraps are still range-checked afterwards β€” no impact).

Build / run

./build.sh   # n/a (shell probe)
./run.sh     # documents no Conexant hardware/cxm device on this guest

Fix

fix.diff bounds both walks by array extent (NUM_ELEMENTS(...)) and treats running off the end as -1 β€” the invariant becomes enforced instead of assumed. git apply --check clean; never applied to sys/.

VERDICT.md
↓ download raw

DF-4540 VERDICT β€” untested (Info/hardening; hardware-gated), structure certain

The two walks (path:line)

  1. cxm_tuner_select_frequency() TV branch: * guard freq < min_freq || freq > max_freq β†’ -1 at cxm_tuner.c:706-708 * walk for (band_code = sc->tuner->band_codes; band_code->freq > freq; band_code++); at cxm_tuner.c:725-727 * band_codes[3] (cxm.h:619), fully initialized in all 21 entries of cxm_tuners[] (cxm_tuner.c:269-499) β€” no terminator slot exists. * Verified invariant: for every tuner, band_codes[2].freq == min_freq (FI1216/FM1216/FQ1216ME/ FQ1216ME MK3/FM1216ME MK3 48250; FI1236/FM1236/Temic 4036/4039/ TAPC-H701F/TAPC-H001F/TCL 55250; FI1246/FM1246/Temic 4066/ Microtune 45750; Temic 4006/4009/LG TPI8PSB11D/TPI8PSB01N/ TAPE-H001F 48250). Because freq >= min_freq, the walk always stops at index ≀ 2. In bounds today only by this data invariant.
  2. cxm_tuner_select_channel(): * guard channel < min_channel || channel > max_channel β†’ -1 at cxm_tuner.c:784-787 * walk at cxm_tuner.c:789-791, assignments[17] (cxm.h:594); bg_air_channels (cxm_tuner.c:112-128) initializes all 17 slots β€” no terminator slot. * Verified invariant: the last assignment of each of the 9 channel tables has channel == min_channel (us_air 2, us_cable 1, bg_air 2, bg_cable 2, bg_australia 1, i_air 1, l_air 1, japan_air 1, japan_cable 1) β€” so the walk stops at or before the final slot for every accepted channel.

If either invariant is broken by a future table edit, the walk runs off the .rodata array: OOB read of successive 4/8-byte fields until some garbage freq/channel compares low β€” the fetched band/control bytes are then transmitted on the I2C bus (cxm_tuner.c:750-752). Reachable by whichever user can open /dev/cxmN (bktr-style TVTUNER ioctls, cxm.c:2819-2824/2856-2866).

Why it is Info, not higher

With the current 21 tuner entries and 9 channel tables the invariants hold universally (verified entry-by-entry above); no attacker input can drive either walk out of bounds today. The finding documents the unguarded dependency β€” the same "latent" class as DF-1681.

Verification status

Untested on the guest (and unnecessary for an Info hardening finding): the cxm driver requires a Conexant CX2381x PCI function; the audit guest has none (run.log: PCI-ABSENT: no vendor-14f1 function, no /dev/cxm*), so the tuner paths cannot execute there. The invariant verification above was performed statically against the tables in this tree. Status untested, impact none, confidence certain (about the structural facts).

Fix validation

fix.diff replaces the terminator assumption with explicit array-extent bounds in both walks (NUM_ELEMENTS), returning -1 on exhaustion; git apply --check clean. Not runtime-observable on this guest β†’ fix_status not_testable.

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
baseline no→ patch + rebuild →patched clean

fix.diff adds explicit NUM_ELEMENTS bounds plus exhaustion checks to both walks in cxm_tuner.c; git apply --check clean. Runtime behaviour identical for the current tables (invariant holds), so baseline vs patched cannot differ observably even with hardware; fix_status not_testable on this guest.

findings/poc/DF-4540/fix.diff
↓ fix.diffper-fix-DF-4540

Confirmed kernel references

Detail

Evidence (decisive lines)

findings/poc/DF-4540/VERDICT.md (entry-by-entry invariant verification); cxm_tuner.c:725-727 and cxm_tuner.c:789-791 walks; cxm.h:619 band_codes[3]; cxm.h:594 assignments[17]; guards cxm_tuner.c:706-708 / cxm_tuner.c:784-787; run.log/env.txt (no vendor-14f1 function, no /dev/cxm*)

PoC changes

authored fresh shell probe (no seed); documents trigger-hardware absence

Verified recommended fix

Bound both walks by array extent (NUM_ELEMENTS) and return -1 on exhaustion instead of relying on the min_freq/min_channel data invariants

Verdict

Both tuner-table walks in cxm_tuner.c have no terminator element and no array-extent bound: the band-code walk at cxm_tuner.c:725-727 iterates struct cxm_tuner.band_codes[3] (cxm.h:619) whose three slots are fully initialized in all 21 cxm_tuners[] entries (no terminator slot exists), and the channel-assignment walk at cxm_tuner.c:789-791 iterates assignments[17] (cxm.h:594) which bg_air_channels fully populates (cxm_tuner.c:112-128). They stay in bounds only because unenforced data invariants hold in the current tables: freq >= min_freq (cxm_tuner.c:706-708) combined with band_codes[2].freq == min_freq for all 21 entries, and channel >= min_channel (cxm_tuner.c:784-787) combined with the last assignment channel == min_channel for all 9 channel tables -- both verified entry-by-entry in this tree. A future table edit breaking either invariant turns an unprivileged TVTUNER_SETCHNL/TVTUNER_SETFREQ ioctl (cxm.c:2824/2864) into a .rodata OOB read whose bytes are then written to the I2C bus (cxm_tuner.c:750). No input can cross the bounds with the current tables, so Info/hardening, same latent class as DF-1681. Untested: cxm needs a Conexant CX2381x PCI function absent from the audit guest (run.log); the invariant verification was static.