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

wait_for_completion()/wait_for_completion_interruptible(): MAX_SCHEDULE_TIMEOUT (LONG_MAX) truncates to int -1 in lksleep() β€” every 'infinite' completion wait expires after one tick with spurious success

Summary

The no-timeout entry points pass LONG_MAX through unsigned long into lksleep's int timo -> -1 -> _callout_setup_quick clamps to next tick -> EWOULDBLOCK after ~10ms -> loop maps to ret=0 SUCCESS before the completion fires. Guest-verified on the exact drm_atomic_helper.c:2627 entry point (ret=0 after 1 tick, completer 500 ticks out, 2 runs; verbatim linux_completion.c linked into KLD); fix A/B validated (499-tick block restored; schedule_timeout's INT_MAX->0 conversion is the in-tree precedent). In-tree consumers drm_atomic_helper.c:2627/2638/2649 + i915 intel_psr.c:869 are hw-gated - latent trap, driver acts on unquiesced hw state. DISTINCT from DF-2222 (signal-spin; this is the truncation at completion.h:90 - opposite failure mode, independent fix).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5235 Β· 16 files
FileTypeDescriptionSize
compbug.c β€” 4.7 KB view raw
Makefile β€” 669 B ↓ download
fix.diff β€” 1011 B view raw
capture.sh β€” 848 B view raw
build.log β€” 7.5 KB view raw
build-fixed.log β€” 7.5 KB view raw
run.log β€” 657 B view raw
run.2.log β€” 768 B view raw
run-fixed.log β€” 488 B view raw
console.txt β€” 3.1 KB view raw
env.txt β€” 495 B view raw
SHA256SUMS β€” 357 B ↓ download
README.md β€” 4.2 KB ↓ raw
VERDICT.md β€” 6.2 KB ↓ raw
manifest.json β€” 1.4 KB view raw
verdict.json β€” 4.2 KB view raw

DF-5235 β€” MAX_SCHEDULE_TIMEOUT truncates to -1: "infinite" completion waits expire after one tick

What this is

PoC + fix validation for the LinuxKPI completion shim (sys/dev/drm/linux_completion.c + sys/dev/drm/include/linux/completion.h).

__wait_for_completion_generic() passes its unsigned long timeout argument straight into lksleep()'s int timo parameter. The no-timeout entry points (wait_for_completion(), wait_for_completion_interruptible()) pass MAX_SCHEDULE_TIMEOUT (LONG_MAX), which on x86_64 truncates to timo = -1. tsleep() treats any nonzero timo as "arm a callout", and _callout_setup_quick() clamps a non-positive tick delta to the next tick β€” so every "wait forever" completion wait returns after ~1 tick (10 ms at hz=100), with success, before the completion ever fired.

Distinct from DF-2222 (same file): DF-2222 is the missing loop break on ERESTART (signal β†’ busy-spin, unkillable). This finding is the timeout truncation (no signal β†’ premature EWOULDBLOCK β†’ spurious success). Opposite failure mode, different code, different fix.

Files

  • compbug.c β€” kernel-module PoC (self-contained, MOD_LOAD runs the test)
  • Makefile β€” kmod build against the guest's live /usr/src
  • linux_completion.c β€” NOT in the pack; copied verbatim at build time from /usr/src/sys/dev/drm/linux_completion.c (sha256 477bdd88e916bf95466e92fad95bff4355cfd0e719efa2b8c742f6aa23fd8a6e, identical to the audit tree) β€” the real upstream entry points are tested.
  • linux_wait.c β€” likewise copied verbatim (only for __init_waitqueue_head; its wait_event-family helpers are dead code here, never executed on the completion path)
  • fix.diff β€” git-apply-able fix (validated below)
  • build.sh, run.sh, capture.sh β€” exact commands
  • build.log β€” baseline (unpatched) build, full
  • build-fixed.log β€” build after applying fix.diff to the guest's /usr/src
  • run.log, run.2.log β€” two baseline runs (unpatched): BUG both times
  • run-fixed.log β€” run after fix: waits block until the real completion
  • console.txt β€” serial console history of every load (A/B/A story)
  • env.txt β€” guest uname/hz/compiler/kldstat
  • SHA256SUMS β€” guest-side hashes of sources + .ko

Build

On the guest (as root), with kernel source at /usr/src:

cd /root/df5235 && make

Run

kldload ./compbug.ko && sleep 13 && dmesg | grep compbug | tail

Expected output

Baseline (unpatched kernel, verified 2 independent loads):

compbug[1] interruptible: ret=0 elapsed=1 ticks (completer due at 500): BUG: returned before completion
compbug[2] wait_for_completion: elapsed=1 ticks (completer due at 500): BUG: returned before completion
compbug[3] control timeout=10: ret=0 elapsed=9|10 ticks: OK
compbug: hz=100, LONG_MAX=9223372036854775807 -> int timo=-1 (LKSLEEP SEES -1)

