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)
PoC verification
Evidence pack
findings/poc/DF-4827 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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.
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 6sys/dev/drm/amd/display/include/gpio_types.h:206-220βenum gpio_ddc_lineends atGPIO_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) bybool 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} andsw_i2cs[7]overlays {underlay_pipe_index,stream_enc_count} (offsets proven live by the probe:&sw_i2cs[6] == &i2c_hw_buffer_in_useexactly).dce_i2c_sw.c:91-92βget_hw_supported_ddc_line()rejects onlyline_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 asstruct dce_i2c_sw *.dce_i2c_sw.c:538βif (!engine) return NULL;does NOT save us: the garbage is non-NULL wheneverpipe_count != 0(always true on real pools) orstream_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 sizedGPIO_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 toMAX_PIPESin the new DC engine code.
2. Reachability / threat model
en(the DDC line) is decided by VBIOS tables:bios_parser2.c:395-405copiesi2c_hw_assist(theI2C_HW_CAPbit) andclk_a_register_indexfrom the ATOM GPIO_I2C table;dc_link_ddc.c:266-276builds the per-linkddc_pinfrom it;hw_translate_dce80.c:190-196(same in dce110/120/dcn10) mapsmmDC_GPIO_DDCVGA_A β GPIO_DDC_LINE_DDC_VGA (=6)andmmDC_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_GETCONNECTORforces 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) | smallfor 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 embeds0xFFFFFFFFlow 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), VA0x600000000. - Patched (run.2.log):
fix.diffapplies 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 printsswi2cs: acquire returned 0 (NULL = correctly rejected)β nodal_ddc_open, no fault; module loaded AND unloaded cleanly; guest stayed up (vm.sh statusβ up). fix.diffboundline < pool->pipe_count== the array domain idiom already used by the HW path (dce_i2c_hw.c:365);pipe_count β€ MAX_PIPESon 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_swalways callsdce_i2c_sw_engine_set_speedfirst (:506), which floorsclock_delay β₯ 12(:375-376) βclock_delay_div_4 β₯ 3;ASSERT(speed)is a no-op in release butspeed ? 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 perwait_for_scl_high_swcall, 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_swSDA-stuck loop (:335-359): bounded byI2C_SW_RETRIES=10.- GPIO NULL family (DF-4787):
dal_gpio_get_value/set_valueare NULL-pin-safe (gpio_base.c:74-96 returnGPIO_RESULT_NULL_HANDLE);ddc->pin_data/pin_clockare non-NULL by construction (gpio_service.c:428-442); return values ignored inread_bit_from_ddc/write_bit_to_ddcbut the defaultedvalue=0is 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_closetolerates NULL pins (gpio_base.c:220-229), double-close safe.- payload/length plumbing:
i2c_request_transaction_data.lengthis 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
fixedfix.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)
Confirmed kernel references
- sys/dev/drm/amd/display/dc/dce/dce_i2c_sw.c:91-92
- sys/dev/drm/amd/display/dc/dce/dce_i2c_sw.c:536
- sys/dev/drm/amd/display/dc/dce/dce_i2c_sw.c:392
- sys/dev/drm/amd/display/dc/inc/core_types.h:151
- sys/dev/drm/amd/display/dc/inc/hw/hw_shared.h:37
- sys/dev/drm/amd/display/include/gpio_types.h:206-220
- sys/dev/drm/amd/display/dc/dce/dce_i2c.c:46-58
- sys/dev/drm/amd/display/dc/dce/dce_i2c_hw.c:365
- sys/dev/drm/amd/display/dc/gpio/dce80/hw_factory_dce80.c:163-164
- sys/dev/drm/amd/display/dc/gpio/dce80/hw_translate_dce80.c:190-196
- sys/dev/drm/amd/display/dc/bios/bios_parser2.c:395-405
- sys/dev/drm/amd/display/dc/core/dc_link_ddc.c:266-276
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.
No comments yet.