Skip to content

Unauthenticated Read File Record (FC 0x14) OOB write → control-flow hijack — nanoMODBUS @ 91d6782 #128

Description

@entropy1337

1. Summary

A Modbus server built on nanoMODBUS can be memory-corrupted and its control flow hijacked by a single unauthenticated Modbus/TCP (or RTU) Read File Record (function code 0x14) request. The response builder in handle_read_file_record accumulates per-sub-request record data into the fixed 260-byte msg.buf with no aggregate size cap, writing up to ~8.7 KB past the buffer. Because msg.buf is the first member of struct nmbs_t, the linear overflow runs straight into the immediately-following callbacks (a table of function pointers, including read_file_record) and platform (read/write function pointers). The response loop re-reads and invokes nmbs->callbacks.read_file_record on later iterations, so an earlier iteration's overwrite of that pointer is called during the same request — a control-flow-hijack primitive, not merely a denial of service.

2. Affected Version & Scope

  • Repository / product: nanoMODBUS — https://github.com/debevv/nanoMODBUS
  • Pinned commit / firmware build: 91d6782 (2026-02-01)
  • Affected range (if known): HEAD 91d6782 and earlier; not fixed at HEAD (verify full range on disclosure).
  • Build/config preconditions: Server role with a read_file_record callback registered (any device that advertises FC 0x14). No non-default flags required. Bug is reachable in both TCP and RTU transports.

Technical Details & Root Cause

nanomodbus.c, handle_read_file_record:

// :1330  request_size = get_1();          // attacker byte, <=245
//        subreq_count = request_size / 7; // up to 35
// :1350  uint8_t response_data_size = 0;  // <-- 8-bit accumulator, WRAPS
// :1358  response_data_size += 2 + (record_length * 2);   // per sub-request
// :1381  if (record_length > 124) return exception(...);  // ONLY size guard: per-sub-request, no aggregate cap
// :1395-1411 response loop:
//        put_1(subreq_data_size + 1);
//        put_1(0x06);
//        uint16_t* subreq_data = (uint16_t*)get_n(record_length * 2);   // get_n advances buf_idx unchecked
//        nmbs->callbacks.read_file_record(file, record, subreq_data, record_length, unit, arg);

// get_n (:100): { uint8_t* p = msg.buf + msg.buf_idx; msg.buf_idx += n; return p; }  // NO bounds check

Data flow: attacker byte request_sizesubreq_count → per-sub-request record_length (≤124, attacker-chosen) → cumulative response bytes N * (2 + record_length*2) = N * 250. With N sub-requests the write is N*250 bytes into a 260-byte buffer: even 2 sub-requests (500 bytes) overflow; 35 sub-requests write ~8.7 KB past msg.buf. get_n returns a pointer past msg.buf[260], and both put_1 and the callback write there.

Why it is control-flow hijack (struct nmbs_t layout, nanomodbus.h):

struct nmbs_t {
    struct { uint8_t buf[260]; uint16_t buf_idx; ... } msg;   // buf at offset 0
    nmbs_callbacks     callbacks;    // <-- FUNCTION POINTERS (incl. read_file_record) right after msg
    ...
    nmbs_platform_conf platform;     // <-- read()/write() FUNCTION POINTERS
    ...
};

The linear OOB write runs from msg.buf into callbacks. The response loop re-reads nmbs->callbacks.read_file_record every iteration, so once an earlier iteration overwrites that pointer, a later iteration calls the overwritten value.

Root cause: missing aggregate bounds check on the response size, compounded by (a) an 8-bit response_data_size accumulator that wraps, and (b) get_n/put_1 performing no bounds check against sizeof(msg.buf).

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