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

DragonFly's Linux-compat seqlock shim returns the seqcount to its pre-write value β€” drm_vblank_count_and_time() accepts torn {count,time} vblank snapshots (protocol violation)

Summary

SYSTEMIC: the DF seqlock shim implements write_sequnlock as sequence-- (0->1->0) instead of Linux's monotonic advance, and read_seqbegin does not wait for even. A reader that begins before a writer and re-checks after the writer COMPLETES sees sequence == start and accepts a torn snapshot - exactly the race seqcounts exist to detect. The vblank reader reads the u64 count and ktime between begin/retry while store_vblank writes both; torn pairs flow to userspace via the unauthenticated vblank UAPI (wrong sequence/tval, mismatched flip timestamps). i915 engine stats share the broken shim. Guest harness with the shim's algorithm verbatim: deterministic torn-pair-accepted + 4057/4189 torn snapshots under 2-thread stress; fixed semantics -> 0. Restore-Linux-semantics fix in the shim (safe global correction).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4197 Β· 11 files
FileTypeDescriptionSize
README.md β€” 3.4 KB ↓ raw
VERDICT.md β€” 4.8 KB ↓ raw
seqlock_aba.c β€” 6.4 KB view raw
build.sh β€” 105 B view raw
run.sh β€” 178 B view raw
build.log β€” 399 B view raw
run.log β€” 1.9 KB view raw
env.txt β€” 402 B view raw
fix.diff β€” 901 B view raw
manifest.json β€” 1.0 KB view raw
verdict.json β€” 3.9 KB view raw

DF-4197 β€” DragonFly's Linux-compat seqlock shim violates the seqcount protocol β†’ torn vblank {count,time} snapshots accepted (drm_vblank_count_and_time)

File under audit: sys/dev/drm/drm_vblank.c (pass 2)

What

sys/dev/drm/include/linux/seqlock.h implements the Linux seqlock API with broken seqcount semantics:

write_seqlock()   { spin_lock; sl->sequence++;  sfence; }   /* 0 -> 1 */
write_sequnlock() { sl->sequence--;  spin_unlock;  sfence; }/* 1 -> 0  ← DECREMENT */
read_seqbegin()   { return READ_ONCE(sl->sequence); }       /* no wait-even */
read_seqretry()   { lfence; return sl->sequence != start; }

A Linux seqcount advances monotonically (each write section bumps the count twice: 0β†’1β†’2β†’4→…), so any writer that started after a reader's read_seqcount_begin() changes the count and forces a retry. The DF shim returns the count to its original value (0β†’1β†’0). A reader that starts before a writer and re-checks after that writer completes observes sequence == start and accepts a torn snapshot β€” precisely the race the seqcount protocol exists to detect.

The in-kernel consumer under audit is the vblank counter/timestamp pair:

  • writer: store_vblank() β€” drm_vblank.c:92-106 (write_seqlock; vblank->time = t; vblank->count += inc; write_sequnlock),
  • reader: drm_vblank_count_and_time() β€” drm_vblank.c:784-788 (do { seq = read_seqbegin; count = vblank->count; time = vblank->time; } while (read_seqretry)).

A torn {count,time} pair is handed to userspace by drm_wait_vblank_ioctl (reply.sequence/tval), drm_crtc_get_sequence_ioctl (:1801-1802) and drm_crtc_queue_sequence_ioctl (:1861, event target sequence), and used internally by drm_crtc_send_vblank_event and page-flip completion timestamps. Result: wrong vblank sequence numbers/timestamps (event delivered against a mismatched counter, waits satisfied early/never) β€” logic corruption of the UAPI vblank contract, no memory unsafety. On i386 builds the unlocked u64 counter reads (drm_vblank_count, :277-285) can tear independently of the seqlock.

Second in-tree consumer of the same shim (same loss of guarantee): i915 engine stats (intel_engine_cs.c:1623 write_seqlock cycles).

How it is proven here

