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

Divide-by-zero kernel panic in drm_gtf_mode_complex() via unvalidated secondary-GTF parameters from crafted EDID

Summary

drm_mode_std()'s LEVEL_GTF2 branch passes GTF_M/2C/K/2J decoded verbatim from an EDID monitor-range descriptor into drm_gtf_mode_complex(). With r[14]=r[15]=0 (GTF_M=0, so GTF_M_PRIME=0) and r[13]=r[17]=200 (GTF_C_PRIME=100), ideal_duty_cycle becomes exactly 100000 and the blanking computation divides by (100000 - ideal_duty_cycle) == 0 -> x86 #DE -> kernel panic. Deterministic; base-block-only EDID (no extension needed); reachable from any connector EDID probe. Physical/adjacent attacker (same vector as DF-1165: $5 DP dongle, KVM, USB-C dock, or hypervisor host) supplies a checksum-valid 128-byte EDID whose range-limit descriptor declares a secondary GTF curve with degenerate parameters; the next mode probe panics the kernel - reliable repeatable DoS of any DragonFly machine with a DRM GPU. VERIFIED on the guest by compiling a MECHANICAL byte-for-byte extraction of drm_gtf_mode_complex from the audit tree (guest /usr/src md5-identical) driven solely with the EDID-decoded values: SIGFPE 3/3 runs. In-kernel trigger not executable on this virtio-only guest - honestly not_reproduced/impact=panic. The identical formula is still present in upstream Linux v5.2 and v6.6 (no known CVE). Fix: guard ideal_duty_cycle bounds (both call sites tolerate NULL). Also killed this pass: all CTA/DisplayID/VSDB walkers re-derived, drm_do_get_edid repair loop, ELD SAD copies, mode-builder divisions; full-file diff vs upstream v5.2 shows only DFly porting shims.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3682 Β· 18 files
FileTypeDescriptionSize
README.md β€” 3.8 KB ↓ raw
VERDICT.md β€” 5.1 KB ↓ raw
gtf_body.inc β€” 5.9 KB ↓ download
gtf_kernel_extract.c β€” 2.9 KB view raw
gtf_fix_test.c β€” 2.1 KB view raw
gtf_body_fixed.inc β€” 6.2 KB ↓ download
gtf_div0.c β€” 4.6 KB view raw
gen_edid.py β€” 3.5 KB view raw
evil_edid.bin β€” 128 B ↓ download
build.sh β€” 208 B view raw
run.sh β€” 112 B view raw
build.log β€” 43 B view raw
run.log β€” 570 B view raw
run.2.log β€” 570 B view raw
env.txt β€” 431 B view raw
fix.diff β€” 1.0 KB view raw
manifest.json β€” 1.3 KB view raw
verdict.json β€” 4.0 KB view raw

DF-3682 β€” Divide-by-zero (kernel panic) in drm_gtf_mode_complex() via crafted-EDID secondary-GTF parameters

What

sys/dev/drm/drm_modes.c:484-485 (reached from sys/dev/drm/drm_edid.c:2188-2193):

ideal_duty_cycle = GTF_C_PRIME * 1000 - (GTF_M_PRIME * 1000000 / hfreq_est);
hblank = total_active_pixels * ideal_duty_cycle / (100000 - ideal_duty_cycle);

GTF_M, GTF_2C, GTF_K, GTF_2J are copied unvalidated from a sink-supplied EDID monitor-range descriptor (bytes r[13..17]) by drm_gtf2_m/2c/k/2j() (drm_edid.c:2026-2055). With

  • r[14]=r[15]=0 β†’ GTF_M = 0 β†’ GTF_M_PRIME = 0
  • r[13]=200, r[17]=200 β†’ GTF_C_PRIME = ((200-200)*K/256 + 200)/2 = 100

