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
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 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
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 /v0/books, en dat geldt ook voor elke doorverwijzing die je later toevoegt.
redirect() werpt nu een 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:
Bouw de state met
reading_server.sample_state(), en de headers met{"Authorization": helpers.basic("ana", "correct horse")}.Log in met het doel
/login?next=/booksen controleer met een assert dat de status 303 is. Dit laat zien dat een schonenextwerkt.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 tekstwritten-by-the-attackerbevat.
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:
Schrijf
header_value(text).Schrijf in
redirect()header_value(location)in de header.Vang in
login_responsedeValueErrorop 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=/booksviareading_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 inresponse["headers"]dat degeen "\r", geen"\n"en niet de tekstwritten-by-the-attackerbevat.
Voer python -m pytest -q uit in de
In reading_server.py:
header_value(text)werptValueError("a header value must be one line")op alstext"\r","\n"of"\x00"bevat, en geeft anderstextonveranderd terug. Hij haalt nooit een teken weg en vervangt er nooit een.redirect(status, location)schrijftheader_value(location)in de headerlocation.login_response(request, user)vangt deop en geeft json_error(400, "next must be one line")terug. De loginbranch vanroute()blijft zoals hij is: die start alleen eenals 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 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.