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

get_hw_supported_ddc_line validates DDC line against GPIO_DDC_LINE_COUNT(8) but sw_i2cs[] is sized MAX_PIPES(6): OOB read of scalar-overlay garbage pointer, then wild kernel write engine->ddc = ddc

Summary

The guard is inherited from the old i2caux code whose arrays WERE sized 8; the new DC resource_pool arrays are MAX_PIPES=6, so DDC lines 6 (VGA pad) and 7 (I2C pad) pass and read pool->sw_i2cs[6]/[7] - aliasing {i2c_hw_buffer_in_use,pad,pipe_count}/{underlay_pipe_index,stream_enc_count}. On every real pool the garbage is non-NULL so the !engine guard fails and acquire_engine writes engine->ddc=ddc THROUGH it. KLD-PROVEN with the REAL audited file: Fatal trap 12, the faulting instruction IS the audited write; fixed module cleanly rejects. VBIOS-sourced line values (the drm fw-parser threat model); any I2C transfer on such a link (hotplug/EDID detect, unpriv-forceable). The HW sibling guards correctly with pipe_count - domain mismatch, not hw constraint. line<pipe_count fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4827 Β· 15 files
FileTypeDescriptionSize
swi2cs_probe.c β€” 7.3 KB view raw
Makefile β€” 1.6 KB ↓ download
build.sh β€” 199 B view raw
run.sh β€” 139 B view raw
run2.sh β€” 1.4 KB view raw
build.log β€” 10.2 KB view raw
build.2.log β€” 10.2 KB view raw
run.log β€” 2.0 KB view raw
run.2.log β€” 2.6 KB view raw
panic.txt β€” 2.1 KB view raw
env.txt β€” 436 B view raw
fix.diff β€” 650 B view raw
README.md β€” 2.8 KB ↓ raw
VERDICT.md β€” 9.3 KB ↓ raw
verdict.json β€” 6.8 KB view raw

DF-4827 β€” sw_i2cs[] array-domain mismatch in dce_i2c_sw.c (OOB read β†’ wild write)

File: sys/dev/drm/amd/display/dc/dce/dce_i2c_sw.c Lines: 91-92 (guard), 536 (OOB index), 392 (wild write) Severity: High Β· Class: CWE-129 improper array-index validation β†’ CWE-787 out-of-bounds write

The bug

get_hw_supported_ddc_line() validates the DDC line number against GPIO_DDC_LINE_COUNT (== 8), but the array it feeds, resource_pool.sw_i2cs[], is sized MAX_PIPES (== 6). DDC lines 6 (VGA pad) and 7 (I2C pad) pass the guard and index past the array into the scalar members of struct resource_pool:

sw_i2cs[6]  aliases { i2c_hw_buffer_in_use, 3B pad, pipe_count }
sw_i2cs[7]  aliases { underlay_pipe_index, stream_enc_count }

Both are read as struct dce_i2c_sw * and are non-NULL garbage on every real pool (e.g. 0x0000000600000000 for pipe_count=6). The subsequent engine->ddc = ddc (dce_i2c_sw.c:392) then performs a wild kernel write to the garbage address.

The sibling HW path guards correctly (if (line < pool->pipe_count), dce_i2c_hw.c:365); only the SW path kept the OLD i2caux guard β€” whose arrays were sized GPIO_DDC_LINE_COUNT (i2caux.h:67) and for which the bound was correct. The old i2caux engine arrays are [GPIO_DDC_LINE_COUNT], the new DC arrays are [MAX_PIPES] β€” the guard was not updated with the domain.

en ∈ {6,7} and hw_supported come straight from the GPU's VBIOS tables (bios_parser2.c:395-405 β†’ dc_link_ddc.c:269-276 β†’ hw_translate_dce80.c:190-196), so the trigger is a crafted/mis-declared VBIOS (flashed card, passthrough ROM) or any legitimate board that maps a connector's DDC to the VGA/I2C pad with the HW-assist bit. Entry is any I2C transfer on that link: HPD/detect EDID reads (automatic) or the DRM i2c bus (amdgpu_dm_i2c_xfer, amdgpu_dm.c:3983-4016). Reached on every DM ASIC (dce_i2c.c:46-58 tries the SW engine first).

Reproduce on the guest

./build.sh          # compiles the REAL /usr/src/sys .../dce_i2c_sw.c (-Werror) + probe stubs
./run.sh            # phase 1: kldload -> PANIC; phase 2: patched -> clean NULL