ideal_duty_cycle becomes exactly 100000, the divisor 100000 - ideal_duty_cycle becomes zero, and the kernel takes a divide error (#DE on x86) β†’ panic. Deterministic, base-block-only EDID.

Reachability (line-level trace)

  1. Sink supplies crafted EDID over DDC/AUX (drm_do_get_edid).
  2. drm_add_edid_modes (drm_edid.c:4729) β†’ add_standard_modes (4770).
  3. drm_mode_std (drm_edid.c:2095): std-timing pair {208, 0x00} decodes to 1912x1195@60; standard_timing_level() (2061) returns LEVEL_GTF2 because a DTD with r[3]==0xFD && r[10]==0x02 exists (revβ‰₯2, feature bit DRM_EDID_FEATURE_DEFAULT_GTF clear).
  4. case LEVEL_GTF2 (drm_edid.c:2177-2194): first drm_gtf_mode() succeeds; drm_mode_hsync(mode) (~75 kHz) > drm_gtf2_hbreak() (=2, r[12]=1) β†’ drm_gtf_mode_complex(dev, 1912, 1195, 60, 0, 0, M=0, 2C=200, K=128, 2J=200).
  5. drm_modes.c:480-485: C'=100, M'=0 β†’ ideal_duty_cycle == 100000 β†’ / 0 β†’ panic.

No DMT hit (1912x1195 is not a DMT mode), no dedup conflict (DTD1 carries pixel_clock=0 so no detailed modes were added first), bad_std_timing() does not match β€” every precondition is satisfiable with a 128-byte checksum-valid base block (evil_edid.bin).

Reproduce (on the DragonFly guest)

The QEMU guest is virtio-only (/dev/dri absent, no drm kld, no GPU), so the in-kernel trigger cannot be executed there. What is executed is the verbatim kernel function: gtf_body.inc is a mechanical byte-for-byte extraction of drm_gtf_mode_complex() from sys/dev/drm/drm_modes.c (git 6cc80ee9; guest /usr/src copy md5-identical: ce51a6174062d43b24b7296cc0b3bc1e). Only struct drm_device / struct drm_display_mode and drm_mode_create()/drm_mode_set_name() are stubbed; every arithmetic statement is unmodified.

./dfbsd-qemu/vm.sh run_root 'sh /tmp/build.sh'   # cc -O2 both harnesses
./dfbsd-qemu/vm.sh run_root 'sh /tmp/run.sh'     # baseline + 3x + fix test

Expected (observed):

baseline exit=136   (128+SIGFPE β€” integer divide trap; kernel: #DE β†’ panic)
run1..run3 exit=136 (deterministic)
fix test exit=0     ("returned NULL (rejected bogus GTF2 params) -> fix OK")

Files

file role
gtf_body.inc verbatim extraction of drm_gtf_mode_complex from the audit tree
gtf_kernel_extract.c harness: EDID byte decoding + verbatim function (baseline trap)
gtf_fix_test.c / gtf_body_fixed.inc same, with the proposed fix guard (no trap)
gen_edid.py β†’ evil_edid.bin the malicious 128-byte EDID base block (valid checksum)
gtf_div0.c independent re-derivation harness (same trap; early confirm)
build.sh / run.sh exact commands run on the guest
build.log / run.log full untrimmed guest output
env.txt guest environment + source-identity proof
fix.diff the fix (guard ideal_duty_cycle), never applied to sys/

Fix

See fix.diff β€” reject degenerate GTF2 parameters before the division (ideal_duty_cycle <= 0 || ideal_duty_cycle >= 100000 β†’ destroy mode, return NULL; both call sites already handle NULL).

VERDICT.md
↓ download raw

DF-3682 β€” VERDICT

Finding: divide-by-zero in drm_gtf_mode_complex() (sys/dev/drm/drm_modes.c:484-485) via unvalidated secondary-GTF parameters taken from a crafted EDID (sys/dev/drm/drm_edid.c:2188-2193, parameters decoded by drm_gtf2_m/2c/k/2j() at drm_edid.c:2026-2055).

Classification: not_reproduced (kernel-level), reproduced=0, impact=panic, confidence=certain for the arithmetic fault and the reachability trace.

Was it reproduced?

  • In-kernel trigger: NO β€” impossible on this guest. The QEMU guest is virtio-only: no /dev/dri, no drm kld, /boot/modules absent, and this tree has no virtio-gpu driver, so no DRM connector ever calls drm_get_edid()/drm_add_edid_modes(). The physical attack vector (malicious monitor / DP dongle / KVM feeding DDC) does not exist in the VM.
  • The fault itself: YES, deterministically, on the verbatim kernel code. gtf_body.inc is a mechanical (python str.index + brace-matching, no hand-retyping) byte-for-byte extraction of drm_gtf_mode_complex() from sys/dev/drm/drm_modes.c at git 6cc80ee9. The guest's /usr/src/sys/dev/drm/drm_modes.c is md5-identical (ce51a6174062d43b24b7296cc0b3bc1e). Compiled with the guest cc at -O2 and fed only values decoded from the crafted EDID bytes (evil_edid.bin: range descriptor r[10]=0x02, r[12]=1, r[13]=200, r[14]=r[15]=0, r[16]=128, r[17]=200, std timing {208, 0x00}), the function takes an integer-divide trap: exit 136 (SIGFPE), 3/3 runs. In kernel context the identical instruction sequence raises #DE β†’ DragonFly trap() β†’ panic.

Why the arithmetic is airtight

  • GTF_M = 0 (r[15]=r[14]=0) β†’ GTF_M_PRIME = K*0/256 = 0.
  • GTF_2C = GTF_2J = 200 β†’ GTF_C_PRIME = ((200-200)*128/256 + 200)/2 = 100.
  • ideal_duty_cycle = 100*1000 - 0 = 100000.
  • Divisor 100000 - ideal_duty_cycle == 0 β†’ #DE. No overflow trickery needed; every intermediate is small and positive.

Reachability trace (why the crafted EDID reaches the call)

  1. drm_do_get_edid accepts the block: header valid, checksum valid (gen_edid.py enforces sum(edid) & 0xFF == 0), version 1 rev 4.
  2. drm_add_edid_modes (drm_edid.c:4729) β†’ add_detailed_modes first: DTD1 is the range descriptor with pixel_clock = r[0..1] = 0, so do_detailed_mode (2818: if (timing->pixel_clock)) adds nothing β€” the probed-mode dedup list in drm_mode_std (2142) stays empty.
  3. add_standard_modes (4770) β†’ drm_mode_std on std timing[0] {208, 0x00}: bad_std_timing no; hsize = 2088+248 = 1912; aspect 00 + revβ‰₯3 β†’ vsize = 191210/16 = 1195; vrefresh 60.
  4. standard_timing_level (2061): rev 4 β‰₯ 2; features & DEFAULT_GTF is clear (byte 0x1A = 0), so CVT not chosen; drm_gtf2_hbreak() = 2 β‰  0 (DTD1 r[3]=0xFD, r[10]=0x02, r[12]=1) β†’ LEVEL_GTF2.
  5. drm_monitor_supports_rb false (r[15]=0 β†’ is_rb bit clear); 1912x1195@60 not in DMT β†’ falls into case LEVEL_GTF2 (2177).
  6. drm_gtf_mode(1912,1195,60) OK; drm_mode_hsync(mode) β‰ˆ 75 kHz > 2 (2186) β†’ drm_gtf_mode_complex(..., M=0, 2C=200, K=128, 2J=200) (2188) β†’ panic at drm_modes.c:484-485.

Exploit chain

Not an escalation primitive: deterministic DoS/panic (sink-driven, the DF-1165 physical threat model — a $5 DP dongle, KVM, dock, or a VM host feeding DDC). No user→root route exists for a divide error.

Fix validation

  • fix.diff guards the divisor (ideal_duty_cycle <= 0 || >= 100000 β†’ destroy mode, return NULL; both call sites β€” drm_mode_std (drm_edid.c:2194 β†’ returns NULL β†’ caller 2720 checks) and the exported API β€” tolerate NULL).
  • Harness-level validation executed on the guest: gtf_fix_test.c uses the same verbatim body with the guard applied β†’ no trap, returns NULL, exit 0 (run.log, "fix test" section).
  • A full fixed-kernel build could not be meaningfully validated on this guest because the baseline kernel trigger itself is untestable there (no DRM device) β†’ fix_status: not_testable at kernel level; the guard's effect is proven at the extracted-function level.

Upstream-shared status

The identical formula (same unguarded division, same unvalidated GTF2 parameters) exists in upstream Linux v5.2 and is still present in v6.6 (drivers/gpu/drm/drm_modes.c β€” verified against raw.githubusercontent.com/torvalds/linux). No CVE known to exist for it. This tree's drm_edid.c/drm_modes.c are upstream-verbatim in this region (full-file diff against v5.2 shows only DFly porting shims).

Artifacts/evidence

  • run.log β€” decisive: baseline exit 136, 3/3, fix test exit 0.
  • build.log β€” full untrimmed compiler output.
  • evil_edid.bin + gen_edid.py β€” the malicious sink payload.
  • env.txt β€” guest uname, /dev/dri absence, md5 source-identity proof.

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

Kernel-level fix validation impossible on this guest (baseline in-kernel trigger itself untestable: no DRM device). Guard logic validated at the extracted-function level: gtf_fix_test.c (same verbatim body + fix.diff guard) returns NULL with no trap, exit 0, while the unguarded baseline traps 3/3.

findings/poc/DF-3682/run.log ('fix test' section), findings/poc/DF-3682/gtf_body_fixed.inc, findings/poc/DF-3682/fix.diff
↓ fix.diffper-fix-DF-3682

Confirmed kernel references

Detail

Exploit chain

crafted EDID base block (range descriptor flags=0x02 with degenerate GTF2 params + ordinary standard timing) -> DDC/AUX read -> drm_do_get_edid -> drm_add_edid_modes -> add_standard_modes -> drm_mode_std LEVEL_GTF2 -> drm_gtf_mode_complex -> hblank division by zero -> kernel panic. No escalation route (pure DoS).

Evidence (decisive lines)

run.log: baseline exit=136 3/3 (SIGFPE) + fix test exit=0; gtf_body.inc = verbatim extraction (md5-proven identical source on guest); evil_edid.bin/gen_edid.py = malicious sink payload with valid checksum; env.txt = guest has no /dev/dri, no drm kld; fix.diff = divisor guard validated at extracted-function level (gtf_fix_test.c)

PoC changes

No runnable kernel path exists on the guest (no DRM device); PoC pivoted to a mechanical verbatim-extraction harness compiled from the tree's own drm_gtf_mode_complex body, driven purely by EDID-decoded values; fix validated on the same extracted body.

Verified recommended fix

Guard the blanking computation: reject ideal_duty_cycle <= 0 || >= 100000 in drm_gtf_mode_complex (destroy mode, return NULL); both call sites already handle NULL.

Verdict

Divide-by-zero in drm_gtf_mode_complex (sys/dev/drm/drm_modes.c:484-485): GTF_M/GTF_2C/GTF_K/GTF_2J are taken unvalidated from a sink-supplied EDID range-limit descriptor (drm_edid.c:2188 via drm_gtf2_*, drm_edid.c:2026-2055). With M=0 (r[15]=r[14]=0) and 2C=2J=200 (r[13]=r[17]=200), C'=100 and M'=0 make ideal_duty_cycle exactly 100000, so the divisor (100000 - ideal_duty_cycle) is zero -> kernel #DE -> panic. The in-kernel trigger could not be executed on this guest (virtio-only: no /dev/dri, no drm kld, no GPU, no virtio-gpu driver in the tree), so the run is classified not_reproduced; however the fault itself is proven deterministically by compiling a MECHANICAL byte-for-byte extraction of the kernel function from the audit tree (guest /usr/src copy md5-identical) and feeding it only values decoded from the crafted 128-byte checksum-valid EDID (evil_edid.bin): SIGFPE exit 136, 3/3 runs. Reachability of the call in-kernel is proven by line-level trace (README.md/VERDICT.md steps 1-6): base-block-only EDID, no extensions needed, sink-driven physical vector identical to DF-1165's threat model. The identical formula is still present in upstream Linux v6.6 (no known CVE).