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

syscons CONS_SET mode change (sc_set_text_mode) vs tty output (scstart/sc_puts): no common lock across scp->vtb/history teardown - reproducible kernel panic and heap double-free window

Summary

sc_set_text_mode rewrites scp geometry and tears down/reallocates scp->vtb and history while holding only vga_token - which LWKT drops whenever the M_WAITOK allocation sleeps - while the tty output path writes the same vtb and history holding only the syscons_mtx spinlock: the paths share no lock. Concurrent CONS_SET + console output writes emulator glyphs into freed M_SYSCONS slabs (UAF write / double free) with torn geometry. GUEST-VERIFIED 3/3 kernel panics within 60s (lwkt_reltokref assertion) with clean ioctl-only/writer-only controls; an interim serialized-swap fix exposed the raw BADFREE2 heap double-free at sc_vtb_destroy naming the exact free. Any holder of a writable /dev/ttyvN fd (a real console-login session; vidcontrol/X vs getty output). Fix validated: patched kernel survives 3 consecutive 60s fatal-stress rounds (~1M CONS_SETs, ~3M writes). M_NOWAIT-alloc + commit-under-syscons_mtx + free-outside fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4542 Β· 16 files
FileTypeDescriptionSize
README.md β€” 3.4 KB ↓ raw
VERDICT.md β€” 4.6 KB ↓ raw
scstress.c β€” 4.0 KB view raw
scprobe.c β€” 3.1 KB view raw
build.sh β€” 120 B view raw
run.sh β€” 156 B view raw
run.log β€” 1.9 KB view raw
run.panic1_bootlog.txt β€” 2.2 KB view raw
run.panic2_bootlog.txt β€” 2.2 KB view raw
run.panic3_bootlog.txt β€” 2.2 KB view raw
fixv1.BADFREE2_bootlog.txt β€” 2.1 KB view raw
env.txt β€” 608 B view raw
code_hashes.txt β€” 153 B view raw
fix.diff β€” 7.2 KB view raw
manifest.json β€” 1.1 KB view raw
verdict.json β€” 4.4 KB view raw

DF-4542 β€” syscons CONS_SET mode change vs tty output: no common lock β†’ reproducible kernel panic (+ UAF-write window)

Summary

sc_set_text_mode() (sys/dev/misc/syscons/scvidctl.c:54-184) tears down and reallocates the per-vty shadow screen buffer (scp->vtb, via sc_alloc_scr_buffer(), free+kmalloc with wait=TRUE, i.e. it can SLEEP mid-mutation) and the scrollback ring (scp->history, via lock-free sc_alloc_history_buffer()), and rewrites scp->xsize/ysize/ xpos/... while holding only lwkt's vga_token.

The tty output path (scstart() β†’ sc_puts() β†’ terminal emulator writing scp->vtb and pushing scroll lines into scp->history) holds only the syscons_mtx spinlock.

The two paths share no lock. vga_token is not held by the output path and is dropped by LWKT whenever the mode-change thread blocks inside the M_WAITOK allocation, so the output/render path can execute against a half-updated scp: freed vtb/history rings being written by the emulator (UAF write), torn xsize vs. buffer geometry (OOB write).

Under contention this corrupts kernel synchronization state and panics:

panic with 1 spinlocks held
panic: assertion "count & TOK_COUNTMASK" failed in _lwkt_reltokref
       at /usr/src/sys/kern/lwkt_token.c:458
cpuid = N
Trace: lwkt_relalltokens() ... lwkt_switch()

Reproduction (audit guest, DragonFly 6.5-DEVELOPMENT, X86_64_GENERIC)

scstress.c β€” N concurrent ioctl threads doing CONS_SET text-mode cycles (80x25 ↔ 80x50 ↔ 80x30 ↔ 80x60) on /dev/ttyv1..4, plus writer threads spamming write() (forcing sc_puts + history scroll).

Isolation matrix (60 s each):

mode stressors result
1 CONS_SET + KDSETMODE/KDRASTER churn only (~2.9M ioctls) clean
2 writers only (~1.3M writes) clean
10 CONS_SET only + writers panic (same signature)
3/7 full mix (adds KD_TEXT1/KDRASTER churn, VT_ACTIVATE cycler) panic

3/3 panics in the CONS_SET+writer configurations, zero panics in any single-stressor round. Minimal pair = CONS_SET (sc_set_text_mode) concurrent with tty writes (scstart/sc_puts).

Threat model

