● CWE-352 · OWASP A01:2021

Cross-Site Request Forgery (CSRF, CWE-352): Funktionsweise und Schutz

Eine Seite auf einer fremden Website kann den Browser eines angemeldeten Nutzers dazu bringen, einen datenändernden Request an Ihre App zu senden, und der Browser hängt das Sitzungscookie automatisch an, sodass eine Route, die nur das Cookie prüft, die Aktion ausführt. Wie Synchronizer-Tokens, SameSite-Cookies und Sec-Fetch-Site-Prüfungen das verhindern, mit einem Beispiel in Flask und Flask-WTF.

Einfache Erklärung (ELI5)

Ihr Büro hat eine Poststelle, die jeden unterschriebenen Auftragszettel in Ihrem Postausgangskorb erledigt, und sie vertraut den Zetteln, weil sie in Ihrem Korb liegen, an den nur Sie herankommen. Ein Besucher bittet Sie, beim Hinausgehen einen verschlossenen Umschlag in Ihren Korb zu legen. Darin steht auf einem Zettel: „Ändern Sie die Lieferadresse für alle meine Pakete.“ Die Poststelle findet ihn in Ihrem Korb, nimmt an, dass er von Ihnen stammt, und erledigt ihn. CSRF funktioniert genauso: Der Browser hängt Ihr Sitzungscookie an jeden Request an Ihre Bank, auch an einen, den eine andere Website ausgelöst hat. Die Lösung ist ein geheimer Stempel, den nur Seiten aus Ihrem eigenen Büro auf einen Zettel setzen können, damit die Poststelle Zettel ohne ihn ablehnt.

Kernkonzepte & Begriffe

Umgebungs-Zugangsdaten
Browser hängen Cookies und HTTP-Authentifizierung an Requests für eine Website, egal welche Seite sie ausgelöst hat. Ein Request von evil.example an app.example trägt weiterhin das Sitzungscookie von app.example, sofern das SameSite-Attribut des Cookies das nicht verhindert.
SameSite-Attribut des Cookies
Strict hält das Cookie bei jedem websiteübergreifenden Request zurück, Lax sendet es nur bei GET-Navigationen auf oberster Ebene, und None sendet es immer (und verlangt Secure). Chromium behandelt Cookies ohne Attribut als Lax, Firefox und Safari nicht, und „same-site“ umfasst auch benachbarte Subdomains.
Synchronizer-Token
Ein an die Sitzung gebundener Zufallswert, den der Server in jedes Formular oder jede Seite schreibt und bei jedem unsicheren Request verlangt. Eine fremde Website kann ihn nicht lesen und daher keinen gültigen Request fälschen. CSRFProtect aus Flask-WTF gibt ihn über csrf_token() aus und prüft das Feld csrf_token oder den Header X-CSRFToken.
Fetch Metadata (Sec-Fetch-Site)
Moderne Browser senden bei jedem Request Sec-Fetch-Site: cross-site, same-site, same-origin oder none, und Seiten können den Header nicht fälschen. Wer unsichere Methoden ablehnt, die nicht same-origin sind, blockiert CSRF mit einer einzigen serverseitigen Prüfung.
Zustandsänderung per GET
Eine Route, die Daten per GET ändert, lässt sich mit einem <img>-Tag oder einem Link auslösen, und SameSite=Lax sendet das Cookie bei GET-Navigationen auf oberster Ebene weiterhin mit. request.values liest neben dem Formular-Body auch den Query-String.

Schritt-für-Schritt Angriffsablauf

Schritt 1

Das Opfer ist angemeldet

Es hat ein Sitzungscookie für app.example, gesendet mit SameSite=None oder ohne Attribut in einem Browser, der nicht standardmäßig Lax verwendet.

Schritt 2

Es öffnet eine Seite unter Kontrolle des Angreifers

Sie enthält ein verstecktes Formular, das an https://app.example/account/email sendet und sich beim Laden selbst abschickt. Ein Formular mit dem harmlosen Testwert email=test@example.com zeigt den Fall.

Schritt 3

Der Browser sendet den Request samt Cookie

Der Request trägt die Sitzung des Opfers, und nichts verrät dem Server, dass das Formular auf einer anderen Website lag.

Schritt 4

Der Server ändert das Konto

Die E-Mail-Adresse gehört nun dem Angreifer, der danach das Passwort zurücksetzt und das Konto übernimmt. 2008 wiesen Zeller und Felten CSRF-Lücken dieser Art bei ING Direct, YouTube und der New York Times nach.

Quellcode: Verwundbar vs. Sicher

VERWUNDBARE IMPLEMENTIERUNG
# app.py: das Sitzungscookie ist der einzige Beleg, dass der Nutzer es wollte
import os

from flask import Flask, abort, request, session

from .db import db

app = Flask(__name__)
app.config.update(
    SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_SAMESITE="None",
)


# GET funktioniert auch, und request.values liest auch den Query-String
@app.route("/account/email", methods=["GET", "POST"])
def change_email():
    user_id = session.get("user_id")
    if user_id is None:
        abort(401)
    db.execute("UPDATE users SET email = ? WHERE id = ?", (request.values["email"], user_id))
    return {"status": "ok"}
GEHÄRTETER SICHERHEITS-PATCH
# app.py: Token, SameSite-Cookie und Fetch Metadata zusammen
import os

from flask import Flask, abort, request, session
from flask_wtf.csrf import CSRFProtect

from .db import db

app = Flask(__name__)
app.config.update(
    SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SAMESITE="Lax",
)
# jeder POST braucht csrf_token; Formulare rendern {{ csrf_token() }}, fetch() sendet X-CSRFToken
csrf = CSRFProtect(app)


# von fremden Websites ausgelöste Schreibzugriffe werden vor der Route abgelehnt
@app.before_request
def reject_cross_site_writes():
    if request.method not in ("GET", "HEAD", "OPTIONS"):
        if request.headers.get("Sec-Fetch-Site", "same-origin") not in ("same-origin", "none"):
            abort(403)


# nur POST, Daten aus dem Formular-Body
@app.post("/account/email")
def change_email():
    user_id = session.get("user_id")
    if user_id is None:
        abort(401)
    db.execute("UPDATE users SET email = ? WHERE id = ?", (request.form["email"], user_id))
    return {"status": "ok"}

Checkliste für Engineering & Systemsicherheit

Quellen

← Zum vollständigen Sicherheitsverzeichnis Alle Anleitungen zu Schwachstellen →