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
--- 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 Cookie zurücksendet, oder None, wenn die Antwort kein Cookie setzt.
Der Test hat vier Schritte:
Bau mit
reading_server.sample_state()einen Zustand auf.Der Angreifer: Sende
POST /prefsmit den Daten{"page_size": 5}und behaltesteps.cookie_pair(...)der Antwort. Das ist die ID, die der Angreifer kennt.Das Opfer: Sende
POST /loginfü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.Wieder der Angreifer: Sende
GET /memit demselben Cookie und sichere zu, dass der BenutzerNoneist.
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 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
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
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 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
Schreib in
tests/test_attacks.pytest_attack_fixed_session_is_not_promoted()und sieh zu, wie er fehlschlägt.Schreib in
reading_server.pyrotate_session(state, old_id, user).Ersetze im Anmeldezweig die vier vorgegebenen Zeilen durch
session_id = rotate_session(state, session_id_from(request), user).Sorg am Ende von
handle()dafür, dass die Regel fürno-store/prefsund jede Antwort mit einem Headerset-cookieabdeckt.Fülle die Zeile F5 von
SECURITY.mdaus: 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
Schreib in
tests/test_attacks.pytest_attack_fixed_session_is_not_promoted(). Bau mitreading_server.sample_state()einen Zustand auf. SendePOST /prefsmit den Daten{"page_size": 5}und behaltesteps.cookie_pair(...)der Antwort: Das ist die ID, die der Angreifer kennt. SendePOST /loginfür ana mit ihrem Passwort und diesemin einem Header Cookieund sichere den Status 200 zu. SendeGET /memit demselben Cookie und sichere zu, dasshelpers.json_of(...)["user"]Noneist. Führpython -m pytest -qaus: Dieser Test muss fehlschlagen.Schreib in
reading_server.pyrotate_session(state, old_id, user). Entferne den Eintrag, denold_idnennt, ausstate["sessions"], falls es einen gibt;old_idkannNonesein oder eine ID, die der Server nie ausgegeben hat. Starte die neue Session mitstart_session(state, user, page_size=...)und übergib die Seitengröße der alten Session, oderDEFAULT_PAGE_SIZE, wenn es keine alte Session gab. Gib die neue ID zurück.Ersetze im Anmeldezweig die vier vorgegebenen Zeilen durch
session_id = rotate_session(state, session_id_from(request), user).Sorg am Ende von
handle()dafür, dass die Regel fürno-store/prefsund jede Antwort mit einem Headerset-cookieabdeckt.Fülle die Zeile F5 von
SECURITY.mdaus, nenne darin deinen Test und setz ihren Status aufclosed 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.