Skip to content

[Bug] [Smart] terminal tty: ioctl passes raw user pointer into kernel dereference — arbitrary kernel read/write from unprivileged user process #11937

Open

Description

@XueDugu

RT-Thread Version

master (v5.4.0-dev, verified unpatched 2026-10-28) and all releases since v5.1.0 (introduced by PR #8672, commit 83e95bd, 2024-03-28). v5.0.x is unaffected (the terminal subsystem did not exist yet).

Affected area

RT-Thread Smart / terminal (tty) subsystem — components/lwp/terminal/ + components/drivers/serial/serial_tty.c + the sys_ioctl path.

Hardware-BSP vendor

Others (QEMU vexpress-a9; applies to every RT_USING_SMART board)

Architecture

ARM 32-bit Cortex-A verified (qemu-vexpress-a9); the defect is architecture-independent and also affects AArch64 RT-Smart builds.

Board and hardware details

QEMU vexpress-a9 (Cortex-A9) with the stock qemu-vexpress-a9 .config + RT-Thread CI rtsmart fragment (RT_USING_SMART=y → LWP_USING_TERMINAL default y). No extra hardware or mounts are needed: on any RT-Smart build every serial port is automatically registered as a tty device (/dev/ttyS<n>, dev_serial.c rt_hw_serial_register() → rt_hw_serial_register_tty()); the boot banner confirms Using /dev/ttyS0 as default console.

Develop Toolchain

arm-linux-musleabi gcc 7.3 (RT-Thread CI toolchain) for the kernel; freestanding user-space PoC using official syscalls only.

Describe the bug

An unprivileged RT-Smart user process can read and write arbitrary kernel memory (and kill the kernel with a single NULL pointer) using only official APIs: open("/dev/ttyS0") + ioctl(TIOCSWINSZ/TIOCGWINSZ).

Where this code comes from, and why it is safe in FreeBSD but not here

The terminal subsystem was ported from FreeBSD's tty framework into master by PR #8672 (commit 83e95bd, 2024-03-28); the files still carry the original BSD-2-Clause headers ("Copyright (c) 2008 Ed Schouten ed@FreeBSD.org"). Comparing against FreeBSD sys/kern/tty.c (main branch), the relevant ioctl cases are verbatim identical, e.g.:

/* FreeBSD sys/kern/tty.c */
case TIOCGWINSZ:
    /* Obtain window size. */
    *(struct winsize*)data = tp->t_winsize;
    return (0);
case TIOCSWINSZ:
    /* Set window size. */
    tty_set_winsize(tp, data);
    return (0);

This code has no problem in FreeBSD: FreeBSD's sys_ioctl() (sys/kern/sys_generic.c) copyin()s the user argument into a kernel stack buffer before dispatching to the cdev driver, so data always points to kernel memory there — the dereference pattern is safe by construction upstream.

The port dropped that premise, and RT-Smart adds a boundary FreeBSD never had to defend inside this code:

  1. sys_ioctl() (components/lwp/lwp_syscall.c:817) forwards the raw user pointer for all non-flock commands — there is no copyin layer like FreeBSD's;
  2. the ported-in adapter lwp_tty_ioctl_adapter() (components/lwp/terminal/freebsd/tty_compat.c:508-521) — an RT-Thread-specific function that does not exist in FreeBSD — only copies the TCGETS/TCSETS family in/out (tty_compat.c:348-408) and forwards the raw pointer for TIOCSCTTY / TIOCNOTTY / TIOCGPGRP / TIOCSPGRP / TIOCGSID / TIOCGWINSZ / TIOCSWINSZ / FIONREAD (error = IOCTL(cmd, (rt_caddr_t)args, fflags, td);). The ptmx master side does the same in its default branch (tty_ptmx.c:200 → tty_pts.c ptsdev_ioctl, e.g. TIOCGETA writes sizeof(struct termios) bytes);
  3. in RT-Smart the kernel and the user process live in separate address spaces — so when a user process passes a kernel address as the ioctl argument, the FreeBSD-inherited dereference now runs in kernel mode on an address the caller was never allowed to touch:
/* components/lwp/terminal/freebsd/tty.c:1341 */
case TIOCGWINSZ:
    *(struct winsize *)data = tp->t_winsize;   /* 8-byte write to ANY kernel address */
    return 0;
/* tty.c:1343 → tty_set_winsize, tty.c:1095 */
case TIOCSWINSZ:
    tty_set_winsize(tp, data);                 /* memcmp + 8-byte read from ANY kernel address */

Notably, TIOCSPGRP in the same passthrough set does validate (lwp_in_user_space() + copy, tty.c:1291-1303 — an RT-Thread addition; FreeBSD has no such branch), which shows this exact hazard was recognized for one command, but the rest of the set was missed.

Exploitation primitives (user process passes a kernel address as arg):

  • Arbitrary kernel 8-byte READ: ioctl(fd, TIOCSWINSZ, KADDR) then ioctl(fd, TIOCGWINSZ, &buf)
  • Arbitrary kernel 8-byte WRITE with fully controlled value: ioctl(fd, TIOCSWINSZ, &pattern) then ioctl(fd, TIOCGWINSZ, KADDR)
  • ~60-byte partially controlled write on a ptmx master fd: ioctl(mfd, TIOCGETA, KADDR) (tty_pts.c:303)
  • Deterministic kernel panic: ioctl(fd, TIOCSWINSZ, NULL) (tty.c:1095 dereferences NULL)

Reproduction (full-chain verified on QEMU RT-Smart, kernel master@5d1fb66d, unprivileged user process as /sbin/init using only official syscalls):

  1. Control: with user-space buffers, TIOCSWINSZ(&w) / TIOCGWINSZ(&r) round-trips exactly (6f 00 de 00 33 33 44 44); the shell still answers version. Kernel alive.

  2. Arbitrary read: TIOCSWINSZ(0xc00b0388) (address of rt_tick_get in kernel .text) leaks 00 48 2d e9 04 b0 8d e2 — byte-identical to the ELF contents at that address; TIOCSWINSZ(0xc00f40fc) (_uart_device) leaks kernel pointers 0xc0114010 / 0xc00d93d8. Kernel stays alive.

  3. Arbitrary write: TIOCSWINSZ with pattern 42 41 44 43 46 45 48 47, then TIOCGWINSZ(0xc0102f58) (kernel .bss tty_nin). A GDB hardware watchpoint on 0xc0102f58 catches the store at tty.c:1341 (Old (tty_nin=0, tty_nout=0xB0) → New (tty_nin=0x43444142, …)); reading the address back through the read primitive returns the exact 8-byte pattern. Kernel stays alive.

    Backtrace for the write (user-supplied kernel pointer flowing unvalidated through every layer):

    sys_ioctl (lwp_syscall.c:817, data=0xc0102f58 <tty_nin>)
    → ioctl (dfs_posix.c:737) → fcntl → dfs_file_ioctl (dfs_file.c:1658)
    → dfs_devfs_ioctl (devfs.c:234, args=0xc0102f58)
    → tty_fops_ioctl (tty_device.c:114, arg=0xc0102f58)
    → lwp_tty_ioctl_adapter (tty_compat.c:520, args=0xc0102f58)
    → ttydev_ioctl (tty.c:650) → tty_ioctl (tty.c:1395)
    → tty_generic_ioctl (tty.c:1341)  ← *(struct winsize *)0xc0102f58 = tp->t_winsize
    
  4. Availability: TIOCGWINSZ(0xc00b0388) overwrites the first instructions of rt_tick_get (kernel .text is writable in this configuration) → repeated data aborts → kernel dies. Also TIOCSWINSZ(NULL) → data abort at 0x0 → kernel dies.

Expected behavior

The tty layer must never dereference a user-supplied raw pointer: FreeBSD guarantees this by copying ioctl args in/out at the syscall boundary. In RT-Thread, a kernel address or NULL passed as arg must be rejected with EFAULT, and user addresses must go through lwp_get_from_user()/lwp_put_to_user().

Actual behavior

Arbitrary kernel read/write plus trivial kernel panic, reachable by any unprivileged RT-Smart user process through official POSIX APIs on a default image (no mounts, no privileges, no races).

Suggested fix

In lwp_tty_ioctl_adapter() (and the ptm_fops_ioctl() default branch), copy in/out the payloads of every forwarded command into kernel stack buffers, mirroring FreeBSD sys_ioctl — one change closes all sibling sinks at once (TIOCSWINSZ/TIOCGWINSZ/TIOCGPGRP/TIOCGSID/FIONREAD on the slave side; TIOCGETA/TIOCGPTN/TIOCGPGRP/TIOCGSID/FIONREAD/TIOCSIG/TIOCPKT on the master side). Alternatively, reject lwp_in_user_space(data) inside tty_generic_ioctl()/ptsdev_ioctl().

Other additional context

  • CWE-787 / CWE-125 (out-of-bounds write/read via unchecked pointer trust); CWE-476 for the NULL variant.
  • CVSS 3.1: AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H = 8.8 (scope-change reading: the attacked authority is the unprivileged user process, the impacted resource is the kernel; the S:U reading gives 7.8). All three impact axes are demonstrated with serial logs and a GDB watchpoint, not inferred.
  • Attack preconditions, stated explicitly: requires an RT-Smart build (RT_USING_SMART=y; LWP_USING_TERMINAL is default-y and serial ttys are auto-registered, so default Smart images ship the vulnerable devices); the attacker is any user-mode process; the /dev/ttyS<n> entry needs no mount (a /dev/pts/* pty entry also exists when ptyfs is mounted).
  • Not a duplicate: no existing issue/PR touches the terminal subsystem (searched winsize / TIOCGWINSZ / terminal ioctl / lwp tty / ptmx); none of the known CVEs cover it; verified still unpatched on origin/master as of 2026-10-28 (last touch was [smart] add FIONREAD ioctl support for tty #8996, 2024-05-28).
  • The bug is in the RT-Thread porting/adaptation layer (the missing copyin premise + the RT-Thread-specific passthrough adapter), not in the FreeBSD-derived logic itself — the FreeBSD code only becomes unsafe because its precondition disappeared. Full repro artifacts (4-mode PoC, QEMU session harness, GDB watchpoint script, complete serial logs) available on request.

Activity

  1. github-actions commented on Oct 28, 2026

    @github-actions

    如需认领并处理此 Issue,请评论 /claim。

    To claim and work on this issue, comment /claim.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions