Skip to content

[Bug] signalfd: unbounded global index into per-fd array causes kernel heap out-of-bounds write from the 11th signalfd() call (RT-Smart) #11933

Open

Description

@XueDugu

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, ...) executes sfd->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_SIGNALFD is 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 its rt_lwp_notify node from lwp->signalfd_notify_head (no unlink site exists anywhere in the tree). After close(fd), any signal delivered to the process (lwp_signal_kill → lwp_signal_notify, lwp_signal.c:1039) invokes signalfd_callback() on the freed block — sigismember() read plus rt_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, backtrace sys_kill → lwp_signal_kill → lwp_signal_notify → signalfd_callback (signalfd.c:204) → rt_mutex_take → _rt_mutex_take (ipc.c:1343); control run without close() 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

  1. github-actions commented on Oct 26, 2026

    @github-actions

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

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

  2. added a commit that references this issue on Oct 29, 2026
    0eb68b5
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