⬢ DragonFlyBSD Kernel Audit
← triage · dashboard
DF-4878

dal_i2caux_release_engine(): unsynchronized engine state — engine->ddc NULL-wipe race ends in dal_ddc_close(NULL) NULL-deref panic (DAL2 mutex dropped) + wrong-owner pin close

Summary

The acquire->submit->release critical section runs with no lock (the DAL2 mutex was dropped in this port - admitted in the header) - a concurrent acquirer of the same engine can pass the pin double-open guard in the window between close and the NULL store, so T1 wipes T2's fresh ddc (immediate NULL deref in submit or dal_ddc_close(NULL), which derefs without guard) or closes T2's pins mid-transaction. HPD-detect vs mode-set on one connector; narrow-window DoS. Guard-the-close + restore-the-mutex fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4878 · 6 files
FileTypeDescriptionSize
README.md — 3.6 KB ↓ raw
VERDICT.md — 3.2 KB ↓ raw
env.txt — 195 B view raw
fix.diff — 663 B view raw
manifest.json — 665 B view raw
verdict.json — 3.0 KB view raw
README.md
↓ download raw

DF-4878 — i2caux engine acquire/release is unsynchronized: engine->ddc

NULL-wipe race ends in dal_ddc_close(NULL) NULL-deref

sys/dev/drm/amd/display/dc/i2caux/i2caux.c:420-429:

void dal_i2caux_release_engine(
    struct i2caux *i2caux,
    struct engine *engine)
{
    engine->funcs->release_engine(engine);

    dal_ddc_close(engine->ddc);   /* :426 — no NULL guard */

    engine->ddc = NULL;           /* :428 — wipes concurrent acquirer's ddc */
}

The whole acquire→submit→release engine critical section runs without any lock — the header even documents the omission (sys/dev/drm/amd/display/dc/i2caux/i2caux.h:91: "// [anaumov] in DAL2, there is a Mutex"). DAL2's mutex was dropped in this port.

Race (two threads, same DDC line / same engine)

Engines are per-line on all current ASICs (i2c_sw_engines[] / aux_engines[], i2caux.c:385/409; the "generic" engines are never created — dce80/dce110 constructs end with "TODO Generic I2C SW and HW"). Two concurrent transactions on one connector (e.g. HPD-workqueue EDID read racing a DRM-ioctl AUX transfer) both target the same engine object:

  • T1 in dal_i2caux_release_engine: dal_ddc_close(engine->ddc) done (pins closed, gpio->pin = NULL — gpio_base.c open/close state machine), but T1 has not yet executed engine->ddc = NULL.
  • T2 calls engine->funcs->acquire() → dal_aux_engine_acquire() (aux_engine.c:48-77) / dal_i2caux_i2c_sw_engine_acquire_engine() (i2c_sw_engine.c:462-476): the pin double-open guard (dal_gpio_open_ex, gpio_base.c:59-70) now passes (T1 already closed), T2 opens the pins and stores engine->ddc = ddc (aux_engine.c:75 / i2c_sw_engine.c:473).
  • T1 resumes: engine->ddc = NULL — wipes T2's ddc pointer.
  • T2 submits: for the SW i2c path dal_i2c_sw_engine_submit_channel_request() immediately dereferences engine->base.ddc (i2c_sw_engine.c:491 start_sync(ctx, ddc, ...)) → NULL deref; for the AUX path T2 survives to its own release and calls dal_ddc_close(NULL) (i2caux.c:426) → dal_gpio_close(ddc->pin_clock) on NULL (gpio_service.c:587-590, which has no NULL guard — dal_gpio_close guards its argument but dal_ddc_close dereferences ddc itself) → fatal trap.

Secondary hazard in the same window: if T2's acquire completes before T1's dal_ddc_close runs, T1 closes T2's pins mid-transaction (wrong owner close), corrupting the gpio pin state machine for the running bit-banged/AUX sequence.

Reachability: dal_i2caux_submit_i2c_command / submit_aux_command (i2caux.c:109-267) are the EDID/DDC/AUX datapath of the amdgpu display stack (callers: dc_link_ddc.c:598 dal_i2caux_submit_aux_command and the i2c-algo path), driven by HPD detect, DP AUX, and DRM ioctls. Root is needed for DRM master ioctls, but HPD-driven detection runs concurrently with them by design, so unprivileged-triggerable events (hotplug) can supply the second thread.

Status: untested (in situ) — no AMD GPU on the audit guest

amdgpu.ko is present on the guest but the only VGA is QEMU's std VGA (vgapci0 8086:1111 — env.txt), so the amdgpu driver never attaches and the DC i2caux object is never constructed. This is a hardware-availability blocker; the interleaving above is established purely from the lock-free code layout (two adjacent stores at i2caux.c:426/428 vs. the store in the acquire path at aux_engine.c:75).

Fix

fix.diff (hardening sink): guard if (engine->ddc) dal_ddc_close(...) in dal_i2caux_release_engine, killing the NULL-deref sink. The complete fix is to restore the DAL2 mutex (serialize acquire→submit→release per i2caux) — see recommended_fix in the finding.

