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

v1 command-table helper hub leaves bp->cmd_helper NULL for DCE12/DCN generations on the legacy BIOS parser; construct ignores the init failure β€” whole-vtable NULL dereference at the first BIOS command

Summary

The parsers partition on VBIOS header rev (not ASIC gen) with a parser1 fallback, so a DCE12/DCN ASIC with a legacy-format VBIOS gets parser1 with cmd_helper==NULL (the init failure is ignored) - the first BIOS command loads a function pointer from NULL+0x20. The sibling helper2 hub serves exactly these generations (the coverage gap). Family NULL-matrix completed: dce80 2, dce110 5, dce112 3, dce112-2 0. Userspace semantics harness SIGSEGV; hardware-gated. Mirror-helper2 + honor-the-failure fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5075 Β· 11 files
FileTypeDescriptionSize
README.md β€” 1.7 KB ↓ raw
VERDICT.md β€” 8.4 KB ↓ raw
trigger_nullhelper.c β€” 4.3 KB view raw
build.sh β€” 66 B view raw
run.sh β€” 90 B view raw
build.log β€” 126 B view raw
run.log β€” 399 B view raw
env.txt β€” 252 B view raw
fix.diff β€” 1.7 KB view raw
manifest.json β€” 1.6 KB view raw
verdict.json β€” 5.4 KB view raw

DF-5075 evidence pack

What

sys/dev/drm/amd/display/dc/bios/command_table_helper.c β€” the v1 DC BIOS command-table helper hub leaves *h NULL (returns false, value ignored) for DCE_VERSION_12_0 / DCN_VERSION_1_0 / DCN_VERSION_1_01 / UNKNOWN, while the legacy-parser fallback (old-format VBIOS header on DCE12/DCN silicon) can still construct parser1 on exactly those ASICs. Every BIOS command then calls through a NULL vtable pointer (bp->cmd_helper->…, e.g. command_table.c:204) β†’ kernel page-fault panic at display bring-up.

Build / run (guest, unprivileged)

sh build.sh    # cc -O2 -Wall -o trigger_nullhelper trigger_nullhelper.c
sh run.sh      # expect: "[+] child died SIGSEGV ..." and exit=42

The harness is a userspace semantics demo of the defect chain (kzalloc-NULL β†’ ignored init failure β†’ unguarded indirect call), not a kernel PoC: the kernel path needs AMD DCE12/DCN silicon with a legacy-format VBIOS, and the audit guest has no AMD GPU at all (env.txt).

Expected output

[*] kzalloc'd bios_parser: bp->cmd_helper = 0x0 (NULL)
[*] init_cmd_tbl_helper(dce_version=1200) returned; return value ignored, cmd_helper = 0x0
[*] calling bp->cmd_helper->assign_control_parameter() (member at +0x20)...
[+] child died SIGSEGV at the indirect call through the NULL vtable -- semantics chain (1)-(4) confirmed
exit=42

Contents

  • VERDICT.md β€” full narrative, reachability chain, negative results, matrix
  • trigger_nullhelper.c / build.sh / run.sh / build.log / run.log
  • env.txt β€” guest evidence (uname, no AMD GPU, no class-0c:07 device)
  • fix.diff β€” hub coverage + caller return-check; git apply --check verified
  • manifest.json, verdict.json
VERDICT.md
↓ download raw

DF-5075 β€” dal_bios_parser_init_cmd_tbl_helper leaves bp->cmd_helper NULL for DCE12/DCN ASICs on the legacy BIOS parser; the sole caller ignores the failure β€” whole-vtable NULL dereference at the first BIOS command

File: sys/dev/drm/amd/display/dc/bios/command_table_helper.c (pass 2, GLM 5.3) Severity: Low Β· CWE-476 (NULL Pointer Dereference) Β· confidence: certain (code chain), latent (VBIOS/ASIC-mismatch-gated) Status: untested for the kernel path (guest has no AMD GPU); the C-semantics chain was demonstrated on the guest (run.log, exit 42).

1. Root cause

