Skip to main content

Free 30-min security demo  — We'll scan your real code and show live findings, no commitment Book Now

Offensive360
ZeroDays CVE-2026-11826
High CVE-2026-11826 CVSS 8.8 OpenPLC Runtime v3 C++

Heap Overflow in OpenPLC v3 Modbus Master

CVE-2026-11826 is a heap-based buffer overflow in OpenPLC v3's getData() function that enables heap corruption and denial of service via a crafted Modbus config.

Offensive360 Research Team
Affects: OpenPLC_v3 (all commits prior to archive on 2026-04-04)
Source Code View Patch

Overview

CVE-2026-11826 is a heap-based buffer overflow residing in the getData() helper function within webserver/core/modbus_master.cpp of OpenPLC Runtime v3. The function reads a delimited token from a configuration string into a caller-supplied buffer without ever consulting a size parameter or enforcing an upper bound on bytes written. When parseConfig() invokes it with the 100-byte MB_device.dev_name field, a device name longer than 99 characters overflows the heap allocation and corrupts adjacent struct members.

The attack surface is the OpenPLC web interface itself. An authenticated user — any account with access to the /modbus configuration endpoint — can POST a crafted device_name value that exceeds 100 bytes. OpenPLC persists this value verbatim into mbconfig.cfg. On the next load of that configuration file, parseConfig() triggers the overflow during normal startup or reload, corrupting the protocol, dev_address, and ip_port fields that follow dev_name in the heap-allocated struct. A 200-byte payload writes 100 bytes past the allocation boundary.

OpenPLC v3 is widely deployed in educational ICS/SCADA environments, hobbyist PLC setups, and research testbeds running real ladder-logic control loops. While v4 is confirmed unaffected, v3 installations that have not migrated face a persistent denial-of-service risk with no upstream patch forthcoming: the repository was archived on 2026-04-04, and the original maintainer has stated that no fix will be released for v3.


Technical Analysis

The root cause is a classic unbounded string extraction pattern. getData() iterates over an input string, copying bytes between two delimiter characters into a destination buffer until it encounters the closing delimiter or the null terminator — whichever comes first. Neither the function signature nor the implementation carries any notion of the destination buffer’s capacity.

// webserver/core/modbus_master.cpp — vulnerable getData() implementation
// dest is a caller-supplied heap buffer; no size parameter exists.
void getData(char *source, char initial_delimiter, char final_delimiter, char *dest)
{
    int i = 0, j = 0;
    bool reading = false;

    while (source[i] != '\0')
    {
        if (source[i] == initial_delimiter)
        {
            reading = true;
            i++;
            continue;
        }
        if (source[i] == final_delimiter && reading)
            break;

        if (reading)
            dest[j++] = source[i]; // ← no bounds check on j
        i++;
    }
    dest[j] = '\0'; // ← potential one-past-end write as well
}

parseConfig() calls this function to populate MB_device.dev_name, which is allocated as a 100-byte field inside a heap-resident struct:

// Relevant portion of the MB_device struct (logical layout from source)
struct MB_device {
    char dev_name[100];    // offset   0 — overflowed by getData()
    uint8_t protocol;      // offset 108 — overwritten by bytes 101-108
    char dev_address[100]; // offset 109 — overwritten by bytes 109-208
    uint16_t ip_port;      // offset 210 — overwritten by bytes 209-210
    // … further fields
};

When mbconfig.cfg contains a device_name entry of, say, 200 characters, getData() copies all 200 bytes into dev_name[100], writing 100 bytes beyond the allocation. The first overwritten region is protocol at offset 108, followed immediately by dev_address and ip_port. Because the struct is heap-allocated, the overflow smashes heap metadata and adjacent allocations rather than a stack frame, making controlled exploitation harder but crash-based denial of service trivially reproducible. A proof-of-concept HTTP POST looks like:

POST /modbus HTTP/1.1
Host: <openplc-host>:8080
Content-Type: application/x-www-form-urlencoded
Cookie: session=<valid-session>

device_name=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAA&device_type=0&...

The value is stored without validation and parsed on the next parseConfig() call, which occurs at startup or when the configuration is reloaded via the UI.


Impact