seqlock_aba.c (guest, DragonFly cc 8.3, -O2):

  • demo β€” deterministic single-thread interleave of the exact shim functions (verbatim semantics from seqlock.h:69-99): reader begins (seq=0), loads OLD count, writer runs a COMPLETE write section (0β†’1β†’0), reader loads NEW time, read_seqretry returns 0 β†’ TORN PAIR {count=100(old), time=200000(new)} ACCEPTED.
  • demo_fixed β€” identical interleave with corrected (Linux monotonic) semantics: sequence ends at 2, retry fires, torn pair rejected.
  • stress β€” real 2-thread contention, 4M write cycles: DF semantics: torn_accepted=4057/4189 (two runs); fixed semantics: torn_accepted=0.

Reproduce

cc -O2 -Wall -pthread -o seqlock_aba seqlock_aba.c
./seqlock_aba demo          # TORN PAIR ACCEPTED, rc=1
./seqlock_aba demo_fixed    # torn rejected, rc=0
./seqlock_aba stress        # torn_accepted=4057, rc=1
./seqlock_aba stress_fixed  # torn_accepted=0, rc=0

Scope

Kernel-level manifestation needs a DRM device (guest has none β€” hardware gated), but the shim defect itself is proven exactly: the harness copies the shim's algorithm 1:1. The fix (fix.diff) restores Linux seqcount semantics in sys/dev/drm/include/linux/seqlock.h.

VERDICT.md
↓ download raw

DF-4197 VERDICT

Status: inconclusive (kernel manifestation hardware-gated: no DRM device in guest) β€” the shim protocol violation itself is proven exactly and deterministically on the DragonFly guest, including under 2-thread stress (4057 torn snapshots accepted under DF semantics, 0 under fixed semantics). Impact: corrupted vblank counter/timestamp pairs delivered through the DRM UAPI (logic corruption; no memory unsafety, no info leak of kernel memory β€” both torn values are legitimate vblank values). uid=0 chain N/A.

Root cause (path:line)

  • sys/dev/drm/include/linux/seqlock.h:77-83 β€” write_sequnlock() does sl->sequence--, returning the seqcount to its pre-write value (0β†’1β†’0 per write section). Linux's write_seqcount_end() increments (0β†’1β†’2), making the count change visible forever after.
  • sys/dev/drm/include/linux/seqlock.h:90-94 β€” read_seqbegin() is a plain load; it neither waits for the count to become even nor otherwise prevents beginning a read against a count that a completed writer has "restored".

Consequence for the audited file:

  • sys/dev/drm/drm_vblank.c:784-788 β€” reader of {count,time}; a writer (store_vblank, drm_vblank.c:102-105) completing entirely inside the reader's two field loads leaves sequence == start β†’ no retry β†’ torn pair accepted.
  • Consumers: drm_vblank.c:1518 (drm_wait_vblank_reply), :1801-1802 (drm_crtc_get_sequence_ioctl), :1861/:1899 (queue_sequence), drm_crtc_send_vblank_event (:913), drm_crtc_arm_vblank_event (:888).
  • On i386 the u64 count itself can tear (two 32-bit halves); on amd64 each field is atomic but the pair is not.

Also affected (same shim, out-of-file consumer, mentioned for blast radius): i915 engine stats write_seqlock (sys/dev/drm/i915/intel_engine_cs.c:1623).

Proof

