01 - Executive Summary

open62541 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, 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
Softwareopen62541 (OPC UA C stack)
TypeInteger Overflow to Heap Buffer Overflow (CWE-190, CWE-122)
Affected>= 1.5.0, < 1.5.7
Fixed in1.5.7 (commit 0cf48067, PR #8298 and #8302)
VectorNetwork, server to client (malicious or compromised OPC UA server)
SeverityCritical · CVSS 9.2 (v4.0)
CVECVE-2026-116014
Fix actionUpgrade to 1.5.7; treat upstream OPC UA servers as untrusted
AuthorBUG|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
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
/* 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
/* 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
/* 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
/* 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)
/* 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
/* 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

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.