The immediate consequence of successful exploitation is heap corruption that terminates the PLC process control loop. In any environment where OpenPLC v3 is performing real-time control — even a research or training context — an abrupt crash halts all I/O processing, dropping outputs to their default (usually de-energized) state. Depending on the physical process under control, this constitutes a safety-relevant denial of service.

Beyond the crash, an attacker who can predict heap layout may influence which bytes overwrite protocol, dev_address, and ip_port. This enables silent mis-configuration of the Modbus master: for example, redirecting Modbus TCP connections to an attacker-controlled endpoint to intercept or inject process data. The CVSS 8.8 HIGH score reflects the combination of network-accessible attack vector, low attack complexity once authenticated, no required privileges beyond a standard web UI account, and high impact across availability and integrity dimensions (AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:H).

The lack of any upstream patch and the archival of the repository make this a permanently unmitigated finding for v3 deployments.


How to Fix It

Primary recommendation: migrate to OpenPLC Runtime v4, which the vendor has confirmed is not affected by this vulnerability.

If migration is not immediately feasible, the following hardening measures reduce risk:

  1. Restrict web interface access to trusted IP ranges using a firewall or reverse proxy; eliminate anonymous or broadly shared credentials.
  2. Apply a bounds-checked getData() replacement in a local fork:
// Fixed getData() — caller passes dest buffer size
void getData(char *source, char initial_delimiter, char final_delimiter,
             char *dest, size_t dest_size)
{
    size_t j = 0;
    bool reading = false;

    for (size_t i = 0; source[i] != '\0'; i++)
    {
        if (source[i] == initial_delimiter) { reading = true; continue; }
        if (source[i] == final_delimiter && reading) break;

        if (reading)
        {
            if (j >= dest_size - 1)   // enforce upper bound
                break;
            dest[j++] = source[i];
        }
    }
    dest[j] = '\0';
}

// Updated call site in parseConfig():
getData(line, '"', '"', mb_device.dev_name, sizeof(mb_device.dev_name));
  1. Validate device_name length server-side in the HTTP handler before writing to mbconfig.cfg, rejecting any value exceeding 99 bytes with an appropriate HTTP 400 response.
  2. Run the OpenPLC process under a restricted OS user with no write access outside its working directory to limit the blast radius of any heap-corruption primitive.

Our Take

This vulnerability is a textbook example of a pattern that has existed in C/C++ codebases for decades: a utility function that accepts a destination pointer but no destination size, creating an implicit contract that callers will never supply input larger than the buffer — a contract that is essentially unenforceable at scale. The function is correct in isolation only if the input is always bounded upstream. In OpenPLC v3, it was not.

ICS and SCADA software inherits particular risk from this class of bug because the systems often run with elevated trust, minimal network segmentation, and availability requirements that make crashes costly. The fact that exploitation requires authentication reduces the CVSS score from Critical, but in environments where web UI credentials are shared or weakly managed — common in OT/ICS deployments — that mitigation is thin. Enterprises operating any C/C++ process-control software should treat unbounded string operations as a first-class audit target.


Detection with SAST

This vulnerability class maps to CWE-122: Heap-based Buffer Overflow and CWE-120: Buffer Copy without Checking Size of Input (‘Classic Buffer Overflow’). Offensive360’s SAST engine flags this pattern by tracking data flow from externally influenced string sources (file reads, HTTP parameters) through functions that perform delimiter-based copies without a length argument, and verifying whether a bounds check constrains the write before it reaches a fixed-size heap buffer.

Specific code patterns our rules target include:

  • Functions with signature (char *src, ..., char *dst) that perform character-by-character copy loops with an incrementing index and no guard comparing that index against a maximum.
  • Call sites where the destination argument is a struct field whose size is statically known but not passed to the copy function.
  • Configuration-parsing routines that consume attacker-reachable file content without pre-validation of field lengths.

A taint-tracking pass that marks HTTP POST parameters as tainted and follows the data through mbconfig.cfg writes into parseConfig() and then into getData() will surface this exact path. Pairing SAST with dynamic fuzzing of the /modbus endpoint — supplying progressively longer device_name values — will confirm exploitability and establish a precise overflow boundary.


References

#heap-overflow #buffer-overflow #modbus #ics-scada #denial-of-service

Detect this vulnerability class in your codebase

Offensive360 SAST scans your source code for CVE-2026-11826-class vulnerabilities and thousands of other patterns — across 60+ languages.