Eingaben, die zu Code werden · Übung
Halte einen Zeilenumbruch aus einem Header heraus
Ein Login muss den Leser oft weiterschicken: zurück zu der Seite, die er wollte, bevor er zum Anmelden aufgefordert wurde. Deine Leseliste kann das, seit dieses Kapitel begonnen hat. POST /login?next=/books beantwortet ein korrektes Login mit 303 und einem location-Header. Die
def login_response(request, user):
"""Return the answer to a correct login: 303 to next when the target names one, or 200."""
params = parse_qs(urlsplit(request["target"]).query)
if "next" in params:
return redirect(303, params["next"][0])
return json_response(200, {"user": user})
Sie nimmt Text aus der Anfrage und schreibt ihn in einen Header. Lektion 3 hat das eine Stelle genannt, an der Daten in Text übergehen, der geparst wird. Sieh zu, wie es auf deinem eigenen Server passiert:
python pl_sec.py injection
Das Szenario hat vier Teile, einen für jede Stelle, die dieses Kapitel absichert. In dieser Lektion geht es um den ersten:
--- a header: ana logs in, and next carries an encoded line break ---
POST /login?next=/%0d%0aSet-Cookie:%20note=written-by-the-attacker -> 303
the head of the reply, line by line:
location: /
Set-Cookie: note=written-by-the-attacker
content-length: 0
set-cookie: __Host-session=4AxVgGU18_wz0Copyj-ihA; HttpOnly; Max-Age=28800; Path=/; SameSite=Lax; Secure
cache-control: no-store
the request wrote a line of your reply: Set-Cookie: note=written-by-the-attacker
Deine note. Die Ergebniszeile sagt 4 of 4 attacks worked, und die anderen drei Teile warten auf die nächsten Lektionen.
Wie die Zeile dorthin kam
Verfolge den
Das Anfrageziel enthält %0d%0a, sechs harmlose Zeichen. parse_qs dekodiert die Prozentkodierung von Query-Werten, wie es soll, und %0d%0a wird zu einem Wagenrücklauf und einem Zeilenvorschub, kurz CR und LF. Also ist params["next"][0] der Text /, ein echter Zeilenumbruch und dann Set-Cookie: note=written-by-the-attacker.
redirect() steckt diesen Text unverändert in den location-Header. http_adapter schreibt jeden Header als seinen Namen, einen Doppelpunkt, den Wert und einen Zeilenumbruch, und es schreibt den Wert so, wie er übergeben wurde.
Ein Client beendet einen Header am Zeilenumbruch. Er liest location: / und dann einen zweiten Header.
Ein Header ist eine Zeile, und dein Server hat Text aus einer Anfrage entscheiden lassen, wo diese Zeile endet. Das ist Header-Injection. Ein Angreifer, der jemanden dazu bringt, eine solche Anfrage zu senden, kann ein Cookie seiner Wahl in den Browser dieser Person setzen, und mit zwei Zeilenumbrüchen hintereinander kann er den Kopf beenden und einen Body schreiben. Die Session-Fixierung aus Kapitel 4 begann mit einem Cookie, das ein Angreifer gewählt hatte.
Das Anfrageziel enthält %0d%0a, keinen Zeilenumbruch. Woher kommt der Zeilenumbruch?
Ablehnen, nicht reparieren
Eine Lösung wäre, die Zeilenumbrüche zu löschen und den Rest zu behalten. Solche Reparaturen laden zu einem zweiten Trick ein. Lösche nur \n, und ein einzelnes \r bleibt stehen. Lösche das Paar \r\n, und das passiert:
repaired = "/\r\r\n\nSet-Cookie: note=x".replace("\r\n", "")
print(repaired == "/\r\nSet-Cookie: note=x")
Es gibt True aus. Das Entfernen eines Paars hat die Zeichen drumherum zu einem neuen Paar zusammengeschoben, und der reparierte Wert hat wieder einen Zeilenumbruch.
Es gibt eine einfachere Regel, und sie kostet nichts, weil ein Header-Wert mit einem Zeilenumbruch nie eine ehrliche Eingabe ist. header_value(text) löst ValueError("a header value must be one line") aus, wenn der Text \r, \n oder das NUL-Zeichen \x00 enthält, das in einem Header-Wert ebenfalls nichts zu suchen hat. Andernfalls gibt es den Text unverändert zurück.
Warum löst header_value etwas aus, statt die Zeilenumbrüche zu löschen?
Schütze die Stelle, die schreibt
Die Prüfung könnte in login_response stehen, neben dem Wert, der das Problem verursacht hat. Lektion 3 hat die bessere Regel geliefert: Die Verteidigung gehört dorthin, wo der Text verwendet wird. redirect() ist die eine Funktion, die Text in einen location-Header verwandelt, also schreibt sie header_value(location). Dann durchläuft jede /v0/books-Pfade, und genauso jede Weiterleitung, die du später hinzufügst.
redirect() löst jetzt für einen solchen Wert etwas aus, und login_response muss antworten. Es fängt den json_error(400, "next must be one line") zurück.
Ein mitgeliefertes Detail macht diese Ablehnung sauber. Der Login-Zweig von route() ruft zuerst login_response auf und startet die Session nur, wenn der Status der Antwort unter 400 liegt. Ein Login, das mit 400 abgelehnt wird, hinterlässt keine Session und setzt kein Cookie.
Warum steht die Schutzmaßnahme in redirect() und nicht nur im Login-Zweig?
Deine Aufgabe
Schreib zuerst den Angriff. Füge in tests/test_attacks.py test_attack_next_cannot_add_a_header_line hinzu:
Baue den Zustand mit
reading_server.sample_state()und die Header mit{"Authorization": helpers.basic("ana", "correct horse")}.Melde dich mit dem Ziel
/login?next=/booksan und stelle sicher, dass der Status 303 ist. Das zeigt, dass ein sauberesnextfunktioniert.Melde dich mit dem Ziel
/login?next=/%0d%0aSet-Cookie:%20note=written-by-the-attackeran. Stelle für jeden Header der Antwort sicher, dass sein Wert kein"\r", kein"\n"und nicht den Textwritten-by-the-attackerenthält.
Der Test prüft, was nie passieren darf, und nicht, welchen Status der Server wählt. Führe ihn aus:
python -m pytest -q
Er endet mit 1 failed, 22 passed, und der Fehler ist ein Wagenrücklauf, der im location-Wert gefunden wurde.
Dann, in reading_server.py:
Schreib
header_value(text).Schreib in
redirect()header_value(location)in den Header.Fang in
login_responsedenValueErrorab und antworte mit 400.
Führe die Tests noch einmal aus, 23 passed, und dann das Szenario:
--- a header: ana logs in, and next carries an encoded line break ---
POST /login?next=/%0d%0aSet-Cookie:%20note=written-by-the-attacker -> 400
the head of the reply, line by line:
content-type: application/json
content-length: 34
cache-control: no-store
the body of the reply: {"error": "next must be one line"}
no line of the reply came from the request
Die Ergebniszeile sagt jetzt 3 of 4 attacks worked. Ein falsches Passwort wird wie vorher beantwortet: mit 401, egal was next enthält.
Klicke dann auf Check my work. Lies den zweiten Teil des Szenarios, bevor du weitermachst. Ein next von //other.example/welcome ist eine saubere Zeile, also lässt header_value es durch, und die Antwort schickt den Leser auf eine andere Site. Darum geht es in der nächsten Lektion.
Aufgabe
Verhindere, dass Text aus einer Anfrage einer Antwort eine Zeile hinzufügt, und beweise es mit einem Test.
Schreib in tests/test_attacks.py unter dem Kommentar für Kapitel 7 test_attack_next_cannot_add_a_header_line:
baue den Zustand mit
reading_server.sample_state()und die Header{"Authorization": helpers.basic("ana", "correct horse")};sende
POST /login?next=/booksüberreading_server.handle(helpers.make_request("POST", target, headers=headers), state)und stelle sicher, dass der Status 303 ist;sende ein zweites Login mit dem Ziel
"/login?next=/%0d%0aSet-Cookie:%20note=written-by-the-attacker"und stelle für jeden Namen inresponse["headers"]sicher, dass derkein "\r", kein"\n"und nicht den Textwritten-by-the-attackerenthält.
Führe python -m pytest -q im
In reading_server.py:
header_value(text)löstValueError("a header value must be one line")aus, wenntext"\r","\n"oder"\x00"enthält, und gibttextsonst unverändert zurück. Es entfernt oder ersetzt nie ein Zeichen.redirect(status, location)schreibtheader_value(location)in denlocation-Header.login_response(request, user)fängt denab und gibt json_error(400, "next must be one line")zurück. Der Login-Zweig vonroute()bleibt, wie er ist: Er startet einenur, wenn der Status der Antwort unter 400 liegt.
Führe python -m pytest -q und python pl_sec.py injection noch einmal aus.
Klicke dann auf Check my work. Es ruft header_value() mit Werten auf, die du noch nicht gesehen hast: einzeilige Werte, mit Leerzeichen, einem Tab und Buchstaben mit Akzent, die unverändert zurückkommen müssen, und Werte mit einem Wagenrücklauf, einem Zeilenvorschub, beidem oder einem NUL, die ValueError auslösen müssen. Es ruft redirect() mit einem Ziel auf, das einen Zeilenumbruch enthält. Es ruft handle() mit eigenen Benutzern auf: Ein korrektes Login, dessen next einen kodierten Zeilenumbruch enthält, muss 400 bekommen, ohne Session und ohne next, ein Login ohne next, ein falsches Passwort und ein alter /v0/books-Pfad müssen wie vorher antworten. Es sendet dasselbe Login als Bytes durch http_adapter, im Speicher, und liest die Zeilen, die zurückkommen. Zuletzt führt es deinen Test über seinen Namen gegen eigene Server aus: Er muss gegen einen bestehen, der ein solches next ablehnt, und fehlschlagen gegen einen, der next so in location schreibt, wie es kam, und gegen einen, der jedes Login mit 401 beantwortet. Wenn du diese Lektion nach Lektion 5 noch einmal prüfst, wird statt der 400 auch eine 303 nach / akzeptiert. Nichts aus deinem Terminal wird bewertet.