Repository navigation
[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
Description
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, ormask & (events | POLLERR | POLLHUP)whereeventsitself is the
OOB-read heap data, i.e. partially groomable).
Steps to reproduce (full chain, no host-side shortcuts):
- Build the
qemu-vexpress-a9BSP with the official CI smart fragment:
cat .github/utest/configs/rtsmart/rtsmart.cfg >> bsp/qemu-vexpress-a9/.config
(plusscons --pyconfig-silent && sconswithRTT_CC_PREFIX=arm-linux-musleabi-). - Place a freestanding user program as
/sbin/initon the FATsd.binrootfs
(RT-Smart execs/sbin/initas pid 1 at boot). The program issues one raw syscall:
sys_call(SYS_poll /* RT-Thread syscall #9 */, fds, 0x20000001, 0)
withfdspointing at a valid user buffer{fd=1, events=POLLIN}.
PoC source: one file, ~200 lines, no libc, puresvc(available on request). - 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()atlwp_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) whileepoll_do()uses the
originalcntasmaxeventsandmemcpys a 16-bytestruct epoll_eventper ready-list
entry whose 8-bytedatapayload is fully attacker-chosen viaepoll_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 boardepoll_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
github-actions commentedon Oct 23, 2026
如需认领并处理此 Issue,请评论
/claim。To claim and work on this issue, comment
/claim.Yunlk commentedon Oct 24, 2026
代码层面确认了报告描述的问题,并且在同一个文件里又找到两处同类模式。先说明我没做完整运行时复现,原因放在最后。
1. 报告指出的问题:确认
components/lwp/lwp_syscall.c:914的sys_poll()(ARCH_MM_MMU分支):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()—— 同一模式的完整复制cnt是int,同样由用户传入。区别是这里连符号性都值得注意:cnt为负时cnt * sizeof在size_t下会变成极大值。后续逻辑与 poll 相同——分配回绕后的小块,却按原始
cnt去读写。b)
msghdr的msg_iovlen—— 乘法做了两次这里不仅
sizeof(iovec) * iovlen可能回绕,回绕后的小值再乘 2 也可能溢出。lwp_get_from_user(uiov, ...)随后按iovs_size拷贝,而uiov的指向已经落在分配块之外。socketpair/sendmsg这类接口在 RT-Smart 上通常是普通用户进程可用的,所以风险面不止 poll。3. 建议的修复方向
三处都是同一个形状:先用用户可控的计数做乘法得到大小,再用这个大小做校验和分配。建议抽一个统一的校验,在乘法之前挡住:
然后各入口:
要点:
sys_epoll_pwait的cnt是int,需要先判负再做乘法。lwp_user_accessible_ext()里那样用除法形式(count > SIZE_MAX / size),语义等价。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-,内核侧可以编所以上面全部是代码静态核对 + 算术推导,没有实测
data abort或越界写计数。这一点必须标明,不能当成复现结果看。如果维护者希望有实测数据,需要先解决用户态那套(musl 工具链 + 用户 ELF 的打包/加载方式)。我可以在这个前提下继续做,或者如果 CI 里已经有能跑 RT-Smart 用户程序的作业,请指个路,我按那个环境补复现。
5. 另外想确认一点
报告提到"
lwp_user_accessible_ext()的!size早退只能挡住回绕成 0 的情况"。我看了sys_poll里用的是lwp_user_accessable()(没有_ext后缀),能否确认这两个名字在你们分支上是不是同一个函数、还是当前 master 上已经改过名?这会影响我按哪个函数写补丁。Yunlk commentedon Oct 24, 2026
/claim
github-actions commentedon Oct 24, 2026
@Yunlk has claimed this issue.