mitmario.dev

CSRF

PHP Sandbox 4 Min Lesezeit 3 BeispieleLektion 3 von 7

Alle Angriffe bisher haben eine Lücke in deinem Code ausgenutzt: eine geklebte Abfrage, eine rohe Ausgabe. CSRF ist anders. Dein Code kann fehlerfrei sein, und der Angriff funktioniert trotzdem, weil er nicht deinen Code ausnutzt, sondern den Browser deines Besuchers.

Der Ablauf in fünf Sätzen

Dein Besucher ist bei dir angemeldet, sein Browser hat also dein Sitzungscookie. Er ruft eine ganz andere Seite auf, die ihm jemand geschickt hat. Diese Seite enthält ein verstecktes Formular, das auf deine Anwendung zeigt und sich beim Laden von selbst absendet. Der Browser schickt die Anfrage los und legt, weil sie an dich geht, dein Sitzungscookie automatisch dazu. Für deine Anwendung sieht das aus wie eine ganz normale, angemeldete Aktion, und sie führt sie aus.

Eine fremde Seite sendet in deinem Namen
<!doctype html>
<html lang="de">
<head><meta charset="utf-8"><title>Gratis Katzenbilder</title></head>
<body>
    <h1>Gratis Katzenbilder!</h1>
    <p>Sieht harmlos aus. Im Hintergrund schickt diese Seite aber ein
       Formular an deine Notiz-App, ohne dass du etwas anklickst.</p>

    <form method="post" action="/speichern" id="angriff">
        <input type="hidden" name="text" value="Von einer fremden Seite eingeschleust">
    </form>

    <script>document.getElementById("angriff").submit();</script>
</body>
</html>

Es liegen zwei Dateien nebeneinander: deine Notiz-App und angriff.html, die fremde Seite. Tipp /angriff.html in die Adresszeile über der Seite. Viel zu lesen bekommst du nicht: Ihr Skript schickt beim Laden ein Formular an /speichern, die Weiterleitung bringt dich zurück auf /, und dort steht eine Notiz, die du nie angelegt hast. Im Reiter Netzwerk stehen die zwei Schritte, POST /speichern mit 302 und darunter GET / mit 200. Im Terminal tut curl -si -d 'text=Fremd' localhost:3000/speichern | head -1 dasselbe ohne Katzenbilder: Der Server sagt 302 Found und nimmt die Notiz.

Eine Ehrlichkeit dazu: Die fremde Seite liegt hier auf demselben Server wie deine App, es ist also streng genommen kein cross-site. Was du siehst, ist trotzdem echt: ein Formular, das ohne dein Zutun absendet. Auf einem richtigen Angriff läge angriff.html auf einer fremden Adresse, und der Browser legte dein Cookie genauso dazu.

Das Token als Antwort

Der Trick des Angreifers ist, dass er die Anfrage nachbauen kann, ohne deine Seite je gesehen zu haben: Er kennt die Adresse und die Feldnamen, das reicht. Also gibst du ihm etwas mit, das er nicht kennen kann.

Du erzeugst beim Aufbau des Formulars eine lange Zufallsfolge, das Token, und legst sie an zwei Orte: in die Sitzung des Besuchers und in ein verstecktes Feld im Formular. Kommt der POST zurück, vergleichst du beide. Der ehrliche Absender hat das Token aus dem Formular und schickt es mit. Die fremde Seite kennt es nicht, ihr Feld ist leer oder erfunden, und ihr POST prallt ab.

Dieselbe App mit einem Token
<!doctype html>
<html lang="de">
<head><meta charset="utf-8"><title>Gratis Katzenbilder</title></head>
<body>
    <h1>Gratis Katzenbilder!</h1>
    <p>Dieselbe fremde Seite wie vorhin. Diesmal kennt sie das Token
       nicht, und ihr Formular prallt mit 403 ab.</p>

    <form method="post" action="/speichern" id="angriff">
        <input type="hidden" name="text" value="Von einer fremden Seite eingeschleust">
    </form>

    <script>document.getElementById("angriff").submit();</script>
</body>
</html>

Dieselbe App, jetzt mit Token, und dieselbe angriff.html. Ruf sie wieder auf: Diesmal endet ihr POST mit 403, im Reiter Browser steht Ungueltiges Token. und im Reiter Netzwerk der POST /speichern mit 403, und keine fremde Notiz erscheint. Dein eigenes Formular funktioniert weiter, denn es trägt das Token, das in deiner Sitzung steht.

