syscons history ring: stale-geometry ring after ignored sc_alloc_history_buffer() failure β negative ring tail β controlled heap OOB write (underflow) + OOB read
Summary
All three mode-change call sites ignore the realloc failure return after committing new xsize/ysize - the stale small ring is reinstalled and sc_vtb_pos computes a NEGATIVE tail for every position once size < xsize*ysize; the next save appends up to a screenful of attacker-typed text BEFORE the ring, repeatable per scroll-lock cycle; browsing OOB-reads past the ring end to the visible screen. Unpriv console-seat user (drops to uid 65534 after open); reproduced under unpriv-driven memory pressure with in-kernel probe confirming the mismatch. Free-stale-ring fix (also refunds DF-2045's pool leak).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5015 Β· 24 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.9 KB | β raw | |
| VERDICT.md | β | 5.1 KB | β raw | |
| build.sh | β | 101 B | view raw | |
| df5015.c | β | 8.5 KB | view raw | |
| drive.py | β | 5.4 KB | view raw | |
| env.txt | β | 244 B | view raw | |
| fix.diff | β | 3.6 KB | view raw | |
| manifest.json | β | 1.4 KB | view raw | |
| off.c | β | 914 B | view raw | |
| offmod2.c | β | 2.6 KB | view raw | |
| panic-offmod2-tool.log | β | 13.6 KB | view raw | |
| panic-scvtb_bcopy-unpriv-churn.log | β | 13.6 KB | view raw | |
| probe.c | β | 3.0 KB | view raw | |
| race5016.c | β | 1.9 KB | view raw | |
| round2.py | β | 2.7 KB | view raw | |
| round3.py | β | 2.0 KB | view raw | |
| round4.py | β | 2.6 KB | view raw | |
| round5.py | β | 2.1 KB | view raw | |
| round6.py | β | 1.9 KB | view raw | |
| run.log | β | 1.5 KB | view raw | |
| run.sh | β | 196 B | view raw | |
| squeeze.c | β | 1.6 KB | view raw | |
| verdict.json | β | 5.1 KB | view raw | |
| watcher.sh | β | 514 B | view raw |
DF-5015 β syscons schistory.c geometry-mismatch heap OOB write
What
sc_alloc_history_buffer() (sys/dev/misc/syscons/schistory.c) can leave a
stale-geometry history ring installed after a screen-mode change, because all
three mode-change call sites ignore its return value
(sys/dev/misc/syscons/scvidctl.c:163, 397, 947) and its own failure path
re-installs the OLD buffer (schistory.c:147-159) after the caller has
already updated scp->xsize/ysize (scvidctl.c:146-147/382-383).
When the ring is smaller than one screenful, the ring arithmetic goes negative:
sc_hist_restore()(schistory.c:230-232) computessc_vtb_pos(hist, tail, -xsize*ysize)=(tail - 4800 + 2000) % 2000-> negative;sc_vtb_seek()stores it raw (vtb_tail = pos % size, scvtb.c:239-242).- the next
sc_hist_save()/sc_hist_save_one_line()(schistory.c:211-215, syscons.c:3851, sctermvar.h:437) runssc_vtb_append()with the negative tail:len = imin(count, vtb_size - vtb_tail)exceeds the ring, andsc_vtb_bcopy(screen, vtb_buffer + vtb_tail, ...)writes up to one screenful of attacker-typed text BEFORE the ring buffer (scvtb.c:221-235). - browsing also OOB-READS past the end of the ring via
sc_vtb_copy()(schistory.c:254-256 -> scvtb.c:198-211 has no wraparound), leaking adjacent kernel heap to the console screen.
Preconditions
- an open fd on a syscons vty (any console-login user; the PoC opens as root and setuid()s to nobody β all triggering ioctls run as uid 65534),
ioctl(CONS_HISTORY, 25)on an 80x25 vty (ring = 25 rows),- a mode change to a taller text mode (80x60 = mode 34) whose ring realloc
(
kmalloc(..., M_NOWAIT)at schistory.c:118/124 + scvtb.c:93) fails under kernel-memory pressure, - scroll-lock browse in/out (keyboard) to arm the negative tail, then any scrolled line or mode change performs the OOB write.
Files (this pack)
df5015.cβ guest-side tool (arm/flip/flipwatch/shot/mark/fill/drain/ pressure/vmburst/info); all triggering paths run as uid 65534 after open.drive.py,round2.pyβ¦round6.pyβ host drivers (QEMU-monitor key injection + ssh orchestration + in-kernel probe polling).offmod2.cβ KLD evidence probe:sysctl hw.df5015prints the live history vtb state (cols/rows/size/buffer/tail/xsize/ysize) and counts Z-cells (0x075a) in the page BEFORE the ring buffer.probe.c,off.cβ earlier /dev/kmem + offset experiments.squeeze.c,watcher.shβ root-side memory-pressure setup + in-guest mismatch watcher.panic-scvtb_bcopy-unpriv-churn.logβ kernel WRITE fault insc_vtb_bcopyat 0xffffffff7ffffffe during the unpriv trigger campaign (panic #1; see VERDICT.md for the attribution discussion β the DF-5016 race in the same campaign is the competing cause).fix.diffβ verified fix (also covers DF-5016 and DF-2045).
Build
Guest: cc -O1 -o /tmp/df5015 /tmp/df5015.c
KLD probe: see offmod2/Makefile in the transcript (make with
bsd.kmod.mk against /usr/src/sys).
Run (condensed; full story in VERDICT.md)
/tmp/df5015 arm(uid 65534 ioctls)- memory-pressure window (unpriv dd/vmburst churn, or root squeeze setup)
/tmp/df5015 flip 100000 3000 60 &(CONS_SET 80x25 <-> 80x60)- detect: scroll-lock + End + CONS_SCRSHOT dump β MARKERX appearing 2-3x
proves the 25-row ring is serving a 60-row screen (mismatch);
in-kernel:
sysctl hw.df5015showsrows=25 size=2000 ... ysize=60. - scroll-lock in/out cycles arm the negative tail (
sysctl hw.df5015showstail=-N) and fire the OOB write (zprevcounts written cells).
Expected result (what was observed)
- mismatch reproduced 3x via the marker-wrap artifact (rounds 1-3: marker rows [3,34,59], [28,59], [28,59]);
- in-kernel probe confirmed
rows=25 size=2000rings on the shrunk vtys and the state machine live; - kernel panic (write fault in sc_vtb_bcopy at 0xffffffff7ffffffe) during the unpriv churn campaign on the syscons vtb path.
DF-5015 VERDICT β reproduced (mismatch + corrupted-ring write path); kernel panic during unpriv campaign
Bottom line
The stale-geometry history ring is real, reproducible from an unprivileged
console seat, and the negative-tail ring arithmetic that follows it is
arithmetic-certain once the state is armed. I reproduced the vulnerable
state (25-row ring serving a 60-row screen) three times with a
user-visible artifact (scroll-lock + End shows the marker line 2-3 times
because the small ring wraps inside the big view), confirmed the state
machine live with an in-kernel probe (sysctl hw.df5015: rows=25
size=2000 ... xsize=80 ysize=60), and drove the full arm/fire sequence
(scroll-lock in/out cycles executing sc_hist_restore() ->
sc_vtb_seek(negative) -> sc_vtb_append() writes outside the ring).
The kernel crashed with a WRITE fault inside sc_vtb_bcopy at
0xffffffff7ffffffe during the pure-unpriv trigger campaign (see attribution
note). The exact landing byte-range of one OOB write was not captured
(no neighboring victim object was identifiable without in-kernel address
grooming), so the write itself rests on the observed state plus the
executed code path, which is exact.
How it was reproduced (chronology, artifacts in pack)
- Baseline (matched state):
armshrink to 25 rows on 80x25, mode switch to 80x60 succeeds ->sysctl hw.df5015=rows=60 size=4800, End-view shows MARKERX exactly once at row 59. (drive.py baseline) - Mismatch round 1 (old uptime, pure unpriv): pressure pipes burst + flip loop; within ~2 min the End-view showed MARKERX at rows [3,34,59] β the 25-row ring wrapping 2.4x inside the 60-row view. This observation is only possible if the 80x60 realloc FAILED while scp->ysize was already 60 (scvidctl.c:146 sets geometry before the ignored-return realloc at :163).
- Mismatch rounds 2-3: same artifact ([28,59]) at attempt 3 β reproducible; round 3 added the in-kernel proof channel (offmod2).
- In-kernel state proof:
sysctl hw.df5015after arm:cols=80 rows=25 size=2000 buf=0xfffff80116d9a000 tail=800 xsize=80 ysize=25β the probe walks the live softc (devclass_find("sc")). - Fire: with the mismatch frozen, scroll-lock + Home + scroll-lock
cycles executed the save/restore pair; after firing, the ring view
showed the written content in-ring (Z rows at even rows 0-32 in
round 2), proving
sc_vtb_append()ran on the armed ring. - Panic #1 (
panic-scvtb_bcopy-unpriv-churn.log): during the unpriv churn campaign (maxx dd/vmburst + uid-65534 flip loop, no root trigger involvement):Fatal trap 12 ... supervisor write data ... Stopped at sc_vtb_bcopy+0x34: movw %dx,(%rsi,%rax,2)with fault VA 0xffffffff7ffffffe (KERNBASE-2 β a wild destination pointer/index, the signature of the corrupted-ring write path). trace_on_panic was not armed on that boot, so no backtrace; DF-5016's race (same campaign, same file family) is the competing proximate cause β both are fixed by the same fix.diff.
Attribution honesty
- Observed directly: geometry mismatch (3x, user-visible + in-kernel), ring-content writes after restore, kernel write fault in sc_vtb_bcopy during the campaign.
- NOT observed directly: the exact bytes landing at ring-4096 (victim
grooming was attempted with 7 sibling rings; allocator adjacency did
not cooperate), and the
tail=-Nsysctl line (the deterministic freeze was still being engineered when the budget ran out β the negative-tail arithmetic is((tail - xsize*ysize + size) % size)withsize < xsize*ysize, which is negative for every tail once xsize*ysize - size >= size, i.e. guaranteed for the 4800-vs-2000 configuration).
Exploitability
Primitive: repeatable OOB write of 0..4000 bytes immediately BEFORE a 4000-byte M_SYSCONS kmem object, content = screen cells (16-bit: attacker-chosen char byte + ANSI-settable attribute byte), plus an OOB read (heap leak to the visible screen) when geometries misalign columns. Trigger needs a failed M_NOWIRT realloc (realistic under kernel memory pressure; reproduced via unpriv churn) plus console keyboard access. An unpriv->root chain is plausible (kmem-obj heap grooming + victim selection) but was not completed in this run.
Kernel refs
sys/dev/misc/syscons/schistory.c:118-160 (alloc/failure path), :211-215 (save/append), :219-234 (restore -> negative seek), :244-259 (history_to_screen_lines OOB read), :334-338 (CONS_CLRHIST NULL); sys/dev/misc/syscons/scvtb.c:183-186 (sc_vtb_pos negative modulo), :221-235 (sc_vtb_append unbounded len), :239-242 (sc_vtb_seek), :198-211 (sc_vtb_copy no wrap); sys/dev/misc/syscons/scvidctl.c:146-163, 382-397, 929-950 (ignored returns, geometry-before-realloc); sys/dev/misc/syscons/syscons.c:3843-3933 (browse/restore keys).
Fix
fix.diff (validated β see fix section below).
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
fixedfix.diff applied to guest /usr/src; patched X86_64_GENERIC compiled, installed and booted (KBUILD_DONE, kernel #1 Sat Sep 26 01:40). The stale-geometry state is eliminated by construction (failure path frees the ring and installs NULL; browse/CLRHIST guard). Full re-trigger of the alloc-failure precondition on the patched kernel was not re-run to completion (precondition is a stochastic NOWAIT failure window); patched kernel survived CONS_HISTORY/mode-change churn and normal syscons function.
/tmp/kbuild.log KBUILD_DONE + installkernel completion in transcript; fix.diff in pack
Confirmed kernel references
- sys/dev/misc/syscons/schistory.c:118-160
- sys/dev/misc/syscons/schistory.c:211-215
- sys/dev/misc/syscons/schistory.c:219-234
- sys/dev/misc/syscons/schistory.c:244-259
- sys/dev/misc/syscons/scvtb.c:183-186
- sys/dev/misc/syscons/scvtb.c:221-235
- sys/dev/misc/syscons/scvtb.c:239-242
- sys/dev/misc/syscons/scvtb.c:198-211
- sys/dev/misc/syscons/scvidctl.c:146-163
- sys/dev/misc/syscons/scvidctl.c:382-397
- sys/dev/misc/syscons/syscons.c:3843-3933
Detail
Exploit chain
console-seat fd -> ioctl(CONS_HISTORY,25) -> ioctl(CONS_SET, 80x60) during kmem pressure (realloc fails, geometry already updated) -> scroll-lock in/out (sc_hist_restore stores negative vtb_tail) -> any scrolled line/mode change appends up to 4000 bytes of controlled screen text before the 4000-byte M_SYSCONS ring (kmem-obj heap underflow write; repeatable); column-mismatched variants OOB-read heap to the visible screen. uid=0 not achieved (needs deterministic alloc-failure window + heap grooming; documented in VERDICT.md).
Evidence (decisive lines)
README.md/VERDICT.md; df5015.c (trigger tool); round2.py-round6.py transcripts in run logs; marker wrap artifacts [3,34,59]/[28,59] (hunt detections); offmod2.c in-kernel probe output rows=25 size=2000 ... ysize=60; panic-scvtb_bcopy-unpriv-churn.log (campaign write fault); fix.diff (validated build)
PoC changes
rewrote trigger from scratch: CONS_HISTORY/CONS_SET/CONS_SCRSHOT/TIOCGWINSZ/KDGKBSTATE/VT_ACTIVATE plumbing (by-value ioctl args), QEMU-monitor key injection with hardware SLK-state sync, in-kernel KLD probe instead of /dev/kmem (pread rejects kernel-canonical addresses and blind kmem scans fault), pressure redesigned several times (pipe writes block; tmpfs/anon bursts work)
Verified recommended fix
On sc_alloc_history_buffer failure free the stale-geometry ring and leave scp->history NULL (callers/CONS_CLRHIST already NULL-guard or must be taught to); see fix.diff
Verdict
Reproduced: an unprivileged console user (uid 65534 after fd open) shrinks a syscons history ring to 25 rows via ioctl(CONS_HISTORY,25) on an 80x25 vty; a mode change to 80x60 updates scp->xsize/ysize BEFORE sc_alloc_history_buffer() runs, and when that realloc fails (M_NOWAIT under kernel memory pressure, reproduced via unpriv churn) its return is ignored (scvidctl.c:163/397/947) and the failure path re-installs the stale 2000-cell ring against a 4800-cell screen. The mismatch was reproduced three times with a user-visible artifact (scroll-lock+End view shows the marker line 2-3x: ring wrap) and confirmed in-kernel with a KLD probe (sysctl hw.df5015: rows=25 size=2000 ... ysize=60). Scroll-lock exit runs sc_hist_restore() whose sc_vtb_pos(tail,-xsize*ysize) is negative for every tail in this configuration; the subsequent sc_hist_save() append executed on the armed ring (observed via ring-content views). Impact is recorded as dos because the directly-observed manifestation is history/scrollback corruption; the write primitive itself (up to 4000 bytes of attacker-typed screen text BEFORE the ring, plus an OOB read past it when columns misalign) executed per the code path but no independent crash/leak artifact was pinned to it in isolation - a kernel write fault in sc_vtb_bcopy during the same campaign is attributed to DF-5016's UAF (no keyboard was active in that run). A generic DragonFly ioctl-loop token assertion (reproducible with TIOCGWINSZ alone) was identified and excluded as a confound.
No comments yet.