Eingaben, die zu Code werden · Übung
Halte einen Dateinamen in seinem Ordner
Ein Buch kann jetzt ein Cover haben. Die Cover sind drei kleine Textdateien im Ordner covers, und das Projekt dieses Kapitels liefert sie unter GET /covers/{name} aus. Der Zweig in route() beginnt so:
cover = pathlib.Path(state["covers_dir"]) / segments[1]
if cover.is_file():
body = cover.read_bytes()
Er fügt einen Namen aus der Anfrage an einen Ordner an und liest die Datei, die er dort findet, egal welche. Führe python pl_sec.py injection aus und lies den dritten Teil:
--- 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
Die zweite Anfrage hat die erste Zeile von SECURITY.md bekommen. Das Szenario bleibt absichtlich innerhalb deines Projektordners, und nichts außerhalb davon wird gelesen. Auf einem echten Server greift dieselbe Anfrage nach einer Konfigurationsdatei oder einem Schlüssel. Eine Datei außerhalb des Ordners zu lesen, den eine Route ausliefern soll, ist
Wie der Name dorthin kam
In einem Pfad bedeutet .. den Ordner darüber. Ein einfaches /covers/../SECURITY.md kommt nicht weit: Dieser Pfad hat drei Segmente, und der Cover-Zweig nimmt genau zwei.
Also versteckt der Angriff den Schrägstrich als %2f. path_segments() teilt den Pfad zuerst an / und dekodiert danach jedes 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
Während der Pfad geteilt wird, ist ..%2fSECURITY.md ein einziges Segment. Dekodiert ist es ../SECURITY.md. Zuerst zu teilen ist richtig, weil ein kodierter Schrägstrich dann nie ändern kann, welche Route eine Anfrage nimmt. Der Name, der ankommt, kann aber trotzdem einen Schrägstrich enthalten, und an den Ordner angefügt lautet er covers/../SECURITY.md.
Warum erreicht /covers/..%2fSECURITY.md den Cover-Zweig mit dem Namen ../SECURITY.md?
Prüfe den Pfad, nicht den Text
Die erste Idee ist, sich den Text des Namens anzusehen. Hier sind drei solche Prüfungen, und der Name, der jede davon aushebelt.
Lehne einen Namen ab, der .. enthält. Ein absoluter Name braucht keine Punkte, weil das Anfügen an einen Ordner den Ordner wegwirft.
Was gibt das aus?
import pathlib
print(pathlib.PurePosixPath("/srv/covers") / "/etc/hosts")
Lehne einen Namen ab, der mit einem Schrägstrich beginnt. Dann ist ../SECURITY.md wieder da.
Füge zuerst an und nimm den Pfad an, wenn sein Text mit dem Text des Ordners beginnt. Jetzt ist ein Ordner namens covers-old neben covers offen: Der Text /srv/covers-old/x.txt beginnt mit /srv/covers.
Jede dieser Prüfungen fragt nach Zeichen. Die eigentliche Frage betrifft das Dateisystem: Liegt diese Datei in jenem Ordner? pathlib kann sie beantworten. resolve() gibt den .. aufgelöst und jedem Link verfolgt. parents enthält jeden Ordner über einem Pfad. Also löse den Ordner auf, löse den angefügten Pfad auf und verlange, dass der Ordner unter path.parents ist. Ein Link im Ordner, der auf eine Datei außerhalb zeigt, wird ebenfalls aufgelöst und abgelehnt.
Eine Prüfung nimmt einen Pfad an, wenn sein Text mit dem Text des Ordners beginnt, /srv/covers. Welche Anfrage entkommt?
Eine Dekodierung, an einer Stelle
Der Router hat den Namen bereits dekodiert. Es ist verlockend, innerhalb der unquote aufzurufen. Eines der Cover zeigt, was das kostet. Sein Dateiname ist rep ort.txt, mit einem Leerzeichen, also fragt ein Client nach /covers/rep%20ort.txt, und die Dekodierung des Routers ergibt den Namen der Datei. Eine Anfrage nach /covers/rep%2520ort.txt nennt etwas anderes: %25 ist ein kodiertes Prozentzeichen, also ist der Name, der ankommt, rep%20ort.txt, und kein Cover hat diesen Namen.
from urllib.parse import unquote
once = unquote("rep%2520ort.txt")
print(once)
print(unquote(once))
print(unquote(unquote("%252e%252e%252fSECURITY.md")))
Es gibt rep%20ort.txt aus, dann rep ort.txt, dann ../SECURITY.md. Die erste Zeile ist das Ergebnis des Routers, der Name, nach dem der Client gefragt hat. Eine zweite Dekodierung macht daraus rep ort.txt, und der Server antwortet mit einer Datei, die der Client nicht genannt hat. Es geht auch andersherum: Auf einem Server, auf dem der Name einer Datei ein Prozentzeichen enthält, ändert eine zweite Dekodierung diesen Namen, und die Datei lässt sich nicht mehr abrufen.
Die dritte Zeile ist schlimmer. Nach der Dekodierung des Routers ist %252e%252e%252f gleich %2e%2e%2f: Text ohne Punkt und ohne Schrägstrich. Eine Prüfung des Texts findet nichts Falsches, und dann macht eine zweite Dekodierung daraus ../. Die Regel ist die aus Lektion 2: Prüfe, was du verwenden wirst. Der Name wird einmal dekodiert, vom Router, und deine Funktion verwendet ihn so, wie er ankommt.
safe_cover_path ruft zur Sicherheit noch einmal unquote(name) auf. Was geht kaputt?
Deine Aufgabe
Schreib safe_cover_path(folder, name) in reading_server.py. Es gibt einen Pfad zurück, oder None:
Gib
Nonezurück, wenn"\x00"im Namen steht./covers/a%00liefert einen solchen Namen, undresolve()löst dafüraus. base = pathlib.Path(folder).resolve()undpath = (base / name).resolve().Gib
Nonezurück, wenn nichtbase in path.parentsgilt.Gib
Nonezurück, wenn nichtpath.is_file()gilt, damit auch der Name eines Ordners eine 404 bekommt.Gib
pathzurück.
Eine Datei in einem Ordner innerhalb von covers besteht all das und wird ausgeliefert. Die Regel betrifft den Ordner, nicht die Anzahl der Schrägstriche im Namen.
Verwende sie dann im Cover-Zweig: cover = safe_cover_path(state["covers_dir"], segments[1]), und liefere die Datei aus, wenn cover is not None gilt. Der Ordner kommt aus dem Zustand, also kann ein Test den Server auf einen eigenen Ordner zeigen lassen. Das machst du in Lektion 8.
Frag nach dem Cover mit dem Leerzeichen und dann nach dem Namen mit dem kodierten Prozentzeichen:
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')])"
Es gibt [200, 404] aus. Du kannst für einen Moment name = unquote(name) an den Anfang deiner Funktion setzen und die Zeile noch einmal ausführen: [200, 200]. Die zweite Anfrage bekommt jetzt eine Datei, nach der sie nicht gefragt hat. Nimm die Zeile wieder heraus.
python -m pytest -q endet weiterhin mit 23 passed. Der dritte Teil des Szenarios lautet jetzt:
--- 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
Die Ergebniszeile sagt 1 of 4 attacks worked. Klicke dann auf Check my work. Der eine Angriff, der übrig ist, setzt einen Titel in eine Seite, und die nächste Lektion stoppt ihn.
Aufgabe
Liefere ein Cover nur aus, wenn sein Name zu einer Datei innerhalb des Cover-Ordners führt.
In reading_server.py:
safe_cover_path(folder, name)gibt den Pfad der Datei zurück, oderNone. Es löst nie etwas aus.Gib
Nonezurück, wenn"\x00"innamesteht.Löse beide auf:
base = pathlib.Path(folder).resolve()undpath = (base / name).resolve().Gib
Nonezurück, wenn nichtbase in path.parentsgilt.Gib
Nonezurück, wenn nichtpath.is_file()gilt.Gib
pathzurück.
Der Name wird so verwendet, wie er ankommt. Der Router hat ihn einmal dekodiert, also wird
unquotein diesernirgends aufgerufen. Eine Datei in einem Ordner innerhalb des Cover-Ordners liegt weiterhin innerhalb und wird zurückgegeben.
Schreib im Cover-Zweig von
route()cover = safe_cover_path(state["covers_dir"], segments[1])und liefere die Datei nur aus, wenncover is not Nonegilt. Jeder andere Name behält die 404, die der Zweig schon hat.
Führe python -m pytest -q und python pl_sec.py injection im
Klicke dann auf Check my work. Es baut an einem temporären Ort einen eigenen Ordner, mit Dateien, die du noch nicht gesehen hast: Dateien mit einem Buchstaben außerhalb von ASCII oder einem Prozentzeichen im Namen, ein Ordner darin, ein Link, der nach außen zeigt, eine Datei neben dem Ordner und ein zweiter Ordner, dessen Name mit denselben Buchstaben beginnt. Es ruft safe_cover_path() mit Namen auf, die gefunden werden müssen, und mit Namen, die None bekommen müssen: Namen mit .., absolute Namen, der Link, der Nachbarordner, ein Ordner, eine fehlende Datei, ein Name mit einem NUL und Namen, die noch prozentkodiert sind. Es ruft handle() mit state["covers_dir"] auf diesen Ordner gesetzt auf und fragt nach Covern mit kodierten Namen. Nichts aus deinem Terminal wird bewertet.