Skip to content

[Bug] lwp: sys_poll nfds wraparound on 32-bit RT-Smart — 8-byte heap block walked as 0x20000001 entries, kernel heap OOB read/write and crash #11925

Open

Description

@XueDugu

Bug Report: sys_poll integer wraparound on 32-bit RT-Smart gives kernel heap out-of-bounds read/write

RT-Thread Version

v5.0.0 – v5.3.0 and current master (v5.4.0-dev, verified at commit 5d1fb66d).
Introduced by commit ecf2d821 (2022-12-03, "sync branch rt-smart. (#6641)"), first release: v5.0.0.

Affected area

Kernel / RT-Smart (light-weight process) — components/lwp/lwp_syscall.c (sys_poll),
components/libc/posix/io/poll/poll.c.

Hardware-BSP vendor

Other (architecture-dependent, not vendor-dependent)

Architecture

ARM 32-bit (any sizeof(size_t) == 4 target with RT_USING_SMART enabled).
64-bit builds are NOT affected (the product nfds * sizeof is promoted to 64-bit and does not
wrap; the oversized allocation simply fails with -ENOMEM).

Board and hardware details

Reproduced on bsp/qemu-vexpress-a9 (Cortex-A9, QEMU vexpress-a9 machine) with the official
CI RT-Smart configuration fragment (.github/utest/configs/rtsmart/rtsmart.cfg) and the
official CI musl toolchain (toolchains-ci v1.7, arm-linux-musleabi-gcc 7.3.0).

Develop Toolchain

GCC (arm-linux-musleabi 7.3.0 for the kernel; the PoC is a freestanding user program that
issues raw svc calls, no libc needed).

Describe the bug

On 32-bit RT-Smart builds, sys_poll() computes the size of the kernel-side pollfd array
with a multiplication that wraps modulo 2^32, while still handing the unwrapped fd count
to the kernel poll loop. An unprivileged user process can therefore make the kernel allocate
an 8-byte heap block and then linearly read and write far beyond it, corrupting the kernel
heap and deterministically killing the system.

Vulnerable code (components/lwp/lwp_syscall.c, sys_poll):

if (!lwp_user_accessable((void *)fds, nfds * sizeof *fds))   /* nfds_t = unsigned int */
    return -EFAULT;

kfds = (struct pollfd *)kmem_get(nfds * sizeof *kfds);       /* wraps on 32-bit */
...
lwp_get_from_user(kfds, fds, nfds * sizeof *kfds);

ret = poll(kfds, nfds, timeout);                             /* original nfds, unwrapped */

With nfds = 0x20000001 and sizeof(struct pollfd) == 8:
0x20000001 * 8 = 0x1_00000008, which wraps to 8 on a 32-bit size_t.
The !size early-out in lwp_user_accessible_ext() only catches products that wrap to
exactly zero, so the wrapped size passes the access check, kmem_get(8) (= rt_malloc(8))
returns an 8-byte kernel heap block, and poll(kfds, 0x20000001, 0) walks
536,870,913 entries starting at that block.

Inside do_pollfd() (poll.c:207-242), for each entry pf:

  • fd = pollfd->fd — out-of-bounds read of kernel heap memory past the 8-byte block;
  • fd_get(fd) — the garbage fd is used as an fdt index (further OOB access);
  • pollfd->revents = mask — out-of-bounds write of a 4-byte value at every 8-byte offset
    (POLLNVAL = 0x20, or mask & (events | POLLERR | POLLHUP) where events itself is the
    OOB-read heap data, i.e. partially groomable).

Steps to reproduce (full chain, no host-side shortcuts):

  1. Build the qemu-vexpress-a9 BSP with the official CI smart fragment:
    cat .github/utest/configs/rtsmart/rtsmart.cfg >> bsp/qemu-vexpress-a9/.config
    (plus scons --pyconfig-silent && scons with RTT_CC_PREFIX=arm-linux-musleabi-).
  2. Place a freestanding user program as /sbin/init on the FAT sd.bin rootfs
    (RT-Smart execs /sbin/init as pid 1 at boot). The program issues one raw syscall:
    sys_call(SYS_poll /* RT-Thread syscall #9 */, fds, 0x20000001, 0)
    with fds pointing at a valid user buffer {fd=1, events=POLLIN}.
    PoC source: one file, ~200 lines, no libc, pure svc (available on request).
  3. Run: qemu-system-arm -M vexpress-a9 -m 512 -kernel rtthread.bin -sd sd.bin -serial stdio

Actual result (serial log):

msh />[poc] sys_poll nfds wraparound attack start
[W/mm.fault] rt_aspace_fault_try_fix: varea not found at 0x48000
data abort:Execption:
r00:0xc0125800 r01:0x00000000 r02:0x0000fdfd r03:0x0004801c
...
sp :0xc0141450 lr :0xc002a25c pc :0xc0040d8c
dfsr:0x00000005
dfar:0x00048040

