Hi,
I reported the findings behind CVE-2026-102508 and CVE-2026-102509 [1][2]. This
follow-up gives the mechanisms and triggers for both. Both are fixed in PLC4X
1.0.0; there is no fixed 0.13.x. PLC4X is a client library, so the attacker is
a malicious or impersonated server, or someone on the network path.
CVE-2026-102508 (0.9.0 - 0.13.1)
--------------------------------
Two defects in the OPC UA asymmetric receive path (0.12.0 - 0.13.1).
Bug 1, AsymmetricEncryptionHandler.verify(): the branch sense is inverted.
signature.update(message);
if (signature.verify(signatureData)) { // missing negation
throw new IllegalArgumentException("Invalid signature");
}
Bug 2, BaseEncryptionHandler.decodeMessage(): the chunk is decrypted, then
verified against the original on-the-wire length.
if (chunk.isEncrypted()) {
bodySize = decrypt(chunkBuffer, chunk, messageLength); // new size
}
if (chunk.isSigned()) {
verify(chunkBuffer, chunk, messageLength); // discarded
}
verify() takes signatureStart = messageLength - signatureSize. Basic256Sha256
uses RSA/ECB/OAEPWithSHA-1AndMGF1Padding, so with a 2048-bit key each 256-byte
ciphertext block decrypts to at most 214 bytes (OAEP overhead 2*hLen + 2 = 42);
the two-block OPN response I used shrank by 84 bytes. The slice read as the
signature is the tail of the real signature followed by stale post-decryption
bytes.
Composed: bug 2 normally hands verify() bytes that do not validate, and bug 1
treats that as success. Zeros, random bytes and a correctly computed signature
are all accepted; only a signature crafted to validate over the misaligned
range is rejected. Fixing the negation alone would reject legitimate OPN
responses.
The bypass covers OPN only: MSG/CLO chunks go through
SymmetricEncryptionHandler, whose HMAC check is fail-closed. On its own it
gives an on-path attacker no channel keys, since those derive from a client
nonce encrypted to the server certificate the client trusts. The certificate
default closes that gap: with no trust-store-file, OpcuaDriverContext used a
permissive verifier and took the server certificate from the unauthenticated
GetEndpoints response (discovery=true is the default). That certificate
encrypts the OPN request, client nonce included, and the user's password.
Downgrade: security-policy defaults to None, and from 0.12.0 SecureChannel
skips an endpoint only when policy and mode both mismatch, so a weaker policy
with a matching mode is admitted:
if (!policyMatch && !msgSecurityMatch) {
continue;
}
>From 0.13.0 the admitted candidates are sorted by ascending securityLevel, so
>the weakest survivor is preferred.
0.9.0 - 0.11.0 fail differently: EncryptionHandler logs a failed check at INFO
and continues; checkSignature() digests the buffer including the signature,
then reads the expected signature from the start of the buffer rather than the
end; and there is no trust-anchor mechanism.
Reconstruction (Basic256Sha256, SignAndEncrypt, discovery=true, no trust
store): a relay answers GetEndpoints with its own certificate; decrypts the
client's OPN request and reads the client nonce; answers OPN with its own
nonce, encrypted to the client certificate, under a signature that need not
validate; derives the symmetric keys; then reads and authors correctly MACed
MSG traffic, including ActivateSession with the password encrypted to the
relay. In my testing (2048-bit keys) this captured the test credentials and
changed a write from 100.0 to 999.0, confirmed on the real server by an
independent observer, with no error at the client. The client was pointed
directly at the relay; the relay's upstream leg used SecurityPolicy.None.
CVE-2026-102509 (0.10.0 - 0.13.1)
---------------------------------
Three defects reached during secure-channel and session setup, before user
authentication. The CVE also covers two defects found during remediation:
collections pre-sized from server-supplied element counts, and recursive mspec
types with no depth limit (Go counterpart: CVE-2026-102510).
1. Byte strings (0.10.0 - 0.13.1). PascalByteString.staticParse() reads a
signed int32 length, and ReadBufferByteBased.readByteArray() runs "new
byte[numberOfBytes]" before checking the bytes exist. Trigger: after HEL/ACK
and the client's OPN, answer with an 88-byte OPNF: the 12-byte message header
(OPN, F, 4-byte size, 4-byte channel id), then securityPolicyUri
(PascalString), senderCertificate and receiverCertificateThumbprint
(PascalByteString). Set senderCertificate.stringLength to FF FF FF 7F: bytes
63-66 with the 47-byte SecurityPolicy#None URI, or from byte 73 with
Basic256Sha256. This header is parsed before any certificate or signature check.
2. Array counts (0.10.0 - 0.13.1). FieldReaderArray.readFieldCount() bounds the
wire count only against Integer.MAX_VALUE, then calls "new
ArrayList<>(itemCount)", allocating the backing Object[] before any element is
read. Exactly 0x7FFFFFFF fails fast ("Requested array size exceeds VM limit");
somewhat smaller counts attempt a multi-gigabyte allocation. Parser-level
trigger: the Variant prefix 86 FF FF FF 7F (Int32 with the array bit set, then
the count). On the wire: noOfServerEndpoints and noOfServerSoftwareCertificates
in CreateSessionResponse, parsed in SecureChannel before ActivateSession.
3. Chunk accumulation (0.12.0 - 0.13.1). Conversation negotiates maxChunkCount
and maxMessageSize but never checks them on receive. Trigger: after OPN and the
client's CreateSessionRequest, stream MSG chunks flagged 'C' (continue) with
the expected channel and token ids and increasing sequence numbers, never
sending 'F' (final). I used 2,000 chunks of 61,440 bytes, roughly 120 MB.
Triggers 2 and 3 pass per-chunk symmetric decoding: under the default None
policy they need no keys; under a secure policy the sender must be a server the
client accepted. Cases 1 and 3 were driven over a socket, case 2 at parser
level with its network route traced in source. The effect is loss of the
client's OPC UA function: case 1 throws OutOfMemoryError repeatedly on the
Netty thread while the client retries; case 3 exits the JVM only with
-XX:+ExitOnOutOfMemoryError.
The array reader is in the shared plc4j-spi. OPC UA and ADS use 32-bit counts
and lengths; Modbus and S7 use 8- or 16-bit ones.
Fixes
-----
- a2dbb6bfc0 (2026-07-10): negation restored, post-decrypt length passed to
verify(), certificate verifier fails closed by default.
- 5a4d5bdb4c (2026-07-10): chunk count and message size limits enforced; array
initial capacity capped at 1024.
- 372501287d (2026-06-12): SPI rewrite; the read buffer checks availability
before allocating. Predates the report but first shipped in 1.0.0.
1.0.0 also defaults to Basic256Sha256 with SignAndEncrypt, rejects an
unverifiable server certificate unless tls.verify=false, and fails the
connection if the negotiated policy is weaker than configured. Until upgrading,
a trust-store-file blocks the impersonating certificate (nothing was captured
in my testing) but leaves the OPN check broken and does not stop trigger 1.
Downstream
----------
Almost every PLC4J consumer I checked pins a pre-1.0.0 release. CVE-2026-102508
needs plc4j-driver-opcua, which plc4j-driver-all includes.
- Apache Camel camel-plc4x: 0.13.1 in 4.23.0 (latest) and LTS 4.22.2 and
4.18.5; 0.13.0 in 4.14.9 (LTS); 0.12.0 in 4.10.0 and 4.8.0. Bundles the OPC UA
driver, so both CVEs; the Spring Boot starter and Karaf feature inherit it.
Camel main moved to 1.0.0 (CAMEL-25617, 2026-10-27) but has not released it.
- PLC4X's own integrations on the old org.apache.plc4x coordinates (NiFi NARs,
Kafka Connect, Calcite, plc4x-opcua-server): 0.13.1 via plc4j-driver-all, so
both CVEs. They are republished at 1.0.0 under org.apache.plc4x.extras.
- Apache StreamPipes 0.98.0: 0.13.1, plc4j-driver-all with opcua excluded (its
OPC UA adapter uses Eclipse Milo). SPI defects only, with ADS among the shipped
drivers.
- Apache HertzBeat 1.8.0 and 1.9.0: 0.12.0, Modbus and S7 drivers only. SPI
defects only, bounded by those protocols' field widths.
Roughly a dozen smaller OT gateways and PLC bridges pin 0.9.0 - 0.13.1;
json-scada is already on 1.0.0. openHAB, NiFi, IoTDB, Ditto and Node-RED do not
depend on PLC4X.
Against a Camel route reading an OPC UA endpoint, a rogue or impersonated
server can harvest the route's OPC UA credentials and alter the values it
carries, so falsified data reaches the historian, database or topic downstream
with no error at the Camel end.
Full write-up:
https://abhinavagarwal07.github.io/posts/apache-plc4x-opcua-signature-bypass-preauth-dos/
Timeline: reported to [email protected] 2026-07-09; routed to the PLC4X team
and fixes committed to develop 2026-07-10; CERT/CC case VU#108987 opened
2026-07-25; 1.0.0 released 2026-09-07; CVEs published 2026-09-30.
[1] https://www.openwall.com/lists/oss-security/2026/09/30/4
[2] https://www.openwall.com/lists/oss-security/2026/09/30/5
Regards,
Abhinav Agarwal