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

dc_sink retain/release contract violated by DM forced-connector paths: emulated_link_detect strands references (permanent ~1KB dc_sink leak per forced-detect cycle) and update_connector_after_detect branch A releases a borrowed dc_sink (premature free with dangling link->local_sink, or kref_put UAF write + dc_sink_free double-free on freed slab after EDID-change detect)

Summary

Two DM paths break the dc_sink_retain/dc_sink_release ownership (dc_sink.c:68-83): emulated_link_detect retains prev_sink and never releases on ANY exit + overwrites link->local_sink without dropping the link ref (two stranded ~1KB sinks per forced-detect cycle, every S3/HPD); update_connector_after_detect branch A releases a BORROWED aconnector->dc_sink - premature free with dangling local_sink, or on EDID-change cycles dc_link_detect already freed it so :993 is kref_put on freed memory ending in dc_sink_free double-free of a groomable slab block. Root/boot video= or drm.edid_firmware= gate + physical monitor swap. Guest has no AMD GPU: ledger_model.c models the exact op sequence and reproduces both (leak: 9 live after 8 cycles; UAF: release-on-freed + double-free; fixed: 1 live, 0 UAF). Full pairing diff in fix.diff.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5218 Β· 11 files
FileTypeDescriptionSize
README.md β€” 3.8 KB ↓ raw
VERDICT.md β€” 8.8 KB ↓ raw
ledger_model.c β€” 8.2 KB view raw
build.sh β€” 112 B view raw
run.sh β€” 132 B view raw
build.log β€” 312 B view raw
run.log β€” 772 B view raw
env.txt β€” 1.3 KB view raw
fix.diff β€” 3.0 KB view raw
manifest.json β€” 1.4 KB view raw
verdict.json β€” 6.1 KB view raw

DF-5218 evidence pack β€” dc_sink refcount ledger violations in DM forced/EDID-override paths

Finding: dc_sink_retain/dc_sink_release (sys/dev/drm/amd/display/dc/core/dc_sink.c:68-83) are misused by two amdgpu_dm paths that run for boot-forced connectors (video= force / EDID-firmware override):

  1. Leak β€” emulated_link_detect() (amdgpu_dm.c:741-815) retains prev_sink (amdgpu_dm.c:754) and never releases it on ANY exit path, and overwrites link->local_sink (amdgpu_dm.c:810) without dropping the link's reference. Every forced-detect cycle (each S3 resume / HPD with no display) permanently strands one heap struct dc_sink (~1 KB).
  2. UAF / double-free β€” amdgpu_dm_update_connector_after_detect() branch A (amdgpu_dm.c:983-1003) calls dc_sink_release(aconnector->dc_sink) (amdgpu_dm.c:993) on a pointer DM holds borrowed (assigned without retain at amdgpu_dm.c:995, :1044, :3278-3280). The dropped reference is the link's: on a still-live sink this is a refcount undercount (premature free while link->local_sink still points at it); on an EDID-change cycle the sink has already been freed by dc_link_detect (dc_link.c:902) before the update runs, so :993 executes kref_put() on freed memory (UAF write) and, when the poisoned count reaches 0, dc_sink_free() on the freed object (double free / arbitrary-free of a reused slab block).

Reproduce

The kernel path needs an AMD DC GPU (Tonga/Fiji for DCE10, or any DC ASIC) β€” the audit QEMU guest has none (see env.txt), so the runtime trigger cannot be executed there. Two artifacts make the finding verifiable anyway:

  • ledger_model.c β€” an executable model of the exact operation sequence (verbatim ledger from the cited lines), with instrumented refcounts and use-after-free detection. Builds and runs anywhere.
  • VERDICT.md β€” line-by-line source proof of both ledger violations and the reachability chain (who sets connector->force, which ASICs, which detect cycles).
./build.sh          # cc -O2 -Wall -o ledger_model ledger_model.c
./run.sh            # bug-leak | bug-uaf | fixed

Expected (observed on the DFly guest, cc 8.3, see run.log):

[leak-scenario] live dc_sink objects after 8 emulated detects: 9
[leak-scenario] BUG: LEAK REPRODUCED (stranded dc_sink per cycle)
[uaf-scenario] stale sink freed before the :993 release: YES (dc_link.c:902 freed it)
[uaf-scenario] retain/release on freed objects (kref UAF writes): 1
[uaf-scenario] double-free attempts (dc_sink_free on freed): 1
[uaf-scenario] BUG: UAF REPRODUCED (dc_sink_release on freed dc_sink)
[leak-scenario] live dc_sink objects after 8 emulated detects: 1
[leak-scenario] FIXED: exactly 1 live sink (correct)