The v1 command-table helper hub dispatches per ASIC dce_version:

  • sys/dev/drm/amd/display/dc/bios/command_table_helper.c:34-63 β€” handled: DCE 8.0/8.1/8.3 β†’ dce80 table, 10.0/11.0 β†’ dce110, 11.2/11.22 β†’ dce112.
  • command_table_helper.c:58-61 β€” default: BREAK_TO_DEBUGGER() (a no-op in release builds) and return false, leaving *h untouched.

The unhandled set is exactly {DCE_VERSION_12_0, DCN_VERSION_1_0, DCN_VERSION_1_01, DCE_VERSION_UNKNOWN, DCE_VERSION_MAX, …} β€” i.e. every generation that has a resource pool (dc_resource.c:138-146 wires dce120 for DCE_VERSION_12_0 and dcn10 for DCN 1.0/1.01) but no v1 helper table.

The contract violation is caller-side: bios_parser_construct ignores the return value β€”

bp is kzalloc'd (bios_parser.c:100), so an ignored failure leaves bp->cmd_helper == NULL (not wild) β€” a NULL table pointer, distinct from the NULL-member class of DF-4047 / DF-4985 / DF-5000 (explicitly cross-checked; those findings cover initialized tables with missing members).

2. Reachability chain (why parser1 can run on DCE12/DCN silicon)

  1. dc.c:626 dc_version = resource_parse_asic_id(asic_id) β€” FAMILY_AI β†’ DCE_VERSION_12_0, FAMILY_RV β†’ DCN_VERSION_1_0/1_01 (dc_resource.c:84-95).
  2. dc.c:641 dal_bios_parser_create(&bp_init_data, dc_version) β†’ firmware_parser_create first, fall back to the legacy parser bios_parser_create when it returns NULL (bios_parser_interface.c:41-45, comment "Fall back to old bios parser for older asics").
  3. The two parsers partition on the VBIOS ROM header table revision, not on ASIC generation: - parser2 (bios_parser2.c:1948): requires tbl_rev >= 2.2, else fail; - parser1 (bios_parser.c:2895): requires tbl_rev < 2.2, else fail. So a DCE12/DCN ASIC whose VBIOS ROM header is missing, corrupt, or old-format lands on parser1, whose construct then validates only the legacy ATOM tables (master data table, object info β€” bios_parser.c:2897-2932).
  4. dal_bios_parser_init_cmd_tbl_helper(dce_version=12_0/DCN…) β†’ default β†’ false, ignored β†’ bp->cmd_helper stays NULL β†’ construct returns true (bios_parser.c:2940).
  5. dc_construct succeeds (resource pools exist for 12.0/DCN, dc_resource.c:138-146), driver comes up.
  6. First BIOS command executed at mode set derefs the table, e.g. bp->cmd_helper->assign_control_parameter(bp->cmd_helper, cntl, &params) (command_table.c:204, also :219) β€” loads a function pointer from NULL+0x20 (member offset in struct command_table_helper, command_table_helper_struct.h:34-64) β†’ kernel page fault β†’ panic. Other first-touch sites: command_table.c:507 (transmitter_bp_to_atom), :1188, and every one of the 49 cmd_helper-> uses in command_table.c.

Note the sibling hub handles exactly this: dal_bios_parser_init_cmd_tbl_helper2 (command_table_helper2.c:63-75) serves dal_cmd_tbl_helper_dce112_get_table2() for DCE_VERSION_12_0 / DCN 1.0 / 1.01 β€” the v1 hub's omission is not a design decision, it is a coverage gap.

3. Threat model

Genuine Vega10/Raven boards ship atomfirmware-format VBIOSes (header β‰₯ 2.2) β†’ parser2 + helper2 β€” the hole requires a cross-generation pairing: legacy or corrupted VBIOS header on DCE12/DCN silicon (SPI bitrot, bad vendor flash, reflashed card, or a deliberately crafted image β€” the same threat class as DF-3942 ("corrupt VBIOS β†’ NULL-page panic at attach", Low) and DF-4985/DF-5000 ("cross-generation VBIOS pairing")). Impact ceiling: kernel NULL-deref panic (display bring-up DoS), not memory corruption β€” the dereferenced value is NULL, not attacker-controlled data. Physical/trusted-platform prerequisite β‡’ Low.

