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)
PoC verification
Evidence pack
findings/poc/DF-4542 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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β sourcesbuild.sh/run.shrun.panic{1,2,3}_bootlog.txtβ the three panicsenv.txtβ guest environmentfix.diffβ validated fix (never applied to the read-only sys/ tree; applied only inside the guest's /usr/src copy for validation)
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 onlyvga_token(scvidctl.c:64) across the entire scp mutation + buffer reallocation.sc_alloc_scr_buffer()syscons.c:3560-3587 frees the oldscp->vtband kmallocs a new one withwait=TRUEβ kmalloc(M_WAITOK): when the ioctl thread sleeps, LWKT dropsvga_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 writesscp->vtband pushes scroll lines intoscp->historyundersyscons_mtxonly (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 ofscp->history), racing the same writers.- Result: emulator writes into freed M_SYSCONS slabs (UAF write) and
torn
scpgeometry (OOB write window); observed kernel-synchronization corruption β deterministic panic.
Exploitability
- Reach: any process with a writable fd on /dev/ttyvN β
sc_vid_ioctldoes 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 reentrantsyscons_mtxspinlock; free old buffers outside the spinlock; history detach/install/fail-restore undersyscons_mtxinsc_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
fixedfixed - 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
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.
No comments yet.