Case 1 is wait_for_completion_interruptible() β€” the exact entry point used by drm_atomic_helper.c:2627,2638,2649 and i915/intel_psr.c:869 β€” returning 0 (== success in the Linux contract) after one tick although the completer was scheduled 500 ticks (5 s) out.

Case 3 is the control: a small in-range timeout (10 ticks) behaves exactly per contract (ret=0 after ~10 ticks), proving the generic timeout machinery is fine and only the MAX_SCHEDULE_TIMEOUT conversion is broken.

With fix.diff applied to /usr/src (run-fixed.log):

compbug[1] interruptible: ret=0 elapsed=499 ticks (completer due at 500): OK
compbug[2] wait_for_completion: elapsed=499 ticks (completer due at 500): OK
compbug[3] control timeout=10: ret=0 elapsed=9 ticks: OK

Notes

  • run.log's first three lines are stale ring-buffer lines from the preceding patched-module load (the load order was patched β†’ baseline); the six decisive baseline lines are the tail. run.2.log and console.txt show the clean sequence.
  • No panic, no corruption: the demonstrated impact is the broken synchronization primitive itself (spurious early success). In-tree consumers are DRM (i915/amdgpu/radeon), i.e. hardware-gated; on this QEMU guest none probe, hence impact is recorded as "none" with the race consequences described in the finding.
VERDICT.md
↓ download raw

DF-5235 β€” VERDICT

Status: reproduced (on-guest, two independent baseline loads; fix validated A/B) Impact: none demonstrated on this guest (primitive = broken synchronization: "infinite" kernel completion waits return spurious success after 1 tick; in-tree consumers are DRM drivers, hardware-gated, absent on this guest) Confidence: certain (code defect proven by execution; consumers traced)

Root cause (path:line)

  • sys/dev/drm/include/linux/completion.h:79-116 β€” __wait_for_completion_generic(struct completion *c, unsigned long timeout, int flags) passes timeout unchanged to lksleep() at completion.h:90.
  • sys/sys/systm.h:414 β€” int lksleep(..., const char *, int timo): the timeout parameter is an int.
  • sys/dev/drm/include/linux/sched.h:78 β€” #define MAX_SCHEDULE_TIMEOUT LONG_MAX = 0x7FFFFFFFFFFFFFFF on x86_64 β†’ truncates to timo = -1 (implementation-defined C conversion; gcc/clang modulo β†’ 0xFFFFFFFF).
  • sys/dev/drm/linux_completion.c:33 and :40 β€” the two no-timeout entry points pass MAX_SCHEDULE_TIMEOUT.
  • sys/kern/kern_synch.c:691-693 β€” if (timo) is TRUE for -1 β†’ _callout_setup_quick(..., timo=-1, endtsleep, td).
  • sys/kern/kern_timeout.c:971-972 β€” c->qtick = sc->curticks + ticks; β†’ curticks-1; if (c->qtick - sc->softticks <= 0) c->qtick = sc->softticks + 1; β†’ the callout fires on the next tick.
  • endtsleep (kern_synch.c:935) sets TDF_TIMEOUT β†’ tsleep returns EWOULDBLOCK β†’ completion.h:92-95 sets timeout_expired=true, ret=0 β†’ loop exits β†’ wait_for_completion_interruptible maps that to 0 = success (linux_completion.c:45).

Contrast with the tree's own correct conversion in sys/dev/drm/include/linux/sched.h:130-133 (schedule_timeout): timo = timeout >= INT_MAX || timeout == MAX_SCHEDULE_TIMEOUT ? 0 : timeout.

What was executed

Kernel module compbug.ko built against the guest's live /usr/src, linking the verbatim upstream linux_completion.c (sha256 477bdd88e916bf95466e92fad95bff4355cfd0e719efa2b8c742f6aa23fd8a6e, byte-identical to the audit tree β€” see SHA256SUMS) and the verbatim linux_wait.c (needed only for __init_waitqueue_head; its wait_event helpers are never executed on the completion path β€” linkage stubs satisfy the current-macro symbols). See compbug.c header comment.

Three cases per load (completer scheduled 500 ticks = 5 s out):

  1. wait_for_completion_interruptible() β€” elapsed=1 tick, ret=0 β†’ spurious success. This is the exact entry point used by sys/dev/drm/drm_atomic_helper.c:2627,2638,2649 (drm_atomic_helper_wait_for_dependencies / wait_for_vblanks family) and sys/dev/drm/i915/intel_psr.c:869 (psr_wait_for_idle).
  2. wait_for_completion() β€” elapsed=1 tick (no in-tree caller today; latent trap, e.g. any ported driver that waits indefinitely).
  3. Control wait_for_completion_timeout(c, 10) β€” ret=0 after 9-10 ticks: the timeout machinery is correct for in-range values; only the MAX_SCHEDULE_TIMEOUT conversion is broken.

Reproduced twice from independent loads (run.log, run.2.log, full story in console.txt). hz=100 confirmed (env.txt), so 1 tick = 10 ms.