Any process holding a writable fd on a /dev/ttyvN can issue CONS_SET β€” sc_vid_ioctl performs no privilege check; only device permissions gate it (root-only 0600 on the default ssh-accessible guest; a user logged in on a real console holds such an fd for their session). Two processes (or threads) suffice: one mode-cycling (e.g. what vidcontrol/X startup does), one printing. Result: reliable local kernel panic (DoS) with a genuine kernel-heap UAF-write window behind it (emulator glyphs written into freed M_SYSCONS slabs).

Fix

fix.diff β€” serialize the mode-change commit against the output path: export syscons_lock()/syscons_unlock(), do the sleeping buffer allocation first, swap pointers + update scp meta fields + reinit the emulator under the (reentrant) syscons_mtx spinlock, and free old buffers outside it. Validated: patched kernel survives the identical stress that panicked 3/3 (see VERDICT.md).

Files

  • scstress.c / scprobe.c β€” sources
  • build.sh / run.sh
  • run.panic{1,2,3}_bootlog.txt β€” the three panics
  • env.txt β€” guest environment
  • fix.diff β€” validated fix (never applied to the read-only sys/ tree; applied only inside the guest's /usr/src copy for validation)
VERDICT.md
↓ download raw

DF-4542 β€” VERDICT

Status: reproduced (impact: panic β€” reliable local kernel DoS from console-reachable ioctls; underlying class is a heap UAF/double-free window). Fix validated on the guest (v2 patch: panic gone across all previously-fatal stress configurations).

Reproduced

Stock DragonFly 6.5-DEVELOPMENT #0 Jul 2 2026 X86_64_GENERIC guest (QEMU/KVM, 6 vCPU, std VGA text console, no VESA/KMS):

  • 3/3 panics in ≀60 s whenever CONS_SET text-mode cycling runs concurrently with write() traffic on the same /dev/ttyvN: panic with 1 spinlocks held panic: assertion "count & TOK_COUNTMASK" failed in _lwkt_reltokref at /usr/src/sys/kern/lwkt_token.c:458 cpuid = 2/5/5 (run.panic{1,2,3}_bootlog.txt)
  • Isolation matrix proves the pair:
  • ioctl churn alone (2.9M ioctls) β€” clean
  • writers alone (1.3M writes) β€” clean
  • CONS_SET + writers β€” panic (mode 10; also full-mix modes 3/7)
  • fix v1 (serialize only the buffer swaps) changed the manifestation into the raw heap double-free: panic: BADFREE2 _kfree() <- sc_vtb_destroy() <- sc_alloc_scr_buffer() <- sc_set_text_mode() <- sc_vid_ioctl() (fixv1.BADFREE2_bootlog.txt) β€” direct confirmation that the mode-change path frees heap buffers that are still being managed concurrently.

Root cause (path:line)

  • sc_set_text_mode() scvidctl.c:54-184 holds only vga_token (scvidctl.c:64) across the entire scp mutation + buffer reallocation.
  • sc_alloc_scr_buffer() syscons.c:3560-3587 frees the old scp->vtb and kmallocs a new one with wait=TRUE β†’ kmalloc(M_WAITOK): when the ioctl thread sleeps, LWKT drops vga_token (lwkt_relalltokens in lwkt_switch), so neither vga_token nor any other lock excludes:
  • the tty output path scstart() β†’ sc_puts() (syscons.c) which writes scp->vtb and pushes scroll lines into scp->history under syscons_mtx only (a spin lock, syscons_lock() syscons.c:244) β€” no common lock;
  • a second concurrent CONS_SET (serialized by vga_token only while nobody sleeps).
  • sc_alloc_history_buffer() schistory.c:72-173 is completely lock-free (read-NULL-out / install / free of scp->history), racing the same writers.
  • Result: emulator writes into freed M_SYSCONS slabs (UAF write) and torn scp geometry (OOB write window); observed kernel-synchronization corruption β†’ deterministic panic.

Exploitability

  • Reach: any process with a writable fd on /dev/ttyvN β€” sc_vid_ioctl does no privilege check (syscons.c:983-986 forwards directly; the only gate is device permission β€” 0600 root on the ssh-only audit guest, but a real console-login session holds such an fd; vidcontrol/X startup issue CONS_SET routinely while getty/init write).
  • The verified product is the reliable panic (DoS). The UAF-write primitive (attacker-controlled glyph/attribute u_shorts written by the emulator into a freed slab, race-window dependent, M_SYSCONS grooming) was NOT weaponized to uid=0 in this run β€” the win requires deterministic race timing; recorded as the escalation route.

Fix validation

  • v1 (swap under syscons_mtx only): BADFREE2 remained (CONS_SET-vs-CONS_SET interleave via token-drop-at-sleep) β€” valuable negative result.
  • v2 (fix.diff, in this pack): allocate the new vtb first with M_NOWAIT; commit meta fields + vtb swap + sc_init_emulator() + cut-buffer under the reentrant syscons_mtx spinlock; free old buffers outside the spinlock; history detach/install/fail-restore under syscons_mtx in sc_alloc_history_buffer(); sc_alloc_scr_buffer() hardened the same way + refuses to install an invalid buffer.
  • Patched kernel (#1 Mon Sep 21 10:17:20): three consecutive 60 s rounds of the exact fatal configurations (mode 10 Γ—2, mode 7 Γ—1; ~1M CONS_SET, ~3M writes, VT cycling) β€” all clean, guest alive (run.log).

fix_status: fixed (baseline reproduced 3/3; patched reproduced 0/3).

Residual (noted for upstream)

The async renderer's draw phase runs without syscons_mtx (scrn_update drops it, syscons.c) and without vga_token for most of its vtb reads; a theoretical render-vs-mode-change read race remains (active console only). Upstream may want vga_token (or a dedicated lock) around the vgarndr text draw loops as follow-up hardening.

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

fixed - v2-patched kernel survived 3x60s of the exact stress (modes 10,7,10) that panicked the stock kernel 3/3 and BADFREE2'd the v1 patch; behavior change confirmed (no panic, clean exits, guest alive)

run.log (baseline panics + v1 BADFREE2 + 3 clean v2 rounds); fix.diff (v2); VERDICT.md
↓ fix.diffper-fix-DF-4542

Confirmed kernel references

Detail

Exploit chain

unprivileged console session (writable /dev/ttyvN fd; sc_vid_ioctl has no privilege check) -> thread A cycles CONS_SET text modes (what vidcontrol/X do), thread B prints (getty/init/logins qualify) -> emulator writes glyphs into vtb/history slabs freed concurrently by the mode change -> kernel-heap UAF write / double-free -> observed: deterministic kernel panic (DoS). Weaponization to uid=0 would require deterministic race timing plus M_SYSCONS slab grooming (victim object reuse between free and emulator write); not completed in this run - the reliable panic plus the BADFREE2 double-free proof is the delivered impact.

Evidence (decisive lines)

run.log (all rounds); run.panic{1,2,3}_bootlog.txt (token assertion, cpuid 2/5/5); fixv1.BADFREE2_bootlog.txt (double-free backtrace sc_vtb_destroy<-sc_alloc_scr_buffer<-sc_set_text_mode); VERDICT.md (full analysis); fix.diff (validated v2 patch)

PoC changes

authored scstress.c from scratch (4 vt x [CONS_SET ioctl thread + writer thread] + optional KD_TEXT1/KDRASTER churn and VT_ACTIVATE cycler; mode bitmask for the isolation matrix). No seed existed.

Verified recommended fix

allocate new buffers M_NOWAIT first, commit scp meta + vtb/history swaps + emulator reinit under the reentrant syscons_mtx, free old buffers outside the spinlock (fix.diff)

Verdict

Reproduced 3/3 on the stock guest kernel: concurrent CONS_SET text-mode cycling and write() traffic on the same /dev/ttyvN panics the kernel within 60s (lwkt_reltokref TOK_COUNTMASK assertion, lwkt_token.c:458). Isolation matrix: ioctl-churn-only (2.9M ioctls) and writers-only (1.3M writes) are clean; the minimal fatal pair is CONS_SET + writers. Root cause: sc_set_text_mode (scvidctl.c:54-184) mutates scp and frees/reallocates scp->vtb (kmalloc M_WAITOK inside sc_alloc_scr_buffer) and scp->history (lock-free sc_alloc_history_buffer) holding only vga_token, which LWKT drops at every sleep, while the tty output path scstart/sc_puts writes the same buffers under only the syscons_mtx spinlock - no common lock. fix v1 (swap-only serialization) turned the panic into a raw 'panic: BADFREE2' double-free at sc_vtb_destroy<-sc_alloc_scr_buffer<-sc_set_text_mode, directly confirming the heap-double-free class. fix v2 (M_NOWAIT alloc-first, commit under reentrant syscons_mtx, frees outside, history detach/install under syscons_mtx) was built and booted on the guest: the identical stress that panicked 3/3 now survives 3 consecutive 60s rounds (~1M CONS_SETs, ~3M writes) with the guest alive.