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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5015 Β· 24 files
FileTypeDescriptionSize
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) computes sc_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) runs sc_vtb_append() with the negative tail: len = imin(count, vtb_size - vtb_tail) exceeds the ring, and sc_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.df5015 prints 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 in sc_vtb_bcopy at 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)

  1. /tmp/df5015 arm (uid 65534 ioctls)
  2. memory-pressure window (unpriv dd/vmburst churn, or root squeeze setup)
  3. /tmp/df5015 flip 100000 3000 60 & (CONS_SET 80x25 <-> 80x60)
  4. 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.df5015 shows rows=25 size=2000 ... ysize=60.
  5. scroll-lock in/out cycles arm the negative tail (sysctl hw.df5015 shows tail=-N) and fire the OOB write (zprev counts 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=2000 rings 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.
VERDICT.md
↓ download raw

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)

  1. Baseline (matched state): arm shrink 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)
  2. 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).
  3. Mismatch rounds 2-3: same artifact ([28,59]) at attempt 3 β€” reproducible; round 3 added the in-kernel proof channel (offmod2).
  4. In-kernel state proof: sysctl hw.df5015 after arm: cols=80 rows=25 size=2000 buf=0xfffff80116d9a000 tail=800 xsize=80 ysize=25 β€” the probe walks the live softc (devclass_find("sc")).
  5. 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.
  6. 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=-N sysctl line (the deterministic freeze was still being engineered when the budget ran out β€” the negative-tail arithmetic is ((tail - xsize*ysize + size) % size) with size < 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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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
↓ fix.diffper-fix-DF-5015

Confirmed kernel references

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.