VERDICT.md
↓ download raw

DF-4878 VERDICT — unsynchronized i2caux engine release NULL-wipe → dal_ddc_close(NULL)

Status: untested in situ (no AMD GPU on guest); race established by line trace

Root cause

dal_i2caux_release_engine() (sys/dev/drm/amd/display/dc/i2caux/i2caux.c:420-429) performs, with no synchronization whatsoever:

  1. engine->funcs->release_engine(engine) — for aux engines just a register update (aux_engine_dce110.c:73-78), for SW i2c engines a no-op (i2c_sw_engine.c:559-561);
  2. dal_ddc_close(engine->ddc) — closes both DDC pins;
  3. engine->ddc = NULL.

The acquire path (dal_aux_engine_acquire aux_engine.c:48-77; dal_i2caux_i2c_sw_engine_acquire_engine i2c_sw_engine.c:462-476) opens the pins and stores engine->ddc = ddc (aux_engine.c:75, i2c_sw_engine.c:473). The pin double-open guard lives in dal_gpio_open_ex (gpio->pin != NULL → GPIO_RESULT_ALREADY_OPENED, gpio_base.c:59-70) — which re-opens the instant T1's dal_ddc_close completes, i.e. between steps 2 and 3 of a concurrent release. T1's step 3 then NULLs the ddc pointer T2 just installed:

  • SW i2c path: T2's dal_i2c_sw_engine_submit_channel_request() uses struct ddc *ddc = engine->base.ddc (i2c_sw_engine.c:491) → NULL deref in start_sync.
  • AUX path: T2 completes the transaction and its own release calls dal_ddc_close(NULL) (i2caux.c:426) → ddc->pin_clock on NULL (gpio_service.c:587-590 — dal_ddc_close has no NULL guard, unlike dal_gpio_close at gpio_base.c:220-227) → fatal trap.

The port itself flags the missing lock: i2caux.h:91 /* [anaumov] in DAL2, there is a Mutex */ — DAL2 serialized acquire→submit→release; this tree does not. Related known family: DF-4787/DF-4788 (gpio_service open path), DF-4808 (hw-engine accounting) — all different files; this file's defect is the release-side wipe.

Converse interleaving (T2 acquires before T1's close): T1 closes T2's pins mid-transaction — wrong-owner close corrupting the gpio state machine.

Why untested

The guest's only display device is QEMU std VGA (env.txt: vgapci0 8086:1111); amdgpu.ko never attaches, the DC i2caux object is never constructed, and its datapath (dc_link_ddc.c:598 → i2caux.c:109/196) cannot be entered. Hardware blocker, not a refutation — every store/load in the interleaving was verified at the cited lines.

Impact

Narrow-window kernel NULL-deref panic (or gpio state corruption) inside the display DDC/AUX path, triggered by concurrent hotplug-detect vs mode-set transactions on one connector. DoS-class; not a corruption primitive (the wiped value is NULL, not attacker data).

Fix validation

fix_status: not_testable (hardware absent). fix.diff guards the close — if (engine->ddc) dal_ddc_close(engine->ddc); — removing the panic sink (the wrong-owner close and full serialization need the DAL2 mutex; recommended in the finding text).

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

No AMD GPU on guest; the guarded close removes the panic sink by construction; full serialization needs the mutex (recommended in finding)

fix.diff (git apply --check OK)
↓ fix.diffper-fix-DF-4878

Confirmed kernel references

Detail

Exploit chain

T1 release between dal_ddc_close (i2caux.c:426) and engine->ddc=NULL (:428) while T2 acquire passes the now-clear pin-open guard (gpio_base.c:59-70) and sets engine->ddc=ddc (aux_engine.c:75 / i2c_sw_engine.c:473) -> T1 wipes it -> T2 submits (SW: i2c_sw_engine.c:491 NULL ddc) or T2 releases (i2caux.c:426 dal_ddc_close(NULL) -> gpio_service.c:590 NULL deref) -> panic

Evidence (decisive lines)

VERDICT.md (interleaving with every load/store cited); README.md; env.txt (vgapci0 8086:1111 std VGA, amdgpu.ko unloaded)

PoC changes

n/a — no AMD GPU on guest (hardware blocker); race proven by code layout

Verified recommended fix

Guard dal_ddc_close(engine->ddc) against NULL (sink fix, fix.diff) and restore the DAL2 per-i2caux mutex across acquire->submit->release (root fix)

Verdict

dal_i2caux_release_engine() runs unsynchronized (the DAL2 mutex was dropped — i2caux.h:91) and stores engine->ddc = NULL two statements after dal_ddc_close(); a concurrent acquirer of the same per-line engine can open the just-closed pins and install its ddc pointer inside that window, which the release then wipes — the SW-i2c submit path dereferences the NULL ddc immediately, and the AUX path ends in dal_ddc_close(NULL) whose callee dereferences ddc->pin_clock without a NULL guard (gpio_service.c:587-590) -> fatal trap. Narrow-window display-path panic; not runnable on the audit guest because its only VGA is QEMU std VGA so amdgpu never attaches.