Zum Inhalt
wannacode

Sitzungen, Cookies und CSRF

Kurz gesagt: CSRF ist ein Angriff, bei dem eine andere Website eine Anfrage an deine schickt und der Browser das Sitzungs-Cookie anhängt; SameSite-Cookies und ein CSRF-Token halten ihn auf.

Problem

Die Adresse, die Maria nicht geändert hat

Maria, eine Käuferin, schreibt dem Support des Shops: Ihr Paket ging an „Schwindelgasse 13“, aber sie wohnt in der „Bahnhofstraße 15“. Ihr Passwort ist stark, und niemand kennt es. Im Log des Shops steht eine Anfrage POST /account/address um 21:14, mit ihrer Sitzung, aus ihrem Browser.

Maria schwört, dass sie um 21:14 nicht im Shop war. Sie war auf einer Seite „Gewinne einen Preis!“ in einem anderen Tab. Niemand hat ihr Passwort oder ihr Cookie gestohlen. Wie hat dann ihr eigener Browser ihre Adresse geändert?

Die Idee

Wie der Shop sich merkt, dass du angemeldet bist

HTTP hat kein Gedächtnis: Jede Anfrage steht für sich (du hast es in der Lektion über das Netz gesehen). Also legt der Shop nach der Anmeldung eine Sitzung an: Er schreibt in eine Tabelle „diese Id ist Maria“ und schickt die Id dem Browser in einem Cookie, mit dem Header Set-Cookie. Von da an schickt der Browser das Cookie bei jeder Anfrage an den Shop von selbst mit, und der Shop findet Maria über die Id.

Die Id ist wie eine Garderobenmarke: Wer sie hält, bekommt den Mantel. Also muss sie sein:

  • zufällig: 32 Bytes aus einem kryptografischen Generator, kein Zähler und kein gewöhnliches Random. Sonst lässt sich die nächste Id erraten;
  • HttpOnly: Die Skripte der Seite sehen sie nicht. Auch ein fremdes, per XSS eingeschleustes Skript kann sie nicht lesen;
  • Secure: Sie geht nur über HTTPS, nie über einen offenen Kanal;
  • SameSite: wenn sie von der Seite einer anderen Website zum Shop geht. Das ist die zweite Hälfte der Lektion.

Das Passwort wird einmal geprüft, bei der Anmeldung (wie in der Lektion über Passwörter), und danach funktioniert nur noch die Id. Also gibt der Shop bei jeder Anmeldung eine neue Id und vergisst die alte. Warum, steht unter „Genauer, für Neugierige“.

Sieh es dir an

Die Sitzung im Code

Das Cookie und die Anfrage sind Objekte. Die Website, von der die Anfrage kommt, ist ein Feld: So siehst du in einem Prozess und ohne Netz alles, was Browser und Server tun.

record Cookie(string Name, string Value, string SameSite)
{
public string Header() =>
$"{Name}={Value}; HttpOnly; Secure; SameSite={SameSite}; Path=/";
}
// Site: die Website der Seite, von der die Anfrage kommt
record Request(string Site, string Method, string Path,
Dictionary<string, string> Cookies,
Dictionary<string, string> Form);
class Shop(string sameSite)
{
readonly Dictionary<string, string> sessions = new(); // Id → Käufer
public Dictionary<string, string> Addresses { get; } = new();
static string NewId() =>
Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));
public Cookie Login(string user, string? oldId)
{
// bei jeder Anmeldung eine neue Id; die alte gilt nicht mehr
if (oldId is not null) sessions.Remove(oldId);
var id = NewId();
sessions[id] = user;
return new Cookie("session", id, sameSite);
}
public string? Who(Request request) =>
request.Cookies.TryGetValue("session", out var id)
? sessions.GetValueOrDefault(id)
: null;
}
RandomNumberGenerator.GetBytes(32) liefert kryptografisch zufällige Bytes, und Base64Url.EncodeToString macht daraus Text, der in ein Cookie passt: 43 Zeichen.

Maria meldet sich zweimal an, dann probiert der Browser die neue Id, die alte und eine ausgedachte:

var shop = new Shop("Lax");
var browser = new Browser();
var first = shop.Login("maria", null);
var second = shop.Login("maria", first.Value); // meldet sich erneut an
Console.WriteLine($"id: {second.Value.Length} Zeichen");
Console.WriteLine($"die beiden Anmeldungen ergeben verschiedene Ids: {(first.Value == second.Value ? "nein" : "ja")}");
Console.WriteLine("Set-Cookie: " + second.Header().Replace(second.Value, "…"));
browser.Store(second);
Console.WriteLine($"die neue Id → {Visit(shop, browser)}");
browser.Store(first);
Console.WriteLine($"die alte Id → {Visit(shop, browser)}");
browser.Store(new Cookie("session", "123456", "Lax"));
Console.WriteLine($"eine ausgedachte Id → {Visit(shop, browser)}");
Ausgabe
id: 43 Zeichen
die beiden Anmeldungen ergeben verschiedene Ids: ja
Set-Cookie: session=…; HttpOnly; Secure; SameSite=Lax; Path=/
die neue Id → maria
die alte Id → niemand
eine ausgedachte Id → niemand

Die Ids sind bei jedem Lauf anders, deshalb gibt das Programm nur die Länge und „ja/nein“ aus. Nach der zweiten Anmeldung führt die alte Id zu niemandem.

Die Idee

Der Browser schickt das Cookie von selbst

Das hatte die Seite „Gewinne einen Preis!“:

<form action="https://shop.example/account/address" method="post">
<input type="hidden" name="address" value="Schwindelgasse 13">
</form>
<script>document.forms[0].submit()</script>

Ein verstecktes Formular an den Shop und eine Zeile, die es sofort abschickt. Der Angreifer kennt Marias Cookie nicht und braucht es nicht. Der Browser sieht eine Anfrage an shop.example und hängt das Cookie des Shops von selbst an. Der Handler findet Maria dadurch und ändert die Adresse.

Das heißt CSRF (Cross-Site Request Forgery). Das Cookie beweist, wer im Browser sitzt, aber nicht, welche Seite die Anfrage gestartet hat. Ein Handler, der nur dem Cookie vertraut, vertraut jeder Seite im Internet, die Maria öffnet.

Sieh es dir an

Das andere Formular und die zwei Schutzmaßnahmen

// POST /account/address
public int ChangeAddress(Request request)
{
var user = Who(request);
if (user is null) return 401; // keine Sitzung
Addresses[user] = request.Form["address"];
return 200;
}
Schritt für Schritt, in Worten
  1. Maria meldet sich bei shop.example an. Das Passwort stimmt, also öffnet der Shop eine Sitzung für sie.
  2. Die Sitzungs-Id besteht aus 32 Zufallsbytes. Der Shop merkt sich: Diese Id ist Maria.
  3. Die Id geht im Sitzungs-Cookie zurück. Ab jetzt schickt der Browser sie bei jeder Anfrage an den Shop mit.
  4. In einem anderen Tab öffnet Maria free-gifts.example. Seine Seite hat ein verstecktes Formular, das an den Shop postet und sich selbst abschickt.
  5. Das Cookie ist SameSite=None, also hängt der Browser es an die Anfrage an. Der Handler findet Maria dadurch.
  6. Er vertraut dem Cookie und ändert die Adresse: 200, Schwindelgasse 13. Maria hat im Shop nichts angeklickt.
  7. Lösung 1: SameSite=Lax. Bei einem POST von einer anderen Website lässt der Browser das Cookie zu Hause. Seiten des Shops bekommen es weiter.
  8. Dasselbe Formular, aber ohne das Cookie sieht der Handler keine Sitzung: 401. Die Adresse bleibt Bahnhofstraße 15.
  9. Lösung 2: ein CSRF-Token. Die Sitzung hält ein zufälliges Token, und die Seite des Shops legt es in ein verstecktes Feld ihres Formulars.
  10. Auch mit SameSite=None kann die andere Seite dieses Token nicht lesen. Ihr Formular kommt mit dem Cookie, aber ohne das Token an: 403.
  11. Das Formular des Shops schickt das Token mit, und es passt: 200, Gartenweg 7.
  12. Das Cookie sagt, wer du bist. Nur das Token sagt, dass die Anfrage von einer Seite des Shops kommt.

Der Browser entscheidet, ob das Cookie mitgeht. Die Regel steht im Attribut SameSite: None (geht immer mit), Lax (von einer anderen Website nur mit GET) und Strict (von einer anderen Website nie).

class Browser
{
readonly Dictionary<string, Cookie> jar = new(); // die Cookies von shop.example
public void Store(Cookie cookie) => jar[cookie.Name] = cookie;
// eine Seite irgendeiner Website schickt eine Anfrage an den Shop
public Request Send(string site, string method, string path,
Dictionary<string, string>? form = null)
{
var cookies = new Dictionary<string, string>();
foreach (var c in jar.Values)
{
if (Attach(c, site, method)) cookies[c.Name] = c.Value;
}
return new Request(site, method, path, cookies, form ?? new());
}
public static bool Attach(Cookie cookie, string site,
string method)
{
// dieselbe Website: Das Cookie geht immer mit
if (site == "shop.example") return true;
return cookie.SameSite switch
{
"None" => true,
"Lax" => method == "GET",
_ => false, // Strict
};
}
}

Der Handler aus dem Problem vertraut dem Cookie:

// POST /account/address
public int ChangeAddress(Request request)
{
var user = Who(request);
if (user is null) return 401; // keine Sitzung
Addresses[user] = request.Form["address"];
return 200;
}

Maria, der Shop und die zwei Formulare, zuerst mit SameSite=None, dann mit Lax:

foreach (var sameSite in new[] { "None", "Lax" })
{
var shop = new Shop(sameSite);
var browser = new Browser();
browser.Store(shop.Login("maria", null));
shop.Addresses["maria"] = "Bahnhofstraße 15";
Console.WriteLine($"SameSite={sameSite}");
// eine Seite einer fremden Website schickt ihr Formular an den Shop
Show(shop, browser.Send("free-gifts.example", "POST", "/account/address",
new() { ["address"] = "Schwindelgasse 13" }));
// das Formular des Shops selbst
Show(shop, browser.Send("shop.example", "POST", "/account/address",
new() { ["address"] = "Gartenweg 7" }));
}
Ausgabe
SameSite=None
free-gifts.example → 200, Adresse: Schwindelgasse 13
shop.example → 200, Adresse: Gartenweg 7
SameSite=Lax
free-gifts.example → 401, Adresse: Bahnhofstraße 15
shop.example → 200, Adresse: Gartenweg 7

Mit Lax bekommt das andere Formular 401, und das Formular des Shops selbst geht durch: Es kommt von derselben Website.

Probier es aus

Sage die Ausgabe voraus

Wann geht das Cookie mit? Was gibt das aus?

var lax = new Cookie("session", "…", "Lax");
var strict = new Cookie("session", "…", "Strict");
Console.WriteLine(Browser.Attach(lax, "free-gifts.example", "GET"));
Console.WriteLine(Browser.Attach(lax, "free-gifts.example", "POST"));
Console.WriteLine(Browser.Attach(strict, "free-gifts.example", "GET"));
Console.WriteLine(Browser.Attach(strict, "shop.example", "POST"));
Antwort

Die Idee

Das Token, das die andere Seite nicht lesen kann

SameSite ist eine Schutzmaßnahme des Browsers. Sie ist gut, aber allein nicht genug: Alte Browser, Subdomains derselben Website und Handler, die bei GET etwas ändern, umgehen sie. Also hat der Shop auch eine eigene Schutzmaßnahme: ein CSRF-Token.

Bei der Anmeldung bekommt die Sitzung einen weiteren Zufallswert, das Token. Die Seite des Shops legt es in ein verstecktes Feld ihres Formulars. Der Handler akzeptiert die Anfrage nur, wenn das Token im Formular zu dem in der Sitzung passt.

Die andere Seite kann eine Anfrage an den Shop schicken, aber die Antwort nicht lesen: Der Browser lässt es nicht zu (das ist die Same-Origin-Policy). Sie kennt das Token also nicht und kann es nicht ins Formular legen.

public Cookie Login(string user, string? oldId)
{
if (oldId is not null) sessions.Remove(oldId);
var id = NewId();
sessions[id] = new Session(user, NewId()); // und ein CSRF-Token für die Formulare
return new Cookie("session", id, sameSite);
}
public Session? Find(Request request) =>
request.Cookies.TryGetValue("session", out var id)
? sessions.GetValueOrDefault(id)
: null;
// die Seite des Shops legt das Token in ein verstecktes Feld
public string FormToken(Request request) => Find(request)!.Csrf;
public int ChangeAddress(Request request)
{
var s = Find(request);
if (s is null) return 401; // keine Sitzung
var sent = request.Form.GetValueOrDefault("csrf", "");
if (!Same(sent, s.Csrf)) return 403;
Addresses[s.User] = request.Form["address"];
return 200;
}
Das Token wird mit einer Funktion verglichen, die alle Bytes ansieht, ohne beim ersten Unterschied anzuhalten. Sonst verrät die Antwortzeit, wie viel vom Anfang stimmt.
static bool Same(string a, string b) =>
// vergleicht alle Bytes, ohne beim ersten Unterschied anzuhalten
CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b));

Auch mit SameSite=None wird das andere Formular aufgehalten:

var shop = new Shop("None");
var browser = new Browser();
browser.Store(shop.Login("maria", null));
shop.Addresses["maria"] = "Bahnhofstraße 15";
Console.WriteLine("SameSite=None + CSRF");
Show(shop, browser.Send("free-gifts.example", "POST", "/account/address",
new() { ["address"] = "Schwindelgasse 13" }));
// die fremde Website kennt das Token nicht und rät
Show(shop, browser.Send("free-gifts.example", "POST", "/account/address",
new() { ["address"] = "Schwindelgasse 13", ["csrf"] = "123456" }));
// die Seite des Shops legt das Token in ein verstecktes Feld
var token = shop.FormToken(browser.Send("shop.example", "GET", "/account"));
Show(shop, browser.Send("shop.example", "POST", "/account/address",
new() { ["address"] = "Gartenweg 7", ["csrf"] = token }));
Ausgabe
SameSite=None + CSRF
free-gifts.example → 403, Adresse: Bahnhofstraße 15
free-gifts.example → 403, Adresse: Bahnhofstraße 15
shop.example → 200, Adresse: Gartenweg 7

