0%

Sessies die standhouden · oefening

Een nieuwe id bij de login

Les 1 volgde de fixatieaanval op papier. Voer hem uit tegen je server:

python pl_sec.py fixation

Het hulpprogramma start je server op voor vier verbindingen en speelt beide personen. Het stuurt vier verzoeken. De veranderen bij elke run, dus ze zijn hier ingekort:

--- 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

Het hulpprogramma laat de kant van de server van de aanval zien. De stap waarin de id de browser van het slachtoffer bereikt, is één regel tekst, (pl_sec put it there), omdat niets hier een browser is.

Schrijf eerst de aanval

De aanval wordt een test voordat je iets repareert. Open tests/steps.py, dat nieuw is in dit hoofdstuk. Het bevat stappen die een bezoeker zet, één keer opgeschreven zodat je tests kort blijven. Deze les heeft er één van nodig: steps.cookie_pair(response) geeft de tekst name=value terug van de die een antwoord zet, en dat is precies wat een client terugstuurt in zijn header Cookie, of None als het antwoord geen cookie zet.

De test heeft vier stappen:

  1. Bouw een state met reading_server.sample_state().

  2. De aanvaller: stuur POST /prefs met de data {"page_size": 5} en bewaar steps.cookie_pair(...) van het antwoord. Dat is de id die de aanvaller kent.

  3. Het slachtoffer: stuur POST /login voor ana met haar wachtwoord en die cookie, en controleer dat de status 200 is. Dit is de vooraf. Zonder die voorwaarde zou een server die elke login weigert de test doorstaan, en zou hij niets gesloten hebben.

  4. Weer de aanvaller: stuur GET /me met dezelfde cookie en controleer dat de gebruiker None is.

Het loginverzoek draagt twee headers, de inloggegevens en de geplante cookie:

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

Voer de tests uit:

python -m pytest -q

De run eindigt met 1 failed, 8 passed, en de falende regel luidt AssertionError: assert 'ana' is None. Die mislukking is bevinding F5.

Een nieuwe id, en bijna niets meegenomen

rotate_session(state, old_id, user) doet drie dingen. Hij verwijdert de vermelding die old_id aanduidt uit state["sessions"]. Hij start een nieuwe sessie voor user met start_session, die een nieuwe id maakt met secrets.token_urlsafe(16). En hij neemt één mee, de paginagrootte van de bezoeker, door die als page_size=... mee te geven.

Verder kopieert hij niets. Wat een bezoekerssessie ook bevat, het is er vóór de login in gezet, door wie de id ook had, en dat kan de aanvaller zijn. Vandaag heeft een sessie twee velden, dus alles kopiëren zou geen kwaad doen. Latere hoofdstukken voegen velden toe. Een die velden op naam kopieert, is een allowlist: een nieuw veld blijft achter tot iemand besluit dat het veilig is om mee te nemen. Een functie die het hele record kopieert, neemt het volgende veld ook mee, en niemand heeft dan iets besloten.

Waarom kopieert rotate_session page_size en verder niets uit de oude sessie?

Drie aanroepers

De logintak roept rotate_session(state, session_id_from(request), user) aan, en session_id_from kan drie soorten waarden teruggeven:

  • None, voor een login zonder cookie.

  • Een id die de server nooit heeft uitgegeven, omdat een client elke tekst als cookie kan sturen.

  • Een id die een levende sessie aanduidt, van een bezoeker of van een eerdere login.

Alle drie moeten op dezelfde manier eindigen: één nieuwe vermelding voor de gebruiker, en geen vermelding onder de oude id. state["sessions"].pop(old_id, None) geeft de verwijderde sessie terug, of None als geen sessie die id heeft, dus één aanroep dekt de laatste twee gevallen. Als er geen oude sessie was, krijgt de nieuwe DEFAULT_PAGE_SIZE.

Een cache mag geen Set-Cookie bewaren

Een header Set-Cookie houdt een cache niet tegen om een antwoord op te slaan. Een gedeelde cache die er een opsloeg, zou dezelfde sessie-id aan elke volgende bezoeker geven. De standaarden houden een cache die zich eraan houdt al weg van de login van deze dienst, die een POST met een header Authorization is. De regel hieronder is voor elk antwoord dat ze een cache wel zouden laten bewaren, en voor caches die zo zijn ingesteld dat ze meer bewaren dan zou moeten.

De regel: een antwoord dat een cookie zet, zegt Cache-Control: no-store, en dat doet ook elke sessieroute. Het eind van handle() heeft er een deel van, uit with Python:

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