4. Verification performed

  • Kernel path: not testable on this guest β€” no AMD GPU (env.txt: only vgapci0@pci0:0:2:0 chip=0x11111234, the QEMU virtual VGA; no drm/amd device attaches). Same treatment as DF-4985/DF-5000.
  • C-semantics harness (trigger_nullhelper.c) ran on the DragonFly guest as an unprivileged user: replicates steps (1)-(4) β€” zero-filled storage, ignored init failure, unguarded indirect call through the NULL vtable β€” child dies SIGSEGV, harness exits 42 (run.log). This proves the primitive exists at the language level; it is not a kernel PoC.
  • fix.diff: git apply --check passes against the pristine tree; never applied to sys/.

5. Negative results (classes hunted and killed)

  • assign_control_parameter hub impl (command_table_helper.c:169-206) writes only fixed fields of the caller-stack DIG_ENCODER_CONTROL_PARAMETERS_V2 (command_table.c:197/212 allocate the exact struct) β€” no size/type issue; the three vtable members it derefs (transmitter_bp_to_atom, encoder_action_to_atom, encoder_mode_bp_to_atom) are non-NULL in every table that wires assign_control_parameter (only dce80 wires it, :341-343 of dce80's initializer; dce110/dce112 leave the member NULL β€” that member gap is DF-4047/DF-4985, not re-reported).
  • usPixelClock = cpu_to_le16((uint16_t)(pixel_clock / 10)) (:201) truncates above 655.35 MHz β€” inherent ATOM V2 field width (u16 in the interface definition), upstream-inherited display-glitch class, not kernel memory safety; killed.
  • transmitter_bp_to_atom default β†’ 0 (:135-138) and encoder_mode_bp_to_atom default β†’ CRT (:164-166): silent fallbacks, upstream-inherited; no enum value that maps wrong on supported silicon (UNIPHY_A..F + TRAVIS_LCD covered); killed as Info-noise.
  • Output-pointer helpers (controller_id_to_atom :71-74, clock_source_id_to_ref_clk_src :212-215) NULL-check their out-params and return false on default β€” clean.
  • No allocation, no locks, no refcounts, no copyin/copyout in this file β€” the entire attack surface is the dispatch contract.
  • Cross-hub type confusion ruled out: helper2 reuses the same struct command_table_helper (command_table_helper2.h:29-32) so the dce80 table served via helper2 (command_table_helper2.c:40-44) is layout- compatible; the NULL members there are DF-5000's, already filed.
  • Family matrix completed (the prompt's DF-5000 completion ask): | member | dce80 | dce110 | dce112 | dce112-2 (parser2) | |---|---|---|---|---| | controller_id_to_atom | βœ“ | βœ“ | βœ“ | βœ“ | | encoder_action_to_atom | βœ“ | βœ“ | βœ“ | βœ“ | | encoder_mode_bp_to_atom | βœ“ | βœ“ | βœ“ | βœ“ | | engine_bp_to_atom | βœ“ | βœ“ | βœ“ | βœ“ | | assign_control_parameter | hub | NULL | NULL | hub2 | | clock_source_id_to_atom | βœ“ | βœ“ | βœ“ | βœ“ | | clock_source_id_to_atom_phy… | βœ“ | βœ“ | βœ“ | βœ“ | | clock_source_id_to_ref_clk_src | hub | NULL | NULL | hub2 | | transmitter_bp_to_atom | hub | NULL | NULL | hub2 | | encoder_id_to_atom | hub | hub | hub | hub2 | | signal_type_to_atom_dig_mode | βœ“ | βœ“ | βœ“ | βœ“ | | hpd_sel_to_atom | βœ“ | βœ“ | βœ“ | βœ“ | | dig_encoder_sel_to_atom | βœ“(dead0=DF-2030 sibling) | βœ“ | βœ“(DF-4936) | βœ“ | | phy_id_to_atom | βœ“ | βœ“ | βœ“ | βœ“ | | disp_power_gating_action… | βœ“ | βœ“ | βœ“ | βœ“ | | dc_clock_type_to_atom | NULL (DF-5000) | NULL (DF-4985) | βœ“ | βœ“(DF-4935) | | transmitter_color_depth… | NULL (DF-5000) | NULL (DF-4985) | βœ“ | βœ“ | (this hub file adds no new NULL members β€” its gap is the version dispatch, filed here as DF-5075.)

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

not_testable - requires AMD DCE12/DCN hardware with a legacy-format VBIOS; the audit guest has no AMD GPU, so neither baseline nor patched kernel path can execute. fix.diff is git-apply --check verified against the pristine sys/ tree; it mirrors the only in-tree handling of these generations (command_table_helper2.c:63-75) plus the caller-side return check.

findings/poc/DF-5075/fix.diff (git apply --check passes; never applied to sys/)
↓ fix.diffper-fix-DF-5075

Confirmed kernel references

Detail

Evidence (decisive lines)

VERDICT.md sections 1-3 (hub default path :58-61, caller ignoring at bios_parser.c:2936, the ROM-header-revision parser partition bios_parser.c:2895 / bios_parser2.c:1948, fallback bios_parser_interface.c:41-45, pool support dc_resource.c:138-146); run.log (guest userspace semantics demo, exit 42); env.txt (guest has no AMD GPU); fix.diff (git apply --check passes)

PoC changes

evidence pack authored fresh in pass 2 (no seed). trigger_nullhelper.c is a guest userspace semantics harness mirroring steps (1)-(4) of the kernel chain, not a kernel PoC: no AMD GPU exists on the audit guest.

Verified recommended fix

Mirror helper2 coverage: serve dal_cmd_tbl_helper_dce112_get_table2() for DCE_VERSION_12_0/DCN1_0/1_01 in the v1 hub, and make bios_parser.c:2936 check the return and fail construct -- see fix.diff.

Verdict

Pass-2 audit of the v1 command-table helper hub (command_table_helper.c). The hub covers only DCE 8.0/8.1/8.3/10.0/11.0/11.2/11.22 (:38-56); for DCE_VERSION_12_0, DCN_VERSION_1_0, DCN_VERSION_1_01 and DCE_VERSION_UNKNOWN the default path (:58-61) returns false leaving h untouched. The sole caller, bios_parser_construct, IGNORES the return (bios_parser.c:2936) inside a construct whose bp is kzalloc'd (bios_parser.c:100) -- so the failure leaves bp->cmd_helper == NULL while construct returns true. Reachability: the parser choice partitions on the VBIOS ROM header revision (parser2 requires >=2.2, bios_parser2.c:1948; parser1 requires <2.2, bios_parser.c:2895), NOT on ASIC generation, and dal_bios_parser_create falls back to parser1 whenever firmware_parser_create fails (bios_parser_interface.c:41-45). A DCE12/DCN ASIC (dc.c:626, dc_resource.c:84-95) with a legacy-format or header-corrupted VBIOS therefore gets parser1, a NULL cmd_helper, a SUCCESSFUL dc_construct (pools exist for 12.0/DCN, dc_resource.c:138-146), and then a whole-vtable NULL dereference at the first BIOS command -- e.g. bp->cmd_helper->assign_control_parameter at command_table.c:204/219 loads a function pointer from NULL+0x20 -- a kernel page-fault panic at display bring-up. The sibling hub proves the omission is a gap, not a design choice: command_table_helper2.c:63-75 serves the dce112 helper2 table for exactly DCE_VERSION_12_0/DCN1_0/1_01. Distinct from DF-4047/DF-4985/DF-5000 (initialized tables with NULL members*): here the table pointer itself is NULL. Threat model equals DF-3942/DF-4985/DF-5000 (crafted/corrupt or cross-generation-paired VBIOS on modern silicon; physical prerequisite) -> Low. Kernel path untestable on this guest (no AMD GPU, env.txt); the C-semantics chain was demonstrated on the guest as an unprivileged user (trigger_nullhelper.c: ignored init failure -> NULL vtable -> SIGSEGV at the indirect call, exit 42, run.log). fix.diff (hub coverage mirroring helper2 + caller return-check) passes git apply --check; not applied to sys/.