Abschnitt 15 · Lektion 3
CSRF
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.
<?php
// Eine Notiz-App ohne Schutz. Sie glaubt jedem POST auf /speichern.
session_start();
$db = new PDO("sqlite:" . __DIR__ . "/daten.db", null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$db->exec("CREATE TABLE IF NOT EXISTS notizen (id INTEGER PRIMARY KEY, text TEXT NOT NULL)");
function e(?string $w): string { return htmlspecialchars($w ?? "", ENT_QUOTES, "UTF-8"); }
$pfad = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
if ($pfad === "/speichern" && $_SERVER["REQUEST_METHOD"] === "POST") {
$t = trim($_POST["text"] ?? "");
if ($t !== "") {
$db->prepare("INSERT INTO notizen (text) VALUES (:t)")->execute([":t" => $t]);
}
header("Location: /", true, 302);
exit;
}
$notizen = $db->query("SELECT text FROM notizen ORDER BY id DESC")->fetchAll();
?>
<!doctype html>
<html lang="de">
<head><meta charset="utf-8"><title>Deine Notizen</title></head>
<body>
<h1>Deine Notizen</h1>
<form method="post" action="/speichern">
<input type="text" name="text" size="30"> <button type="submit">Anlegen</button>
</form>
<ul>
<?php foreach ($notizen as $n) { ?>
<li><?= e($n["text"]) ?></li>
<?php } ?>
</ul>
</body>
</html> <!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.
<?php
// Dieselbe App, jetzt mit einem Token. Erzeugt in der Sitzung, ins
// Formular gelegt, beim Empfang verglichen.
session_start();
if (empty($_SESSION["token"])) {
$_SESSION["token"] = bin2hex(random_bytes(16));
}
$token = $_SESSION["token"];
$db = new PDO("sqlite:" . __DIR__ . "/daten.db", null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$db->exec("CREATE TABLE IF NOT EXISTS notizen (id INTEGER PRIMARY KEY, text TEXT NOT NULL)");
function e(?string $w): string { return htmlspecialchars($w ?? "", ENT_QUOTES, "UTF-8"); }
$pfad = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
if ($pfad === "/speichern" && $_SERVER["REQUEST_METHOD"] === "POST") {
if (!hash_equals($_SESSION["token"], $_POST["token"] ?? "")) {
http_response_code(403);
echo "Ungueltiges Token.";
exit;
}
$t = trim($_POST["text"] ?? "");
if ($t !== "") {
$db->prepare("INSERT INTO notizen (text) VALUES (:t)")->execute([":t" => $t]);
}
header("Location: /", true, 302);
exit;
}
$notizen = $db->query("SELECT text FROM notizen ORDER BY id DESC")->fetchAll();
?>
<!doctype html>
<html lang="de">
<head><meta charset="utf-8"><title>Deine Notizen</title></head>
<body>
<h1>Deine Notizen</h1>
<form method="post" action="/speichern">
<input type="hidden" name="token" value="<?= e($token) ?>">
<input type="text" name="text" size="30"> <button type="submit">Anlegen</button>
</form>
<ul>
<?php foreach ($notizen as $n) { ?>
<li><?= e($n["text"]) ?></li>
<?php } ?>
</ul>
</body>
</html> <!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
<?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, kostenlosZu 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.