Why it matters (threat)

Every DRM commit that waits for hw_done/cleanup_done "indefinitely" proceeds after 10 ms whether or not the hardware/atomic-commit worker has signaled completion. Linux semantics: block until complete(); return -ERESTARTSYS on signal. DragonFly actual: return success after 10 ms. Downstream consequence class: use of an object whose hardware state is not yet quiesced (plane update on an unflipped CRTC, PSR exit before idle, cleanup before the last vblank) β€” racy malfunction to potential UAF on real GPU hardware. Not triggerable on this guest (QEMU, no i915/amdgpu device; DRM modules not loaded β€” env.txt kldstat), hence impact recorded as "none" rather than dos/corruption. No unprivileged trigger path exists without the hardware.

Relationship to DF-2222 (do not merge)

DF-2222 = completion.h:91-102 switch never breaks out of the loop on ERESTART β†’ with PCATCH and a pending signal, busy-spin forever. DF-5235 (this) = completion.h:90 passes LONG_MAX into an int β†’ without any signal, premature EWOULDBLOCK after 1 tick β†’ spurious success. With a signal pending, tsleep's CURSIG short-circuit (kern_synch.c:622-630) returns before the callout is armed, so DF-2222 dominates; without a signal, the truncation dominates. Opposite failure modes, independent lines, independent fixes; fixing one does not fix the other.

Fix validation (A/B, same guest, same module)

  1. Baseline: pristine /usr/src, make β†’ kldload β†’ run.log/run.2.log: elapsed=1, BUG (both loads).
  2. Apply fix.diff to the guest's /usr/src copy (sys/dev/drm/include/linux/completion.h: convert timeout >= INT_MAX β†’ timo 0 exactly like schedule_timeout), make clean && make (build-fixed.log) β†’ kldload β†’ run-fixed.log: elapsed=499 ticks, waits block until the real completer fires, ret=0 (true success); control case unchanged (9 ticks).
  3. Reverted the guest's /usr/src header to pristine afterwards (verified: lksleep(&c->wait... timeout) at line 90 again).

fix_status: fixed β€” baseline behavior (premature return) gone; semantics now match Linux for both entry points; explicit-timeout path untouched.

Kernel references

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

Applied fix.diff to the guest's /usr/src copy of completion.h, rebuilt the same module (build-fixed.log), re-ran the identical PoC: baseline elapsed=1 tick BUG (both loads) -> patched elapsed=499 ticks, waits block until the real completer fires, control case unchanged (9-10 ticks). Premature-return behavior gone; Linux return semantics restored. fix_patched_reproduced=0 means the BUG behavior did NOT reproduce on the patched build (desired outcome).

fix.diff; run.log (baseline BUG); run.2.log (baseline BUG, 2nd load); run-fixed.log (patched OK elapsed=499); build.log vs build-fixed.log; console.txt full history
↓ fix.diffper-fix-DF-5235

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log + run.2.log: 'compbug[1] interruptible: ret=0 elapsed=1 ticks ... BUG' (baseline, 2 loads); run-fixed.log: 'elapsed=499 ticks ... OK' (after fix.diff); console.txt: full A/B/A load history; env.txt: hz=100, no drm modules loaded; SHA256SUMS: tested linux_completion.c byte-identical (477bdd88...) to the audit tree; compbug.c:34-47 stubs are linkage-only, never executed on the completion path

PoC changes

PoC authored from scratch (no seed): kmod compiling the VERBATIM sys/dev/drm/linux_completion.c (+ linux_wait.c for __init_waitqueue_head, with linkage-only stubs for the never-executed wait_event/current path); needed KCFLAGS + -include kconfig.h per sys/dev/drm/drm/Makefile conventions after plain CFLAGS+= was overridden by bsd.kmod.mk

Verified recommended fix

In __wait_for_completion_generic(), convert the timeout to lksleep's int timo exactly like schedule_timeout(): timo = (timeout >= INT_MAX) ? 0 : (int)timeout; and pass timo to lksleep (see fix.diff).

Verdict

wait_for_completion()/wait_for_completion_interruptible() pass MAX_SCHEDULE_TIMEOUT (LONG_MAX) into lksleep()'s int timo, truncating to -1 on x86_64; _callout_setup_quick() clamps the negative delta to the next tick, so every 'infinite' completion wait returns spurious success after 1 tick (10 ms at hz=100) before the completion fires. Proven on-guest: drm_atomic_helper.c:2627/i915 intel_psr.c:869 entry point returned ret=0 after 1 tick with the completer scheduled 500 ticks out (two independent loads); control with an in-range 10-tick timeout behaves per contract; fix.diff (INT_MAX->timo 0 conversion, same as schedule_timeout) makes both waits block the full 499-500 ticks until the real completer. Demonstrated impact is the broken synchronization primitive itself; in-tree consumers are DRM drivers (hw-gated, absent on this QEMU guest), so no dos/corruption is observable here.