Zum Inhalt
wannacode

Injektionen: wenn Daten zum Befehl werden

Problem

Ein Vorschaubild mit Überraschung

Käufer laden Fotos zu ihren Bewertungen hoch. Das Werkzeug des Shops macht aus jedem ein Vorschaubild: Es startet das Bildprogramm (zum Beispiel magick aus ImageMagick) mit dem Namen der Datei.

Hier steht statt des echten Konverters echo. Es gibt nur seine Argumente aus, deshalb zeigt die Ausgabe genau, was beim Programm angekommen ist:

import subprocess
def make_thumbnail(file_name):
command = "echo resize " + file_name
subprocess.run(command, shell=True)
make_thumbnail("shoes.jpg")
make_thumbnail("shoes.jpg; echo injected")
Ausgabe
resize shoes.jpg
resize shoes.jpg
injected

Der erste Name ist gewöhnlich. Der zweite ist shoes.jpg; echo injected, und die Ausgabe hat drei Zeilen, nicht zwei. injected hat ein Befehl ausgegeben, den der Käufer in den Namen der Datei geschrieben hat. Hier ist es ein harmloses echo, aber an seiner Stelle könnte jeder Befehl stehen, und er läuft mit den Rechten des Servers des Shops.

Die Idee

Text, der in einer Sprache landet

Ein Befehl für die Shell ist eine Sprache. In ihr bedeutet ; „Ende des Befehls, es folgt ein weiterer“. Der Code hat den Namen des Käufers in diese Sprache geklebt, und die Zeichen im Namen haben die Bedeutung des Befehls geändert. Das ist Befehlsinjektion (command injection).

Dasselbe hast du schon mit SQL gesehen. Wenn ein Programm ' OR '1'='1 in eine Abfrage klebt, schließt das Anführungszeichen den Text zu früh, und die Bedingung wird für jede Zeile wahr. Die Lösung war ein Parameter: Die Abfrage und der Wert gehen getrennt an die Datenbank. Das ist die Lektion „Datenbanken“ im Kurs zur Sprache und „SQL-Injection und Parameter“ im SQL-Kurs, deshalb wiederholen wir sie hier nicht.

Überall, wo Text in einer Sprache landet, haben manche Zeichen eine Bedeutung:

Wo der Text landet Zeichen mit Bedeutung Wie er Daten bleibt
eine SQL-Abfrage ', --, ; ein Parameter
ein Befehl für die Shell ;, &&, $( ), ein Leerzeichen eine Argumentliste, ohne Shell
ein Dateipfad .., /, \, ein Pfad von der Wurzel ein kanonischer Pfad und eine Prüfung, oder eine Nummer aus einer Liste
eine HTML-Seite <, >, ", & Escaping (die nächste Lektion)

Die allgemeine Idee: Lass Daten nicht zum Teil des Befehls werden. Gib sie dem System getrennt, auf eine Weise, die das System nie als Befehl liest.

Sieh es dir an

Wie der Name zum Befehl wird

Beide Fälle aus der Lektion, vor und nach der Korrektur. Der Code neben der Szene wechselt mit dem Schritt:

def make_thumbnail(file_name):
command = "echo resize " + file_name
subprocess.run(command, shell=True)
Schritt für Schritt, in Worten
  1. Ein Käufer lädt ein Foto mit dem Namen „shoes.jpg; echo injected“ hoch. Das Werkzeug des Shops macht daraus ein Vorschaubild.
  2. Der Code klebt den Namen in einen String für die Shell. Der Name ist keine Daten mehr: er ist Teil eines Befehls.
  3. Für die Shell beendet „;“ einen Befehl. Der String wird zu zwei Befehlen, und den zweiten hat der Käufer geschrieben. Hier gibt er nur „injected“ aus; es könnte jeder Befehl sein.
  4. Die Korrektur: keine Shell. Das Programm bekommt eine Liste von Argumenten, und der ganze Name ist ein Argument. Darin ist „;“ ein gewöhnliches Zeichen.
  5. Der Rechnungsdownload hängt den Namen aus der Adresse an den Ordner invoices/. Der Name ist „../settings.txt“, und „..“ heißt „einen Ordner nach oben“.
  6. Der Pfad steigt aus invoices/ heraus, und gelesen wird settings.txt daneben. Der Name hat eine andere Datei gewählt.
  7. Die Korrektur: erst ausrechnen, wo der Pfad wirklich endet (der kanonische Pfad), dann prüfen, dass er noch in invoices/ liegt. settings.txt liegt nicht darin: abgelehnt.