Deterministic demo (verbatim shim algorithm; writer completes between the reader's two loads β€” the canonical seqcount race):

$ ./seqlock_aba demo
[demo] semantics=DF-shim
[demo] read_seqbegin -> 0; reader loaded count=100 (preempted here...)
[demo] writer ran a FULL cycle: count=101 time=200000, sequence=0
[demo] reader resumed: loaded time=200000, read_seqretry=0
[demo] RESULT: TORN PAIR ACCEPTED {count=100(old), time=200000(new)} β€” reader believes it is consistent
rc=1
$ ./seqlock_aba demo_fixed
[demo] writer ran a FULL cycle: count=101 time=200000, sequence=2
[demo] RESULT: retry=1 β€” torn pair REJECTED, reader retries
rc=0

Stress (2 threads, 4M writer cycles, invariant time == count*1000):

[stress] semantics=DF-shim  consistent=8910351 torn_accepted=4189
[stress] semantics=FIXED(Linux) consistent=6953440 torn_accepted=0

(earlier independent run: 7767673/4057 and 6980918/0 β€” see run.log)

Both semantics variants live in the same binary; *_fixed compiles the corrected write_sequnlock (sequence++) and read_seqbegin (wait-even), i.e. exactly what fix.diff applies to the shim header.

Why Low severity

The torn values are still real vblank counts/timestamps (no kernel-memory disclosure), and the practical effect is limited to wrong sequence numbers / timestamps / event-target computations on the vblank UAPI β€” visible as compositor glitches or early/missed vblank waits. No memory-safety consequence found on any consumer of count/time.

Fix validation

fix.diff (sys/dev/drm/include/linux/seqlock.h): write_sequnlock advances the count (sequence++) and read_seqbegin spins until the count is even β€” restoring Linux read_seqcount_begin/write_seqcount_end semantics. Harness-level validation: demo_fixed/stress_fixed reject every torn snapshot (0 accepted over ~7M reads). A full DRM kernel build/boot is not possible on this guest (no GPU), so kernel-level validation is fix_status: inconclusive. All in-tree users (drm_vblank store/read, i915 engine stats) use the API in the Linux-expected way, so the semantic restoration is safe for them.

Killed negatives (adjacent code audited)

  • store_vblank writer discipline: writers are serialized by the embedded real spinlock AND by vblank_time_lock (assert_spin_locked, :98) β€” writer vs writer is fine; only the reader protocol is broken.
  • seqcount_t section of the same header (__read_seqcount_begin waits for even) is correct β€” only the seqlock_t half is broken.
  • drm_vblank_count (:277-285) unlocked read: upstream-identical; on amd64 practically atomic; noted here as an i386-only aggravator of this same finding rather than a separate one.

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

inconclusive
baseline reproduced→ patch + rebuild →patched clean

fix.diff restores monotonic seqcount semantics (write_sequnlock increments; read_seqbegin waits for even β€” identical to the *_fixed harness variant). Harness A/B: demo_fixed rejects the torn pair (retry=1) and stress_fixed accepts 0 torn pairs over ~7M reads vs 4057/4189 baseline. Kernel-level validation impossible on this guest (no DRM device); harness-level validation passed cleanly.

run.log stress lines 'DF-shim ... torn_accepted=4057/4189' vs 'FIXED(Linux) ... torn_accepted=0'; fix.diff
↓ fix.diffper-fix-DF-4197

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log: demo 'TORN PAIR ACCEPTED' rc=1 / demo_fixed 'REJECTED' rc=0; stress torn_accepted=4057 and 4189 (two runs) vs stress_fixed torn_accepted=0; VERDICT.md verbatim shim-vs-Linux semantics comparison

PoC changes

harness written fresh (no seed): verbatim shim algorithm from seqlock.h:69-99 (pthread_mutex stands in for struct spinlock; sfence/lfence kept), verbatim store_vblank/drm_vblank_count_and_time bodies, plus a fixed-semantics variant compiled into the same binary for A/B contrast

Verified recommended fix

Restore Linux seqcount semantics in the shim: write_sequnlock -> sl->sequence++ and read_seqbegin -> spin until even (see fix.diff)

Verdict

Kernel manifestation is hardware-gated on this guest (no DRM device), but the defect itself is the shim, and the shim's exact algorithm is reproduced deterministically and under 2-thread stress on the DragonFly guest: sys/dev/drm/include/linux/seqlock.h write_sequnlock() decrements the sequence count back to its pre-write value (0->1->0) instead of advancing it monotonically (Linux: 0->1->2) and read_seqbegin() does not wait for an even count, so drm_vblank_count_and_time (drm_vblank.c:784-788) accepts a torn {count,time} pair whenever exactly one store_vblank (drm_vblank.c:92-106) completes inside its two field loads. Demo: TORN PAIR ACCEPTED {count=100(old), time=200000(new)} with read_seqretry==0 under DF semantics vs retry==1 under corrected semantics; stress: torn_accepted=4057/4189 across two runs under DF semantics vs 0 under fixed semantics (~8M reads each). Torn values are legitimate vblank counters (no kernel-memory disclosure) but corrupt the vblank UAPI: wrong reply.sequence/tval (drm_wait_vblank_reply:1518), wrong DRM_IOCTL_CRTC_GET_SEQUENCE output (:1801), wrong event target sequence in DRM_IOCTL_CRTC_QUEUE_SEQUENCE (:1861), mismatched flip-completion timestamps. i915 engine stats (intel_engine_cs.c:1623) share the broken shim.