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

drm_mode_create_tile_group() ERR_PTR vs NULL contract mismatch poisons connector->tile_group β€” wild-pointer kref_put/deref at four sites (kernel panic)

Summary

drm_mode_create_tile_group returns ERR_PTR(-ENOMEM)/ERR_PTR(ret) on its two failure paths, but its only caller drm_parse_tiled_block checks only 'if (!tg)' - the NULL contract upstream v5.2 actually implements. The ERR_PTR passes the check, is stored in connector->tile_group with has_tile already true, and is then kref_put or dereferenced by connector_cleanup, set_tile_property, and two drm_edid sites - deterministic page fault at 0xfffffffffffffff4 (write) or 0x4 (read). Trigger: a monitor/DP-MST dock/USB-C dock/VM virtual display presenting an EDID with a DisplayID TILED_DISPLAY block, probed while the tile-group alloc fails (memory pressure, aggravatable by an unprivileged local user); next probe/property update/unload panics. Demo shows exact fault addresses on the guest; kernel-level hardware-gated. Restore-NULL-contract fix (two tokens).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4194 Β· 11 files
FileTypeDescriptionSize
trigger.c β€” 4.0 KB view raw
build.sh β€” 118 B view raw
run.sh β€” 104 B view raw
build.log β€” 197 B view raw
run.log β€” 1.1 KB view raw
env.txt β€” 289 B view raw
fix.diff β€” 479 B view raw
VERDICT.md β€” 4.4 KB ↓ raw
README.md β€” 1.6 KB ↓ raw
manifest.json β€” 1.5 KB view raw
verdict.json β€” 4.2 KB view raw

DF-4194 evidence pack β€” DRM tile-group ERR_PTR/NULL contract mismatch

Finding: drm_mode_create_tile_group() (sys/dev/drm/drm_connector.c:1917,1929) returns ERR_PTR on failure while its only caller drm_parse_tiled_block() (sys/dev/drm/drm_edid.c:5141) checks only "!tg" β€” the upstream-Linux-v5.2 NULL contract. The ERR_PTR is stored in connector->tile_group (drm_edid.c:5150) and later dereferenced/kref_put by four sites, panicking the kernel at fixed unmapped addresses (0xfffffffffffffff4 / 0x4).

Contents: trigger.c userspace demonstration of the mismatch + exact fault addresses (layout of struct drm_tile_group, ERR_PTR(-ENOMEM) arithmetic) build.sh build command (runs on the DFBSD guest; any unix works) run.sh run command build.log full untrimmed compiler output (guest: DragonFly cc/gcc) run.log full untrimmed output of the decisive run (guest) env.txt guest uname + DRM-absence proof (no /dev/dri, no drm kmod, only QEMU virtio-vga) fix.diff git-apply-able one-line-per-path fix vs sys/dev/drm/drm_connector.c (authored post-verification; never applied to sys/) VERDICT.md full narrative with line citations and crash-site table manifest.json / verdict.json machine-readable records

Expected output of ./run.sh: the ERR_PTR value 0xfffffffffffffff4 shown to pass the caller's NULL check, and the two computed fault addresses (0xfffffffffffffff4 for the kref_put sites, 0x4 for the ->id sites).

Status: untested at the kernel level on this guest (no DRM hardware β€” hardware-gated), source-level certain.

VERDICT.md
↓ download raw

DF-4194 β€” DRM tile-group ERR_PTR/NULL contract mismatch β†’ wild-pointer deref (kernel panic)

sys/dev/drm/drm_connector.c β€” pass 2 (GLM 5.3 re-audit), source-level certain, hardware-gated.

The bug (one paragraph)

drm_mode_create_tile_group() (sys/dev/drm/drm_connector.c:1909) returns an ERR_PTR on both failure paths β€” ERR_PTR(-ENOMEM) at line 1917 (kzalloc failure) and ERR_PTR(ret) at line 1929 (idr_alloc failure) β€” but its only caller, drm_parse_tiled_block() (sys/dev/drm/drm_edid.c:5137-5142), checks only if (!tg), the NULL contract that upstream Linux v5.2 actually implements (verified: upstream returns NULL on both paths). An ERR_PTR is non-NULL, so the error check passes and line 5150 stores the poison: connector->tile_group = ERR_PTR(-ENOMEM) (== (struct drm_tile_group *)-12). Every subsequent consumer of connector->tile_group then dereferences or refcounts a wild pointer and the kernel takes a page fault at a fixed unmapped address. Note connector->has_tile = true was already set at drm_edid.c:5120 before the failure, so nothing else guards the dereferences.

Crash sites (all reached after the poison is stored)

site code effective address
sys/dev/drm/drm_edid.c:5148 re-probe of the same sink: drm_mode_put_tile_group(dev, connector->tile_group) β†’ kref_put 0xfffffffffffffff4 (write)
sys/dev/drm/drm_edid.c:5218-5220 probe of any sink without the tile block (out_drop_ref) β†’ same kref_put 0xfffffffffffffff4 (write)
sys/dev/drm/drm_connector.c:387-389 drm_connector_cleanup() at driver unload β†’ drm_mode_put_tile_group 0xfffffffffffffff4 (write)
sys/dev/drm/drm_connector.c:1489-1493 drm_connector_set_tile_property() (DP MST path, drm_dp_mst_topology.c:1219/2566): snprintf("%d", connector->tile_group->id) 0x4 (read: -12+offsetof(id)=+16)
sys/dev/drm/drm_fb_helper.c:2388 fb-helper debug print tile_group->id 0x4 (read)

