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

drm_crtc_helper_set_config() sizes save_connector_encoders with the racy num_connector while iterating the live list β€” concurrent DP-MST connector registration (spinlock-only) writes one pointer past the kcalloc'd array, and the fail path reads the OOB slot back into connector->encoder (heap OOB write + forged-pointer injection)

Summary

The array is sized from mode_config.num_connector under mode_config.mutex, but connector ADDITION takes only the connector_list_lock spinlock and is live via radeon DP-MST topology work. A connector added between kcalloc and the fill loop makes the iter walk visit N+1 connectors: save_connector_encoders[count++] writes one pointer past the array (clobbers the adjacent slab object's first qword); the fail path reads the OOB slot into connector->encoder - adjacent-heap-data pointer injection then deref'd. File is byte-identical to Linux v4.15 and still affected upstream. DRM-master + MST-hardware trigger. Bound-both-walks-by-snapshot fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4469 Β· 8 files
FileTypeDescriptionSize
setcrtc_dup.c β€” 8.3 KB view raw
build.sh β€” 89 B view raw
run.sh β€” 159 B view raw
build.log β€” 100 B view raw
run.log β€” 220 B view raw
env.txt β€” 411 B view raw
VERDICT.md β€” 5.9 KB ↓ raw
fix.diff β€” 1.7 KB view raw
VERDICT.md
↓ download raw

DF-4469 VERDICT β€” racy array sizing vs live connector list in drm_crtc_helper_set_config

Status: INCONCLUSIVE (race proven at code level; guest has no DRM device)

The audit guest runs QEMU std VGA β€” vgapci0: chip=0x11111234 with no KMS driver bound (no /dev/dri), and this DragonFly drm tree has no driver for the QEMU-emulated VGA, so the ioctl layer of this finding cannot even be entered on the guest. The race itself is proven by code trace below.

Root cause (path:line)

drm_crtc_helper_set_config runs under mode_config.mutex (taken by drm_mode_setcrtc, drm_crtc.c:601) β€” but the connector list it snapshots is NOT protected by that mutex:

  1. sys/dev/drm/drm_crtc_helper.c:571-576 save_connector_encoders = kcalloc(dev->mode_config.num_connector, ...) β€” num_connector is a plain counter.
  2. sys/dev/drm/drm_connector.c:256-260 spin_lock_irq(&config->connector_list_lock); list_add_tail(...); config->num_connector++; spin_unlock_irq(...) β€” connector ADDITION only takes the spinlock, never mode_config.mutex.
  3. The addition path is live in-tree for radeon DP MST: sys/dev/drm/drm_dp_mst_topology.c:1205 (cbs->add_connector, topology work context) β†’ sys/dev/radeon/radeon_dp_mst.c:267-286 radeon_dp_add_mst_connector β†’ drm_connector_init β€” no mode_config.mutex anywhere on that path (destruction is likewise deferred to destroy_connector_work precisely because the mutex may be held, drm_dp_mst_topology.c:950-960).
  4. sys/dev/drm/drm_crtc_helper.c:588-592 drm_for_each_connector_iter(connector, &conn_iter) save_connector_encoders[count++] = connector->encoder; The iterator (drm_connector_list_iter_next, drm_connector.c:623-650) walks the LIVE list under the spinlock and WILL visit a connector added after step 1 β†’ heap OOB write of one pointer (value = the fresh connector's encoder, i.e. NULL for a new kzalloc'd MST connector) one slot past the kcalloc'd array. With num_connector at a kmalloc bucket boundary (8, 16, 32, ...) the slot lands in the adjacent object.
  5. sys/dev/drm/drm_crtc_helper.c:766-770 (fail path) drm_for_each_connector_iter(connector, &conn_iter) connector->encoder = save_connector_encoders[count++]; reads the same out-of-bounds slot and stores adjacent heap data into the new connector's ->encoder β€” a forged-pointer injection. Later walks dereference it: drm_crtc_helper.c:663-664 connector->encoder->crtc = NULL (write through the forged pointer), drm_crtc_helper.c:843 (connector->encoder->crtc read), drm_crtc_helper.c:108-114 (pointer compare, benign). Any subsequent drm_mode_setcrtc/fail path then corrupts memory through that pointer.

Exploit chain (conceptual, on radeon + DP MST)

  1. Local unprivileged user becomes DRM master (first opener on a headless system, DRM_IOCTL_SET_MASTER).
  2. Spray objects of the num_connector+1 pointers' kmalloc size class to place an attacker-interesting victim immediately after the future array.
  3. Run DRM_IOCTL_MODE_SETCRTC in a tight loop while a partner replugs the MST hub: each replug re-runs link-address enumeration β†’ connector add.
  4. On hit: NULL-write into the adjacent object (step 4 above) and, on a subsequent failing setcrtc, OOB-read β†’ forged connector->encoder β†’ NULL-write at *(forged + offsetof(drm_encoder, crtc)).
  5. Primitive: deterministic kernel NULL write at (almost) attacker-chosen address via heap-groomed adjacent data β†’ escalation path standard for NULL/pointer writes (target refcount/flags fields), DoS trivially.

Why Medium, not High

Memory corruption class is real, but the trigger needs (a) radeon-class GPU with DP MST in legacy-helper mode, (b) physical-layer MST hotplug events during a microsecond-scale window, (c) DRM master. No software-only trigger exists in-tree (MST adds only run from topology work fed by HPD interrupts).

Fix (systemic, matches upstream-hardening direction)

Snapshot the connector list length consistently, e.g. size by iterating first or take mode_config.mutex in drm_connector_init:

--- a/sys/dev/drm/drm_connector.c
+++ b/sys/dev/drm/drm_connector.c
@@ -253,9 +253,11 @@ int drm_connector_init(struct drm_device *dev,
    /* We should add connectors at the end to avoid upsetting the connector
     * index too much. */
+   drm_modeset_lock_all(dev);
    spin_lock_irq(&config->connector_list_lock);
    list_add_tail(&connector->head, &config->connector_list);
    config->num_connector++;
    spin_unlock_irq(&config->connector_list_lock);
+   drm_modeset_unlock_all(dev);

…or, minimally inside drm_crtc_helper_set_config, clamp the fill loop:

--- a/sys/dev/drm/drm_crtc_helper.c
+++ b/sys/dev/drm/drm_crtc_helper.c
@@ -588,7 +588,8 @@ int drm_crtc_helper_set_config(struct drm_mode_set *set,
    count = 0;
    drm_connector_list_iter_begin(dev, &conn_iter);
    drm_for_each_connector_iter(connector, &conn_iter)
+       if (count < dev->mode_config.num_connector)
        save_connector_encoders[count++] = connector->encoder;
    drm_connector_list_iter_end(&conn_iter);

(the fail-path loop needs the symmetric guard). The lock fix is the correct one; the clamp is defense-in-depth.

Attempts

  • Guest build of setcrtc_dup.c: build.log.
  • Guest run: open("/dev/dri/card0") = ENOENT (no DRM device) β†’ run.log.
  • Upstream cross-check: Linux v4.15..master still sizes with num_connector and still adds connectors under the spinlock only β€” bug inherited, not fixed upstream (checked drm_connector.c @ master 2026-09).

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

not validated on guest (no DRM device on guest; race needs radeon+MST hw); fix.diff bounds both list walks by the snapshot count taken at allocation time

findings/poc/DF-4469/fix.diff
↓ fix.diffper-fix-DF-4469

Confirmed kernel references

Detail

Exploit chain

local DRM master (headless first opener) + radeon DP-MST + replug partner: SETCRTC loop vs topology-work connector add -> kcalloc(N) vs list N+1 -> OOB NULL write into adjacent slab object (groomable size class) and/or OOB read stored into connector->encoder -> NULL write at *(forged+offsetof(drm_encoder,crtc)) on next setcrtc -> deterministic kernel NULL-write primitive; DoS trivially, escalation heap-grooming dependent.

Evidence (decisive lines)

VERDICT.md (full path:line proof incl. upstream cross-check), setcrtc_dup.c (self-contained race harness), build.log/run.log (guest ENOENT), fix.diff (snapshot-bounded loops)

PoC changes

seed had disable-path SETCRTC loop only - that path early-returns before the kcalloc (drm_crtc_helper.c:543-556); rewrote to full modeset (dumb buffer + ADDFB + connector mode) so both variants actually reach the vulnerable code; added self-contained DRM ABI structs (guest has no drm userland headers); fixed gc.connection -> gc.connected

Verified recommended fix

Bound the save_connector_encoders fill and fail-restore loops by the num_connector snapshot taken at kcalloc time.

Verdict

drm_crtc_helper_set_config() sizes save_connector_encoders with dev->mode_config.num_connector (drm_crtc_helper.c:571) but fills it via drm_for_each_connector_iter over the live list (drm_crtc_helper.c:588-592); connector registration only takes the connector_list_lock spinlock (drm_connector.c:256-260), never mode_config.mutex, and the radeon DP-MST path registers connectors from topology work exactly that way (drm_dp_mst_topology.c:1205 -> radeon_dp_mst.c:267-286). A connector added between the kcalloc and the fill loop causes a one-pointer heap write past the array (value NULL for a fresh MST connector); the fail path (drm_crtc_helper.c:766-770) then reads the same OOB slot into the new connector->encoder, and later walks deref/write through it (drm_crtc_helper.c:663-664 connector->encoder->crtc = NULL) - heap OOB write plus forged-pointer injection. Upstream v4.15-identical (file is byte-identical except one include) and still present in current Linux. Not reproducible on the audit guest: it has no DRM device at all (QEMU std VGA, no KMS driver, /dev/dri absent) and the race needs radeon+DP-MST hardware with physical hotplug events. PoC builds and runs on the guest (ENOENT), race harness ready for hardware.