Backtrace (addr2line on the crash addresses printed by the kernel):

fd_get    components/dfs/dfs_v2/src/dfs.c:500
poll_do   components/libc/posix/io/poll/poll.c:281
poll      components/libc/posix/io/poll/poll.c:355
sys_poll  components/lwp/lwp_syscall.c:933        <-- poll(kfds, nfds, timeout) with unwrapped nfds
arch_syscall_exit  components/lwp/arch/arm/cortex-a/lwp_gcc.S:193

r03 = 0x0004801c is the linearly advanced pf pointer — the walk has already covered
dozens of KB past the 8-byte kmem block when the first unmapped access (dfar=0x48040)
faults. Because do_pollfd() assigns pf->revents on every iteration, 4096+ out-of-bounds
4-byte writes into the kernel heap occur before the fault point
. After the first data abort
the kernel then enters a cascade of 726 consecutive data aborts (heap metadata corrupted;
every subsequent kernel path dies) — a full system crash.

Control run: the same program with the legal value nfds = 1 returns 0, sets
revents = 0, and the system stays alive (msh keeps running). The only differing input is
the nfds value.

Expected result: sys_poll() must validate that nfds * sizeof *fds does not overflow
(e.g. if (nfds > (SIZE_MAX / sizeof *fds)) return -EINVAL;) — the same for sys_epoll_pwait's
cnt * sizeof(struct epoll_event) (see below) — or clamp/limit the count consistently for
allocation, copy and traversal.

Other additional context

  • Suggested fix (one-line family):
    if (nfds > (size_t)-1 / sizeof *fds) return -EINVAL;   /* sys_poll, before access check */
    if ((unsigned)cnt > (size_t)-1 / sizeof(struct epoll_event)) return -EINVAL; /* sys_epoll_pwait */
  • Sibling path with the same root cause (not the subject of this report but should be fixed
    together): sys_epoll_pwait() at lwp_syscall.c:10519-10522 —
    kmem_get(cnt * sizeof(struct epoll_event)) wraps the same way on 32-bit
    (cnt = 0x10000001, sizeof = 16 → allocates 16 bytes) while epoll_do() uses the
    original cnt as maxevents and memcpys a 16-byte struct epoll_event per ready-list
    entry whose 8-byte data payload is fully attacker-chosen via epoll_ctl(EPOLL_CTL_ADD);
    level-triggered entries are re-appended to the ready list, so a single ready fd can smear
    the same attacker-controlled 16-byte pattern linearly across the kernel heap. We verified
    the code path statically; on our board epoll_create1() currently fails for an unrelated
    reason so we did not complete a runtime reproduction of this variant.
  • Attack prerequisites (explicit): 32-bit RT-Smart build; the attacker needs to run code
    as any unprivileged user process (e.g. a third-party application or module on the device);
    no privileges, no race, no user interaction, single syscall.
  • CVSS 3.1: 6.8 (AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:H) — deterministic kernel heap
    OOB write + full system crash; 64-bit builds unaffected.
  • CWE: CWE-190 (Integer Overflow or Wraparound) leading to CWE-787/CWE-125
    (OOB write/read) and CWE-400.
  • Relationship to existing reports: none found (issue search for poll/epoll overflow
    empty; [Bug] Insufficient pointer validation vulnerability in the sys_select system call in Smart version of RT-Thread #10298 concerns sys_select's timeout pointer validation — different function, root
    cause and sink; the CVE-2025 lwp family covers pointer-validation bugs, not size arithmetic).