Expected (baseline):

swi2cs: OOB READ pool.sw_i2cs[6] == 0x0000000600000000 (pipe_count=6 << 32 | hw_buf_in_use=0)
swi2cs: FIRING - calling the REAL dce_i2c_acquire_i2c_sw_engine(&pool, &ddc)...
Fatal trap 12: page fault while in kernel mode
fault virtual address  = 0x600000000
fault code             = supervisor write data, page not present
Stopped at  dce_i2c_acquire_i2c_sw_engine+0x59:  movq %rbx,(%r12)   <-- engine->ddc = ddc

Expected (fix.diff applied): swi2cs: acquire returned 0 (NULL = correctly rejected) β€” no panic, module loads and unloads cleanly.

The only stubbed layer is the GPIO service (dal_ddc_open etc.); its emulated success for en=6 is documented from source in VERDICT.md Β§3.

VERDICT.md
↓ download raw

DF-4827 β€” VERDICT

Bottom line

REPRODUCED (in-kernel, real audited code): deterministic wild write β†’ kernel panic in dce_i2c_acquire_i2c_sw_engine(). The faulting instruction is the audited engine->ddc = ddc; (dce_i2c_sw.c:392) writing through the OOB-derived garbage pointer pool->sw_i2cs[6] == 0x0000000600000000. Fix validated behaviorally: with fix.diff (line < pool->pipe_count bound, mirroring dce_i2c_hw.c:365), the same call returns NULL cleanly, no fault, module load/unload clean. The full driver entry (real amdgpu probe β†’ dce_i2c_submit_command on a VGA-pad DDC link) is hardware-gated on this guest (no AMD GPU), but is proven by source trace below.

1. Root cause, line by line

  • sys/dev/drm/amd/display/dc/inc/hw/hw_shared.h:37 β€” #define MAX_PIPES 6
  • sys/dev/drm/amd/display/include/gpio_types.h:206-220 β€” enum gpio_ddc_line ends at GPIO_DDC_LINE_I2C_PAD (=7), GPIO_DDC_LINE_COUNT = 8 (DDC1..6 = 0..5, DDC_VGA = 6, I2C_PAD/VIP = 7).
  • sys/dev/drm/amd/display/dc/inc/core_types.h:151 β€” struct dce_i2c_sw *sw_i2cs[MAX_PIPES]; followed (line 152-157) by bool i2c_hw_buffer_in_use; unsigned int pipe_count; unsigned int underlay_pipe_index; unsigned int stream_enc_count; β€” i.e. sw_i2cs[6] overlays {i2c_hw_buffer_in_use,3B pad,pipe_count} and sw_i2cs[7] overlays {underlay_pipe_index,stream_enc_count} (offsets proven live by the probe: &sw_i2cs[6] == &i2c_hw_buffer_in_use exactly).
  • dce_i2c_sw.c:91-92 β€” get_hw_supported_ddc_line() rejects only line_found >= GPIO_DDC_LINE_COUNT β†’ 6 and 7 pass.
  • dce_i2c_sw.c:536 β€” engine = pool->sw_i2cs[line]; ← OOB read for line 6/7 β†’ scalar garbage interpreted as struct dce_i2c_sw *.
  • dce_i2c_sw.c:538 β€” if (!engine) return NULL; does NOT save us: the garbage is non-NULL whenever pipe_count != 0 (always true on real pools) or stream_enc_count/underlay_pipe_index != 0.
  • dce_i2c_sw.c:541 β†’ 404-417 β†’ 380-395 β€” dce_i2c_engine_acquire_sw() β†’ dce_i2c_sw_engine_acquire_engine() β†’ dal_ddc_open() succeeds (see Β§3) β†’ engine->ddc = ddc; (dce_i2c_sw.c:392) writes a valid kernel pointer to the garbage address β†’ supervisor write to unmapped/non-canonical address β†’ page fault β†’ panic (or, if the address were mapped, silent cross-object corruption at (pipe_count << 32)).
  • Contrast β€” the HW sibling of the same driver guards correctly: dce_i2c_hw.c:362-367: if (ddc->hw_info.hw_supported) { enum gpio_ddc_line line = dal_ddc_get_line(ddc); if (line < pool->pipe_count) dce_i2c_hw = pool->hw_i2cs[line]; }. The old-generation i2caux arrays are sized GPIO_DDC_LINE_COUNT (i2caux.h:67), so ITS guard (i2caux.c:287) is correct β€” the bound was simply not updated when the array domain changed to MAX_PIPES in the new DC engine code.

2. Reachability / threat model

  • en (the DDC line) is decided by VBIOS tables: bios_parser2.c:395-405 copies i2c_hw_assist (the I2C_HW_CAP bit) and clk_a_register_index from the ATOM GPIO_I2C table; dc_link_ddc.c:266-276 builds the per-link ddc_pin from it; hw_translate_dce80.c:190-196 (same in dce110/120/dcn10) maps mmDC_GPIO_DDCVGA_A β†’ GPIO_DDC_LINE_DDC_VGA (=6) and mmDC_GPIO_I2CPAD_A β†’ GPIO_DDC_LINE_I2C_PAD (=7).
  • Every I2C transfer on such a link reaches the bug: dc_submit_i2c (dc.c:1736-1748) β†’ dce_i2c_submit_command (dce_i2c.c:46-58, SW engine tried first on every DM ASIC) β†’ dce_i2c_acquire_i2c_sw_engine.
  • Unprivileged reachability of the transfer itself: EDID reads on hotplug/detect (DRM_IOCTL_MODE_GETCONNECTOR forces detect from any local user on real hardware) and the DRM i2c adapter (amdgpu_dm_i2c_xfer, amdgpu_dm.c:3983-4016).
  • Attacker preconditions: control of the GPU VBIOS tables (malicious/flashed GPU, PCIe passthrough with forged ROM β€” the classic drm firmware-parser threat model) or a legitimately-mapped VGA DDC line with HW assist. No AMD GPU on this audit guest, so the hardware entry could not be exercised.

3. What the harness proves and what is stubbed

The KLD (swi2cs_probe.c) links the real, unmodified audited file (guest /usr/src/sys/.../dce_i2c_sw.c, sha256 75d99a9e… == audited tree) compiled with the amdgpu module's own flags (-Werror, see build.log). It constructs a real struct resource_pool with realistic scalars (pipe_count=6, underlay=-1, stream_enc_count=3 β€” values left by the resource constructors; dce80_resource.c etc.) and a struct ddc whose pin_data->en = GPIO_DDC_LINE_DDC_VGA, hw_info.hw_supported = true.

Only the GPIO-service functions are stubbed. The stub dal_ddc_open returning GPIO_RESULT_OK for en=6 mirrors the real service: * hw_factory_dce80.c:163-164 β€” number_of_pins[GPIO_ID_DDC_DATA/CLOCK] = 8 β†’ dal_gpio_service_open's busyness bitmap (gpio_service.c:233-241) covers en=6/7 and the pin create path succeeds; * ddc_regs.h β€” ddc_data_regs(id)/ddc_clk_regs(id) tables have valid entries for GPIO_DDC_LINE_DDC_VGA; * the probe output itself proves dal_ddc_get_line (real semantics: the en member, gpio_service.c:566-570) yields 6 and that get_hw_supported_ddc_line accepts it.

Everything after the stub is the real audited instruction stream β€” and it faults at dce_i2c_acquire_i2c_sw_engine+0x59: movq %rbx,(%r12) writing to 0x600000000 (fault code = supervisor write data, page not present), which is exactly engine->ddc = ddc with %r12 = OOB garbage sw_i2cs[6].

4. Exploitability

  • The write VALUE is a valid kernel heap pointer (ddc), the write ADDRESS is (pipe_count << 32) | small for line 6 β€” with pipe_count ∈ {2,3,4,6} the target is one of a few fixed ~0x2..0x6_00000000 addresses: canonical, normally unmapped β†’ reliable panic (local DoS given the VBIOS precondition); silent arbitrary-object corruption only in the unlikely case the target is mapped.
  • For line 7 (sw_i2cs[7] = stream_enc_count<<32 | underlay_pipe_index), the address embeds 0xFFFFFFFF low bits when underlay is -1 β€” non-canonical β†’ GPF.
  • Full unprivβ†’root escalation would additionally require controlling the scalars aliased by the OOB read (pipe_count is fixed per ASIC) β€” not a practical grooming target; the realistic ceiling is denial of service plus the theoretical mapped-address corruption. High severity rests on the wild-write-into-driver-flow class and the firmware-parser attack surface.

5. Fix validation (behavioral, same harness)

  • Baseline (run.log): panic β€” Stopped at dce_i2c_acquire_i2c_sw_engine+0x59: movq %rbx,(%r12), VA 0x600000000.
  • Patched (run.2.log): fix.diff applies clean to the guest's dce copy (patch -p1, "Hunk #1 succeeded at 532"), the module rebuilds with -Werror (build.2.log, compiles the patched real dce_i2c_sw.c), and the same call prints swi2cs: acquire returned 0 (NULL = correctly rejected) β€” no dal_ddc_open, no fault; module loaded AND unloaded cleanly; guest stayed up (vm.sh status β†’ up).
  • fix.diff bound line < pool->pipe_count == the array domain idiom already used by the HW path (dce_i2c_hw.c:365); pipe_count ≀ MAX_PIPES on all pools, so the index is provably in-bounds after the fix; line 6/7 DDC handles gracefully fall back (acquire returns NULL, submit falls to the HW engine which equally rejects, call fails with an error instead of corrupting memory).

6. Negative result notes (what was checked and killed in this file)

  • div0 in wait_for_scl_high_sw (dce_i2c_sw.c:104): impossible β€” dce_i2c_submit_command_sw always calls dce_i2c_sw_engine_set_speed first (:506), which floors clock_delay β‰₯ 12 (:375-376) β†’ clock_delay_div_4 β‰₯ 3; ASSERT(speed) is a no-op in release but speed ? speed : DCE_I2C_DEFAULT_I2C_SW_SPEED (:371) handles 0.
  • Clock-stretch/SCL waits: bounded β€” I2C_SW_TIMEOUT_DELAY=3000Β΅s (dce_i2c_sw.h:32); scl_retry_max = 3000/delay β†’ β‰ˆ3ms per wait_for_scl_high_sw call, worst β‰ˆ27ms/byte, β‰ˆ7s per 256-byte EDID transfer on a dead bus β€” bounded stall (hardening note only, same shape as the pass-1-clean i2caux i2c_sw_engine).
  • start_sync_sw SDA-stuck loop (:335-359): bounded by I2C_SW_RETRIES=10.
  • GPIO NULL family (DF-4787): dal_gpio_get_value/set_value are NULL-pin-safe (gpio_base.c:74-96 return GPIO_RESULT_NULL_HANDLE); ddc->pin_data/pin_clock are non-NULL by construction (gpio_service.c:428-442); return values ignored in read_bit_from_ddc/write_bit_to_ddc but the defaulted value=0 is protocol-fatal only, not memory-unsafe.
  • release_engine_dce_sw/dal_ddc_close (dce_i2c_sw.c:65-71): engine->ddc is always set by a successful acquire before submit; dal_gpio_close tolerates NULL pins (gpio_base.c:220-229), double-close safe.
  • payload/length plumbing: i2c_request_transaction_data.length is u32 (dce_i2c_hw.h:235), loops bounded by caller-supplied counts (i2c_write_sw/i2c_read_sw:273-316) β€” no OOB vs the caller's buffers.

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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff (line < pool->pipe_count guard, mirroring the HW path's existing idiom at dce_i2c_hw.c:365) applies clean to the guest's dce_i2c_sw.c ('Hunk #1 succeeded at 532'), rebuilds with -Werror (build.2.log compiles the patched real file), and BEHAVIORALLY fixes the panic: the identical probe call that faulted on baseline now prints 'swi2cs: acquire returned 0 (NULL = correctly rejected)' with no dal_ddc_open side effect, module loads AND unloads cleanly, guest stays up (run.2.log, vm.sh status: up). Line 6/7 DDC handles now gracefully fall back: acquire returns NULL, dce_i2c_submit_command proceeds to the HW engine which equally rejects, and the I2C call fails with an error instead of corrupting memory.

fix.diff; run.2.log ('acquire returned 0 (NULL = correctly rejected)', KLDLOAD2_OK, KLDUNLOAD_OK); build.2.log (patched real dce_i2c_sw.c compiled -Werror, link of swi2cs.ko)
↓ fix.diffper-fix-DF-4827

Confirmed kernel references

Detail

Exploit chain

VBIOS-controlled preconditions: I2C record with I2C_HW_CAP whose DDC clock pin register is mmDC_GPIO_DDCVGA_A (or I2CPAD) -> ddc_pin->pin_data->en = 6/7 with hw_supported=1 (dc_link_ddc.c:266-276) -> any I2C transfer on that link (hotplug/EDID detect, or DRM_IOCTL i2c via amdgpu_dm_i2c_xfer:3983-4016, in-kernel unprivileged-forced) -> dc_submit_i2c -> dce_i2c_submit_command (SW first, dce_i2c.c:46-58) -> dce_i2c_acquire_i2c_sw_engine: guard passes (line < GPIO_DDC_LINE_COUNT=8), OOB read sw_i2cs[6] = (pipe_count<<32)|flag (non-NULL) -> dal_ddc_open OK -> engine->ddc = ddc writes a kernel pointer to ~0x2-0x6_00000000 (fixed per-ASIC, canonical, unmapped) -> page fault/panic (reliable DoS); if the aliased address were mapped this is silent cross-object corruption of arbitrary kernel memory. Realistic ceiling: local DoS with VBIOS precondition (uid0 not practical - write address is nearly fixed by pipe_count, not groomable).

Evidence (decisive lines)

run.log (baseline panic: OOB read 0x0000000600000000, 'Fatal trap 12', VA=0x600000000, 'Stopped at dce_i2c_acquire_i2c_sw_engine+0x59: movq %rbx,(%r12)' == engine->ddc = ddc at dce_i2c_sw.c:392); run.2.log (patched: 'swi2cs: acquire returned 0 (NULL = correctly rejected)', KLDLOAD2_OK + KLDUNLOAD_OK, guest up); panic.txt; build.log/build.2.log (-Werror builds of the real audited file, baseline + patched); env.txt (uname, cc 8.3); fix.diff

PoC changes

wrote the KLD from scratch (swi2cs_probe.c + Makefile): no seed PoC existed. Three iterations to a clean build: (1) plain sys/ includes clash with -include kconfig.h -> rewrote as linux-compat module (linux/module.h) like real amdgpu files; (2) gcc -Werror=array-bounds rejected the probe's own &pool.sw_i2cs[6] subscript (the compiler itself confirming the OOB) -> char arithmetic; (3) generated fix.diff with real diff -u after a hand-written hunk failed patch(1).

Verified recommended fix

Bound the sw_i2cs[] index by the array domain, not GPIO_DDC_LINE_COUNT: 'if (get_hw_supported_ddc_line(ddc, &line) && line < pool->pipe_count) engine = pool->sw_i2cs[line];' (mirrors dce_i2c_hw.c:365); see fix.diff

Verdict

Deterministic wild-write panic reproduced in-kernel: the probe KLD compiles the REAL unmodified audited dce_i2c_sw.c (guest /usr/src/sys sha256 75d99a9e... == audit tree) with the amdgpu module's own -Werror flags and calls the real dce_i2c_acquire_i2c_sw_engine() on a faithfully-constructed resource_pool + ddc (pin_data->en=GPIO_DDC_LINE_DDC_VGA=6, hw_supported=true). The real get_hw_supported_ddc_line accepts line 6 (6 < GPIO_DDC_LINE_COUNT==8), reads pool->sw_i2cs[6] OUT OF BOUNDS (array is MAX_PIPES==6, sw_i2cs[6] aliases {i2c_hw_buffer_in_use,pad,pipe_count} -> 0x0000000600000000, non-NULL), and the real engine->ddc = ddc (dce_i2c_sw.c:392) faults: 'Fatal trap 12 ... fault virtual address = 0x600000000 ... supervisor write data ... Stopped at dce_i2c_acquire_i2c_sw_engine+0x59: movq %rbx,(%r12)'. Only the GPIO-service layer is stubbed; its success for en=6 is source-documented (hw_factory_dce80.c:163 number_of_pins[DDC_DATA]=8, hw_translate_dce80.c:190 DDCVGA->en 6, gpio_service.c:224-290). The full hardware entry (amdgpu probe -> dce_i2c_submit_command on a VGA/I2C-pad DDC link) is gated on AMD hardware absent from this guest; en and the HW-cap bit come from the GPU VBIOS (bios_parser2.c:395-405), so the real-world trigger is a crafted/mis-declared VBIOS (flashed card, passthrough ROM) or a board legitimately mapping DDC to the VGA pad - every DM ASIC reaches this code (dce_i2c.c:46-58, SW engine tried first). Fix validated BEHAVIORALLY: with fix.diff (line < pool->pipe_count, mirroring dce_i2c_hw.c:365) the same call returns NULL cleanly, no dal_ddc_open, no fault, module load/unload clean, guest stays up.