Once the EDID has been parsed once with a failing allocation, a panic is essentially unavoidable: the next probe of the same monitor, the next probe of a different monitor, driver unload, or (MST) the immediate property update all hit a site.

Trigger conditions

  1. A sink whose EDID carries a DisplayID extension with a TILED_DISPLAY block (drm_find_displayid_extension β†’ drm_parse_display_id β†’ tag DATA_BLOCK_TILED_DISPLAY, drm_edid.c:5183-5186). Threat actor: malicious monitor / DP-MST dock / USB-C dock / VM virtual display.
  2. The tile-group creation must fail at that instant: kzalloc(GFP_KERNEL) or idr_alloc(GFP_KERNEL) failure β€” i.e. kernel memory pressure, which an unprivileged local user can aggravate. (drm_mode_get_tile_group returning NULL for a first-seen topology is the normal case.)

No user account or ioctls needed beyond the DRM probe itself; the probe runs from drm_mode_getconnector with count_modes==0 (fill_modes β†’ drm_helper_probe_single_connector_modes β†’ drm_get_edid β†’ drm_get_displayid).

Why not reproduced on the QEMU guest

The standard audit guest has no DRM device (/dev/dri absent, no drm/i915 kmods; only QEMU's virtio-vga vgapci0@pci0:0:2.0 β€” see env.txt). There is no connector, no EDID path, and no way to inject an EDID from inside the guest. The demonstration binary (trigger.c) therefore reproduces the pointer arithmetic of the mismatch on the guest β€” the exact fault addresses the four sites will compute β€” rather than the kernel fault. The chain itself is proven statically with line-level citations and a port-divergence check against upstream Linux v5.2.

Fix

Return NULL on failure, restoring the upstream contract the caller expects (fix.diff):

    tg = kzalloc(sizeof(*tg), GFP_KERNEL);
    if (!tg)
-       return ERR_PTR(-ENOMEM);
+       return NULL;
@@
    } else {
        kfree(tg);
-       tg = ERR_PTR(ret);
+       tg = NULL;
    }

With NULL, the caller's if (!tg) return -ENOMEM; fires, the parse error propagates to drm_get_displayid's out_drop_ref which clears has_tile and tile_group β€” the exact upstream behavior.

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 restores the upstream v5.2 NULL contract (both failure paths return NULL); the caller's existing 'if (!tg) return -ENOMEM;' then fires and drm_get_displayid's out_drop_ref clears has_tile/tile_group exactly as upstream. git apply --check passes vs the read-only sys/ tree. Kernel-build validation was not performed: the guest has no DRM hardware, so neither baseline nor patched kernel can exercise the path; the change is the literal upstream fix.

findings/poc/DF-4194/fix.diff; upstream v5.2 source excerpt in VERDICT.md
↓ fix.diffper-fix-DF-4194

Confirmed kernel references

Detail

Exploit chain

malicious monitor/dock/VM-display EDID with a DisplayID TILED_DISPLAY block + GFP_KERNEL alloc failure under (locally aggravatable) kernel memory pressure -> drm_get_edid -> drm_get_displayid -> drm_parse_tiled_block stores ERR_PTR in connector->tile_group -> next probe / sink change / unload / MST set_tile_property dereferences or kref_puts (struct drm_tile_group*)-12 -> page fault at 0xfffffffffffffff4 or 0x4 -> kernel panic (local DoS; fixed-address fault, no corruption primitive).

Evidence (decisive lines)

VERDICT.md crash-site table; run.log (guest-computed fault addresses 0xfffffffffffffff4 and 0x4 from the real struct layout); env.txt (no DRM device on guest); fix.diff (git apply --check clean vs sys/); upstream v5.2 comparison quoted in VERDICT.md.

PoC changes

No seed PoC existed; authored trigger.c from scratch. First guest build hit a missing include (ENOMEM undeclared) β€” fixed; rebuilt clean, rc=0, zero warnings.

Verified recommended fix

Make drm_mode_create_tile_group() return NULL (not ERR_PTR) on both failure paths, matching upstream Linux v5.2 and the caller's existing NULL check.

Verdict

Source-certain ERR_PTR/NULL contract mismatch. drm_mode_create_tile_group() (sys/dev/drm/drm_connector.c:1917,1929) returns ERR_PTR on allocation failure, but its only caller drm_parse_tiled_block() (sys/dev/drm/drm_edid.c:5141) checks only 'if (!tg)' β€” the NULL contract upstream Linux v5.2 actually implements (verified against git.kernel.org v5.2: both failure paths return NULL). The ERR_PTR passes the check and is stored as connector->tile_group (drm_edid.c:5150) with connector->has_tile already true (drm_edid.c:5120). Four later sites dereference or kref_put the poison pointer at fixed unmapped addresses (0xfffffffffffffff4 write via kref_put at drm_edid.c:5148/5218 and drm_connector.c:388; 0x4 read via ->id at drm_connector.c:1490 and drm_fb_helper.c:2388) -> deterministic kernel page fault/panic on the next EDID probe, sink change, MST property update, or driver unload. Kernel-level reproduction was not possible on the audit guest: it has no DRM device at all (/dev/dri absent, no drm/i915 kmods, only QEMU virtio-vga β€” env.txt), so the EDID/DisplayID path cannot be exercised; trigger.c instead demonstrates the exact pointer arithmetic on the guest (run.log).