Activity

  1. github-actions commented on Oct 23, 2026

    @github-actions

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

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

  2. Yunlk commented on Oct 24, 2026

    @Yunlk
    Contributor

    代码层面确认了报告描述的问题,并且在同一个文件里又找到两处同类模式。先说明我没做完整运行时复现,原因放在最后。

    1. 报告指出的问题:确认

    components/lwp/lwp_syscall.c:914 的 sys_poll()(ARCH_MM_MMU 分支):

    if (!lwp_user_accessable((void *)fds, nfds * sizeof *fds))   /* :920 */
        return -EFAULT;
    
    kfds = (struct pollfd *)kmem_get(nfds * sizeof *kfds);       /* :925  32 位下回绕 */
    ...
    lwp_get_from_user(kfds, fds, nfds * sizeof *kfds);           /* :931  只拷回绕后的大小 */
    ret = poll(kfds, nfds, timeout);                             /* :933  用的是未回绕的 nfds */
    if (ret > 0)
        lwp_put_to_user(fds, kfds, nfds * sizeof *kfds);         /* :936 */

    nfds 是 nfds_t(unsigned int),sizeof(struct pollfd) == 8,所以 0x20000001 * 8 = 0x1_00000008 在 32 位 size_t 上截成 8。分配 8 字节、poll() 却按 536,870,913 个表项遍历 —— 与报告一致。

    顺带一点:#else(非 MMU)分支 :942 也用了同样的乘法做访问校验,同样会回绕,只是那条路径不再分配内核缓冲。

    2. 另外两处同类模式(报告未提及)

    a) sys_epoll_pwait() —— 同一模式的完整复制

    /* components/lwp/lwp_syscall.c:10508 */
    if (!lwp_user_accessable((void *)ev, cnt * sizeof(struct epoll_event)))   /* :10519 */
        return -EFAULT;
    
    kev = kmem_get(cnt * sizeof(struct epoll_event));                        /* :10522 */

    cnt 是 int,同样由用户传入。区别是这里连符号性都值得注意:cnt 为负时 cnt * sizeof 在 size_t 下会变成极大值。

    后续逻辑与 poll 相同——分配回绕后的小块,却按原始 cnt 去读写。

    b) msghdr 的 msg_iovlen —— 乘法做了两次

    /* 约 :5778,sys_sendmsg / sys_recvmsg 的公共路径 */
    iovs_size = sizeof(*kmsg->msg_iov) * kmsg->msg_iovlen;   /* :5778  第一次乘法 */
    if (!lwp_user_accessable(kmsg->msg_iov, iovs_size))
    {
        return -EFAULT;
    }
    
    kiov = kmem_get(iovs_size * 2);                          /* :5785  再乘 2,二次回绕 */
    uiov = (void *)kiov + iovs_size;

    这里不仅 sizeof(iovec) * iovlen 可能回绕,回绕后的小值再乘 2 也可能溢出。lwp_get_from_user(uiov, ...) 随后按 iovs_size 拷贝,而 uiov 的指向已经落在分配块之外。

    socketpair / sendmsg 这类接口在 RT-Smart 上通常是普通用户进程可用的,所以风险面不止 poll。

    3. 建议的修复方向

    三处都是同一个形状:先用用户可控的计数做乘法得到大小,再用这个大小做校验和分配。建议抽一个统一的校验,在乘法之前挡住:

    /* 返回 RT_FALSE 表示 count * size 会溢出 */
    static rt_bool_t lwp_size_mul_ok(size_t count, size_t size)
    {
        return (count == 0) || (size <= (size_t)-1 / count);
    }

    然后各入口:

    if (!lwp_size_mul_ok(cnt, sizeof(struct pollfd)))
    {
        return -EINVAL;
    }

    要点:

    1. 乘法前校验,不要"先乘再看结果",否则编译器可能把溢出当成 UB 优化掉,逻辑也不直观。
    2. sys_epoll_pwait 的 cnt 是 int,需要先判负再做乘法。
    3. 如果维护者倾向保守,也可以像 lwp_user_accessible_ext() 里那样用除法形式(count > SIZE_MAX / size),语义等价。
    4. 修 sys_poll 时建议同一补丁覆盖三处——它们的成因完全一样,分开提交会让后来者以为只剩一处。

    4. 为什么没做运行时复现

    报告是在 bsp/qemu-vexpress-a9 + .github/utest/configs/rtsmart/rtsmart.cfg + musl 工具链 + freestanding 用户程序下复现的。我这边:

    • rtsmart.cfg 在树里(4 项:RT_USING_SMART / MEMHEAP / DFS_V2 / RT_UTEST_MEMHEAP),BSP 默认交叉前缀是 arm-none-eabi-,内核侧可以编
    • 但没有 arm-linux-musleabi 工具链,也没有现成路径把 freestanding 用户 ELF 塞进镜像再以普通进程身份跑起来

    所以上面全部是代码静态核对 + 算术推导,没有实测 data abort 或越界写计数。这一点必须标明,不能当成复现结果看。

    如果维护者希望有实测数据,需要先解决用户态那套(musl 工具链 + 用户 ELF 的打包/加载方式)。我可以在这个前提下继续做,或者如果 CI 里已经有能跑 RT-Smart 用户程序的作业,请指个路,我按那个环境补复现。

    5. 另外想确认一点

    报告提到"lwp_user_accessible_ext() 的 !size 早退只能挡住回绕成 0 的情况"。我看了 sys_poll 里用的是 lwp_user_accessable()(没有 _ext 后缀),能否确认这两个名字在你们分支上是不是同一个函数、还是当前 master 上已经改过名?这会影响我按哪个函数写补丁。

  3. Yunlk commented on Oct 24, 2026

    @Yunlk
    Contributor

    /claim

  4. github-actions commented on Oct 24, 2026

    @github-actions

    @Yunlk has claimed this issue.

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions