// HackTheBox

Codify

CVE-2023-30547 (vm2 sandbox escape, <=3.9.16) → svc → joshua → root

HackTheBoxEasyLinux Jul 20, 2026 6 min read

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

  • vm2 sandbox 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 | bash sidesteps 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/ControlPersist is 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.
← All writeups