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:
static void makeThumbnail(String fileName) { String command = "echo resize " + fileName; run(new ProcessBuilder("sh", "-c", command));}
static void run(ProcessBuilder builder) { try { Process process = builder.start(); System.out.print(new String(process.getInputStream().readAllBytes())); process.waitFor(); } catch (IOException | InterruptedException e) { throw new RuntimeException(e); }}
makeThumbnail("shoes.jpg");makeThumbnail("shoes.jpg; echo injected");resize shoes.jpgresize shoes.jpginjectedDer 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:
static void makeThumbnail(String fileName) { String command = "echo resize " + fileName; run(new ProcessBuilder("sh", "-c", command));}static void makeThumbnail(String fileName) { String command = "echo resize " + fileName; run(new ProcessBuilder("sh", "-c", command));}static void makeThumbnail(String fileName) { run(new ProcessBuilder("echo", "resize", fileName));}static String download(String name) throws IOException { Path file = invoices.resolve(name); return Files.readString(file);}static String download(String name) throws IOException { Path file = invoices.resolve(name).normalize(); if (!file.startsWith(invoices)) { return "Abgelehnt: außerhalb des Rechnungsordners"; } return Files.readString(file);}Schritt für Schritt, in Worten
- Ein Käufer lädt ein Foto mit dem Namen „shoes.jpg; echo injected“ hoch. Das Werkzeug des Shops macht daraus ein Vorschaubild.
- Der Code klebt den Namen in einen String für die Shell. Der Name ist keine Daten mehr: er ist Teil eines Befehls.
- 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.
- Die Korrektur: keine Shell. Das Programm bekommt eine Liste von Argumenten, und der ganze Name ist ein Argument. Darin ist „;“ ein gewöhnliches Zeichen.
- 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“.
- Der Pfad steigt aus invoices/ heraus, und gelesen wird settings.txt daneben. Der Name hat eine andere Datei gewählt.
- 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 letzte. Die E-Mail ist Text vom Käufer, und new ProcessBuilder("sh", "-c", …) gibt sie als Teil des Befehls an die Shell. Die anderen beiden Zeilen übergeben eine Argumentliste, und pdf setzt sich aus der Bestellnummer zusammen, nicht aus Text des Käufers.
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.
static void makeThumbnail(String fileName) { run(new ProcessBuilder("echo", "resize", fileName));}
static void run(ProcessBuilder builder) { try { Process process = builder.start(); System.out.print(new String(process.getInputStream().readAllBytes())); process.waitFor(); } catch (IOException | InterruptedException e) { throw new RuntimeException(e); }}
makeThumbnail("shoes.jpg");makeThumbnail("shoes.jpg; echo injected");resize shoes.jpgresize shoes.jpg; echo injectedDer zweite Name kam als ein einziges Argument beim Konverter an, mit dem ; darin. Einen zweiten Befehl gibt es nicht.
new ProcessBuilder("echo", "resize", fileName) startet echo direkt, ohne Shell. Jeder String ist ein eigenes Argument.Genauer, für Neugierige
- Am besten startest du gar kein externes Programm. Für Bilder gibt es Bibliotheken: ImageIO aus der Standardbibliothek oder Thumbnailator. 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-xtrotzdem als eine seiner Optionen lesen. Deshalb nehmen viele Programme vor den Dateinamen ein--(„Ende der Optionen“) an, oder du setzt./vor den Namen. -
Runtime.exec(String)startet keine Shell: Es zerlegt den String an den Leerzeichen. Deshalb macht;dort keinen zweiten Befehl, aber ein Leerzeichen im Namen zerlegt ihn in zwei Argumente. Verlass dich nicht darauf:ProcessBuildermit einer Liste sagt klar, was ein Argument ist.
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:
static String download(String name) throws IOException { Path file = invoices.resolve(name); return Files.readString(file);}
System.out.println(download("1041.txt"));System.out.println(download("../settings.txt"));Rechnung 1041: 59 €payment_api_key=demo-not-realDer 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:
- Berechne, wo der Pfad wirklich endet. Das ist der kanonische Pfad: vollständig, ohne
.und... - Prüfe, dass der kanonische Pfad noch im Rechnungsordner liegt. Wenn nicht, lehne ab.
static String download(String name) throws IOException { Path file = invoices.resolve(name).normalize(); if (!file.startsWith(invoices)) { return "Abgelehnt: außerhalb des Rechnungsordners"; } return Files.readString(file);}
System.out.println(download("1041.txt"));System.out.println(download("../settings.txt"));Rechnung 1041: 59 €Abgelehnt: außerhalb des Rechnungsordnersnormalize() entfernt . und .., und Path.startsWith 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.
normalize()arbeitet nur mit dem Text des Pfads. Wenn im Ordner eine symbolische Verknüpfung nach außen sein kann, nimmtoRealPath()(sie verlangt, dass die Datei existiert). - Ein Pfad von der Wurzel.
invoices.resolve("/etc/hosts")gibt/etc/hostszurück: 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:
static Path invoices;
Path shop = Files.createTempDirectory("shop");invoices = Files.createDirectory(shop.resolve("invoices"));Files.writeString(invoices.resolve("1041.txt"), "Rechnung 1041: 59 €");Files.writeString(invoices.resolve("1042.txt"), "Rechnung 1042: 118 €");Files.writeString(shop.resolve("settings.txt"), "payment_api_key=demo-not-real");Probier es aus
Der kanonische Pfad
Vervollständige die Zeile, die berechnet, wo der Pfad endet:
static String download(String name) throws IOException {
Path file = invoices.resolve(name).();
if (!file.startsWith(invoices)) {
return "Abgelehnt: außerhalb des Rechnungsordners";
}
return Files.readString(file);
}normalize() berechnet den vollständigen Pfad ohne ... Die Prüfung in der nächsten Zeile schaut darauf, nicht auf den Text aus der Adresse.
Probier es aus
Drei Namen
Was gibt der korrigierte Download für diese drei Namen aus?
System.out.println(download("1042.txt"));System.out.println(download("../invoices/1042.txt"));System.out.println(download("../settings.txt"));1042.txt liegt im Ordner. ../invoices/1042.txt verlässt invoices/ und kommt zurück: Der kanonische Pfad ist invoices/1042.txt, deshalb geht er durch. ../settings.txt endet außerhalb des Ordners: abgelehnt.
Übung
Die Rechnung per Nummer
Löse die Aufgabe in deinem Editor.
Per Nummer, nicht per Name. Schreibe downloadById(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:
Rechnung 1041: 59 €Rechnung 1042: 118 €Abgelehnt: keine solche RechnungAbgelehnt: keine solche RechnungWeitere Aufgaben
- Eine fremde Rechnung. Die Nummer
1042steht 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
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Listen · HashMap und HashSet · Datenbanken · Nicht vertrauenswürdige Eingaben
- Geht weiter in: XSS: ein fremdes Skript in deiner Seite
- Aufgaben zum Thema: Sicherheit für Entwickler