| Description | open62541 contains a client-side stack exhaustion vulnerability in commit ca356b088ada7dee824d1b4acd07c1ff07ce242b. The issue is reachable when a client invokes UA_Client_getRemoteDataTypes() against a malicious OPC UA server that returns an attacker-controlled HasSubtype chain under the standard Structure DataType hierarchy. In the verified reproduction, the victim is the unmodified official example examples/custom_datatype/client_types_custom.c, which calls UA_Client_getRemoteDataTypes(client, 0, NULL, &newTypes) after establishing a normal OPC UA connection. This is therefore not an application-side API misuse and not a parser-only crash. It is a library-level client vulnerability triggered through a real network interaction path.
The affected logic is implemented in src/client/ua_client_util.c. When UA_Client_getRemoteDataTypes() is called with dataTypesNodesSize == 0, the function allocates a NodeId tree for visited DataTypes and starts recursive traversal from UA_NS0ID(STRUCTURE). The traversal is performed by browseDataTypesRecursive(). That helper sends a Browse request with browseDirection = Forward, referenceTypeId = HasSubtype, and nodeClassMask = DataType. For each returned ReferenceDescription, the code only applies local filtering rules: the target ExpandedNodeId must be local, the target must not already correspond to a known built-in or previously imported custom type, and the target NodeId must not already exist in the ZIP tree used for deduplication. If these conditions are satisfied, the client allocates a new NodeIdTreeEntry, inserts it into the ZIP tree, increments treeSize, and immediately recurses into browseDataTypesRecursive() again for the newly returned subtype. There is no recursion depth limit, no node budget, no breadth budget, no iterative work queue, and no structural validation of the remote subtype topology before descending further.
Because of that design, a malicious server can keep the recursion alive indefinitely by returning exactly one fresh local DataType node in each Browse response. This defeats the intended deduplication logic without violating the syntactic expectations of the client. ZIP_FIND() only prevents revisiting an already seen NodeId; it does not protect against an attacker-generated chain of always-new NodeIds such as ns=1;i=1001, ns=1;i=1002, ns=1;i=1003, and so on. As a result, the recursion depth grows linearly with attacker-controlled graph depth, and the client stack is consumed frame by frame until the process crashes with stack-overflow. The defect is therefore best characterized as uncontrolled recursion causing client-side denial of service.
The vulnerability was reproduced on the official victim example rather than on a custom client harness. The server side was replaced with a minimal malicious OPC UA server that preserves the normal protocol flow but manipulates only the DataType subtype topology. The observable network path is a real OPC UA session establishment and service sequence: HEL/ACK, OpenSecureChannel, FindServers, GetEndpoints, CreateSession, ActivateSession, Read of NamespaceArray, Browse of Structure, and then repeated Browse calls driven internally by UA_Client_getRemoteDataTypes(). This matters because it shows the crash is not caused by malformed transport framing before session setup and is not the result of directly calling an internal parser in isolation.
A baseline run confirms that the official example behaves normally when the server does not extend the subtype chain. In the benign mode, the server returns NamespaceArray correctly and stops after the initial Browse from Structure, so custom_datatype_client exits without crashing. Under the malicious mode, the server returns a single fresh local HasSubtype target per Browse, creating a long linear chain. In the reported environment, a depth parameter around 15000 reliably triggered the failure. On other systems, the exact number may vary because compiler optimization, ASan frame layout, thread stack size, and debugger attachment all influence the remaining stack space, but the underlying defect and trigger condition remain unchanged.
The AddressSanitizer symptom is a stack-overflow report with many repeated browseDataTypesRecursive frames originating from src/client/ua_client_util.c. The reproduced output showed SUMMARY: AddressSanitizer: stack-overflow together with repeated frames at the recursive call site. Additional GDB tracing at the recursive descent point showed that the recursion began at the standard Structure node and then advanced through an attacker-generated chain of previously unseen local numeric NodeIds. Representative observations included recursion[1] with treeSize=1 at ns=0;i=22 and next node ns=1;i=1001, recursion[2] with treeSize=2 and next node ns=1;i=1002, recursion[3] with treeSize=3 and next node ns=1;i=1003, and much deeper states such as recursion[1000] and recursion[5000] with treeSize increasing monotonically. In the documented GDB run, final_seen reached 7687 before the process faulted. These values show that each Browse response contained one accepted reference, that the reference was always a new local DataType node, that the deduplication tree never blocked descent, and that the call depth expanded exactly as the malicious server intended.
During one debug run, the first visible SIGSEGV appeared in __asan_memcpy reached from UInt16_decodeBinary, NodeId_decodeBinary, ExpandedNodeId_decodeBinary, decodeBinaryStructure, Array_decodeBinary, and then UA_Client_browse. This does not change the root cause analysis. The most plausible interpretation is that the stack had already been driven extremely deep by browseDataTypesRecursive(), and the next BrowseResponse decoding step became the first operation to fault under near-exhausted stack conditions. In other words, the immediate crash site may appear in decoding, but the vulnerability is not best described as a decoder bug.
The issue is not a demonstrated code execution primitive. Based on the currently verified evidence, the security impact is a remote denial of service against open62541-based client processes that invoke remote DataType discovery on attacker-controlled or attacker-influenced servers. Any application that uses UA_Client_getRemoteDataTypes() directly, or indirectly relies on the same logic path during custom type discovery, may be forced to terminate unexpectedly when connected to a malicious endpoint. Because the vulnerable behavior exists in the library implementation itself and is reproducible with an upstream example, the issue has product-level security relevance and is not limited to a single downstream program.
The affected source path can be summarized as follows. The example entry point is examples/custom_datatype/client_types_custom.c, where the client connects and invokes UA_Client_getRemoteDataTypes(). The library-side decision to initiate traversal from Structure occurs in src/client/ua_client_util.c when dataTypesNodesSize is zero. The recursive browsing logic resides in browseDataTypesRecursive() in the same file. The recursion is driven by the code path that allocates NodeIdTreeEntry objects, inserts them via ZIP_INSERT, increments the traversal state, and immediately calls browseDataTypesRecursive() again for the newly returned subtype NodeId. The first observable crash may surface later in the Browse response decoding stack through UA_Client_browse in src/client/ua_client_highlevel.c and the binary decoding helpers in src/ua_types_encoding_binary.c, but those frames are downstream manifestations of stack exhaustion rather than the original design flaw.
A robust fix should remove unbounded recursion from browseDataTypesRecursive() entirely. The safer design is to replace recursive descent with an explicit iterative work queue or stack stored on the heap, combined with hard limits such as a maximum number of traversed nodes and a maximum traversal depth budget. At minimum, the implementation should abort traversal when the depth exceeds a conservative threshold, but an iterative design is preferable because it prevents stack exhaustion by construction. The traversal should also retain the visited-node deduplication that already exists, because deduplication alone is insufficient but still useful to prevent cycles and repeated processing. |
|---|