Klapp dazu die beiden POSTs im Reiter Netzwerk auf und vergleich, was im Anfrage-Rumpf steht. Bei deinem eigenen Formular ein token= mit der langen Zufallsfolge und dahinter das text=, beim POST der fremden Seite nur das text=. Mehr ist der Unterschied nicht, und mehr braucht die Abwehr auch nicht.

Im Terminal kannst du beide Absender spielen. curl -si -d 'text=Fremd' localhost:3000/speichern | head -1 ist die fremde Seite und bekommt 403 Forbidden. Der ehrliche Weg holt sich erst das Formular und schickt dann mit dem Token ab, und zwar in einer Zeile, denn jeder Befehl fängt hier eine frische Shell an, und eine Variable aus dem Befehl davor ist beim nächsten schon weg: T=$(curl -s -c jar localhost:3000/ | grep -o 'value="[0-9a-f]*"' | cut -d'"' -f2); curl -si -b jar -d "text=Echt&token=$T" localhost:3000/speichern | head -1 merkt sich Token und Sitzungscookie und bekommt die 302.

Verglichen wird mit hash_equals

hash_equals und die leere Falle
<?php

// Warum hash_equals und nicht ===, und die Falle mit dem leeren Token.

$echt = "6278c6f1387d8a3c55c8e851cade0e58";

echo "Richtiges Token stimmt ueberein:\n";
var_dump(hash_equals($echt, "6278c6f1387d8a3c55c8e851cade0e58"));

echo "\nFalsches Token nicht:\n";
var_dump(hash_equals($echt, "falsch"));

echo "\nDie Falle: zwei leere Zeichenketten gelten als gleich.\n";
var_dump(hash_equals("", ""));

echo "\nDeshalb reicht der Vergleich allein nicht. Steht in der Sitzung\n";
echo "gar kein Token und schickt der Angreifer auch keins, waere das\n";
echo "hash_equals true. Man prueft also zuerst, ob ueberhaupt eins da ist:\n";
$sitzung = "";
$gesendet = "";
$ok = $sitzung !== "" && hash_equals($sitzung, $gesendet);
var_dump($ok);

Für den Vergleich nimmst du hash_equals($ausDerSitzung, $ausDemFormular) und nicht ===. Der Grund ist fein, aber real: Ein gewöhnlicher Zeichenvergleich hört beim ersten Unterschied auf, und daraus lässt sich über die gemessene Zeit erraten, wie viele Zeichen am Anfang schon stimmen. hash_equals braucht immer gleich lang, egal wo der Unterschied liegt.

Das dritte Beispiel zeigt außerdem eine Falle: hash_equals("", "") ist wahr. Steht in der Sitzung gar kein Token und schickt der Angreifer auch keins, wäre der Vergleich zufrieden. Deshalb prüfst du zuerst, ob überhaupt ein Token in der Sitzung liegt, und vergleichst erst dann.

Und noch eine zweite Verteidigung

SameSite=Lax an deinem Sitzungscookie, aus Lektion 12.2, ist die zweite Linie: Der Browser schickt das Cookie bei einem abgeschickten Formular von einer fremden Seite gar nicht erst mit, und ohne Cookie ist der Besucher für deine App nicht angemeldet. Warum das nicht reicht und du das Token trotzdem brauchst? Weil Lax eine Voreinstellung des Browsers ist, auf die du dich nicht überall verlassen kannst, und weil ältere Browser sie nicht kennen. Zwei Verteidigungen, von denen jede für sich hilft, sind besser als eine.

Übrigens: Im Browser dieses Kurses lässt sich SameSite=Lax gar nicht vorführen. Der Rahmen liegt auf einer fremden Adresse, deshalb schreibt die Plattform jedes Cookie auf SameSite=None um, sonst hielte hier keine Sitzung. Das Token dagegen wirkt unabhängig davon, und genau deshalb baust du es in der Aufgabe ein.

Zum Mitnehmen

Der Angriff, der ohne eine einzige Lücke in deinem Code funktioniert. Er nutzt aus, dass der Browser deines Besuchers dessen Sitzungscookie bei jeder Anfrage an dich mitschickt, auch wenn die Anfrage von einer ganz anderen Seite ausgelöst wurde.

Jetzt du

Basis Konto, kostenlos

Zu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.

Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.

In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.

Was in dieser Lektion steckt

  • Artikel mit 3 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe, dein Code läuft auf einem Server

    Öffnet sich mit dem Basis Konto.