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

amdgpu bit-bang i2c adapters registered without lock_ops β†’ NULL function-pointer call in i2c_transfer() β†’ kernel panic on first DDC/EDID access

Summary

The bit branch registers via i2c_bit_add_bus only, but DF's compat core installs lock_ops ONLY in i2c_add_adapter - every i2c_transfer on a !hw_capable GPIO DDC bus calls lock_ops->lock_bus() through NULL (radeon's identical branch carries the explicit DFly fix; only amdgpu forgot it). REPRODUCED on guest with the REAL unmodified amdgpu_i2c.c+linux_i2c.c: Fatal trap 12 at i2c_transfer+0x2b (VA 0x0), guest down; the radeon-style i2c_add_adapter fix behaviorally validated (clean chain, load/unload). Boot/hotplug-time crash on any AMD-GPU system; fixed-NULL ceiling = DoS.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4961 Β· 15 files
FileTypeDescriptionSize
agi2c_probe.c β€” 8.0 KB view raw
Makefile β€” 1.4 KB ↓ download
build.sh β€” 244 B view raw
run.sh β€” 189 B view raw
build.log β€” 11.8 KB view raw
build.2.log β€” 11.8 KB view raw
run.log β€” 1.2 KB view raw
run.2.log β€” 2.1 KB view raw
panic.txt β€” 732 B view raw
env.txt β€” 203 B view raw
fix.diff β€” 694 B view raw
README.md β€” 3.8 KB ↓ raw
VERDICT.md β€” 6.9 KB ↓ raw
manifest.json β€” 1.6 KB view raw
verdict.json β€” 7.7 KB view raw

DF-4961 β€” amdgpu_i2c.c bit-bang adapters have NULL adapter->lock_ops β†’ NULL function-pointer call in i2c_transfer() β†’ kernel panic

What

amdgpu_i2c_create()'s bit-bang branch (sys/dev/drm/amd/amdgpu/amdgpu_i2c.c:193-212) registers the adapter with i2c_bit_add_bus() only. In DragonFly's linux-compat i2c core, ONLY i2c_add_adapter() installs adapter->lock_ops (sys/dev/drm/linux_i2c.c:51-52); i2c_bit_add_bus() does not (it only sets algo and retries, linux_i2c.c:128-134). Every subsequent i2c_transfer() on such an adapter executes adapter->lock_ops->lock_bus(adapter, I2C_LOCK_SEGMENT) at linux_i2c.c:99 β€” a call through a NULL pointer β†’ kernel panic.

radeon's identical bit branch carries an explicit #ifdef __DragonFly__ i2c_add_adapter(&i2c->adapter); #endif with the comment "setup lock_ops" (radeon_i2c.c:970-972), and the DP aux core assigns its own lock_ops (drm_dp_helper.c:1124) β€” only amdgpu_i2c.c's bit branch omits it.

Reachability (production)

  • Every !rec->hw_capable GPIO DDC bus (legacy/VGA-class connectors; the hw_capable && amdgpu_hw_i2c branch at amdgpu_i2c.c:184 is not taken for them β€” amdgpu_hw_i2c defaults to 1, amdgpu_drv.c:88, irrelevant here).
  • First i2c_transfer() on the adapter: EDID probe via drm_get_edid(..., &ddc_bus->adapter) (amdgpu_connectors.c:308-313) β†’ drm_do_probe_ddc_edid (drm_edid.c:1552), or this file's own amdgpu_i2c_router_select_ddc_port() (amdgpu_display.c:463, amdgpu_connectors.c:292) / ..._cd_port() (dce_v8_0.c:3229, dce_v10_0.c:3341, dce_v11_0.c:3467).
  • Trigger is kernel-driven (display detect / HPD poll) once the amdgpu driver is bound β€” no user interaction needed; panic on driver load/probe.

PoC (this directory)

The probe KLD compiles the REAL, UNMODIFIED audited files from the guest's own /usr/src/sys (sha256-verified identical to the audit tree) β€”

β€” plus agi2c_probe.c (fake drm_device/pci_dev/bus_rec/connector + linkage stubs for the never-reached atombios hw algo / mmio accessors / logging). The NULL deref happens in i2c_transfer() BEFORE any hardware access, so no AMD GPU is required.

Build

./build.sh        # or manually: transfer agi2c_probe.c + Makefile, then
                  #   cd /tmp/df4961 && make      (-Werror, bsd.kmod.mk)

Run

./run.sh          # phase 1: baseline  -> PANIC
                  # phase 2: fix.diff applied to /usr/src copy, rebuild
                  #         -> lock_ops set, no panic, clean kldunload

Expected output

Baseline (run.log):

agi2c: chan=0xfffff8008de6f580 adapter.name='AMDGPU i2c bit bus DF4961-probe'
agi2c: adapter.algo=0xffffffff826025a0 (== &i2c_bit_algo: YES - bit bus) master_xfer=0xffffffff82600d10
agi2c: adapter.lock_ops=0  <- i2c_transfer() needs this non-NULL
agi2c: FIRING - REAL amdgpu_i2c_router_select_ddc_port(&fconn)...
Fatal trap 12: page fault while in kernel mode
fault virtual address = 0x0
Stopped at i2c_transfer+0x2b: movq (%rax),%rax        <- loading lock_bus from NULL

Guest dies (vm.sh status β†’ down).

Patched (run.2.log):

agi2c: adapter.lock_ops=0xffffffff826025d0  <- i2c_transfer() needs this non-NULL
agi2c: lock_ops set - fix applied, firing anyway (no panic expected)
agi2c: FIRING - REAL amdgpu_i2c_router_select_ddc_port(&fconn)...
agi2c: [drm_dbg stub amdgpu_i2c_get_byte] i2c 0x03 0x00 read failed
... (4 get/put ops complete via the compat bit_xfer stub)
agi2c: survived amdgpu_i2c_router_select_ddc_port (unfixed build panics ...)
agi2c: unloaded

KLDLOAD2_OK + KLDUNLOAD_OK, guest stays up.

Fix

fix.diff β€” mirror radeon: call i2c_add_adapter() after i2c_bit_add_bus() in the bit branch (git-apply-able against the repo root).

VERDICT.md
↓ download raw

DF-4961 β€” VERDICT

Bottom line

REPRODUCED (in-kernel, real audited code): NULL function-pointer dereference β†’ kernel panic. The probe KLD compiles the REAL, UNMODIFIED sys/dev/drm/amd/amdgpu/amdgpu_i2c.c (the audited file) and the REAL sys/dev/drm/linux_i2c.c (the DragonFly linux-compat i2c core), both sha256-verified byte-identical to the audit tree, and drives the production chain with the audited file's own exported API:

amdgpu_i2c_create(dev, rec, "DF4961-probe")        amdgpu_i2c.c:160-219
  rec->hw_capable == false β†’ bit branch            amdgpu_i2c.c:193-212
    i2c_bit_add_bus(&i2c->adapter)                 amdgpu_i2c.c:207
      β†’ sets adapter->algo only, NOT lock_ops      linux_i2c.c:128-134
  β†’ returns chan with adapter.lock_ops == NULL     (printed: 0)
amdgpu_i2c_router_select_ddc_port(&fconn)          amdgpu_i2c.c:341-366
  β†’ amdgpu_i2c_get_byte()                          amdgpu_i2c.c:284-316
    β†’ i2c_transfer(&i2c_bus->adapter, msgs, 2)     amdgpu_i2c.c:309
      β†’ adapter->lock_ops->lock_bus(...)           linux_i2c.c:99
        == (*(struct i2c_lock_operations *)NULL)->lock_bus()

Observed (run.log): adapter.lock_ops=0, then Fatal trap 12: page fault ... fault virtual address = 0x0 ... Stopped at i2c_transfer+0x2b: movq (%rax),%rax β€” the very instruction that loads the lock_bus function pointer from the NULL lock_ops. The guest died (vm.sh status β†’ down).

