0%

Invoer die code wordt · oefening

Houd een regeleinde uit een header

Een login moet de lezer vaak doorsturen: terug naar de pagina die hij wilde zien voordat hem werd gevraagd in te loggen. Je leeslijst kan dat sinds het begin van dit hoofdstuk. POST /login?next=/books beantwoordt een correcte login met 303 en een header location. De die het antwoord bouwt, is nieuw en kwam mee met het project van dit hoofdstuk:

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

Hij neemt tekst uit het verzoek en schrijft die in een header. Les 3 noemde dat een plek waar data overgaat in tekst die wordt geparset. Kijk hoe het gebeurt op je eigen server:

python pl_sec.py injection

Het scenario heeft vier delen, één voor elke plek die dit hoofdstuk bewaakt. Deze les gaat over het eerste:

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

Je zal anders zijn. De tweede is niet van jou. Je server heeft geen met de naam note. De resultaatregel zegt 4 of 4 attacks worked, en de andere drie delen wachten op de volgende lessen.

Hoe de regel daar kwam

Volg de door drie stappen.

Het verzoekdoel bevat %0d%0a, zes onschuldige tekens. parse_qs decodeert de procenttekens in querywaarden, zoals het hoort, en %0d%0a wordt een carriage return en een line feed, kortweg CR en LF. Dus params["next"][0] is de tekst /, een echt regeleinde, en daarna Set-Cookie: note=written-by-the-attacker.

redirect() zet die tekst zoals hij is in de header location. http_adapter schrijft elke header als zijn naam, een dubbele punt, de waarde en een regeleinde, en hij schrijft de waarde zoals hij hem krijgt.

Een client beëindigt een header bij het regeleinde. Hij leest location: /, en daarna een tweede header.

Een header is één regel, en je server liet tekst uit een verzoek bepalen waar die regel eindigt. Dit is header-injectie (header injection). Een aanvaller die iemand zo’n verzoek laat versturen, kan een cookie naar keuze van de aanvaller in de browser van die persoon zetten, en met twee regeleinden achter elkaar de kop beëindigen en een body schrijven. De sessiefixatie uit hoofdstuk 4 begon bij een cookie die een aanvaller had gekozen.

Het verzoekdoel bevat %0d%0a, geen regeleinde. Waar komt het regeleinde vandaan?

Weiger, repareer niet

Eén oplossing is de regeleinden weghalen en de rest houden. Zulke reparaties nodigen uit tot een tweede truc. Haal alleen \n weg, en een losse \r staat er nog. Haal het paar \r\n weg, en dan gebeurt dit:

repaired = "/\r\r\n\nSet-Cookie: note=x".replace("\r\n", "")
print(repaired == "/\r\nSet-Cookie: note=x")

Het drukt True af. Door één paar weg te halen, schoven de tekens eromheen tegen elkaar aan tot een nieuw paar, en de gerepareerde waarde heeft weer een regeleinde.

Er is een eenvoudigere regel, en die kost niets, want een headerwaarde met een regeleinde erin is nooit eerlijke invoer. header_value(text) werpt ValueError("a header value must be one line") op als de tekst \r, \n of het NUL-teken \x00 bevat, dat evenmin iets in een headerwaarde te zoeken heeft. Anders geeft hij de tekst onveranderd terug.

Waarom werpt header_value een exception op in plaats van de regeleinden weg te halen?

Bewaak de plek die schrijft

De controle zou in login_response kunnen, naast de waarde die het probleem veroorzaakte. Les 3 gaf de betere regel: de verdediging hoort waar de tekst wordt gebruikt. redirect() is de ene functie die tekst omzet in een header location, dus die schrijft header_value(location). Elke van de dienst gaat dan door de controle, ook die voor de oude paden /v0/books, en dat geldt ook voor elke doorverwijzing die je later toevoegt.

redirect() werpt nu een op voor zo’n waarde, en login_response moet antwoorden. Die vangt de ValueError op en geeft json_error(400, "next must be one line") terug.

Eén meegeleverd detail maakt die weigering schoon. De loginbranch van route() roept eerst login_response aan, en start de sessie alleen als de status van het antwoord onder 400 ligt. Een login die met 400 wordt geweigerd, laat geen sessie achter en zet geen cookie.