Probier es aus

Finde die Zeile

Der Shop schickt einen Kassenbon: Er erzeugt ein PDF und sendet es an die E-Mail des Käufers. Welche Zeile gibt Text vom Käufer an die Shell?

Wähle die Zeile mit dem Fehler.

Die Idee

Eine Argumentliste statt eines Strings

Die Lösung ist nicht, den Namen zu „säubern“. Sie ist, dass keine Shell da ist, die ihn liest. Das Programm wird mit einer Argumentliste gestartet: Das erste Element ist das Programm, und jedes weitere kommt vollständig beim Programm an, egal welche Zeichen es enthält.

import subprocess
def make_thumbnail(file_name):
subprocess.run(["echo", "resize", file_name])
make_thumbnail("shoes.jpg")
make_thumbnail("shoes.jpg; echo injected")
Ausgabe
resize shoes.jpg
resize shoes.jpg; echo injected

Der zweite Name kam als ein einziges Argument beim Konverter an, mit dem ; darin. Einen zweiten Befehl gibt es nicht.

subprocess.run mit einer Liste und ohne shell=True startet das Programm direkt. Jedes Element der Liste ist ein eigenes Argument.
Genauer, für Neugierige
  • Am besten startest du gar kein externes Programm. Für Bilder gibt es Bibliotheken: Pillow. Dann gibt es keinen Befehl, in dem Text landen könnte.
  • Behalte deine eigenen Namen. Speichere die hochgeladene Datei unter einem Namen, den das Programm selbst erfindet (eine Nummer oder eine UUID), und lass den Namen des Käufers als Daten in der Datenbank. Dann kommt nur dein Name beim Befehl und im Pfad an.
  • Ein Argument, das mit - beginnt. Ohne Shell ist ; harmlos, aber das Programm kann einen Namen wie -x trotzdem als eine seiner Optionen lesen. Deshalb nehmen viele Programme vor den Dateinamen ein -- („Ende der Optionen“) an, oder du setzt ./ vor den Namen.
  • os.system(String) und subprocess.run(String, shell=True) starten eine Shell. Wenn du unbedingt eine Shell brauchst, setzt shlex.quote die Anführungszeichen richtig, aber die Liste ohne Shell ist klarer und sicherer.

Problem

Eine Rechnung, die keine Rechnung ist

Ein Käufer lädt eine Rechnung von /invoices/1041.txt herunter. Der Server nimmt den Namen aus der Adresse und hängt ihn an den Rechnungsordner:

def download(name):
file = INVOICES / name
return file.read_text(encoding="utf-8")
print(download("1041.txt"))
print(download("../settings.txt"))
Ausgabe
Rechnung 1041: 59 €
payment_api_key=demo-not-real

Der zweite Name ist ../settings.txt. In einem Pfad bedeutet .. „einen Ordner höher“. Der Pfad hat invoices/ verlassen, und der Server hat settings.txt gelesen, die daneben liegt, mit dem Zahlungsschlüssel.

Das ist path traversal (Pfad-Traversierung): Der Name aus der Anfrage wählt eine Datei außerhalb des Ordners, den der Code im Sinn hatte. Das Programm hier legt seinen eigenen temporären Ordner an und liest nur darin; auf einem echten Server kann .. viele Ebenen nach oben steigen.

Die Idee

Ein kanonischer Pfad, dann eine Prüfung

Die Lösung hat zwei Schritte:

  1. Berechne, wo der Pfad wirklich endet. Das ist der kanonische Pfad: vollständig, ohne . und ...
  2. Prüfe, dass der kanonische Pfad noch im Rechnungsordner liegt. Wenn nicht, lehne ab.
def download(name):
file = (INVOICES / name).resolve()
if not file.is_relative_to(INVOICES):
return "Abgelehnt: außerhalb des Rechnungsordners"
return file.read_text(encoding="utf-8")
print(download("1041.txt"))
print(download("../settings.txt"))
Ausgabe
Rechnung 1041: 59 €
Abgelehnt: außerhalb des Rechnungsordners
resolve() macht den Pfad vollständig, entfernt .. und folgt symbolischen Verknüpfungen. is_relative_to vergleicht die Teile des Pfads, nicht den Text: invoices-old liegt nicht in invoices.