Fix validated behaviorally: with fix.diff (the same #ifdef __DragonFly__ i2c_add_adapter(&i2c->adapter); that radeon's bit branch already carries, radeon_i2c.c:970-972) applied to the guest's /usr/src/sys copy, the rebuilt module shows adapter.lock_ops=0xffffffff826025d0 (set), the identical call chain runs to completion β€” the compat bit_xfer stub returns 0, all four router get/put byte ops take their "failed" debug path β€” NO panic, module loads AND unloads cleanly, guest stays up (run.2.log).

1. Root cause, line by line

  • sys/dev/drm/amd/amdgpu/amdgpu_i2c.c:193-212 β€” the bit branch sets up i2c->bit.* callbacks and calls ONLY i2c_bit_add_bus(&i2c->adapter) (line 207). i2c_add_adapter() is never called on this path (the hw branch at lines 184-192 does call it, line 190).
  • sys/dev/drm/linux_i2c.c:45-55 β€” i2c_add_adapter() is the ONLY code that installs a default lock_ops (if (!adapter->lock_ops) adapter->lock_ops = &i2c_default_lock_ops;).
  • sys/dev/drm/linux_i2c.c:128-134 β€” DF's i2c_bit_add_bus() sets adapter->algo and adapter->retries only. (Upstream Linux's i2c_bit_add_bus internally calls i2c_add_adapter; the DF port does not, so the CALLER must β€” radeon does.)
  • sys/dev/drm/linux_i2c.c:92-104 β€” i2c_transfer() dereferences adapter->lock_ops->lock_bus(...) (line 99) with no NULL check.
  • Corroboration that this is a known DF-specific requirement:
  • sys/dev/drm/radeon/radeon_i2c.c:969-973 β€” the identical radeon bit branch carries #ifdef __DragonFly__ /* setup lock_ops */ i2c_add_adapter(&i2c->adapter); #endif.
  • sys/dev/drm/drm_dp_helper.c:1124,1156 β€” the DP aux i2c adapter assigns its own lock_ops (drm_dp_i2c_lock_ops) and calls i2c_add_adapter(). Only amdgpu's bit branch omits it.