Real-world trigger (hardware present)

  1. Boot with a forced connector and EDID override, e.g. video=DP-1:1920x1080e (force-digital) or drm.edid_firmware=DP-1:edid/your-edid.bin β€” connector->force is set from the command line in drm_connector_get_cmdline_mode() (sys/dev/drm/drm_connector.c:137-140); dc_em_sink is then created on the first mode_valid call (amdgpu_dm.c:3320-3322 -> handle_edid_mgmt -> create_eml_sink).
  2. Leak: leave the connector headless and suspend/resume (or HPD-cycle) repeatedly β€” one stranded struct dc_sink per emulated_link_detect.
  3. UAF: with a display attached, cause two detect cycles whose EDIDs differ (replug a different monitor / KVM switch): the second dc_link_detect frees the old sink at dc_link.c:902, then amdgpu_dm.c:993 releases the freed object.

Fix

fix.diff (never applied to sys/): emulated_link_detect adopts the link_disconnect_sink-style transfer + release-on-all-exits pairing; branch A stops releasing/retaining borrowed references. The model's fixed mode demonstrates the corrected ledger.

VERDICT.md
↓ download raw

DF-5218 β€” VERDICT

Status: inconclusive (runtime) / proven at source level (ledger model reproduces both violations). Impact class (if hardware present): kernel heap leak + kref UAF write / double-free. Reproduced on guest: no β€” the path requires an AMD DC GPU; the guest has only the QEMU emulated VGA (vendor 0x1234), so amdgpu binds zero devices and dc_sink_create() never executes (proven in env.txt).

1. What was audited

