Skip to content

[Bug] libdl/dlmodule: no bounds validation on ELF relocation metadata - malformed .mo gives arbitrary kernel-mode memory write with controlled value during load #11898

Open

Description

@XueDugu

RT-Thread Version

master @ 5d1fb66 (v5.4.0-dev; components/libc/posix/libdl/dlelf.c verified byte-identical to origin/master as of 2026-10-19). Affected since v3.1.0; introduced by commit efe4523 ("[Kernel] move module to libc/libdl", 2018-08-30) which added dlelf.c/arch/arm.c with the unchecked relocation write. Verified present in every release tag since (checked by reading arch/arm.c/dlelf.c at v3.1.0, v4.0.0, v5.3.0 and master).

Affected area

Components / libc (libdl dynamic module loader, RT_USING_MODULE)

Hardware/BSP vendor

Not applicable / Other

Architecture

ARM Cortex-A/R/M 32-bit (reproduced), RISC-V 32, x86 — every architecture with a dlmodule_relocate port (arch/arm.c, arch/riscv.c, arch/x86.c share the same pattern). Not applicable to aarch64 (no port).

Board and hardware details

QEMU vexpress-a9 (Cortex-A9), stock bsp/qemu-vexpress-a9 defconfig + RT_USING_MODULE=y, FAT16 SD image holding the module file. Any board with the dynamic-module feature enabled is affected.

Develop Toolchain

arm-none-eabi-gcc 10.3.1 (gcc / scons)

Describe the bug

The dynamic module loader (dlopen()/msh auto-exec of .mo files) performs no bounds validation whatsoever on the ELF relocation metadata it parses. The write target of every relocation is computed directly from the attacker-controlled 32-bit Elf32_Rel.r_offset in the module file:

// components/libc/posix/libdl/arch/arm.c:33-37  (arch/riscv.c & arch/x86.c: same pattern)
where = (Elf32_Addr *)((rt_uint8_t *)module->mem_space
                       + rel->r_offset                 // <-- file-supplied, never checked
                       - module->vstart_addr);
...
case R_ARM_GLOB_DAT:
case R_ARM_JUMP_SLOT:
    *where = (Elf32_Addr)sym_val;                      // arm.c:65-66

and the value sym_val is the address of any symbol the module names from the kernel export table (dlmodule_symbol_find(strtab + sym->st_name), dlelf.c:193). On 32-bit targets mem_space + r_offset wraps modulo 2^32, so a malformed .mo obtains an arbitrary-address, attacker-valued 4-byte kernel-mode write, executed during dlmodule_load() — before any module code runs, so this is not "modules are trusted code": the corruption comes purely from the loader parsing malformed input.

The whole metadata chain feeding that write is likewise unchecked (same root cause, independent read primitives): r_info symbol index (24-bit, symtab[ELF32_R_SYM()] OOB), sh_link/sh_link->sh_link (unbounded shdr[] indexing → wild rel/symtab/strtab pointers), e_shoff/e_phoff/e_shstrndx (never compared against the file length; dlmodule_load() only checks magic + ELF class), p_offset+p_filesz / sh_offset+sh_size (heap over-read as memcpy source), and st_value (wild read feeding module->priority/stack_size). e_machine is not validated either, so cross-architecture ELF files are also interpreted.