Waarom zit de bewaking in redirect() en niet alleen in de loginbranch?

Je taak

Schrijf eerst de aanval. Voeg in tests/test_attacks.py test_attack_next_cannot_add_a_header_line toe:

  1. Bouw de state met reading_server.sample_state(), en de headers met {"Authorization": helpers.basic("ana", "correct horse")}.

  2. Log in met het doel /login?next=/books en controleer met een assert dat de status 303 is. Dit laat zien dat een schone next werkt.

  3. Log in met het doel /login?next=/%0d%0aSet-Cookie:%20note=written-by-the-attacker. Controleer voor elke header van het antwoord dat zijn waarde geen "\r", geen "\n" en niet de tekst written-by-the-attacker bevat.

De test controleert wat nooit mag gebeuren, en niet welke status de server kiest. Voer hem uit:

python -m pytest -q

Hij eindigt met 1 failed, 22 passed, en de fout is een carriage return die in de waarde van location werd gevonden.

Daarna, in reading_server.py:

  1. Schrijf header_value(text).

  2. Schrijf in redirect() header_value(location) in de header.

  3. Vang in login_response de ValueError op en antwoord 400.

Voer de tests opnieuw uit, 23 passed, en daarna het scenario:

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

De resultaatregel zegt nu 3 of 4 attacks worked. Een verkeerd wachtwoord wordt beantwoord zoals eerst: 401, wat next ook bevat.

Druk daarna op Check my work. Lees het tweede deel van het scenario voordat je verdergaat. Een next van //other.example/welcome is één schone regel, dus header_value laat hem door, en het antwoord stuurt de lezer naar een andere site. Dat is de volgende les.

Opdracht

Voorkom dat tekst uit een verzoek een regel aan een antwoord toevoegt, en bewijs het met een test.

Schrijf in tests/test_attacks.py, onder het commentaar voor hoofdstuk 7, test_attack_next_cannot_add_a_header_line:

  • bouw de state met reading_server.sample_state(), en de headers {"Authorization": helpers.basic("ana", "correct horse")};

  • stuur POST /login?next=/books via reading_server.handle(helpers.make_request("POST", target, headers=headers), state) en controleer met een assert dat de status 303 is;

  • stuur een tweede login met het doel "/login?next=/%0d%0aSet-Cookie:%20note=written-by-the-attacker", en controleer voor elke naam in response["headers"] dat de geen "\r", geen "\n" en niet de tekst written-by-the-attacker bevat.

Voer python -m pytest -q uit in de : de test moet eerst falen.

In reading_server.py:

  • header_value(text) werpt ValueError("a header value must be one line") op als text "\r", "\n" of "\x00" bevat, en geeft anders text onveranderd terug. Hij haalt nooit een teken weg en vervangt er nooit een.

  • redirect(status, location) schrijft header_value(location) in de header location.

  • login_response(request, user) vangt de op en geeft json_error(400, "next must be one line") terug. De loginbranch van route() blijft zoals hij is: die start alleen een als de status van het antwoord onder 400 ligt.

Voer python -m pytest -q en python pl_sec.py injection opnieuw uit.

Druk daarna op Check my work. Het roept header_value() aan met waarden die je niet hebt gezien: waarden van één regel, met spaties, een tab en letters met accenten, die onveranderd terug moeten komen, en waarden met een carriage return, een line feed, beide, of een NUL, die ValueError moeten opwerpen. Het roept redirect() aan met een locatie die een regeleinde bevat. Het roept handle() aan met eigen gebruikers: een correcte login waarvan next een gecodeerd regeleinde bevat, moet 400 krijgen zonder sessie en zonder , en geen enkele header van het antwoord mag een regeleinde bevatten; een schone next, een login zonder next, een verkeerd wachtwoord en een oud pad /v0/books moeten antwoorden zoals eerst. Het stuurt dezelfde login als bytes door http_adapter, in het geheugen, en leest de regels die terugkomen. Als laatste draait het je test op naam tegen eigen servers: hij moet slagen tegen een die zo’n next weigert, en falen tegen een die next zoals hij binnenkwam in location schrijft en tegen een die op elke login 401 antwoordt. Als je deze les na les 5 opnieuw laat controleren, wordt een 303 naar / geaccepteerd in plaats van de 400. Niets uit je terminal wordt beoordeeld.