/prefs ontbreekt. Die kwam mee met de bezoekerssessie, hij zet een cookie, en niemand heeft hem aan de regel toegevoegd. Voeg hem toe aan de , en voeg een tweede toe die no-store zet zodra "set-cookie" in response["headers"] staat. De tweede regel dekt een route die iemand volgend jaar toevoegt en vergeet op te nemen.

Een andere dienst start een bezoekerssessie bij GET /, met een header Set-Cookie en zonder Cache-Control. Een gedeelde cache ervoor slaat dat antwoord op en serveert het aan de volgende bezoeker. Wat krijgt die bezoeker?

Je opdracht

  1. Schrijf in tests/test_attacks.py test_attack_fixed_session_is_not_promoted() en zie hem falen.

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

  3. Vervang in de logintak de vier meegeleverde regels door session_id = rotate_session(state, session_id_from(request), user).

  4. Laat aan het eind van handle() de regel voor no-store gelden voor /prefs en voor elk antwoord met een header set-cookie.

  5. Vul rij F5 van SECURITY.md: wat er blootgesteld is, de aanval, de oplossing, en de naam van je test.

Voer python -m pytest -q opnieuw uit. Het eindigt nu met 9 passed. Voer daarna python pl_sec.py fixation nog eens uit. Het login-antwoord zet een andere cookie dan die de aanvaller kende, de GET /me van de aanvaller antwoordt {"user": null, "page_size": 20}, die van ana antwoordt {"user": "ana", "page_size": 5}, en de resultaatregel luidt the id the attacker knew now answers as: nobody.

Een bevinding is gesloten als haar test slaagt, dus zet de status van F5 op closed in 4.2.

Klik daarna op Check my work. Een login is nu veilig op het moment dat hij gebeurt. Na dat moment leeft de sessie zo lang als de server draait, en dat verandert de volgende les.

Opdracht

Schrijf de fixatieaanval als test, en geef daarna elke login een nieuwe .

  1. Schrijf in tests/test_attacks.py test_attack_fixed_session_is_not_promoted(). Bouw een state met reading_server.sample_state(). Stuur POST /prefs met de data {"page_size": 5} en bewaar steps.cookie_pair(...) van het antwoord: dat is de id die de aanvaller kent. Stuur POST /login voor ana met haar wachtwoord en die in een header Cookie, en controleer status 200. Stuur GET /me met dezelfde cookie en controleer dat helpers.json_of(...)["user"] None is. Voer python -m pytest -q uit: deze test moet falen.

  2. Schrijf in reading_server.py rotate_session(state, old_id, user). Verwijder de vermelding die old_id aanduidt uit state["sessions"] als die er is; old_id kan None zijn of een id die de server nooit heeft uitgegeven. Start de nieuwe sessie met start_session(state, user, page_size=...), met de paginagrootte van de oude sessie, of DEFAULT_PAGE_SIZE als er geen oude sessie was. Geef de nieuwe id terug.

  3. Vervang in de logintak de vier meegeleverde regels door session_id = rotate_session(state, session_id_from(request), user).

  4. Laat aan het eind van handle() de regel voor no-store gelden voor /prefs en voor elk antwoord met een header set-cookie.

  5. Vul rij F5 van SECURITY.md, noem je test erin, en zet de status op closed in 4.2 zodra de test slaagt.

Voer python -m pytest -q uit in de tot het eindigt met 9 passed, en python pl_sec.py fixation om de aanval te zien mislukken.

Klik daarna op Check my work. Die roept rotate_session aan met een eigen state: een bezoekerssessie met een paginagrootte en één extra sleutel, geen oude sessie, en een id die de server nooit heeft uitgegeven. De oude vermelding moet weg zijn, de nieuwe sessie moet de gebruiker en de paginagrootte bevatten en verder niets uit de oude, en de nieuwe id moet uit secrets komen met 16 bytes of meer; een rotate_session die de id zelf maakt met secrets.token_hex(16) wordt geaccepteerd. Daarna roept hij handle() aan met gebruikers die hij je niet heeft laten zien: POST /prefs, een login die de cookie van de bezoeker stuurt, en GET /me met de oude en de nieuwe cookie, en hij leest Cache-Control bij POST /prefs en bij de login. Hij voert je test drie keer uit, elke keer tegen een server die hij op de plaats van reading_server.handle en reading_server.sample_state zet: een die bij de login een nieuwe id uitgeeft, waar je test moet slagen; een die de id houdt, waar hij moet falen; en een die elke login weigert, waar hij moet falen. Als laatste leest hij rij F5 van SECURITY.md: de cellen Exposed, Attack en Fix hebben tekst nodig, de cel Test heeft de naam van je test nodig, en de status moet beginnen met closed. De formulering van de rij wordt niet beoordeeld. Niets uit je terminal wordt beoordeeld.