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 .. 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 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:
Geef
Noneterug als"\x00"in de naam staat./covers/a%00levert zo’n naam af, enresolve()werpt ervoor op. base = pathlib.Path(folder).resolve(), enpath = (base / name).resolve().Geef
Noneterug tenzijbase in path.parents.Geef
Noneterug tenzijpath.is_file(), zodat ook de naam van een map een 404 krijgt.Geef
pathterug.
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, ofNone. Hij werpt nooit eenop. Geef
Noneterug als"\x00"innamestaat.Los beide op:
base = pathlib.Path(folder).resolve()enpath = (base / name).resolve().Geef
Noneterug tenzijbase in path.parents.Geef
Noneterug tenzijpath.is_file().Geef
pathterug.
De naam wordt gebruikt zoals hij binnenkomt. De router heeft hem één keer gedecodeerd, dus
unquotewordt nergens in dezeaangeroepen. 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 alscover 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.