---
id: 10395
title: "open62541: Integer Overflow → Heap Overflow → RCE."
cve: "CVE-2026-116014"
cvss: null
severity: "unknown"
vendor: null
product: null
affected_versions: null
source_domain: "bugpwn.com"
source_url: "https://bugpwn.com/blog/open62541-cve-2026-116014.html"
published_at: "2026-10-24T00:00:00Z"
snapshot_at: "2026-10-28T23:55:15Z"
archived_by: "Phying News (https://phying.com/item/10395)"
lang: en
---

# open62541: Integer Overflow → Heap Overflow → RCE.

*Published on: 2026-10-24 · Source: [bugpwn.com](https://bugpwn.com/blog/open62541-cve-2026-116014.html)*

## 01 - Executive Summary

[**open62541**](https://open62541.org/) is one of the most widely used open source implementations of OPC UA (IEC 62541), the protocol that lets industrial machines talk to each other: PLCs, IIoT gateways and SCADA systems. It is a lightweight, portable C library embedded directly inside controllers, field devices and the servers that collect their data. Its binary decoder therefore sits on the front line across a very large number of connected devices.

The library is maintained by [**o6 Automation GmbH**](https://www.o6-automation.com), a Karlsruhe based company founded by the creators of open62541 and its official maintainer. The company provides support, training, consulting and custom development, and sells commercial products built on top of the library. open62541 reached its first stable release, 1.0, in 2019 and is distributed under the Mozilla Public License 2.0, free of charge including for commercial use, which is what lets it be embedded in closed products.

While auditing the library we found an **integer overflow** in the code that builds data types on the fly from the custom definitions a server sends. The total size of a type is computed by adding up its fields in a 16 bit counter, and nothing checks that this counter does not overflow. A malicious or compromised OPC UA server can therefore describe a structure whose total size is larger than 65535 bytes: the counter wraps around and lands on a small value. A client that reads these types then reserves a buffer that is too small and writes the fields past its end. The result is a heap overflow, linear and fully controlled, with bytes chosen by the attacker.

Real impact on deployments

Any OPC UA client built on open62541 that connects to an untrusted server and reads its custom DataTypes is exposed. In OPC UA the trust usually flows the other way, so the attack surface is easy to overlook: here the **server attacks the client**. On embedded and RTOS targets with simple allocators and no ASLR, the overflow is a realistic path to remote code execution. On hardened desktop and server glibc it is at least a reliable remote crash and, with heap grooming and an information leak, more.

Attribute | Value  
---|---  
Software| open62541 (OPC UA C stack)  
Type| Integer Overflow to Heap Buffer Overflow (CWE-190, CWE-122)  
Affected| `>= 1.5.0`, `< 1.5.7`  
Fixed in| `1.5.7` (commit `0cf48067`, PR #8298 and #8302)  
Vector| Network, server to client (malicious or compromised OPC UA server)  
Severity| Critical · CVSS 9.2 (v4.0)  
CVE| CVE-2026-116014  
Fix action| Upgrade to 1.5.7; treat upstream OPC UA servers as untrusted  
Author| BUG|PWN Research · Sadoc ADONON  
  
## 02 - Context: open62541 and OPC UA custom types

### A C stack at the edge of the plant

Because the OPC UA protocol is rich, a very large share of what arrives over the network passes through the binary decoder. The binary decoder turns the received bytes into usable in-memory data. Since it is the first code to handle attacker controlled input, it is a natural research target.

OPC UA is strongly typed: every value that travels over the network always carries an indication of what it represents (a number, some text, a date...). Beyond these basic formats, a server can define its own custom structures. To describe such a structure, it provides a `StructureDefinition`: the ordered list of its fields, where each one states the nature of its content. A client that wants to read those values first fetches this description from the server to learn how the structure is laid out, then rebuilds it in memory in order to decode the data.

The runtime descriptor

In open62541, every type, built in or custom, is described by a single internal structure: `UA_DataType`. The key field here is `memSize`: the number of bytes the decoded value takes up in memory. It is computed once, when the type is built. After that, every memory allocation and every position calculation during decoding relies on it without ever rechecking it. So if `memSize` is wrong, everything that follows is wrong too: wrong buffer size, wrong offsets, memory corruption.

## 03 - Root cause: a 16 bit size with no guard

The `UA_DataType` descriptor packs several properties into bitfields. Two of them matter here, and both are narrow. `memSize` is stored in **16 bits** , so it saturates at 65535. The member count, `membersSize`, is stored in **8 bits** , so it saturates at 255. Hold on to that second number: it is what turns a crash into a weapon later.

include/open62541/types.h \- The two narrow bitfields

__COPY
    
    
    struct UA_DataType {
        /* ... typeName, typeId, encoding ids ... */
        UA_UInt32 memSize     : 16; /* Size of the struct in memory  -> max 65535 */
        UA_UInt32 typeKind    : 6;
        UA_UInt32 pointerFree : 1;
        UA_UInt32 overlayable : 1;
        UA_UInt32 membersSize : 8;  /* How many members      -> max 255   */
        UA_DataTypeMember *members;
    };

The function that turns a server supplied `StructureDefinition` into one of these descriptors is `UA_DataType_fromStructureDescription`. It walks the fields, and for each one it adds the member's size plus its alignment padding into `memSize`. The accumulation is the whole bug: the running total is cast to `UA_UInt16` and added back into the 16 bit field with no check that it still fits.

src/ua_types_definition.c:274 \- UA_DataType_fromStructureDescription: unguarded accumulation

__COPY
    
    
    /* For every field of the server supplied StructureDefinition: */
    
        if (type->typeKind == UA_DATATYPEKIND_UNION) {
            if (memSize + dtm->padding > type->memSize)
                type->memSize = (UA_UInt16)(memSize + dtm->padding);
        } else {
            /* Increase the memSize for the current member */
            type->memSize += (UA_UInt16)(memSize + dtm->padding);
            /* No check that the running total still fits in 16 bits. */
        }

Why a guard was expected here

The inputs to this function come straight off the network from a peer the client does not control. The sizes being summed are attacker influenced. A size that is computed from untrusted input and then used for allocation is exactly the place where an overflow check belongs, and there was none.

## 04 - The wrap: when the total exceeds 65535

The consequence is plain modular arithmetic. Suppose a crafted type whose fields genuinely sum to 66000 bytes of memory. The true size does not fit in 16 bits, so the stored value becomes 66000 mod 65536, which is 464. The descriptor now claims the struct is 464 bytes long while its real decoded form needs 66000. Every consumer of that descriptor is now operating on a number that is almost 150 times too small.

There is a second consequence, even more useful to an attacker. The member count is stored in a separate counter, an 8 bit one, which overflows on its own, **independently of the size**. So two different counters, both wrapped: one decides how many members the decoder will write, the other decides how many bytes are reserved. This decoupling is what makes it possible to aim precisely instead of smashing everything. Instead of a blunt 65 KB overwrite, the attacker tunes the overflow so the size lands just below the real length of the data. The write then goes only a few bytes too far, right onto the neighbouring area it has chosen.

__ Overflow_Arithmetic // memSize : 16
    
    
    // True memory footprint of the crafted struct
    real_size   = 66000  bytes
    
    // Stored into a 16 bit field: wraps modulo 65536
    memSize     = 66000 % 65536 = 464  bytes
    
    // Allocation trusts memSize ...
    buffer      = calloc(1, 464)
    
    // ... but decode writes the real members
    written     = 66000  bytes
    overflow    = 66000 - 464 = 65536 bytes past the end

## 05 - The sink: decode writes past the buffer

The wrong (wrapped) size only becomes dangerous once a value of this custom structure actually arrives and has to be decoded. The binary decoder handles the structure in a function called `decodeBinaryStructure`. It first reserves a single buffer whose size comes from the descriptor, via `memSize`. Then it writes the members into it one by one, each at its own position: after each member it advances by that member's size, plus a few padding bytes to align the next one.

src/ua_types_encoding_binary.c \- decodeBinaryStructure: alloc from memSize, write at cumulative offset

__COPY
    
    
    /* Allocation is sized from the (wrapped) descriptor */
    void *ptr = ctxCalloc(ctx, 1, type->memSize);   // 464 bytes
    
    for (size_t i = 0; i < type->membersSize; i++) {
        const UA_DataTypeMember *m  = &type->members[i];
        const UA_DataType       *mt = m->memberType;
    
        ptr += m->padding;                 // advance by member padding
        retval |= decodeBinaryJumpTable[mt->typeKind](ctx, ptr, mt);
        ptr += mt->memSize;                 // advance past member -> walks beyond 464
    }
    /* Scalar members are copied verbatim: attacker controlled bytes. */
    /* Decode returns GOOD. The corruption persists, nothing aborts.  */

Two properties make this a clean primitive rather than a random smash. First, simple-value members are copied out of the stream essentially verbatim, so the bytes that land past the end of the buffer are chosen by the attacker. Second, the decode returns a success status: there is no sanity check that the cumulative offset stayed within the allocation, so nothing aborts and the corrupted heap is handed back to the application as a valid value.

## 06 - Reachability: the server attacks the client

The interesting part of this bug is who ends up on the receiving end. The vulnerable function is fed directly from values that a server provides. When a client calls `UA_Client_getRemoteDataTypes`, open62541 passes the server's structure definitions straight into the type builder through its public helper in `src/client/ua_client_util.c`. A malicious or compromised server, or an attacker on the path, delivers a poisoned definition, and the overflow fires inside the **client** while it decodes the server's response.

This inverts the usual mental model. OPC UA deployments are built to protect servers from clients, so a server supplied value that corrupts the client is the kind of surface that gets little scrutiny. The auto browse variant of the call, which compiles every structured type the server exposes, makes it worse: a single poisoned DataType node on the server is enough, and that exact pattern appears in the shipped custom type example.

Not a standalone server bug

A standalone open62541 server is not reachable through this flaw. You cannot send it a poisoned type definition: writing the `DataTypeDefinition` attribute is not allowed, and the other way to add a type (the add-nodes service) never runs attacker data through the vulnerable code. A server process is only affected if it also acts as a client, for example a gateway that fetches its types from an upstream device. If that upstream is hostile, the gateway is the one that gets corrupted. This is exactly the condition behind the `AT:P` (Attack Requirements: Present) in the CVSS v4.0 vector: to be a victim, you have to be reading the attacker's custom types.

## 07 - Proof of concept

We confirmed the bug end to end on the real library: a stock open62541 client connecting over TCP to a malicious server. The client performs a normal session, requests the remote DataTypes, and reads a value of the crafted type. The heap overflow fires inside the client during the response decode. Built with AddressSanitizer, the client aborts with a clean `heap-buffer-overflow` whose stack trace points straight through the structured decode path.

### The two vulnerable spots the PoC drives

For reference, the PoC exercises the two code paths analysed earlier. First the unguarded accumulation that wraps `memSize` (the integer overflow), then the structured decode that writes the members into the undersized buffer (the heap overflow).

src/ua_types_definition.c:274 \- Integer overflow: unguarded memSize accumulation

__COPY
    
    
    /* For every field of the server supplied StructureDefinition: */
    
        if (type->typeKind == UA_DATATYPEKIND_UNION) {
            if (memSize + dtm->padding > type->memSize)
                type->memSize = (UA_UInt16)(memSize + dtm->padding);
        } else {
            /* Increase the memSize for the current member */
            type->memSize += (UA_UInt16)(memSize + dtm->padding);  // <== INTEGER OVERFLOW: the running total wraps past 65535
            /* No check that the running total still fits in 16 bits. */
        }

src/ua_types_encoding_binary.c \- Heap overflow: decode writes past the undersized buffer

__COPY
    
    
    /* Allocation is sized from the (wrapped) descriptor */
    void *ptr = ctxCalloc(ctx, 1, type->memSize);   // 464 bytes (wrapped size)
    
    for (size_t i = 0; i < type->membersSize; i++) {
        const UA_DataTypeMember *m  = &type->members[i];
        const UA_DataType       *mt = m->memberType;
    
        ptr += m->padding;                 // advance by member padding
        retval |= decodeBinaryJumpTable[mt->typeKind](ctx, ptr, mt);  // <== HEAP OVERFLOW: writes past the 464-byte buffer
        ptr += mt->memSize;                 // advance past member -> walks beyond 464
    }
    /* Scalar members are copied verbatim: attacker controlled bytes. */
    /* Decode returns GOOD. The corruption persists, nothing aborts.  */

__ Attack_Flow // open62541 memSize overflow
    
    
    PHASE 1 : the client learns the poisoned type
    
      CLIENT  --- OpenSecureChannel / CreateSession --->  MALICIOUS SERVER
      CLIENT  --- Read DataTypeDefinition         --->
              <-- StructureDefinition (large fields, sizes sum to 66000) ---
          |
          v
      UA_DataType_fromStructureDescription()
          |                              // memSize wraps 16 bits
          v
      runtime descriptor built: memSize = 464, real = 66000
    
    
    PHASE 2 : a value of that type triggers the write
    
      CLIENT  --- Read (value of crafted type)    --->
              <-- ExtensionObject payload ----------------
          |
          v
      decodeBinaryStructure()
          |
          v
      ptr = ctxCalloc(1, 464)            // undersized buffer
          |
          v
      member decode writes 66000 bytes of attacker data
          |
          v
      HEAP BUFFER OVERFLOW inside the CLIENT process

### Observed crash

AddressSanitizer // client process
    
    
    ==PID==ERROR: AddressSanitizer: heap-buffer-overflow
    WRITE of size 8 at 0x... thread T0
        #0 ... in UInt64_decodeBinary
        #1 ... in decodeBinaryStructure
        #2 ... in Variant_decodeBinaryUnwrapExtensionObject
        #3 ... in Variant_decodeBinary
        #4 ... in processMSGResponse
        #5 ... in UA_Client_readValueAttribute
    
    0x... is located 0 bytes after a 464-byte region
    allocated by thread T0 in ctxCalloc

### From crash to code execution

The step from crash to control is a function of the target's allocator, not of the bug. The decoupled member count lets the overflow be tuned down to a few bytes past the end, so the write can be aimed at a chosen heap neighbour rather than scribbling blindly. On embedded and RTOS firmware, where allocators are simple and ASLR is usually absent, placing an object whose first field is a callback pointer right after the undersized buffer turns the overflow into a function pointer overwrite, and the next time the application invokes that callback, control transfers to an attacker chosen address. On hardened glibc the same primitive needs a heap grooming step and an information leak to defeat ASLR, which raises the bar without changing the nature of the flaw.

What we are and are not publishing

The vulnerability is fixed in 1.5.7, so this writeup describes the root cause, the wrap, and the networked crash in full. We are deliberately **not** releasing the weaponized single shot exploit (heap grooming harness, function pointer overwrite, chosen target address). The techniques are standard and summarized above; a copy and paste exploit adds risk to the fleet still running old versions without adding anything to the analysis.

## 08 - Disclosure and CVE

This issue was reported privately to the open62541 maintainers, who were quick and professional throughout. The fix landed in the 1.5.7 release, and the identifier **CVE-2026-116014** was assigned, with a public advisory published by VulnCheck on 24 October 2026. The finding is credited to BUG|PWN Research and Sadoc ADONON.

1

Discovery

Found during memory safety research on the open62541 binary decoder and type machinery, with a working AddressSanitizer proof of concept over TCP.

2

Private report

Root cause, reachability through `UA_Client_getRemoteDataTypes`, and the PoC shared privately with the maintainers.

3

Fix

Overflow guards and field count limits merged through PR #8298 and #8302, commit `0cf48067`, shipped in 1.5.7.

4

Publication

CVE-2026-116014 assigned, VulnCheck advisory public on 24 October 2026.

### Severity breakdown

The CVSS v4.0 base score is **9.2, Critical** , with vector `CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N`. The attack is remote, needs no privileges and no user interaction, and fully compromises the confidentiality, integrity and availability of the client process. The single factor holding it back from a perfect score is `AT:P`: the victim has to read custom types from the attacker controlled server, which is common in gateway and client tooling but is not automatic for every connection.

## 09 - The fix

The 1.5.7 fix is small and exactly on target. The running size is now accumulated in a wide `size_t` local instead of the 16 bit bitfield, the total is checked against `UA_UINT16_MAX` after every member and again after the final alignment padding, and definitions with more than 255 fields are rejected up front so the 8 bit member count can no longer be truncated. Only once the size is known to fit is it committed back into `memSize`. On any overflow the type is cleared and the function returns `BADENCODINGLIMITSEXCEEDED`.

src/ua_types_definition.c @ 1.5.7 \- commit 0cf48067 (condensed)

__COPY
    
    
    /* 1. Reject too many fields before building anything */
    if (sd->fieldsSize > UA_BYTE_MAX)
        return UA_STATUSCODE_BADENCODINGLIMITSEXCEEDED;
    
    /* 2. Accumulate in a wide local, not the 16 bit field */
    size_t accSize = type->memSize;
    
    for (...) {
        /* ... */
        accSize += memSize + dtm->padding;
        if (accSize > UA_UINT16_MAX)    /* 3. guard per member */
            return UA_STATUSCODE_BADENCODINGLIMITSEXCEEDED;
    }
    
    accSize += end_padding;
    if (accSize > UA_UINT16_MAX)        /* 4. guard after padding */
        return UA_STATUSCODE_BADENCODINGLIMITSEXCEEDED;
    
    type->memSize = (UA_UInt16)accSize;  /* committed only when it fits */

### Before / after

Side by side, here is what changes in `UA_DataType_fromStructureDescription`: the accumulation moves out of the 16 bit field into a wide `size_t`, and every total is now guarded before it is committed back into `memSize`.

src/ua_types_definition.c \- before / after, commit 0cf48067

__COPY
    
    
    /* UA_DataType_fromStructureDescription() */
    
    + /* reject field counts that cannot fit the 8-bit membersSize */
    + if (sd->fieldsSize > UA_BYTE_MAX) {
    +     UA_DataType_clear(type);
    +     return UA_STATUSCODE_BADENCODINGLIMITSEXCEEDED;
    + }
    
    + size_t accSize = type->memSize;      // accumulate in a wide local
    
      for (...) {                          // for every field
    -     type->memSize += (UA_UInt16)(memSize + dtm->padding);   // wraps at 65535
    +     accSize += memSize + dtm->padding;
    +     if (accSize > UA_UINT16_MAX) {   // guard after each member
    +         UA_DataType_clear(type);
    +         return UA_STATUSCODE_BADENCODINGLIMITSEXCEEDED;
    +     }
      }
    
    - type->memSize += end_padding;        // no final check
    + accSize += end_padding;
    + if (accSize > UA_UINT16_MAX) {       // guard after final padding
    +     UA_DataType_clear(type);
    +     return UA_STATUSCODE_BADENCODINGLIMITSEXCEEDED;
    + }
    + type->memSize = (UA_UInt16)accSize;  // committed only when it fits

### What to do

1

Upgrade to 1.5.7 or later

This is the complete fix. Every client and every gateway built on open62541 that may read custom DataTypes should move to 1.5.7. If you vendor the amalgamated source, regenerate it from the fixed tag.

2

Treat upstream servers as untrusted

If your product embeds a client that pulls types from third party OPC UA servers (gateways, aggregation, bridges), the server is part of your attack surface. Authenticate and constrain which upstreams a client will browse and compile types from.

3

Look for the sibling pattern

A width narrowing (a size_t count stored into a narrow field used for allocation) is a recurring bug class in this codebase. Audit any other place where a wire supplied count feeds a bitfield or a fixed width integer before an allocation.

## 10 - References

  * VulnCheck, _advisory for CVE-2026-116014_ : [vulncheck.com](https://www.vulncheck.com/advisories/open62541-1.5.0-before-1.5.7-integer-overflow-heap-buffer-overflow-via-custom-datatype)
  * open62541, _fix commit 0cf48067_ : [github.com/open62541/open62541](https://github.com/open62541/open62541/commit/0cf4806768c493bc6ccffcd4419da24153b970ec)
  * open62541, _pull requests #8298 and #8302_ : [PR #8298](https://github.com/open62541/open62541/pull/8298) · [PR #8302](https://github.com/open62541/open62541/pull/8302)
  * open62541, _release v1.5.7_ : [github.com/open62541/open62541](https://github.com/open62541/open62541/releases/tag/v1.5.7)
  * open62541, _vulnerable code at v1.5.6, src/ua_types_definition.c:274_ : [github.com/open62541/open62541](https://github.com/open62541/open62541/blob/v1.5.6/src/ua_types_definition.c#L274)
  * MITRE, _CWE-190: Integer Overflow or Wraparound_ : [cwe.mitre.org](https://cwe.mitre.org/data/definitions/190.html)
  * MITRE, _CWE-122: Heap-based Buffer Overflow_ : [cwe.mitre.org](https://cwe.mitre.org/data/definitions/122.html)
  * CVE.org, _CVE-2026-116014 record_ : [cve.org](https://www.cve.org/CVERecord?id=CVE-2026-116014)
  * NVD, _CVE-2026-116014 detail_ : [nvd.nist.gov](https://nvd.nist.gov/vuln/detail/cve-2026-116014)
  * CVE Details, _CVE-2026-116014_ : [cvedetails.com](https://www.cvedetails.com/cve/CVE-2026-116014/)
  * o6 Automation GmbH, _official maintainer of open62541_ : [o6-automation.com](https://www.o6-automation.com)
  * open62541, _official project website_ : [open62541.org](https://open62541.org/)
  * GitHub Advisory Database, _GHSA-78c3-xxg6-2pf3_ : [github.com/advisories](https://github.com/advisories/GHSA-78c3-xxg6-2pf3)



Conclusion

The lesson is not that OPC UA is fragile but that a size derived from untrusted input must be validated before it sizes an allocation, and a 16 bit field is a trap the moment the quantity it holds is attacker influenced. The decoupled 8 bit member count is what upgraded a crash into a tunable write primitive. Width narrowing between a count and the allocation it governs is a classic, quiet source of memory corruption, and it is worth hunting for wherever a stack parses rich, peer supplied structures.

---
> 📦 **Full-Text Markdown Snapshot** archived by [Phying News](https://phying.com/item/10395) · Original source: [bugpwn.com](https://bugpwn.com/blog/open62541-cve-2026-116014.html)
