Repository navigation
[Bug] signalfd: unbounded global index into per-fd array causes kernel heap out-of-bounds write from the 11th signalfd() call (RT-Smart) #11933
Description
RT-Thread Version
master @ 5d1fb66 (v5.4.0-dev; signalfd.c verified byte-identical to origin/master as of 2026-10-26). Affected since v5.0.2; introduced by commit 7c3d966 (#8001, "Add the system call signalfd", 2023-09-11) — the unbounded-global-index pattern is present in the very first version. All releases v5.0.2 / v5.1.0 / v5.2.0 / v5.2.1 / v5.2.2 / v5.3.0 + master verified vulnerable via per-tag blob reads.
Affected area
RT-Smart
Hardware/BSP vendor
Not applicable / Other
Architecture
ARM ARMv7-A (qemu-vexpress-a9, 32-bit RT-Smart). The bug is architecture-independent C code in components/libc/posix/io/signalfd/; on 64-bit RT-Smart builds the same out-of-bounds write lands with an 8-byte stride. Prerequisite: RT_USING_SMART=y — RT_USING_POSIX_SIGNALFD is default y under RT_USING_SMART (components/libc/posix/Kconfig), and both official QEMU BSP Smart configs ship it enabled, so every default RT-Smart build is affected.
Board and hardware details
QEMU vexpress-a9 (Cortex-A9), RT-Thread Smart build, FAT sd-card root filesystem, PoC installed as /sbin/init. Any RT-Smart board is expected to be affected.
Develop Toolchain
arm-linux-musleabi gcc 7.3.0 (official RT-Thread CI toolchain) for the user-space PoC; the stock kernel build of the BSP.
Describe the bug
signalfd_add_notify() indexes the per-file-descriptor array sfd->lwp[RT_SIGNALFD_MAX_NUM] with the global, monotonically increasing, never-decremented file-scope counter is_head_init, with no bound check:
// components/libc/posix/io/signalfd/signalfd.c
static int is_head_init = 0; // :26 GLOBAL (not per-fd, not per-process)
struct rt_signalfd_ctx {
sigset_t sigmask; // +0
struct rt_mutex lock; // +128
siginfo_t info[SIGINFO_MAX]; // +192
int sig_num; // +1472
rt_wqueue_t signalfd_queue; // +1476
struct rt_lwp *lwp[SIGNALFD_SHART_MAX]; // +1496 LAST member, 10 pointers,
}; // sizeof(ctx) = 1536 on arm32
static int signalfd_add_notify(struct rt_signalfd_ctx *sfd)
{
...
for (int i = 0; i < is_head_init; i++) // :229
if (sfd->lwp[i]) { if (sfd->lwp[i] == lwp_self()) is_lwp = 1; }
if (is_lwp == 0)
{
sfd->lwp[is_head_init] = lwp_self(); // :242 OOB WRITE — no bound check
...
is_head_init++; // :256 grows forever; signalfd_close() never decrements
}
}Every successful signalfd() creation increments the global. A fresh ctx only NULLs sfd->lwp[0 .. is_head_init-1] (signalfd_do(), :309-312), so a same-process caller never matches lwp_self() in the scan and always takes the write path. Consequences:
- The 11th
signalfd(-1, ...)executessfd->lwp[10] = lwp_self()—lwp[]ends exactly at the end of the 1536-byte heap block, so this writes a 4-byte kernel pointer onto the first word past the allocation (the small-mem allocator's next-chunk header/metadata). - Every further creation writes 4 bytes further out (
lwp[11],lwp[12], …) — an unbounded, attacker-paced linear kernel-heap out-of-bounds write with a kernel-pointer value. The write offset and repetition count are fully user-controlled; the global counter is shared across all processes, so several processes each creating a few signalfds reach the same threshold.
Reachability: sys_signalfd is syscall #199 in the RT-Thread syscall table — an unprivileged RT-Smart user program triggers it directly with a fully legal signalfd(-1, &mask, 0) call. No malformed input, no privileges, no application cooperation.
Reproduction on qemu-vexpress-a9 (32-bit RT-Smart, stock kernel, PoC as /sbin/init, svc-only freestanding user program):
Control (exactly 10 creations): kernel stays alive, msh responds (log-v1-ctrl2.txt).
Attack (12 creations): kernel data abort → full shutdown. The OOB writes of creations #11/#12 poison the heap metadata of the adjacent chunk; the next allocation inside the 12-creation sequence faults on the poisoned free list (dfar=0x8023c030), backtrace:
arch_syscall_exit (lwp_gcc.S:193)
sys_signalfd (lwp_syscall.c:11161)
signalfd (signalfd.c:384)
signalfd_do (signalfd.c:291)
fd_new → fdt_fd_new → dfs_file_create (dfs_v2/src/dfs.c:260)
rt_calloc → rt_malloc (kservice.c:1160) → rt_smem_alloc (mem.c:336) ← data abort
GDB hardware watchpoint (deterministic single-write proof, 11th creation, ctx = 0xc0143f00, watch *(u32*)(ctx+1536)):
Old value = 3222358305 (0xC00F44C1) ← small-mem allocator metadata word behind the ctx
New value = 3222435520 (0xC01276C0) ← lwp_self(), matches CPU r0 at that instruction
PC = signalfd_add_notify+220 (signalfd.c:242), called from signalfd_do(fd=13)
Expected behavior
is_head_init must never index sfd->lwp[] past RT_SIGNALFD_MAX_NUM - 1 (e.g. reject creation with an error once the registration table is full), and — structurally — per-process registration state should not be tracked in a file-scope global: the per-fd array indexed by a global counter is the wrong owner for this state (two concurrent processes each get slices of the same global counter).
Other additional context
- Severity: CVSS 3.1 7.8 (AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H) — unprivileged local user process to kernel heap corruption; class equivalent to the already-confirmed sys_sigaction OOB ([Bug] lwp: sys_sigaction missing signal-number range check allows out-of-bounds read/write in kernel heap (RT-Smart) #11882). CWE-129 (improper validation of array index) leading to CWE-787/CWE-122.
- Attack prerequisites (explicit): an RT-Smart build (
RT_USING_SMART=y);RT_USING_POSIX_SIGNALFDis the default-on component under RT-Smart (both official qemu-vexpress-a9 and qemu-virt64-aarch64 Smart configs enable it). Attack surface is a local unprivileged user program; not a remote vector. - Related, same-root-cause defect in the same file (lifecycle mismatch between per-fd context and global/linked bookkeeping), reported here as a sibling for completeness:
signalfd_close()frees the ctx but never unlinks itsrt_lwp_notifynode fromlwp->signalfd_notify_head(no unlink site exists anywhere in the tree). Afterclose(fd), any signal delivered to the process (lwp_signal_kill→lwp_signal_notify, lwp_signal.c:1039) invokessignalfd_callback()on the freed block —sigismember()read plusrt_mutex_take()writes into freed (and, once recycled by other allocations, live foreign) memory. Reproduced on the same board: create → close → 30 ×open("/dev/zero")→kill(self, 40)gives a kernel assertion failure in_rt_mutex_take(type check on the recycled bytes) and kernel shutdown, backtracesys_kill → lwp_signal_kill → lwp_signal_notify → signalfd_callback (signalfd.c:204) → rt_mutex_take → _rt_mutex_take (ipc.c:1343); control run withoutclose()keeps the kernel alive. - No existing CVE/issue covers signalfd (NVD RT-Thread CVE set, project issue tracker and PR search for "signalfd"/"is_head_init"/"RT_SIGNALFD" all empty; last upstream change to the file was comment-only in 2024-04).
Activity
github-actions commentedon Oct 26, 2026
如需认领并处理此 Issue,请评论
/claim。To claim and work on this issue, comment
/claim.dfs/posix: enforce bounds check and clean unregister in signalfd