Reproduction (full chain: FAT file → dfs → msh evil → dlmodule_exec → loader; QEMU + gdb-multiarch):

  1. Build bsp/qemu-vexpress-a9 with RT_USING_MODULE=y; attach a FAT16 SD image containing a crafted evil.mo (minimal ET_DYN ARM ELF, one .rel.text entry: R_ARM_GLOB_DAT on symbol rt_kprintf, r_offset = 0xfffe4074).
  2. Type evil in the msh shell (msh auto-tries evil.mo, msh.c:312 dlmodule_exec).
  3. Result: *(_ptr_t)(mem_space + r_offset - vstart) = &rt_kprintf lands at kernel .bss 0x600ec26c (_rt_module_symtab_end, 0x1bf8c bytes before the module allocation) — hardware watchpoint captures Old = 0x600c1a40 → New = 0x600a7870 (&rt_kprintf) with backtrace dlmodule_relocate ← dlmodule_load_shared_object (dlelf.c:201) ← dlmodule_load ← dlmodule_exec ← msh_exec_module (msh.c:312) ← msh_exec ← finsh_thread_entry, rel->r_offset = 0xfffe4074 vs module->mem_size = 0x2000. The kernel keeps running — silent, persistent corruption.
  4. Crash variant (r_offset = 0x9fef7e48, write to the null page): data abort, pc=0x600163f0 (str in dlmodule_relocate), r3=0x40 (computed target), r2=0x600a7870 (value), same 6-frame backtrace, kernel shuts down. In the same session a well-formed module built with the stock toolchain (hello.mo, single R_ARM_JUMP_SLOT at a legitimate in-module offset 0x10338 < 0x1033c) loads, runs and exits cleanly — the only difference between the benign and malicious files is r_offset.
  5. Value control: pointing the same primitive at another address with symbol rt_strlen writes 0x6005e984 there (watchpoint New = &rt_strlen).

Generator, control module, GDB scripts and full serial/GDB logs: see attached (gen_mo.py produces the malformed module; hello.c is the control).

Expected behavior

The loader must validate, against module->mem_size and the file buffer length: every relocation target (vstart_addr <= r_offset within the mapped image), the symbol index in r_info, all section-header indices (sh_link, sh_info, e_shstrndx), the table base offsets (e_shoff/e_phoff + count*sizeof within the file), copy sources (p_offset+p_filesz, sh_offset+sh_size within the file), and e_machine against the running target. Anything outside bounds must fail the load with an error, not write.

Other additional context

  • Attack preconditions (explicit): attacker can place a file on a filesystem the system loads modules from, and the module gets loaded via the documented entry points — msh executing <name>.mo / /bin/<name>.mo (components/finsh/msh.c:255-322) or an application calling dlopen(). Typical exposure: module/plugin hot-updates from SD cards or network downloads, field-deployed kiosks/devices with a shell. The corruption happens inside the loader, before any module code executes — a robustness defect in trusted kernel code parsing untrusted input, not a "malicious module runs code" scenario.
  • Impact: arbitrary kernel-mode memory write with attacker-chosen value (any RTM_EXPORTed symbol address), plus R_ARM_ABS32/R_ARM_RELATIVE add-write and read-modify-write variants → kernel control-flow hijack (overwrite any function pointer/vtable in kernel .data/.bss/heap) or deterministic kernel crash (DoS). CVSS 3.1: 7.1 (AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H), CWE-122/CWE-129 (read-side variants CWE-125).
  • Relationship to prior reports: none — the only recent libdl changes are components: libc: fix reset static variable unsolved in dlmodule_load… #11247 (2026-03, unrelated static unsolved reset) and [components][libc][posix][libdl] Optimization: Redundant calculations… #11062 (2025-12, loop cleanup); neither touches bounds validation. Not related to [Bug] lwp: sys_sigaction missing signal-number range check allows out-of-bounds read/write in kernel heap (RT-Smart) #11882 (lwp sigaction) — different component, different root cause.
  • Fix suggestion (one systematic pass, mirrors the checks Zephyr added for LLEXT): centralize a dlmodule_check_range() and apply it at every file-derived index/offset listed above.

Activity

  1. github-actions commented on Oct 18, 2026

    @github-actions

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

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

  2. BernardXiong commented on Oct 19, 2026

    @BernardXiong
    Member

    好像说的是:

    动态模块加载器对 ELF relocation 的 32 位 r_offset 不做任何边界校验,畸形 .mo 可在 dlopen/msh 自动执行时以
    攻击者控制的值写内核内存;

    这个可以进行一定修复。另外动态模块是挺危险的,类似于一段代码插入到内核中,权限都是全打开的 (也因为这个缘故,在动态模块上未做更多的尝试)。如果需要做到内存隔离,需要走用户态的模式。

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