Probier es aus

Ergänze die Lücke

Welche Funktion vergleicht die Tokens, ohne beim ersten Unterschied anzuhalten?

CryptographicOperations.(Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b));

Probier es aus

Finde den Fehler

Ein Kollege fügt ein CSRF-Token hinzu, und die Tests laufen durch: Ein falsches Token ergibt 403. Eine Woche später werden Adressen wieder von einer anderen Website aus geändert. In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Genauer, für Neugierige

Session Fixation. Der Angreifer holt sich vom Shop eine Id (zum Beispiel als Gast) und schiebt sie Maria unter: in einem Link, in einem Cookie einer Subdomain. Behält der Shop nach ihrer Anmeldung dieselbe Id, ist der Angreifer als Maria angemeldet. Deshalb wird bei der Anmeldung die alte Id gelöscht und eine neue vergeben, wie es login in der Lektion macht.

Abmelden. Beim Abmelden wird die Sitzung auf dem Server gelöscht, nicht nur das Cookie im Browser. Sonst funktioniert eine gestohlene Id nach dem Abmelden weiter.

Lax, genauer. Lax lässt das Cookie von einer anderen Website nur bei einer Navigation der ganzen Seite mit einer „sicheren“ Methode (GET, HEAD) mitgehen: wenn Maria auf einen Link zum Shop klickt. Versteckte Anfragen mit fetch oder in einem <iframe> bekommen es nicht. Deshalb darf GET nie etwas ändern: Sonst reicht ein Link für den Angriff. Hat das Cookie kein SameSite, behandeln Chrome und Edge es als Lax, Firefox und Safari aber nicht immer. Also wird das Attribut ausdrücklich geschrieben.

Origin prüfen. Der Browser schickt die Header Origin und Sec-Fetch-Site, die sagen, woher die Anfrage kommt, und eine Seite einer anderen Website kann sie nicht fälschen. Sie zu prüfen ist eine gute dritte Schicht.

In einem echten Projekt macht das Framework das Token: Spring Security (standardmäßig an), ASP.NET Core antiforgery ([ValidateAntiForgeryToken]), Django (CsrfViewMiddleware), und in Express eine Bibliothek wie csrf-csrf. Auch Sitzungen gibt es fertig: HttpSession in Java, ISession in ASP.NET Core, die Sitzungen von Django und Flask, express-session. Das Präfix __Host- im Namen des Cookies (__Host-session) lässt den Browser Secure, Path=/ und kein Domain verlangen.

HTTPS (die Zertifikate und die Einrichtung des Servers) gehört zur Infrastruktur und wird im DevOps-Kurs ZeroToOps behandelt.

Übung

Eine Bestellung stornieren

Löse die Aufgabe in deinem eigenen Editor.

Der Shop hat auch ein Formular „Bestellung stornieren“: POST /orders/cancel mit einem Feld order. Schreibe den Handler CancelOrder: Ohne Sitzung gibt er 401 zurück, ohne korrektes CSRF-Token 403, sonst markiert er die Bestellung als storniert und gibt 200 zurück. Probiere ihn mit drei Anfragen aus, mit dem Cookie SameSite=None: von der anderen Website mit Marias Cookie, aber ohne Token; von einem Browser, der nicht angemeldet ist; von der Seite des Shops mit dem Token.

Ausgabe
fremde Website: 403, Bestellung 1042: aktiv
nicht angemeldet: 401, Bestellung 1042: aktiv
der Shop: 200, Bestellung 1042: storniert

Weitere Aufgaben

  • Ein neues Token. Gib nach jeder erfolgreichen Adressänderung ein neues CSRF-Token aus. Was passiert, wenn Maria das Formular in zwei Tabs offen hat?
  • Origin. Füge der Anfrage ein Feld origin hinzu und lehne jede Anfrage ab, die etwas ändert und nicht von shop.example kommt.

Test

Teste dich

1. Warum hat das Sitzungs-Cookie HttpOnly?
2. Was nutzt ein CSRF-Angriff aus?
3. Warum hält das CSRF-Token das andere Formular auf, obwohl das Cookie weiter mitgeht?
4. Warum gibt der Shop bei jeder Anmeldung eine neue Sitzungs-Id?
5. Warum wird das Token mit MessageDigest.isEqual / FixedTimeEquals / compare_digest / timingSafeEqual verglichen und nicht mit equals oder ==?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·