0%

Sessions, die halten · Übung

Eine neue ID bei der Anmeldung

Lektion 1 hat den Fixierungsangriff auf Papier nachvollzogen. Führ ihn gegen deinen Server aus:

python pl_sec.py fixation

Das Werkzeug startet deinen Server auf für vier Verbindungen und spielt beide Personen. Es sendet vier Anfragen. Die ändern sich bei jedem Lauf, deshalb sind sie hier gekürzt:

--- the attacker gets a session without logging in ---
the attacker saves a preference     POST /prefs   -> 200
the attacker now knows this cookie: session=<22 characters>
--- the victim logs in ---
the victim's browser now holds the attacker's cookie (pl_sec put it there)
ana logs in, sending that cookie    POST /login   -> 200
the login reply set this cookie:    session=<the same 22 characters>
--- who is who now ---
the attacker, with the cookie from before the login  GET /me -> 200  {"user": "ana", "page_size": 5}
ana, with the cookie her login reply set             GET /me -> 200  {"user": "ana", "page_size": 5}
--- result ---
the id the attacker knew now answers as: ana

Das Werkzeug zeigt die Serverseite des Angriffs. Der Schritt, in dem die ID in den Browser des Opfers gelangt, ist eine Textzeile, (pl_sec put it there), weil hier nichts ein Browser ist.

Schreib zuerst den Angriff

Der Angriff wird zu einem Test, bevor du irgendetwas behebst. Öffne tests/steps.py, das in diesem Kapitel neu ist. Es enthält Schritte, die ein Besucher macht, einmal geschrieben, damit deine Tests kurz bleiben. Diese Lektion braucht einen davon: steps.cookie_pair(response) gibt den Text name=value des zurück, das eine Antwort setzt, also genau das, was ein Client in seinem Header Cookie zurücksendet, oder None, wenn die Antwort kein Cookie setzt.

Der Test hat vier Schritte:

  1. Bau mit reading_server.sample_state() einen Zustand auf.

  2. Der Angreifer: Sende POST /prefs mit den Daten {"page_size": 5} und behalte steps.cookie_pair(...) der Antwort. Das ist die ID, die der Angreifer kennt.

  3. Das Opfer: Sende POST /login für ana mit ihrem Passwort und diesem Cookie und sichere zu, dass der Status 200 ist. Das ist die Vorbedingung. Ohne sie würde ein Server, der jede Anmeldung ablehnt, den Test bestehen und hätte nichts geschlossen.

  4. Wieder der Angreifer: Sende GET /me mit demselben Cookie und sichere zu, dass der Benutzer None ist.

Die Anmeldeanfrage trägt zwei Header, die Zugangsdaten und das untergeschobene Cookie:

headers = {"Authorization": helpers.basic("ana", "correct horse"), "Cookie": planted}

Führ die Tests aus:

python -m pytest -q

Der Lauf endet mit 1 failed, 8 passed, und die fehlschlagende Zeile lautet AssertionError: assert 'ana' is None. Dieser Fehlschlag ist Befund F5.

Eine neue ID, und fast nichts wird übernommen

rotate_session(state, old_id, user) tut drei Dinge. Es entfernt den Eintrag, den old_id nennt, aus state["sessions"]. Es startet mit start_session eine neue Session für user, das mit secrets.token_urlsafe(16) eine neue ID erzeugt. Und es übernimmt einen , die Seitengröße des Besuchers, indem es sie als page_size=... übergibt.

Es kopiert nichts anderes. Was auch immer eine Besucher-Session enthält, wurde vor der Anmeldung hineingeschrieben, von dem, der die ID hatte, und das kann der Angreifer sein. Heute hat eine Session zwei Felder, also würde es nicht schaden, alles zu kopieren. Spätere Kapitel fügen Felder hinzu. Eine , die benannte Felder kopiert, ist eine Allowlist: Ein neues Feld bleibt zurück, bis jemand entscheidet, dass es sicher übernommen werden kann. Eine Funktion, die den ganzen Datensatz kopiert, übernimmt auch das nächste Feld, und niemand hat irgendetwas entschieden.

Warum kopiert rotate_session page_size und sonst nichts aus der alten Session?

Drei Aufrufer

Der Anmeldezweig ruft rotate_session(state, session_id_from(request), user) auf, und session_id_from kann drei Arten von Werten zurückgeben:

  • None, bei einer Anmeldung ohne Cookie.

  • Eine ID, die der Server nie ausgegeben hat, weil ein Client beliebigen Text als Cookie senden kann.

  • Eine ID, die eine aktive Session nennt, die eines Besuchers oder die einer früheren Anmeldung.

Alle drei müssen gleich enden: ein neuer Eintrag für den Benutzer und kein Eintrag unter der alten ID. state["sessions"].pop(old_id, None) gibt die entfernte Session zurück, oder None, wenn keine Session diese ID hat, also deckt ein Aufruf die letzten beiden Fälle ab. Wenn es keine alte Session gab, bekommt die neue DEFAULT_PAGE_SIZE.

Ein Cache darf kein Set-Cookie behalten

Ein Header Set-Cookie hält einen Cache nicht davon ab, eine Antwort zu speichern. Ein gemeinsamer Cache, der eine gespeichert hat, würde jedem späteren Besucher dieselbe Session-ID geben. Die Standards halten einen konformen Cache bereits von der Anmeldung dieses Dienstes fern, die ein POST mit einem Header Authorization ist. Die Regel unten gilt für jede Antwort, die sie einen Cache behalten ließen, und für Caches, die so eingerichtet sind, dass sie mehr behalten, als sie sollten.

Die Regel: Eine Antwort, die ein Cookie setzt, sagt Cache-Control: no-store, und das tut auch jede Session-Route. Das Ende von handle() enthält einen Teil davon, aus with Python:

    if segments == ["login"] or segments == ["logout"] or segments == ["me"]:
        response["headers"]["cache-control"] = "no-store"

/prefs fehlt. Es kam mit der Besucher-Session, es setzt ein Cookie, und niemand hat es zur Regel hinzugefügt. Füge es der hinzu und ergänze ein zweites , das no-store setzt, wann immer "set-cookie" in response["headers"] steht. Die zweite Regel deckt eine Route ab, die jemand nächstes Jahr hinzufügt und aufzulisten vergisst.

Ein anderer Dienst startet bei GET / eine Besucher-Session, mit einem Header Set-Cookie und ohne Cache-Control. Ein gemeinsamer Cache davor speichert diese Antwort und liefert sie dem nächsten Besucher aus. Was bekommt dieser Besucher?

Deine Aufgabe

  1. Schreib in tests/test_attacks.py test_attack_fixed_session_is_not_promoted() und sieh zu, wie er fehlschlägt.

  2. Schreib in reading_server.py rotate_session(state, old_id, user).

  3. Ersetze im Anmeldezweig die vier vorgegebenen Zeilen durch session_id = rotate_session(state, session_id_from(request), user).

  4. Sorg am Ende von handle() dafür, dass die Regel für no-store /prefs und jede Antwort mit einem Header set-cookie abdeckt.

  5. Fülle die Zeile F5 von SECURITY.md aus: was offenliegt, den Angriff, die Behebung und den Namen deines Tests.

Führ python -m pytest -q noch einmal aus. Es endet jetzt mit 9 passed. Führ dann noch einmal python pl_sec.py fixation aus. Die Antwort auf die Anmeldung setzt ein anderes Cookie als das, das der Angreifer kannte, das GET /me des Angreifers antwortet {"user": null, "page_size": 20}, das von ana antwortet {"user": "ana", "page_size": 5}, und die Ergebniszeile lautet the id the attacker knew now answers as: nobody.

Ein Befund ist geschlossen, wenn sein Test durchläuft, also setz den Status von F5 auf closed in 4.2.

Klicke dann auf Check my work. Eine Anmeldung ist jetzt in dem Moment sicher, in dem sie geschieht. Nach diesem Moment lebt die Session, solange der Server läuft, und das ändert die nächste Lektion.

Aufgabe

Schreib den Fixierungsangriff als Test und gib dann jeder Anmeldung eine neue .

  1. Schreib in tests/test_attacks.py test_attack_fixed_session_is_not_promoted(). Bau mit reading_server.sample_state() einen Zustand auf. Sende POST /prefs mit den Daten {"page_size": 5} und behalte steps.cookie_pair(...) der Antwort: Das ist die ID, die der Angreifer kennt. Sende POST /login für ana mit ihrem Passwort und diesem in einem Header Cookie und sichere den Status 200 zu. Sende GET /me mit demselben Cookie und sichere zu, dass helpers.json_of(...)["user"] None ist. Führ python -m pytest -q aus: Dieser Test muss fehlschlagen.

  2. Schreib in reading_server.py rotate_session(state, old_id, user). Entferne den Eintrag, den old_id nennt, aus state["sessions"], falls es einen gibt; old_id kann None sein oder eine ID, die der Server nie ausgegeben hat. Starte die neue Session mit start_session(state, user, page_size=...) und übergib die Seitengröße der alten Session, oder DEFAULT_PAGE_SIZE, wenn es keine alte Session gab. Gib die neue ID zurück.

  3. Ersetze im Anmeldezweig die vier vorgegebenen Zeilen durch session_id = rotate_session(state, session_id_from(request), user).

  4. Sorg am Ende von handle() dafür, dass die Regel für no-store /prefs und jede Antwort mit einem Header set-cookie abdeckt.

  5. Fülle die Zeile F5 von SECURITY.md aus, nenne darin deinen Test und setz ihren Status auf closed in 4.2, sobald der Test durchläuft.

Führ im python -m pytest -q aus, bis es mit 9 passed endet, und python pl_sec.py fixation, um zuzusehen, wie der Angriff scheitert.

Klicke dann auf Check my work. Es ruft rotate_session mit einem eigenen Zustand auf: einer Besucher-Session mit einer Seitengröße und einem zusätzlichen Schlüssel, ohne alte Session und mit einer ID, die der Server nie ausgegeben hat. Der alte Eintrag muss weg sein, die neue Session muss den Benutzer und die Seitengröße enthalten und sonst nichts aus der alten, und die neue ID muss aus secrets kommen, mit 16 Bytes oder mehr; ein rotate_session, das die ID selbst mit secrets.token_hex(16) erzeugt, wird akzeptiert. Dann ruft es handle() mit Benutzern auf, die es dir nicht gezeigt hat: POST /prefs, eine Anmeldung, die das Cookie des Besuchers sendet, und GET /me mit dem alten und dem neuen Cookie, und es liest Cache-Control bei POST /prefs und bei der Anmeldung. Es führt deinen Test dreimal aus, jedes Mal gegen einen Server, den es an die Stelle von reading_server.handle und reading_server.sample_state setzt: einen, der bei der Anmeldung eine neue ID ausgibt, wo dein Test durchlaufen muss; einen, der die ID behält, wo er fehlschlagen muss; und einen, der jede Anmeldung ablehnt, wo er fehlschlagen muss. Zuletzt liest es die Zeile F5 von SECURITY.md: Die Zellen Exposed, Attack und Fix brauchen Text, die Zelle Test braucht den Namen deines Tests, und der Status muss mit closed beginnen. Die Formulierung der Zeile wird nicht bewertet. Nichts aus deinem Terminal wird bewertet.