2. Reachability / threat model

  • Which buses: every !hw_capable GPIO DDC line parsed from the VBIOS GPIO_I2C table (legacy/VGA-class connectors; boards commonly have both hw-capable DP aux lines and GPIO-only lines). The check at amdgpu_i2c.c:184 is rec->hw_capable && amdgpu_hw_i2c; amdgpu_hw_i2c defaults to 1 (amdgpu_drv.c:88) and is irrelevant for !hw_capable records β€” they ALWAYS take the bit branch.
  • Production callers of i2c_transfer() on those adapters:
  • drm_get_edid(connector, &amdgpu_connector->ddc_bus->adapter) (amdgpu_connectors.c:308-313) β†’ drm_do_probe_ddc_edid (drm_edid.c:1552 i2c_transfer(adapter, ...)).
  • This file's own exported router helpers: amdgpu_i2c_router_select_ddc_port (amdgpu_display.c:463, amdgpu_connectors.c:292) and amdgpu_i2c_router_select_cd_port (dce_v8_0.c:3229, dce_v10_0.c:3341, dce_v11_0.c:3467 β€” HPD handlers), both via amdgpu_i2c_get_byte/put_byte (amdgpu_i2c.c:309, :334).
  • Trigger: kernel-driven display detect / HPD polling / EDID probe β€” no user action required once the amdgpu driver is bound. On real hardware this is a boot-time or hotplug-time panic (local DoS, unauthenticated from the "console user" perspective).
  • The full hardware entry is gated on AMD GPU hardware absent from this guest; the panic itself is INDEPENDENT of hardware because lock_bus is called before master_xfer (no mmio access happens first). The probe reproduces the exact code path with the exact audited objects; only the atombios hw algo (unreachable on the bit path), the mmio accessors (never dispatched β€” DF's i2c_bit_algo.master_xfer is the bit_xfer stub, linux_i2c.c:106-111) and logging are stubbed.

3. Observations mapped

Observation Value
Baseline kldload panic Fatal trap 12, VA=0x0, i2c_transfer+0x2b: movq (%rax),%rax, guest down
Patched kldload lock_ops=0xffffffff826025d0, no panic, KLDLOAD2_OK, KLDUNLOAD_OK, guest up
Status reproduced
Impact panic (local DoS on AMD-GPU systems; hardware-gated)
uid0 chain none β€” NULL fp call at fixed address 0, no control over the call target (lock_ops is NULL, not attacker-written); DoS is the ceiling

4. Fix validation

  • fix.diff applied to guest /usr/src/sys/dev/drm/amd/amdgpu/amdgpu_i2c.c (grep confirms the new i2c_add_adapter(&i2c->adapter); at line 216), rebuilt with -Werror (build.2.log), identical probe re-run: previously-fatal chain completes, module unloads cleanly, guest stays up.
  • Side note observed in the fixed run: DF's compat bit_xfer is an explicit stub (linux_i2c.c:106-111 "XXX Linux really does try to transfer some data here") β€” so even with the panic fixed, GPIO bit-bus DDC transfers functionally no-op on DragonFly (every get/put byte logs "read/write failed"). That is a functional gap of the compat layer, not a memory-safety issue, and is outside this finding's scope.
  • The fixed run's [drm_dbg stub] i2c 0x03 0x00 read failed lines also live-demonstrate the uninitialized *val read/print at amdgpu_i2c.c:313-314 (DF-4962): the second %02x is the never-assigned val.

5. Artifacts

run.log (baseline panic) Β· run.2.log (fixed, clean) Β· panic.txt Β· build.log/build.2.log (real-file -Werror builds, baseline + patched) Β· agi2c_probe.c + Makefile (probe KLD) Β· build.sh/run.sh Β· env.txt Β· fix.diff

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 (the radeon-style #ifdef DragonFly i2c_add_adapter() call after i2c_bit_add_bus()) applies clean to the guest's /usr/src copy (grep shows the new call at line 216), rebuilds with -Werror (build.2.log compiles the patched real amdgpu_i2c.c), and BEHAVIORALLY fixes the panic: the identical probe call that faulted at i2c_transfer+0x2b on baseline now runs the full amdgpu_i2c_router_select_ddc_port() chain with lock_ops=0xffffffff826025d0 installed, takes the compat stub bit_xfer 'read/write failed' path for all four router ops, module loads AND unloads cleanly, guest stays up (run.2.log, vm.sh status: up).

fix.diff; run.2.log ('adapter.lock_ops=0xffffffff826025d0', 'survived amdgpu_i2c_router_select_ddc_port', KLDLOAD2_OK, KLDUNLOAD_OK); build.2.log (patched real amdgpu_i2c.c compiled -Werror, link of agi2c.ko); grep of patched /usr/src file showing i2c_add_adapter at line 216
↓ fix.diffper-fix-DF-4961

Confirmed kernel references

Detail

Exploit chain

amdgpu driver bound (module load / boot config) -> VBIOS GPIO_I2C record with hw_capable=0 (every board's legacy/VGA DDC lines) -> amdgpu_i2c_create() bit branch (amdgpu_i2c.c:193-212) leaves adapter.lock_ops NULL -> kernel-driven display detect / HPD poll / EDID probe (amdgpu_connectors.c:308-313 -> drm_edid.c:1552, or amdgpu_display.c:463 / dce_v8_0.c:3229 / dce_v10_0.c:3341 / dce_v11_0.c:3467 router selects) -> i2c_transfer() (amdgpu_i2c.c:309) -> adapter->lock_ops->lock_bus() at linux_i2c.c:99 = call through NULL -> page fault at VA 0x0 -> panic. No uid0: the faulting call loads its target from address 0 - not attacker-controllable content - so the primitive is a fixed NULL fp call (DoS only).

Evidence (decisive lines)

run.log (baseline: 'agi2c: adapter.lock_ops=0', 'agi2c: FIRING - REAL amdgpu_i2c_router_select_ddc_port', 'Fatal trap 12: page fault ... fault virtual address = 0x0', 'Stopped at i2c_transfer+0x2b: movq (%rax),%rax', guest down); run.2.log (patched: 'adapter.lock_ops=0xffffffff826025d0', four drm_dbg 'read/write failed' lines over the compat stub, 'survived amdgpu_i2c_router_select_ddc_port', KLDLOAD2_OK + KLDUNLOAD_OK, guest up); panic.txt; build.log/build.2.log (-Werror builds of the real audited files, baseline + patched); env.txt; fix.diff (git-apply-able, mirrors radeon_i2c.c:970-972)

PoC changes

wrote the KLD from scratch (agi2c_probe.c + Makefile): no seed PoC existed. Build iterations: (1) direct sys/systm.h include clashes with the kconfig.h force-include (same issue DF-4827 hit) - dropped it, linux/printk.h pulls sys/systm.h in a working order; (2) amdgpu_atombios_i2c_init needed its real prototype - include amdgpu_atombios.h; (3) nested '/' and '/' inside explanatory comments tripped -Werror=comment - reworded; (4) kldload failed with 'amdgpu_mm_rreg undefined' (RREG32/WREG32 expand to real functions) - added never-executed linkage stubs for amdgpu_mm_rreg/amdgpu_mm_wreg; also stubbed amdgpu_atombios_i2c_xfer/_func (unreachable on the bit path) and drm_dbg/drm_err (logging; stubs always print so the fixed build's 'read failed' path is visible - the prints also live-demonstrate the uninitialized *val at amdgpu_i2c.c:313-314). amdgpu_hw_i2c defined to its production default 1 (amdgpu_drv.c:88).

Verified recommended fix

In amdgpu_i2c_create()'s bit branch, call i2c_add_adapter(&i2c->adapter) after i2c_bit_add_bus(&i2c->adapter), mirroring radeon_i2c.c:970-972 (or make DF's i2c_bit_add_bus install default lock_ops like upstream); see fix.diff

Verdict

Deterministic NULL function-pointer dereference reproduced in-kernel: the probe KLD compiles the REAL unmodified audited amdgpu_i2c.c plus the REAL linux_i2c.c compat core (guest /usr/src/sys sha256 06714f0b.../73ef89d2... == audit tree) with the amdgpu module's -Werror flags, and calls the real amdgpu_i2c_create() with a !hw_capable bus record (the GPIO-only DDC lines every VBIOS table declares for legacy connectors). amdgpu_i2c_create() takes the bit branch (amdgpu_i2c.c:193-212) which registers the adapter via i2c_bit_add_bus() ONLY - in DF's compat layer only i2c_add_adapter() installs adapter->lock_ops (linux_i2c.c:51-52) - so the adapter comes back with lock_ops == NULL (printed live). The file's own exported amdgpu_i2c_router_select_ddc_port() -> amdgpu_i2c_get_byte() (amdgpu_i2c.c:309) -> i2c_transfer() then executes adapter->lock_ops->lock_bus() at linux_i2c.c:99 through NULL: 'Fatal trap 12 ... fault virtual address = 0x0 ... Stopped at i2c_transfer+0x2b: movq (%rax),%rax' - the exact instruction loading lock_bus from the NULL ops table. Guest died (vm.sh status: down). The deref precedes any hardware access, so no AMD GPU is required for the reproduction; the production entry (real amdgpu probe -> drm_get_edid(&ddc_bus->adapter) at amdgpu_connectors.c:308-313 -> drm_edid.c:1552, or the router_select callers amdgpu_display.c:463 / dce_v*_0.c hpd handlers) is gated on AMD hardware absent from this guest but reaches the identical i2c_transfer() call, kernel-driven on display detect/hotplug with no user action. Fix validated behaviorally: fix.diff (the same #ifdef DragonFly i2c_add_adapter(&i2c->adapter) call radeon's bit branch already carries, radeon_i2c.c:970-972) applied to the guest /usr/src copy, rebuilt -Werror: lock_ops=0xffffffff826025d0, the identical chain completes (compat bit_xfer stub -> four 'read/write failed' debug lines), no panic, module loads AND unloads, guest stays up. uid0 chain: none - the call target is loaded from NULL (fixed address 0), no attacker control over target or arguments; impact ceiling is reliable local DoS (panic) on amdgpu-bound systems.