sys/dev/drm/amd/display/dc/core/dc_sink.c (108 LOC) β€” the DC core sink lifecycle: construct/destruct, dc_sink_create/dc_sink_retain/ dc_sink_release/dc_sink_free. The file itself is a correct, thin kzalloc/construct/kref wrapper:

  • dc_sink_create (dc_sink.c:85-104): kzalloc -> construct -> kref_init (refcount = 1, creator's). construct (dc_sink.c:42-62) only fails when init_params->link == NULL (dc_sink.c:47-48) and in that case nothing has been written, so the kfree on the failure path (dc_sink.c:100) needs no destruct. File-local verdict: clean.
  • destruct (dc_sink.c:34-40) frees dc_container_id β€” dead-but-safe: a tree-wide search shows dc_container_id is only ever assigned NULL (dc_sink.c:38, :55); no code path allocates it. No double-free clone hazard either: the "logically cloneable up to refcount" comment (dc.h:713-716) is vestigial β€” no code memcpys a struct dc_sink.
  • No locking, no usercopy, no ioctl entry β€” the entire attack surface is the retain/release API consumed by DC/DM on display events.

The pass-2 finding is therefore an API-contract violation by its callers (the sink-lifecycle interplay this file audit was scoped to hunt):

emulated_link_detect() (amdgpu_dm.c:741-826) runs for boot-forced connectors when nothing is physically attached (callers: amdgpu_dm.c:869 resume, :1092 HPD, :1219 HPD-RX, :1886 init β€” all gated on aconnector->base.force && new_connection_type == dc_connection_none).

Ledger (verbatim from source):

751: prev_sink = link->local_sink;            /* borrowed                */
753: if (prev_sink != NULL)
754:     dc_sink_retain(prev_sink);           /* +1 temp ref             */
...      /* default: ... return;  (:798)  β€” temp ref LEAKED             */
804: sink = dc_sink_create(&sink_init_data);  /* +1 = link's ref         */
805: if (!sink) { ... return; (:807) }        β€” temp ref LEAKED         */
810: link->local_sink = sink;                 /* old link ref STRANDED   */
812: dm_helpers_read_local_edid(...)          /* DF-1837's overflow path */
     /* function end β€” temp ref LEAKED (no dc_sink_release anywhere)    */

Consequences: on every call with a prior local_sink, two references to the old struct dc_sink are stranded (the link's, overwritten at :810 without release, and the temp from :754, never released on any exit). The object (β‰ˆ1 KB: embedded dc_edid.raw_edid[512] + edid_caps + features_3d[], dc.h:696-717) can never reach refcount 0. Forced headless connector + repeated S3 resume β‡’ unbounded kernel heap growth.

Contrast with the correct sibling dc_link_detect() (dc_link.c:642-902), which retains prev_sink, calls link_disconnect_sink() (dc_link.c:492-500, releases the link ref) and releases the temp ref on every exit path (:696, :710, :724, :735, :759, :791, :902).

3. Violation B β€” release of a borrowed ref β‡’ premature free / UAF

amdgpu_dm_update_connector_after_detect() branch A (amdgpu_dm.c:975-1005) runs for the same forced connectors once dc_em_sink exists (gate :974-975; dc_em_sink created in create_eml_sink, amdgpu_dm.c:3271, on the first mode_valid call β€” amdgpu_dm.c:3320-3322).

DM's convention (everywhere except this branch): aconnector->dc_sink is a borrowed pointer β€” assigned with no dc_sink_retain at amdgpu_dm.c:995, :1001, :1044, :3278-3280. Branch A nevertheless does:

992: if (aconnector->dc_sink != aconnector->dc_em_sink)
993:     dc_sink_release(aconnector->dc_sink);   /* ref DM never took   */

Two states make this a memory-safety bug:

  • Old sink still is link->local_sink (refcount 1 = link's): :993 drops the link's reference β‡’ refcount 0 β‡’ sink freed while link->local_sink still points at it β‡’ use-after-free on every later touch (next dc_link_detect β†’ link_disconnect_sink β†’ dc_sink_release, stream commits, EDID ioctls).
  • EDID-change cycle (the deterministic one): a later dc_link_detect with a differing EDID frees the old sink itself (retain :644 β†’ link_disconnect_sink :647 β†’ create new :755 β†’ release prev :902 β‡’ refcount 0 β‡’ freed). DM still holds the stale borrowed pointer, and the following branch-A update executes dc_sink_release() on the freed object: kref_put() (atomic decrement β€” UAF write into a freed slab block) and, when the corrupted count reaches 0, dc_sink_free() β†’ destruct() + kfree() on the freed block β€” a double free that hands the block back to the allocator while it may already be reused (arbitrary-free of a live slab victim; sink-sized kmalloc zone, groomable timing between the free at dc_link.c:902 and the release at amdgpu_dm.c:993).

Note the accidental interaction: Violation A's stranded references keep the old emulated sinks over-refcounted, which masks Violation B on the headless path β€” the undercount UAF surfaces on the display-attached forced-connector path instead (where emulated_link_detect never runs). Both ledgers are proven in ledger_model.c (bug-leak, bug-uaf modes), whose output matches the by-hand accounting above (run.log).

4. Reachability chain (who can trigger this)

  1. connector->force is set only from the kernel command line (drm_connector_get_cmdline_mode, sys/dev/drm/drm_connector.c:137-140 β€” video=<conn>:...e[d]), from drm.edid_firmware= (sys/dev/drm/drm_edid.c:1606-1625), or by the driver itself (create_eml_sink sets DRM_FORCE_OFF). β‡’ the configuration gate is boot-time/root privilege.
  2. The DC sink machinery itself requires an amdgpu-bound AMD ASIC with DC enabled (DCE_VERSION_10_0 = Tonga/Fiji, dc_resource.c:71-76; DCE 11.x/12.x likewise) β‡’ specific hardware.
  3. Given (1)+(2): the leak repeats on every suspend/resume or HPD cycle of the forced headless connector; the UAF fires on any two detect cycles with differing EDIDs (monitor/KVM swap β€” a physical action that needs no privilege beyond the configured connector).

Severity assessment: Medium β€” genuine kernel heap corruption (memcorrupt class), but gated behind privileged boot configuration and AMD hardware, and the UAF trigger additionally needs a physical EDID change. The leak variant is a slow local DoS only.

5. Why not reproduced on the guest

  • Guest: DragonFly 6.5-DEVELOPMENT x86_64 (uname in env.txt); the only VGA device is vgapci0@pci0:0:2:0 chip=0x11111234 (QEMU virtual VGA, vendor 0x1234). kldload amdgpu succeeds but binds zero devices β€” dmesg shows only [drm] amdgpu kernel modesetting enabled. and no probe output; dc_create/dc_sink_create never run. No KVM device-assignment of an AMD GPU is available in this environment, and QEMU has no AMD DC GPU model.
  • The ledger model (ledger_model.c) was therefore built and executed on the guest (cc 8.3 [DragonFly]) and reproduces both violations exactly (run.log): 9 live sinks after 8 cycles (leak), and one release-on-freed + one double-free attempt in the EDID-change cycle (UAF), with the fixed mode returning to exactly 1 live sink and zero UAF hits.

Attempts: 2 (guest hardware-gate probe incl. amdgpu kldload; ledger-model build+run). Guest was reset clean after probing (vm.sh reset with-src); no kernel changes were made.

6. Fix validation

fix.diff implements: (a) emulated_link_detect adopts the link_disconnect_sink-style reference transfer with goto out + release-on-all-exits; (b) branch A drops the unowned dc_sink_release (amdgpu_dm.c:993) and its counterpart retain (:1003), leaving aconnector->dc_sink uniformly borrowed as in the normal path. A patched kernel cannot be exercised on this guest (no AMD GPU β‡’ the changed code never executes β€” the baseline itself does not run), so fix_status = not_testable; the model's fixed mode (same pairing semantics) demonstrates the corrected ledger returns to zero leaks / zero UAF hits. fix.diff was authored against the read-only sys/ tree and was never applied there.

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

fix.diff authored against read-only sys/ (never applied). Baseline cannot run on this guest (no AMD GPU β€” the amdgpu driver never initializes, so neither baseline nor patched kernel exercises the changed lines); a patched-kernel build would prove nothing about behavior. The corrected pairing semantics are demonstrated by ledger_model.c's 'fixed' mode: exactly 1 live sink after 8 cycles and zero UAF hits, versus the buggy ledger's 9 live sinks and 1 double-free attempt.

fix.diff; run.log (fixed-mode output); VERDICT.md section 6.
↓ fix.diffper-fix-DF-5218

Confirmed kernel references

Detail

Exploit chain

With an AMD DC GPU present and a boot-forced connector: (a) leak β€” leave forced connector headless, suspend/resume repeatedly; each emulated_link_detect strands one dc_sink (heap exhaustion DoS). (b) UAF β€” attach display, cause two detect cycles with differing EDIDs (monitor/KVM swap); dc_link_detect frees the old sink at dc_link.c:902, then amdgpu_dm.c:993 kref_puts the freed object; if the slab block was reused in the window between the free and the release, the decrement corrupts the victim object and a resulting 0-count triggers dc_sink_free (destruct + kfree) on it β€” arbitrary-free primitive on a ~1KB kmalloc zone, groomable toward slab corruption; on this stock guest no uid0 chain was attempted because the driver never initializes.

Evidence (decisive lines)

VERDICT.md (full line-cited ledger proof + reachability chain + hardware gate); run.log (guest: ledger_model bug-leak -> 9 live sinks; bug-uaf -> 'stale sink freed before the :993 release: YES', 1 UAF write, 1 double-free attempt; fixed -> 1 live sink, 0 UAF); env.txt (uname, pciconf chip=0x11111234 QEMU VGA, kldload amdgpu binding zero devices); fix.diff (pairing fix); ledger_model.c (code_hash below).

PoC changes

No seed PoC existed. Authored ledger_model.c: a userspace model of the verbatim dc_sink/dc_link/amdgpu_dm operation sequence with instrumented refcounts and freed-object detection (the kernel path itself is hardware-gated); plus build.sh/run.sh; first host build attempt failed (audit host has no cc) β€” final build/run performed on the DFly guest via base64 transport.

Verified recommended fix

emulated_link_detect: mirror dc_link_detect's pairing (release the link ref via a link_disconnect_sink-style transfer, drop the temp ref on every exit); update_connector_after_detect branch A: stop releasing/retaining the borrowed aconnector->dc_sink (fix.diff).

Verdict

Source-level proven, runtime hardware-gated. The dc_sink lifecycle file itself (dc_sink.c:34-104) is correct; its retain/release contract (dc_sink.c:68-83) is violated by two DM forced-connector paths: (1) emulated_link_detect (amdgpu_dm.c:741-826) retains prev_sink at :754 with no release on any exit and overwrites link->local_sink at :810 without dropping the link ref β€” one permanently stranded struct dc_sink (~1KB) per forced-detect cycle (S3 resume/HPD of a headless forced connector): unbounded kernel heap leak; (2) update_connector_after_detect branch A releases a BORROWED aconnector->dc_sink at amdgpu_dm.c:993 (never retained on store at :995/:1044/:3278) β€” on a still-live local_sink this drops the link's only ref (premature free with link->local_sink dangling), and on an EDID-change cycle the sink is already freed by dc_link_detect (dc_link.c:902) so :993 is dc_sink_release on freed memory: kref_put UAF write and dc_sink_free double-free of a groomable ~1KB slab block. Preconditions: boot-time connector force (video=/drm.edid_firmware=, drm_connector.c:137-140) + AMD DC GPU (DCE10=Tonga/Fiji, dc_resource.c:71-76) + (UAF) physical EDID change. The QEMU guest has only the QEMU virtual VGA (vendor 0x1234); kldload amdgpu binds zero devices and dc_sink_create never executes (env.txt), so no runtime reproduction was possible; an executable model of the exact ledger (ledger_model.c) was built and run on the guest (cc 8.3) and reproduces both violations (leak: 9 live sinks after 8 cycles; UAF: 1 release-on-freed + 1 double-free attempt) with the fixed pairing returning to 1 live sink / 0 UAF (run.log).