September 19, 2026
The Malloc Failed, The Code Said Everything Was Fine, Then the Server Crashed | CVE-2026-90782 |โฆ
CVE-2026โ90782 | A NULL pointer dereference in S2OPC, an OPC UA library built for safety-critical systems

By Harsh Raj Singhania
7 min read
CVE-2026โ90782 | A NULL pointer dereference in S2OPC, an OPC UA library built for safety-critical systems
Ten posts now. Every single one started with reading something someone else wrote. This one started with someone else's CVE in an industrial protocol library, a file reference in the advisory, and a question about what else was in that file.
What S2OPC Is โ And Why It Matters
S2OPC is not a web library. It's not a developer tool. It's an OPC UA implementation built by a French safety and security company called Systerel, specifically for environments where software failures have real consequences โ nuclear facilities, railways, aerospace, industrial automation. The name stands for Safe and Secure OPC.
OPC UA โ Open Platform Communications Unified Architecture โ is the protocol industrial machines use to talk to each other and to the systems monitoring them. When a temperature sensor on a production line sends readings to the SCADA system watching it, that's often OPC UA. When a PLC reports its state to a control dashboard, that's OPC UA. It's the backbone of modern industrial IoT.
S2OPC is the implementation a safety engineer would choose when they need an OPC UA stack that can be deployed in a certified, audited environment. It uses formal B-method verification โ a mathematical specification technique used in safety-critical software development, the same methodology behind train control systems and nuclear plant software. It's exactly the kind of codebase where you'd expect bugs to be rare.
This one wasn't.
How OPC UA Subscriptions Work
To understand the bug you need to understand one OPC UA concept: subscriptions and notifications.
An OPC UA client connects to a server and creates a subscription. Inside that subscription it creates monitored items โ each one watching a specific data point on the server. When values change, the server bundles up the changes and sends them to the client in a publish response.
There are two types of notifications a monitored item can produce. A DataChangeNotification carries the new value of a variable that changed โ a sensor reading, a process variable, a status flag. An EventNotification carries a structured event object โ an alarm, a state transition, something that happened rather than something that changed value.
A single session can have both types active at the same time. One subscription, some items producing data-change notifications, some producing event notifications. That combination is what the bug requires.
The CVE That Led Here
CVE-2026โ67865 was filed against S2OPC for an out-of-bounds read in RepublishResponse handling โ a different function, a different bug. I was reading through the affected file, msg_subscription_publish_bs.c, to understand the context. The pattern holds: reading someone else's bug's location carefully enough to notice something that wasn't part of the original report.
The function I landed on was msg_subscription_publish_bs__alloc_notification_message_items(). Its job is to allocate the memory needed for a publish notification message that might contain both DataChange and Event notification objects.
The Code โ And The One Variable That Does Too Much
The function loops over the notification types it needs to allocate for. If the session has data-change items, it allocates a DataChangeNotification object. If it has event items, it allocates an EventNotificationList object. Both allocations go through SOPC_ExtensionObject_CreateObject(), which returns a SOPC_ReturnStatus โ success or failure.
The problem is that both allocations write their result into the same status variable. Here's the logic, simplified but structurally identical to the vulnerable source:
SOPC_ReturnStatus status = SOPC_STATUS_OK;
OpcUa_DataChangeNotification *dataChangeNotif = NULL;
OpcUa_EventNotificationList *eventNotifList = NULL;
for (int i = 0; i < n; i++) {
if (dataToSet) {
/* First iteration โ allocate DataChangeNotification */
status = create_object(&dataChangeNotif, "DataChangeNotification");
dataToSet = false;
/* If this fails: status = SOPC_STATUS_OOM, dataChangeNotif = NULL */
/* Loop does NOT exit. It continues to the next iteration. */
} else if (eventToSet) {
/* Second iteration โ allocate EventNotificationList */
status = create_object(&eventNotifList, "EventNotificationList");
eventToSet = false;
/* If THIS succeeds: status = SOPC_STATUS_OK */
/* The previous failure is now completely erased from status */
}
}
/* Post-loop: status says SOPC_STATUS_OK. It is lying. */
if (SOPC_STATUS_OK == status && hasData) {
dataChangeNotif->NoOfMonitoredItems = ...; /* dataChangeNotif is NULL */
}SOPC_ReturnStatus status = SOPC_STATUS_OK;
OpcUa_DataChangeNotification *dataChangeNotif = NULL;
OpcUa_EventNotificationList *eventNotifList = NULL;
for (int i = 0; i < n; i++) {
if (dataToSet) {
/* First iteration โ allocate DataChangeNotification */
status = create_object(&dataChangeNotif, "DataChangeNotification");
dataToSet = false;
/* If this fails: status = SOPC_STATUS_OOM, dataChangeNotif = NULL */
/* Loop does NOT exit. It continues to the next iteration. */
} else if (eventToSet) {
/* Second iteration โ allocate EventNotificationList */
status = create_object(&eventNotifList, "EventNotificationList");
eventToSet = false;
/* If THIS succeeds: status = SOPC_STATUS_OK */
/* The previous failure is now completely erased from status */
}
}
/* Post-loop: status says SOPC_STATUS_OK. It is lying. */
if (SOPC_STATUS_OK == status && hasData) {
dataChangeNotif->NoOfMonitoredItems = ...; /* dataChangeNotif is NULL */
}The DataChange allocation fails. dataChangeNotif is set to NULL by the failure path. status is set to SOPC_STATUS_OOM. The loop moves on. The Event allocation succeeds. status is overwritten with SOPC_STATUS_OK. The original failure is gone โ not handled, not logged, not checked again. Just gone.
Now the guard passes: status == SOPC_STATUS_OK. The code proceeds to access dataChangeNotif->NoOfMonitoredItems. dataChangeNotif is NULL. The server crashes.
What Makes This Subtle
This isn't a case of forgetting to check malloc. The error was checked. The return value from SOPC_ExtensionObject_CreateObject() was read and stored into status every time. The function did not ignore the failure.
It just stored both results in the same variable. The second result overwrote the first. Any developer reading this code for the first time would see error handling and move on. The error handling IS there. It just gets quietly erased by the next operation succeeding.
This is a status-clobbering bug, and it's one of the harder categories to catch in code review precisely because all the right things seem to be happening at every individual line.
Proving It
I built a standalone C harness that mirrors the exact structure of the vulnerable loop โ same allocation sequence, same shared status variable, same post-loop guard, same dereference. The only difference is the allocator functions are controllable so I can simulate the DataChange allocation failing and the Event allocation succeeding.
Test 1: normal case โ both allocations succeed
[ALLOC] DataChangeNotification -> OK ptr=0x506000000020
[ALLOC] EventNotificationList -> OK ptr=0x506000000080
status: SOPC_STATUS_OK dataChangeNotif: valid ptr
-> Guard passed. Accessing dataChangeNotif->NoOfMonitoredItems...
-> Survived.[ALLOC] DataChangeNotification -> OK ptr=0x506000000020
[ALLOC] EventNotificationList -> OK ptr=0x506000000080
status: SOPC_STATUS_OK dataChangeNotif: valid ptr
-> Guard passed. Accessing dataChangeNotif->NoOfMonitoredItems...
-> Survived.No problem. When both succeed, status is correct, both pointers are valid.
Test 2: vulnerable โ DataChange fails, Event succeeds
[ALLOC] DataChangeNotification -> FAILED (OOM)
[ALLOC] EventNotificationList -> OK ptr=0x506000000020
status: SOPC_STATUS_OK <-- clobbered by Event success
dataChangeNotif: NULL <-- about to be dereferenced
-> Guard passed (status OK). Accessing dataChangeNotif->NoOfMonitoredItems...[ALLOC] DataChangeNotification -> FAILED (OOM)
[ALLOC] EventNotificationList -> OK ptr=0x506000000020
status: SOPC_STATUS_OK <-- clobbered by Event success
dataChangeNotif: NULL <-- about to be dereferenced
-> Guard passed (status OK). Accessing dataChangeNotif->NoOfMonitoredItems...Then ASan:
ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000
The signal is caused by a READ memory access.
Hint: address points to the zero page.
#0 alloc_vulnerable โ line 83 (the dereference)
#1 main
SUMMARY: AddressSanitizer: SEGV in alloc_vulnerableERROR: AddressSanitizer: SEGV on unknown address 0x000000000000
The signal is caused by a READ memory access.
Hint: address points to the zero page.
#0 alloc_vulnerable โ line 83 (the dereference)
#1 main
SUMMARY: AddressSanitizer: SEGV in alloc_vulnerableAddress 0x000000000000 โ NULL. The pointer that was set to NULL when the allocation failed, then dereferenced when the guard passed because the status said success.
Test 3: fixed โ independent status tracking
[ALLOC] DataChangeNotification -> FAILED (OOM)
dataStatus: FAILURE dataChangeNotif: NULL
-> DataChange alloc failed โ dereference skipped safely.
No crash. Failure correctly propagated.[ALLOC] DataChangeNotification -> FAILED (OOM)
dataStatus: FAILURE dataChangeNotif: NULL
-> DataChange alloc failed โ dereference skipped safely.
No crash. Failure correctly propagated.Test 4: control โ data-only session
[ALLOC] DataChangeNotification -> FAILED (OOM)
status: SOPC_STATUS_FAILURE (correctly propagated)
-> DataChange failure propagates correctly (no second alloc)[ALLOC] DataChangeNotification -> FAILED (OOM)
status: SOPC_STATUS_FAILURE (correctly propagated)
-> DataChange failure propagates correctly (no second alloc)When the session only has data-change items and no event items, there's no second allocation to clobber the status. The failure propagates correctly and the dereference never runs. The bug only exists when a session has both types of monitored items โ which is a realistic and common configuration, not an edge case.
What The Trigger Actually Requires
I want to be honest about what I did and didn't prove here, the same way I was honest in the issue I filed.
I did not demonstrate a crafted OPC UA packet that deterministically causes this from the network. The DataChange allocation failure path is reached through memory exhaustion โ SOPC_ExtensionObject_CreateObject() failing because malloc() returns NULL under OOM conditions. That means triggering this reliably requires the server to be under memory pressure when a publish response for a mixed session is generated.
That's not a trivial condition to engineer remotely. But it's also not purely theoretical โ OOM conditions can be induced through memory exhaustion attacks, through repeated large subscription requests, or under resource-constrained embedded deployments. S2OPC targets constrained industrial devices as well as servers, and "this only fails under OOM" looks different on a 256MB embedded controller than on a modern server.
The ASan output above โ SEGV at address NULL โ is what happens when that condition is met.
The Formal Verification Angle
S2OPC's development process uses B-method, a formal specification technique that allows mathematical proof of correctness properties. I noted in the issue I filed that it might be worth checking whether the formal model has an assumption or invariant that covers this failure interleaving โ something like "if allocations are grouped in a loop, at most one can fail" โ that's assumed true in the spec but not enforced in the generated C.
I'm not saying formal verification failed here. I'm saying there's a gap between proving the spec correct and proving the implementation handles every runtime failure interleaving correctly. This is one of the harder problems in safety-critical software engineering, and it's worth naming.
The Fix
MR !1862, commit 8848f051. The fix tracks the allocation statuses independently so that a second success cannot erase a first failure:
/* Fixed: separate status variables, early exit if DataChange fails */
if (hasData) {
dataStatus = create_object(&dataChangeNotif, "DataChangeNotification");
}
if (hasEvent && dataStatus == SOPC_STATUS_OK) {
eventStatus = create_object(&eventNotifList, "EventNotificationList");
}
/* Now both statuses are checked independently */
if (dataStatus == SOPC_STATUS_OK && hasData) {
dataChangeNotif->NoOfMonitoredItems = ...; /* ptr is valid */
}/* Fixed: separate status variables, early exit if DataChange fails */
if (hasData) {
dataStatus = create_object(&dataChangeNotif, "DataChangeNotification");
}
if (hasEvent && dataStatus == SOPC_STATUS_OK) {
eventStatus = create_object(&eventNotifList, "EventNotificationList");
}
/* Now both statuses are checked independently */
if (dataStatus == SOPC_STATUS_OK && hasData) {
dataChangeNotif->NoOfMonitoredItems = ...; /* ptr is valid */
}When DataChange fails, Event allocation is skipped entirely. No second result to clobber the first. The failure propagates cleanly.
Severity
CVSS 6.0 Medium, vector AV:N/AC:L/AT:P/PR:L/UI:N/VC:N/VI:N/VA:H. The network-reachable path requires a session with both DataChange and Event monitored items (that's the AT:P โ Attack Requirements Present), and a low-privilege subscribing client is enough to create such a session. The impact is availability only: VA:H, a server crash with no confidentiality or integrity consequence demonstrated.
In most contexts, Medium is Medium. In industrial infrastructure monitoring environments, a remotely triggerable server crash on an OPC UA endpoint watching physical systems is worth more attention than the number suggests.
The Habit, One More Time
Ten posts. Same starting point every time โ reading something someone else found, carefully enough to notice what the report didn't cover. CVE-2026โ67865 pointed at a file. The file had another function. The function had a loop with a shared variable and no early exit on failure. The failure sequence produced a crash.
That's all it was.
Ten posts, nine confirmed CVEs (one pending CVE number). If something here is wrong or you work on OPC UA security, the comments are open.
References
- VulnCheck advisory for CVE-2026โ90782: https://www.vulncheck.com/advisories/s2opc-through-1.7.3-null-pointer-dereference-in-alloc-notification-message-items
- GitLab issue #1815 (original report): https://gitlab.com/systerel/S2OPC/-/issues/1815
- Fix MR !1862: https://gitlab.com/systerel/S2OPC/-/merge_requests/1862
- Fix commit
8848f051: https://gitlab.com/systerel/S2OPC/-/commit/8848f051eed069b107ae7cb16a346d6f6386a8f5 - Vulnerable source at 1.7.3 (L106โ147): https://gitlab.com/systerel/S2OPC/-/blob/S2OPC_Toolkit_1.7.3/src/ClientServer/services/b2c/msg_subscription_publish_bs.c#L106-L147
- CVE-2026โ67865 (the advisory that pointed at the file โ not mine): https://nvd.nist.gov/vuln/detail/CVE-2026-67865
- S2OPC project: https://gitlab.com/systerel/S2OPC
- My previous post, CVE-2026โ90781 in ALSA-lib: https://medium.com/@harshrajsinghania/i-found-a-stack-buffer-overflow-in-alsa-lib-the-audio-library-running-on-every-linux-machine
- My first post, CVE-2026โ73194: https://medium.com/@harshrajsinghania/i-found-my-first-cve-by-trying-to-understand-someone-elses-bug-the-story-of-cve-2026-73194-1702f8bde5c1