CVE-2026-34486 — Apache Tomcat Tribes EncryptInterceptor Fail-Open RCE¶
Published 2026-06-04
Critical: Unauthenticated Remote Code Execution
An unauthenticated attacker with TCP access to the Tribes cluster receiver port can execute arbitrary code inside the Tomcat JVM. No credentials, no prior session.
| Field | Detail |
|---|---|
| Project | Apache Tomcat |
| Affected component | org.apache.tomcat:tomcat-tribes (clustering) |
| Severity | HIGH |
| CVSS 3.1 | 7.5 HIGH (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N) |
| CWE | CWE-311 (Missing Encryption of Sensitive Data) |
| Affected versions | 9.0.116, 10.1.53, 11.0.20 (single-point releases only) |
| Fixed versions | 9.0.117, 10.1.54, 11.0.21 |
| GHSA | GHSA-69r9-qgr7-g2wj |
| Impact | Unauthenticated Remote Code Execution |
1. Vulnerability Overview¶
Apache Tomcat ships a clustering subsystem called Tribes that lets multiple nodes share session state. When encryption is enabled for cluster traffic, the interceptor class EncryptInterceptor is supposed to decrypt every inbound message before passing it further into the pipeline. A refactoring commit introduced while fixing an earlier CVE (CVE-2026-29146) accidentally broke this guarantee and created a fail-open condition: if decryption throws an exception, the raw, un-decrypted bytes are forwarded anyway. Because the downstream pipeline eventually calls ObjectInputStream.readObject() with no class filtering, an attacker who can reach the Tribes receiver port (TCP 4000 by default, no authentication required) can send a crafted Java serialization gadget chain and achieve arbitrary code execution inside the Tomcat JVM.
Root cause¶
File: java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptor.java
Method: messageReceived(ChannelMessage msg)
The bad commits (6d955cc for Tomcat 11, 607ebc0 for Tomcat 9/10, both titled "Add support for new algorithms provided by JPA providers") moved the call to super.messageReceived(msg) from inside the try block to after the catch block. The effect: an exception from decrypt() is caught and logged, but execution falls through to super.messageReceived(msg), forwarding the original untouched bytes.
Vulnerable code (9.0.116 / 10.1.53 / 11.0.20):
public void messageReceived(ChannelMessage msg) {
try {
byte[] data = msg.getMessage().getBytes();
data = encryptionManager.decrypt(data); // throws GeneralSecurityException on bad input
XByteBuffer xbb = msg.getMessage();
xbb.clear();
xbb.append(data, 0, data.length);
// super.messageReceived() is NOT here
} catch (GeneralSecurityException gse) {
log.error(sm.getString("encryptInterceptor.decrypt.failed"), gse);
// exception swallowed — execution continues
}
super.messageReceived(msg); // BUG: raw bytes forwarded regardless of decrypt outcome
}
The fix (commits 1fab40cc for Tomcat 11, 55f3eb91 for Tomcat 10, 776e12b3 for Tomcat 9) moves super.messageReceived(msg) back inside the try block so that a GeneralSecurityException causes the message to be silently dropped rather than forwarded:
@@ -140,10 +140,10 @@ public void messageReceived(ChannelMessage msg) {
xbb.clear();
xbb.append(data, 0, data.length);
+ super.messageReceived(msg);
} catch (GeneralSecurityException gse) {
log.error(sm.getString("encryptInterceptor.decrypt.failed"), gse);
}
- super.messageReceived(msg);
With the fix in place, a decryption failure terminates message processing inside the catch block and super.messageReceived() is never reached. Without it, super.messageReceived() eventually calls XByteBuffer.deserialize(), which uses ObjectInputStream.readObject() with no class filtering, so attacker-controlled bytes containing a gadget chain (CommonsCollections6 was used in this reproduction) result in arbitrary command execution.
2. Vulnerable Environment¶
The reproduction environment is a single Docker container running the official tomcat:11.0.20-jdk17-temurin-jammy image, configured with a Tribes cluster whose channel carries the vulnerable EncryptInterceptor. No second cluster node is needed; the fail-open path lives in the receiver's inbound processing, which a single node exercises for any frame arriving on the receiver port.
Key configuration decisions:
- Tomcat 11.0.20 is pinned exactly. The fail-open
messageReceived()exists only in the three single-point releases 9.0.116, 10.1.53, and 11.0.20. Earlier releases are unaffected by this specific bug (though they carry the earlier CVE-2026-29146 issue). - Commons Collections 3.2.1 is placed on the Tomcat server classpath at
/usr/local/tomcat/lib/commons-collections-3.2.1.jar(SHA1-verified against Maven Central in the Dockerfile). This is the gadget source that makes a CommonsCollections deserialization chain reachable on the target. EncryptInterceptoris configured inserver.xmlwith a static 128-bit AES key (cafebabecafebabecafebabecafebabe). The padded cipher variant (AES/CBC/PKCS5Padding) ensures that attacker-supplied unencrypted bytes triggerIllegalBlockSizeException, which is the specific exception that exposes the fail-open path.- The Tribes NioReceiver binds
0.0.0.0:4000inside the container, exposed to the host at127.0.0.1:4000. No authentication sits in front of it. - The JVM runs as the unprivileged
tomcatservice user (UID 999), established viagosu. Files created by hijacked control flow therefore carrytomcatownership, providing a distinct identity that the verification step checks. - A
/markerdirectory writable by thetomcatuser is created at image build time; the entrypoint script wipes any stale marker files on every container start so each run begins with a clean baseline.
Topology:
| Name | Role | Network | Ports |
|---|---|---|---|
cve-2026-34486-tomcat |
Vulnerable Tomcat 11.0.20 node | env_default bridge |
127.0.0.1:4000 (Tribes receiver), 127.0.0.1:8080 (HTTP) |
Standing up the environment:
Download the environment files (env.zip) or reference the individual files: env/Dockerfile, env/docker-compose.yml, env/config/server.xml, env/config/entrypoint.sh.
Wait for the container to become healthy (the healthcheck tests that the Tribes receiver socket accepts connections). Then confirm the environment is the correct vulnerable substrate:
# 1. Receiver reachable
nc -z -w 3 127.0.0.1 4000 && echo "receiver up"
# 2. Exact vulnerable version
docker exec cve-2026-34486-tomcat /usr/local/tomcat/bin/version.sh | grep "Server number"
# Expected: Server number: 11.0.20.0
# 3. EncryptInterceptor loaded and receiver bound
docker logs cve-2026-34486-tomcat 2>&1 | grep -E "EncryptInterceptor|Receiver Server Socket bound"
# 4. Gadget on classpath
docker exec cve-2026-34486-tomcat ls /usr/local/tomcat/lib/commons-collections-3.2.1.jar
# 5. Clean marker baseline; JVM running as service user
docker exec cve-2026-34486-tomcat bash -c 'ls -A /marker; ps -o user= -C java'
To verify that the patched version is not vulnerable, rebuild the image from tomcat:11.0.21-jdk17-temurin-jammy with the same server.xml and confirm that no marker is created when the exploit is fired.
3. How to Exploit¶
The exploit is entirely self-contained in three Python/Bash scripts. No Java toolchain or ysoserial jar is needed. Download the exploit files (exploit.zip) or reference individually: exploit/run.sh, exploit/gadget.py, exploit/send.py.
Step 1 — Confirm receiver is up and the substrate is vulnerable¶
nc -z -w 3 127.0.0.1 4000 && echo "receiver up" && \
docker exec cve-2026-34486-tomcat /usr/local/tomcat/bin/version.sh | grep "Server number"
Expected output: receiver up and Server number: 11.0.20.0.
Step 2 — Fire the exploit¶
The orchestrating script exploit/run.sh takes a target host, port, and a marker path. The marker path should embed a fresh nonce so the resulting file can be unambiguously attributed to this run:
Internally, run.sh performs two sub-steps:
2a. Build the CommonsCollections6 gadget (pure Python, no Java required):
gadget.py constructs the Java serialization stream entirely in Python using the correct serialVersionUID values and field layouts read directly off Commons Collections 3.2.1 and the JDK 17 runtime. The chain is: HashMap.readObject() → TiedMapEntry.hashCode() → LazyMap.get() → ChainedTransformer → ConstantTransformer(Runtime.class) → InvokerTransformer("getMethod") → InvokerTransformer("invoke") → InvokerTransformer("exec", cmdArray).
2b. Wrap the raw payload in a Tribes wire frame and send it unencrypted:
send.py wraps the serialized bytes in the Tomcat Tribes wire-protocol envelope (FLT2002 … TLF2003) with a valid ChannelData structure and a structurally correct MemberImpl blob, then sends the entire frame over a plain TCP socket to the receiver, without encrypting it. On the server side, EncryptInterceptor.messageReceived() attempts AES decryption, receives javax.crypto.IllegalBlockSizeException (because the input length is not a multiple of 16 for the padded cipher), logs it as a GeneralSecurityException, and, because of the placement bug, calls super.messageReceived(msg) with the original untouched bytes. Those bytes reach XByteBuffer.deserialize() → ObjectInputStream.readObject(), the CommonsCollections6 chain fires, and the Tomcat JVM executes touch /marker/pwned-<NONCE> as the tomcat service user.
Step 3 — Observe the proof via an independent channel¶
The marker file is verified through a privileged docker exec call that is entirely separate from the exploit's own output; it uses root access to inspect the container filesystem directly:
Observed in the verified reproduction run (nonce fb8f88e5b044e839):
docker exec cve-2026-34486-tomcat ls -l /marker/pwned-fb8f88e5b044e839
-rw-r----- 1 tomcat tomcat 0 Jun 3 01:35 /marker/pwned-fb8f88e5b044e839
The file's owner is tomcat (UID 999), the unprivileged service user inside the JVM, not root and not the host running the exploit. The fresh nonce embedded in the filename rules out any pre-placed or incidentally created file. The exploit scripts contain no docker exec, no open(), and no touch of the marker on any host; the file could only have been created by code executing inside the victim JVM.
The corroborating log frame ties the effect specifically to the EncryptInterceptor.messageReceived fail-open path:
Observed log output:
SEVERE [Tribes-Task-Receiver[Catalina-Channel]-3]
org.apache.catalina.tribes.group.interceptors.EncryptInterceptor.messageReceived
Failed to decrypt message
javax.crypto.IllegalBlockSizeException: Input length must be multiple of 16 when decrypting with padded cipher
at org.apache.catalina.tribes.group.interceptors.EncryptInterceptor.messageReceived(EncryptInterceptor.java:135)
Exactly one "Failed to decrypt message" entry appeared in the logs, matching the single exploit run. The stack frame at EncryptInterceptor.java:135 is precisely the super.messageReceived(msg) call site outside the try block that the patch removes.
Environment teardown¶
Once done, stop and remove the container and its volumes:
4. Security Advice¶
Remediation¶
Upgrade to a fixed release:
- Tomcat 11: upgrade to 11.0.21 or later.
- Tomcat 10: upgrade to 10.1.54 or later.
- Tomcat 9: upgrade to 9.0.117 or later.
The fix is a partial revert of the bad refactoring commit: super.messageReceived(msg) is moved back inside the try block so that a GeneralSecurityException during decryption causes the message to be silently dropped (fail-closed), and the deserialization sink is never reached.
Affected releases only
Only the exact single-point releases 9.0.116, 10.1.53, and 11.0.20 are vulnerable to this specific bug. Versions prior to those are not affected by CVE-2026-34486 (though they may carry CVE-2026-29146, the earlier padding oracle issue that prompted the refactoring).
Mitigations and workarounds¶
If an immediate upgrade is not possible:
- Disable
EncryptInterceptor. The fail-open path only triggers whenEncryptInterceptoris configured. Deployments that use Tribes clustering withoutEncryptInterceptordo not expose the deserialization sink through this specific path. Removing the interceptor fromserver.xmleliminates the vulnerability (though it also removes cluster traffic encryption; weigh this trade-off carefully). - Firewall the receiver port. Restrict access to the Tribes receiver port (TCP 4000 by default) to only trusted cluster peers. The vulnerability is only exploitable by a host that can make a direct TCP connection to this port. If cluster peers are all on an isolated private network segment, an edge firewall significantly reduces the attack surface.
- Remove gadget chains from the classpath.
ObjectInputStream.readObject()with no class filtering requires a usable gadget chain on the server classpath. If Commons Collections (and any other exploitable gadget library) can be removed from the classpath, RCE becomes harder, though this is not a reliable mitigation: other gadget chains may exist, and the underlying fail-open bug remains.
Detection¶
A successful attack leaves exactly one server-side log trace before the command executes silently:
SEVERE [Tribes-Task-Receiver[Catalina-Channel]-...]
org.apache.catalina.tribes.group.interceptors.EncryptInterceptor.messageReceived
Failed to decrypt message
javax.crypto.IllegalBlockSizeException: Input length must be multiple of 16 when decrypting with padded cipher
No readObject exception is logged; command execution is silent. Monitor for this log pattern and correlate with unexpected file creation under Tomcat's working directories.
References¶
- NVD CVE-2026-34486: identity, CWE-311, CVSS 7.5
- GHSA-69r9-qgr7-g2wj: affected/fixed versions, package names
- Apache security advisory — Tomcat 11: severity "Important", disclosure timeline
- Apache security advisory — Tomcat 10: 10.1.54 fix details
- Apache security advisory — Tomcat 9: 9.0.117 fix details
- Patch commit 1fab40cc (Tomcat 11): partial revert of 6d955cc
- Patch commit 55f3eb91 (Tomcat 10): partial revert of 607ebc0
- Patch commit 776e12b3 (Tomcat 9): partial revert of 607ebc0
- Apache mailing list announcement: original public disclosure
- striga-ai/CVE-2026-34486: Docker-based end-to-end PoC with CC6 gadget and Tribes frame builder
- 404-src/CVE-2026-34486: Python
exp.pywith ysoserial and interactive shell mode - AirSkye/CVE-2026-34486-poc: bytecode-level analysis and Chinese writeup
- herodevs vulnerability directory: supplementary description