Die Prüfung sucht nicht nach gefährlichen Zeichen. Sie fragt, wo der Pfad endet, deshalb hängt sie nicht davon ab, woran du gedacht hast.

Noch sicherer ist es, wenn der Name gar kein Pfad ist. Die Adresse trägt eine Rechnungsnummer, und der Server sucht die Datei in seiner eigenen Tabelle: Eine Nummer, die es dort nicht gibt, wird abgelehnt. Das ist die Übung unten.

Genauer, für Neugierige
  • Symbolische Verknüpfungen. resolve() folgt symbolischen Verknüpfungen bereits; os.path.normpath tut das nicht.
  • Ein Pfad von der Wurzel. INVOICES / "/etc/hosts" ist /etc/hosts: Der absolute Pfad ersetzt den Ordner. Die Prüfung fängt das ab.
  • Archive. Die Dateinamen in einem Zip-Archiv sind ebenfalls Text von außen. Beim Entpacken muss dieselbe Prüfung für jede Datei laufen (die Falle hat einen Namen: zip slip).
  • Die Vorbereitung. Das Programm legt einen temporären Ordner an, schreibt die zwei Rechnungen und die Einstellungsdatei hinein und löscht ihn am Ende. So beginnt es:
SHOP = Path(tempfile.mkdtemp(prefix="shop")).resolve()
INVOICES = SHOP / "invoices"
INVOICES.mkdir()
(INVOICES / "1041.txt").write_text("Rechnung 1041: 59 €", encoding="utf-8")
(INVOICES / "1042.txt").write_text("Rechnung 1042: 118 €", encoding="utf-8")
(SHOP / "settings.txt").write_text("payment_api_key=demo-not-real", encoding="utf-8")

Probier es aus

Der kanonische Pfad

Vervollständige die Zeile, die berechnet, wo der Pfad endet:

def download(name):
    file = (INVOICES / name).()
    if not file.is_relative_to(INVOICES):
        return "Abgelehnt: außerhalb des Rechnungsordners"
    return file.read_text(encoding="utf-8")

Probier es aus

Drei Namen

Was gibt der korrigierte Download für diese drei Namen aus?

print(download("1042.txt"))
print(download("../invoices/1042.txt"))
print(download("../settings.txt"))
Antwort

Übung

Die Rechnung per Nummer

Löse die Aufgabe in deinem Editor.

Per Nummer, nicht per Name. Schreibe download_by_id(invoice_id). Die Adresse trägt nur eine Rechnungsnummer. Eine Tabelle im Code sagt, welche Nummer welche Datei ist (1041 → 1041.txt, 1042 → 1042.txt). Eine Nummer, die nicht in der Tabelle steht, wird abgelehnt, und der Dateiname kommt nie aus der Anfrage. Probiere es mit 1041, 1042, ../settings.txt und 1043:

Erwartet
Rechnung 1041: 59 €
Rechnung 1042: 118 €
Abgelehnt: keine solche Rechnung
Abgelehnt: keine solche Rechnung

Weitere Aufgaben

  • Eine fremde Rechnung. Die Nummer 1042 steht in der Tabelle, aber die Rechnung gehört einem anderen Käufer. Was muss der Server noch prüfen, bevor er sie zurückgibt?
  • Der Name des Fotos. Ändere das Werkzeug für Vorschaubilder so, dass es die hochgeladene Datei unter einem Namen speichert, den das Programm selbst erfindet (die Nummer der Bewertung und .jpg), und den Namen des Käufers nur als Text aufbewahrt.

Test

Prüfe dich

1. Was haben SQL-Injection, Befehlsinjektion und Pfad-Traversierung gemeinsam?
2. Das Werkzeug für Vorschaubilder startet den Konverter mit einer Argumentliste, und der Name des Fotos ist das letzte Argument. Was passiert mit einem „;“ im Namen?
3. Der korrigierte Download berechnet den kanonischen Pfad und prüft, dass er in invoices/ liegt. Was gibt er für „../invoices/1042.txt“ zurück?
4. Warum ist es keine Lösung, „..“ und „;“ aus der Eingabe zu entfernen?
5. Ein Käufer lädt ein Foto hoch. Wie speicherst du es am sichersten auf der Platte?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·