RT-Thread Version
v5.2.2
Hardware Type/Architectures
N/A
Develop Toolchain
Other
Describe the bug
RT-Smart serial ioctl trusts user pointers and can crash the kernel
Describe the bug
RT-Smart validates and copies user buffers for read() and write(), but sys_ioctl() forwards its third argument directly into the kernel-side ioctl implementation. For serial devices, the file descriptor selects a legitimate kernel-owned device, but the ioctl payload pointer remains user-controlled and is later treated as a trusted kernel pointer to driver-specific data structures.
The concrete affected path is:
user process
-> ioctl(fd, RT_DEVICE_CTRL_CONFIG, user_ptr)
-> sys_ioctl(fd, cmd, data)
-> ioctl(fd, cmd, data)
-> fcntl(fd, cmd, data)
-> dfs_file_ioctl(...)
-> serial_fops_ioctl(...)
-> rt_device_control(device, cmd, args)
-> rt_serial_control(..., RT_DEVICE_CTRL_CONFIG, args)
-> unchecked dereference of args as struct serial_configure *
-> optional board configure callback with attacker-controlled fields
This gives a user process two direct crash paths when it has access to a serial device node:
- Passing an invalid ioctl payload pointer can crash the kernel before any board callback is reached, because
rt_serial_control() dereferences the unchecked pointer as struct serial_configure *.
- Passing a readable user
struct serial_configure can make the kernel consume attacker-controlled fields. On imx6ull-smart, setting baud_rate = 0 while preserving the current buffer size can reach board UART code that divides by cfg->baud_rate.
The second case is concrete for RT-Smart imx6ull serial. The default imx6ull-smart configuration enables RT-Smart, MMU, serial, and POSIX devio, and registers BSP_USING_UART1 as uart0.
Affected code
read() and write() explicitly validate and copy user buffers under ARCH_MM_MMU:
components/lwp/lwp_syscall.c:423
components/lwp/lwp_syscall.c:434
components/lwp/lwp_syscall.c:439
components/lwp/lwp_syscall.c:445
components/lwp/lwp_syscall.c:448
components/lwp/lwp_syscall.c:494
components/lwp/lwp_syscall.c:502
components/lwp/lwp_syscall.c:507
components/lwp/lwp_syscall.c:513
components/lwp/lwp_syscall.c:516
sys_ioctl() does not perform the same user-access check or copy:
sysret_t sys_ioctl(int fd, unsigned long cmd, void* data)
{
int ret = ioctl(fd, cmd, data);
return (ret < 0 ? GET_ERRNO() : ret);
}
Location:
components/lwp/lwp_syscall.c:796
components/lwp/lwp_syscall.c:798
For RT-Thread serial devices with POSIX devio enabled, the serial fops layer resolves the target device from the file object and forwards the unchecked argument into rt_device_control():
static int serial_fops_ioctl(struct dfs_file *fd, int cmd, void *args)
{
rt_device_t device;
int flags = (int)(rt_base_t)args;
int mask = O_NONBLOCK | O_APPEND;
device = (rt_device_t)fd->vnode->data;
switch ((rt_ubase_t)cmd)
{
case FIONREAD:
break;
case FIONWRITE:
break;
case F_SETFL:
flags &= mask;
fd->flags &= ~mask;
fd->flags |= flags;
break;
}
return rt_device_control(device, cmd, args);
}
Location:
components/drivers/serial/dev_serial.c:121
components/drivers/serial/dev_serial.c:141
The serial control path then treats args as a trusted kernel pointer. The first dereference of pconfig->bufsz is already enough to crash the kernel if args is an invalid user pointer. If the pointer is readable, serial->config = *pconfig copies attacker-controlled fields into the serial state:
case RT_DEVICE_CTRL_CONFIG:
if (args)
{
struct serial_configure *pconfig = (struct serial_configure *) args;
if (pconfig->bufsz != serial->config.bufsz && serial->parent.ref_count)
{
return -RT_EBUSY;
}
serial->config = *pconfig;
if (serial->parent.ref_count)
{
serial->ops->configure(serial, (struct serial_configure *) args);
}
}
break;
Locations:
components/drivers/serial/dev_serial.c:1084
components/drivers/serial/dev_serial.c:1087
components/drivers/serial/dev_serial.c:1088
components/drivers/serial/dev_serial.c:1094
components/drivers/serial/dev_serial.c:1098
Opening the device sets ref_count, so the configure callback is reachable after open():
components/drivers/serial/dev_serial.c:73
components/drivers/serial/dev_serial.c:103
components/drivers/core/device.c:271
components/drivers/core/device.c:275
Separately, if the unchecked pointer is readable and the configure callback is reached, imx6ull-smart has a board-specific divide-by-zero path. The board UART configure function uses the user-controlled baud_rate field as a divisor. The assertion only checks the upper bound, so baud_rate = 0 passes this assertion and reaches the division:
RT_ASSERT(cfg->baud_rate <= BAUD_RATE_921600);
periph->UBIR = UART_UBIR_INC(15);
periph->UBMR = UART_UBMR_MOD(HW_UART_BUS_CLOCK / cfg->baud_rate - 1);
Location:
bsp/nxp/imx/imx6ull-smart/drivers/drv_uart.c:191
bsp/nxp/imx/imx6ull-smart/drivers/drv_uart.c:194
The imx6ull-smart BSP enables RT-Smart and serial device access:
bsp/nxp/imx/imx6ull-smart/rtconfig.h:65 RT_USING_SMART
bsp/nxp/imx/imx6ull-smart/rtconfig.h:123 ARCH_MM_MMU
bsp/nxp/imx/imx6ull-smart/rtconfig.h:202 RT_USING_SERIAL
bsp/nxp/imx/imx6ull-smart/rtconfig.h:277 RT_USING_POSIX_DEVIO
bsp/nxp/imx/imx6ull-smart/rtconfig.h:642 BSP_USING_UART1
The same BSP registers UART1 as the serial device named uart0:
bsp/nxp/imx/imx6ull-smart/drivers/drv_uart.c:52
bsp/nxp/imx/imx6ull-smart/drivers/drv_uart.c:60
bsp/nxp/imx/imx6ull-smart/drivers/drv_uart.c:338
Steps to reproduce by source reasoning
I have not reproduced this on physical imx6ull-smart hardware. The source-level trigger requires:
RT_USING_SMART = enabled
ARCH_MM_MMU = enabled
RT_USING_SERIAL = enabled
RT_USING_POSIX_DEVIO = enabled
a serial device node such as /dev/uart0 is accessible
For an invalid user pointer dereference:
#include <fcntl.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <rtdevice.h>
int main(void)
{
int fd = open("/dev/uart0", O_RDWR);
if (fd < 0) {
return 1;
}
/*
* sys_ioctl() forwards this pointer without lwp_user_accessable()
* or lwp_get_from_user(). rt_serial_control() then dereferences it
* as struct serial_configure *.
*/
ioctl(fd, RT_DEVICE_CTRL_CONFIG, (void *)0x1);
close(fd);
return 0;
}
For the imx6ull-smart divide-by-zero path, the ioctl payload must be a readable struct serial_configure. It must also preserve the current serial buffer size so that rt_serial_control() does not return -RT_EBUSY before calling the board configure callback. With the default serial configuration this buffer size is RT_SERIAL_RB_BUFSZ.
#include <fcntl.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <rtdevice.h>
int main(void)
{
struct serial_configure cfg = RT_SERIAL_CONFIG_DEFAULT;
int fd = open("/dev/uart0", O_RDWR);
if (fd < 0) {
return 1;
}
/*
* Keep cfg.bufsz equal to the current default buffer size, but set
* baud_rate to zero. The imx6ull-smart UART configure callback uses
* cfg.baud_rate as a divisor.
*/
cfg.baud_rate = 0;
ioctl(fd, RT_DEVICE_CTRL_CONFIG, &cfg);
close(fd);
return 0;
}
Expected source-level path for the divide-by-zero case:
open("/dev/uart0", O_RDWR)
-> serial_fops_open()
-> rt_device_open()
-> device ref_count becomes nonzero
ioctl(fd, RT_DEVICE_CTRL_CONFIG, &cfg)
-> sys_ioctl()
-> ioctl()
-> fcntl()
-> dfs_file_ioctl()
-> serial_fops_ioctl()
-> rt_serial_control()
-> unchecked reads from user-provided struct serial_configure
-> pconfig->bufsz is accepted because it matches serial->config.bufsz
-> serial->ops->configure(serial, &cfg)
-> imx6ull-smart _uart_ops_configure()
-> HW_UART_BUS_CLOCK / cfg->baud_rate
-> divide by zero when cfg.baud_rate == 0
Security impact
A user process that can open a serial device can pass arbitrary ioctl payload pointers into kernel driver code. At minimum, this can crash the kernel by causing an unchecked user-pointer dereference in rt_serial_control(). If the pointer is readable, the kernel consumes attacker-controlled serial configuration fields. On imx6ull-smart, a user-supplied serial configuration with baud_rate = 0 can additionally reach a divide-by-zero in the UART configure path.
This is a user-kernel boundary issue. The device pointer itself is kernel-owned and selected through the file descriptor; the attacker does not directly supply a raw rt_device_t. The unchecked part is the ioctl payload pointer and the data it points to.
Recommended fix
Do not pass the raw user pointer from sys_ioctl() into driver code. The ioctl layer should validate and copy command-specific payloads before calling the kernel-side implementation, and copy results back to user memory for output commands.
For serial configuration commands, one possible direction is:
case RT_DEVICE_CTRL_CONFIG:
{
struct serial_configure kcfg;
if (!data || !lwp_user_accessable(data, sizeof(kcfg))) {
return -EFAULT;
}
if (lwp_get_from_user(&kcfg, data, sizeof(kcfg)) != sizeof(kcfg)) {
return -EFAULT;
}
return ioctl(fd, cmd, &kcfg);
}
The UART configure implementations should also validate configuration values before programming hardware. In particular, reject baud_rate == 0 before using it as a divisor:
if (cfg->baud_rate == 0 || cfg->baud_rate > BAUD_RATE_921600) {
return -RT_EINVAL;
}
The same audit should be applied to other device ioctl commands that copy callback pointers or nested pointers from the ioctl argument. Those commands should either be unavailable to user space or should use explicit safe kernel-side representations instead of trusting user-provided function pointers.
Other additional context
No response
RT-Thread Version
v5.2.2
Hardware Type/Architectures
N/A
Develop Toolchain
Other
Describe the bug
RT-Smart serial ioctl trusts user pointers and can crash the kernel
Describe the bug
RT-Smart validates and copies user buffers for
read()andwrite(), butsys_ioctl()forwards its third argument directly into the kernel-side ioctl implementation. For serial devices, the file descriptor selects a legitimate kernel-owned device, but the ioctl payload pointer remains user-controlled and is later treated as a trusted kernel pointer to driver-specific data structures.The concrete affected path is:
This gives a user process two direct crash paths when it has access to a serial device node:
rt_serial_control()dereferences the unchecked pointer asstruct serial_configure *.struct serial_configurecan make the kernel consume attacker-controlled fields. On imx6ull-smart, settingbaud_rate = 0while preserving the current buffer size can reach board UART code that divides bycfg->baud_rate.The second case is concrete for RT-Smart imx6ull serial. The default imx6ull-smart configuration enables RT-Smart, MMU, serial, and POSIX devio, and registers
BSP_USING_UART1asuart0.Affected code
read()andwrite()explicitly validate and copy user buffers underARCH_MM_MMU:sys_ioctl()does not perform the same user-access check or copy:Location:
For RT-Thread serial devices with POSIX devio enabled, the serial fops layer resolves the target device from the file object and forwards the unchecked argument into
rt_device_control():Location:
The serial control path then treats
argsas a trusted kernel pointer. The first dereference ofpconfig->bufszis already enough to crash the kernel ifargsis an invalid user pointer. If the pointer is readable,serial->config = *pconfigcopies attacker-controlled fields into the serial state:Locations:
Opening the device sets
ref_count, so the configure callback is reachable afteropen():Separately, if the unchecked pointer is readable and the configure callback is reached, imx6ull-smart has a board-specific divide-by-zero path. The board UART configure function uses the user-controlled
baud_ratefield as a divisor. The assertion only checks the upper bound, sobaud_rate = 0passes this assertion and reaches the division:Location:
The imx6ull-smart BSP enables RT-Smart and serial device access:
The same BSP registers UART1 as the serial device named
uart0:Steps to reproduce by source reasoning
I have not reproduced this on physical imx6ull-smart hardware. The source-level trigger requires:
For an invalid user pointer dereference:
For the imx6ull-smart divide-by-zero path, the ioctl payload must be a readable
struct serial_configure. It must also preserve the current serial buffer size so thatrt_serial_control()does not return-RT_EBUSYbefore calling the board configure callback. With the default serial configuration this buffer size isRT_SERIAL_RB_BUFSZ.Expected source-level path for the divide-by-zero case:
Security impact
A user process that can open a serial device can pass arbitrary ioctl payload pointers into kernel driver code. At minimum, this can crash the kernel by causing an unchecked user-pointer dereference in
rt_serial_control(). If the pointer is readable, the kernel consumes attacker-controlled serial configuration fields. On imx6ull-smart, a user-supplied serial configuration withbaud_rate = 0can additionally reach a divide-by-zero in the UART configure path.This is a user-kernel boundary issue. The device pointer itself is kernel-owned and selected through the file descriptor; the attacker does not directly supply a raw
rt_device_t. The unchecked part is the ioctl payload pointer and the data it points to.Recommended fix
Do not pass the raw user pointer from
sys_ioctl()into driver code. The ioctl layer should validate and copy command-specific payloads before calling the kernel-side implementation, and copy results back to user memory for output commands.For serial configuration commands, one possible direction is:
The UART configure implementations should also validate configuration values before programming hardware. In particular, reject
baud_rate == 0before using it as a divisor:The same audit should be applied to other device ioctl commands that copy callback pointers or nested pointers from the ioctl argument. Those commands should either be unavailable to user space or should use explicit safe kernel-side representations instead of trusting user-provided function pointers.
Other additional context
No response