0%

Invoer die code wordt · oefening

Houd een bestandsnaam binnen zijn map

Een boek kan nu een cover hebben. De covers zijn drie kleine tekstbestanden in de map covers, en het project van dit hoofdstuk serveert ze op GET /covers/{name}. De branch in route() begint zo:

            cover = pathlib.Path(state["covers_dir"]) / segments[1]
            if cover.is_file():
                body = cover.read_bytes()

Hij voegt een naam uit het verzoek samen met een map en leest welk bestand hij daar ook vindt. Voer python pl_sec.py injection uit en lees het derde deel:

--- a path: a cover, and a name that climbs out of the folder ---
GET /covers/kindred.txt       -> 200  Kindred, cover notes.
GET /covers/..%2fSECURITY.md  -> 200  # Security findings: the reading list
a file that is not a cover was served: SECURITY.md, from your own project folder

Het tweede verzoek kreeg de eerste regel van SECURITY.md. Het scenario blijft met opzet binnen je projectmap, en niets daarbuiten wordt gelezen. Op een echte server grijpt hetzelfde verzoek naar een configuratiebestand of een sleutel. Een bestand lezen buiten de map die een route hoort te serveren, is .

Hoe de naam daar kwam

In een pad betekent .. de map erboven. Een gewone /covers/../SECURITY.md komt nergens: dat pad heeft drie segmenten, en de coverbranch neemt er precies twee.

Daarom verstopt de aanval de slash als %2f. path_segments() splitst het pad eerst bij /, en decodeert daarna elk segment:

def path_segments(target):
    """Return the decoded path segments of a request target."""
    segments = []
    for segment in urlsplit(target).path.split("/")[1:]:
        segments.append(unquote(segment))
    return segments

Terwijl het pad wordt gesplitst, is ..%2fSECURITY.md één segment. Gedecodeerd is het ../SECURITY.md. Eerst splitsen is juist, want dan kan een gecodeerde slash nooit veranderen welke route een verzoek neemt. De naam die binnenkomt, kan nog steeds een slash bevatten, en samengevoegd met de map staat er covers/../SECURITY.md.

Waarom bereikt /covers/..%2fSECURITY.md de coverbranch met de naam ../SECURITY.md?

Controleer het pad, niet de tekst

Het eerste idee is naar de tekst van de naam te kijken. Hier zijn drie van zulke controles, en de naam die elk ervan verslaat.

Weiger een naam die .. bevat. Een absolute naam heeft geen punten nodig, want als je hem met een map samenvoegt, wordt de map weggegooid.

Wat drukt dit af?

import pathlib

print(pathlib.PurePosixPath("/srv/covers") / "/etc/hosts")

Weiger een naam die met een slash begint. Dan is ../SECURITY.md terug.

Voeg eerst samen, en accepteer het pad als zijn tekst begint met de tekst van de map. Nu staat een map met de naam covers-old, naast covers, open: de tekst /srv/covers-old/x.txt begint met /srv/covers.

Elk van deze controles vraagt naar tekens. De echte vraag gaat over het bestandssysteem: staat dit bestand binnen die map? pathlib kan dat beantwoorden. resolve() geeft het terug, met elke .. uitgewerkt en elke link gevolgd. parents bevat elke map boven een pad. Los dus de map op, los het samengevoegde pad op, en eis dat de map in path.parents staat. Een link binnen de map die naar een bestand daarbuiten wijst, wordt ook opgelost, en geweigerd.

Een controle accepteert een pad als zijn tekst begint met de tekst van de map, /srv/covers. Welk verzoek ontsnapt?

Eén keer decoderen, op één plek

De router heeft de naam al gedecodeerd. Het is verleidelijk om in de voor de zekerheid nog een keer unquote aan te roepen. Een van de covers laat zien wat dat kost. De bestandsnaam is rep ort.txt, met een spatie, dus een client vraagt om /covers/rep%20ort.txt, en het decoderen door de router levert de naam van het bestand op. Een verzoek om /covers/rep%2520ort.txt noemt iets anders: %25 is een gecodeerd procentteken, dus de naam die binnenkomt is rep%20ort.txt, en geen enkele cover heeft die naam.

from urllib.parse import unquote

once = unquote("rep%2520ort.txt")
print(once)
print(unquote(once))
print(unquote(unquote("%252e%252e%252fSECURITY.md")))

Het drukt rep%20ort.txt af, dan rep ort.txt, dan ../SECURITY.md. De eerste regel is het resultaat van de router, de naam waar de client om vroeg. Een tweede keer decoderen maakt er rep ort.txt van, en de server antwoordt met een bestand dat de client niet noemde. Het werkt ook andersom: op een server waar de naam van een bestand een procentteken bevat, verandert een tweede keer decoderen die naam, en dan is het bestand niet meer op te halen.

De derde regel is erger. Na het decoderen door de router is %252e%252e%252f gelijk aan %2e%2e%2f: tekst zonder punt en zonder slash erin. Een controle op de tekst vindt niets verkeerds, en daarna maakt een tweede keer decoderen er ../ van. De regel is die uit les 2: controleer wat je gaat gebruiken. De naam wordt één keer gedecodeerd, door de router, en je functie gebruikt hem zoals hij binnenkomt.

safe_cover_path roept voor de zekerheid nog een keer unquote(name) aan. Wat gaat er mis?

Je taak

Schrijf safe_cover_path(folder, name) in reading_server.py. Hij geeft een pad terug, of None:

  1. Geef None terug als "\x00" in de naam staat. /covers/a%00 levert zo’n naam af, en resolve() werpt er voor op.

  2. base = pathlib.Path(folder).resolve(), en path = (base / name).resolve().

  3. Geef None terug tenzij base in path.parents.

  4. Geef None terug tenzij path.is_file(), zodat ook de naam van een map een 404 krijgt.

  5. Geef path terug.

Een bestand in een map binnen covers komt door al deze controles, en wordt geserveerd. De regel gaat over de map, niet over het aantal slashes in de naam.

Gebruik hem daarna in de coverbranch: cover = safe_cover_path(state["covers_dir"], segments[1]), en serveer het bestand als cover is not None. De map komt uit de state, zodat een test de server naar een eigen map kan laten wijzen. Dat doe je in les 8.

Vraag om de cover met de spatie, en daarna om de naam met het gecodeerde procentteken:

python -c "import sys; sys.path.insert(0, 'tests'); import helpers, reading_server; print([reading_server.handle(helpers.make_request('GET', target), reading_server.sample_state())['status'] for target in ('/covers/rep%20ort.txt', '/covers/rep%2520ort.txt')])"

Het drukt [200, 404] af. Je kunt even name = unquote(name) bovenaan je functie zetten en de regel opnieuw uitvoeren: [200, 200]. Het tweede verzoek krijgt nu een bestand waar het niet om vroeg. Haal de regel daarna weer weg.

python -m pytest -q eindigt nog steeds met 23 passed. Het derde deel van het scenario luidt nu:

--- a path: a cover, and a name that climbs out of the folder ---
GET /covers/kindred.txt       -> 200  Kindred, cover notes.
GET /covers/..%2fSECURITY.md  -> 404  {"error": "no cover has that name"}
the name outside the folder was refused

De resultaatregel zegt 1 of 4 attacks worked. Druk daarna op Check my work. De ene aanval die overblijft, zet een titel in een pagina, en de volgende les houdt die tegen.

Opdracht

Serveer een cover alleen als zijn naam naar een bestand binnen de covermap leidt.

In reading_server.py:

  • safe_cover_path(folder, name) geeft het pad van het bestand terug, of None. Hij werpt nooit een op.

    1. Geef None terug als "\x00" in name staat.

    2. Los beide op: base = pathlib.Path(folder).resolve() en path = (base / name).resolve().

    3. Geef None terug tenzij base in path.parents.

    4. Geef None terug tenzij path.is_file().

    5. Geef path terug.

  • De naam wordt gebruikt zoals hij binnenkomt. De router heeft hem één keer gedecodeerd, dus unquote wordt nergens in deze aangeroepen.

  • Een bestand in een map binnen de covermap staat nog steeds erbinnen, en wordt teruggegeven.

  • Schrijf in de coverbranch van route() cover = safe_cover_path(state["covers_dir"], segments[1]) en serveer het bestand alleen als cover is not None. Elke andere naam houdt de 404 die de branch al heeft.

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

Druk daarna op Check my work. Het bouwt op een tijdelijke plek een eigen map, met bestanden die je niet hebt gezien: bestanden met een letter buiten ASCII of een procentteken in hun naam, een map erbinnen, een link die naar buiten wijst, een bestand naast de map, en een tweede map waarvan de naam met dezelfde letters begint. Het roept safe_cover_path() aan met namen die gevonden moeten worden en namen die None moeten krijgen: namen met .., absolute namen, de link, de naburige map, een map, een ontbrekend bestand, een naam met een NUL, en namen die nog procentgecodeerd zijn. Het roept handle() aan met state["covers_dir"] ingesteld op die map en vraagt om covers met gecodeerde namen. Niets uit je terminal wordt beoordeeld.