mitmario.dev

SQL-Injection

Node.js Sandbox 5 Min Lesezeit 3 BeispieleLektion 2 von 8

In 12.5 hast du gelernt, dass eine Abfrage mit Fragezeichen sicherer ist als eine mit +. Hier schaust du einmal von der anderen Seite darauf: Was macht jemand damit, der es darauf anlegt?

Der Angriff, den jeder kennt und trotzdem keiner schließt

Der Klassiker heißt SQL-Injection und beruht auf einer einzigen Beobachtung: Wenn dein Programm eine Abfrage als Text zusammensetzt, dann schreibt der Aufrufer an dieser Abfrage mit.

Die Anmeldung ohne Passwort
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE nutzer (id INTEGER PRIMARY KEY, email TEXT, passwort TEXT)");
db.prepare("INSERT INTO nutzer (email, passwort) VALUES (?, ?)").run("test@beispiel.de", "einlangesgeheimnis");

// So stand es vor zwanzig Jahren in jedem Handbuch, und so steht es heute
// noch in mehr Anwendungen, als einem lieb ist: die Bedingung wird
// zusammengeklebt. Das Passwort im Klartext ist die zweite Sünde, siehe 14.1.
function anmeldenAlt(email, passwort) {
  const sql = `SELECT email FROM nutzer WHERE email = '${email}' AND passwort = '${passwort}'`;
  const zeile = db.prepare(sql).get();
  return { sql, treffer: zeile?.email };
}

for (const [email, passwort] of [
  ["test@beispiel.de", "einlangesgeheimnis"],
  ["test@beispiel.de", "geraten"],
  ["x", "' OR '1'='1"],
  ["' OR 1=1 --", "völlig egal"],
]) {
  const { sql, treffer } = anmeldenAlt(email, passwort);
  console.log(sql);
  console.log(`  angemeldet: ${treffer ? `ja, als ${treffer}` : "nein"}`);
}

Vier Anmeldeversuche, und das Beispiel druckt jedes Mal die Abfrage mit aus, die dabei entsteht. Die ersten beiden sind der Normalbetrieb: richtiges Passwort, Anmeldung klappt. Falsches Passwort, Anmeldung klappt nicht. Genau deshalb fällt der Fehler jahrelang nicht auf.

Der dritte Versuch tippt ins Passwortfeld ' OR '1'='1. Das erste Anführungszeichen schließt die Zeichenkette, die das Programm gerade aufgemacht hatte, und ab da steht in der Abfrage kein Passwort mehr, sondern eine Bedingung: „oder wenn eins gleich eins ist”. Eins ist immer gleich eins. Der vierte Versuch macht dasselbe im Adressfeld und hängt -- an, das Kommentarzeichen von SQL. Alles dahinter, auch die Passwortprüfung, wird von der Datenbank überlesen.

Zwei Dinge daran sind wichtig. Erstens braucht der Angreifer keinen Zugang, kein Werkzeug und keine Kenntnis deines Codes, sondern nur ein Eingabefeld. Zweitens sieht man dem Ergebnis nichts an: Die Anwendung meldet freundlich „angemeldet”, so wie sie es immer tut.

Dass hier das Passwort im Klartext in der Datenbank steht, ist ein zweiter Fehler, und den kennst du schon aus 14.1. In der Challenge liegt es richtig gehasht, und dann ist genau diese Form des Angriffs nicht mehr möglich, weil der Vergleich gar nicht mehr in der Datenbank stattfindet. Der Schaden verlagert sich dann nur, er verschwindet nicht.

Wohin sich der Schaden verlagert

Was der Apostroph anrichtet
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE kunden (id INTEGER PRIMARY KEY, name TEXT, ort TEXT)");
const einfuegen = db.prepare("INSERT INTO kunden (name, ort) VALUES (?, ?)");
for (const [name, ort] of [["Adler", "Kiel"], ["Berger", "Mainz"], ["O'Brien", "Cork"], ["Wolf", "Jena"]]) {
  einfuegen.run(name, ort);
}

function verkettet(name) {
  try {
    return JSON.stringify(db.prepare(`SELECT name FROM kunden WHERE name = '${name}'`).all().map((z) => z.name));
  } catch (fehler) {
    // Den Fehler abfangen, nicht durchreichen: ein roher Stacktrace nennt
    // Verzeichnisse, die niemanden etwas angehen.
    return `Absturz, ${fehler.message}`;
  }
}

function mitParameter(name) {
  return JSON.stringify(db.prepare("SELECT name FROM kunden WHERE name = ?").all(name).map((z) => z.name));
}

for (const eingabe of ["Wolf", "O'Brien", "x' OR '1'='1"]) {
  console.log(`Eingabe: ${eingabe}`);
  console.log(`  verkettet      ${verkettet(eingabe)}`);
  console.log(`  mit Parameter  ${mitParameter(eingabe)}`);
}

Drei Eingaben, jede einmal in eine verkettete und einmal in eine parametrisierte Abfrage. Die erste sieht in beiden Spalten gleich aus, und das ist der Normalbetrieb.

Die zweite ist gar kein Angriff. O'Brien ist ein Nachname, den es wirklich gibt, und die verkettete Abfrage stürzt daran ab. Das ist die häufigste Art, wie dieser Fehler in freier Wildbahn auffällt: nicht durch einen Angreifer, sondern durch eine Kundin, die sich nicht anmelden kann. Wer an dieser Stelle anfängt, Apostrophe aus Namen zu entfernen, hat das Problem nicht verstanden und eine Kundin verärgert.

Der dritte Fall ist der eigentliche Schaden. Aus einer Abfrage, die genau einen Datensatz liefern sollte, wird eine, die alle liefert. Der Angreifer muss dafür kein Passwort kennen, er liest die Tabelle einfach ab. Mit einem Parameter kommt bei derselben Eingabe eine leere Liste heraus, weil der Text dort ein Suchbegriff bleibt und keine Bedingung wird.

Die drei Verteidigungen, in dieser Reihenfolge

Erstens: Parameter. Das ist die eigentliche Lösung, und sie löst das Problem vollständig. Ein Fragezeichen ist keine Textersetzung. Die Abfrage wird der Datenbank fertig übergeben, und der Wert kommt getrennt hinterher. Danach kann in dem Wert stehen, was will, denn die Datenbank hat sich längst entschieden, was sie tut.

Zweitens: eine Allowlist für alles, was kein Wert ist. Und davon gibt es mehr, als man denkt.

Der Spaltenname, den kein Fragezeichen rettet
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE kunden (id INTEGER PRIMARY KEY, name TEXT, ort TEXT)");
const einfuegen = db.prepare("INSERT INTO kunden (name, ort) VALUES (?, ?)");
for (const [name, ort] of [["Wolf", "Jena"], ["Adler", "Kiel"], ["Berger", "Mainz"], ["O'Brien", "Cork"]]) {
  einfuegen.run(name, ort);
}

const namen = (zeilen) => zeilen.map((z) => z.name).join(", ");

// Versuch 1: das Fragezeichen soll den Spaltennamen tragen.
console.log(`ORDER BY ?  mit "ort"   ${namen(db.prepare("SELECT name FROM kunden ORDER BY ?").all("ort"))}`);
console.log(`ORDER BY ?  mit "name"  ${namen(db.prepare("SELECT name FROM kunden ORDER BY ?").all("name"))}`);

// Versuch 2: der Name wird angeklebt. Sortiert richtig, und das ist die Falle.
const spalte = "ort";
console.log(`angeklebt   mit "ort"   ${namen(db.prepare(`SELECT name FROM kunden ORDER BY ${spalte}`).all())}`);

// Versuch 3: der Name kommt aus einer Liste, die im Code steht.
const ERLAUBT = ["name", "ort"];
function sortiere(wunsch) {
  if (!ERLAUBT.includes(wunsch)) return "abgewiesen";
  return namen(db.prepare(`SELECT name FROM kunden ORDER BY ${wunsch}`).all());
}
console.log(`Allowlist   mit "ort"   ${sortiere("ort")}`);
console.log(`Allowlist   mit Unsinn  ${sortiere("(SELECT passwort FROM nutzer)")}`);

Die ersten beiden Abfragen sind die unangenehmste Stelle dieses Artikels. ORDER BY ? wirft keinen Fehler. Es sortiert nur nicht. SQLite setzt an dieser Stelle einen Wert ein, und nach einem konstanten Wert zu sortieren heißt: gar nicht sortieren. Die Zeilen kommen in der Reihenfolge zurück, in der sie eingefügt wurden, bei beiden Spaltennamen dieselbe. Wer das nicht nachprüft, liefert monatelang eine Sortierung aus, die keine ist.

Der Grund dahinter ist einfach: Ein Fragezeichen steht für einen Wert, nicht für einen Namen. Tabellennamen, Spaltennamen, ASC gegen DESC und die Anzahl der Werte in einem IN sind Namen und Struktur. Für die gibt es keinen Parameter, und deshalb bleibt nur die Liste im Code. Die dritte Abfrage klebt den Namen an und sortiert korrekt, und genau das macht sie gefährlich: Sie funktioniert, solange spalte aus deinem Code kommt, und wird zur Lücke, sobald sie aus der Anfrage kommt. Die letzten beiden zeigen den Ausweg, und es ist derselbe wie in 15.1.

Drittens: eingeschränkte Rechte. Das Konto, mit dem deine Anwendung an der Datenbank hängt, braucht selten DROP, oft nicht einmal DELETE. Diese Verteidigung verhindert nichts, sie begrenzt den Schaden, falls die ersten beiden versagen. Genau in dieser Reihenfolge gehören die drei auch gedacht.

Was keine Verteidigung ist

Anführungszeichen von Hand verdoppeln. Es klingt vernünftig und es scheitert an Kleinigkeiten: an Zahlenfeldern, die gar keine Anführungszeichen haben, an unterschiedlichen Zeichensätzen, an dem einen Feld, das jemand nächstes Jahr vergisst. Du trittst wieder gegen eine unendliche Menge an, und diesmal gibt es eine fertige Lösung daneben.

Nach verdächtigen Wörtern suchen. Eine Blocklist mit DROP, UNION und -- weist die Nachricht „Ich lasse den Vertrag nicht droppen” ab und lässt zwanzig Schreibweisen durch, an die niemand gedacht hat.

Derselbe Fehler unter anderem Namen

Das Muster ist übertragbar, und das ist der eigentliche Gewinn dieser Lektion. Immer wenn du Text von außen in etwas einbaust, das danach ausgeführt oder aufgelöst wird, hast du dieselbe Lücke, nur mit anderem Namen:

  • In einem Shell-Aufruf heißt sie Command Injection. Ein Semikolon statt eines Apostrophs, und hinter deinem Befehl steht ein zweiter.
  • In einem Dateipfad heißt sie Path Traversal. Zwei Punkte und ein Schrägstrich, und aus deinem Upload-Ordner wird das halbe Dateisystem.
  • In einer HTML-Seite heißt sie XSS, und die kommt in der nächsten Lektion.

Die Ursache ist in allen vier Fällen dieselbe: Daten und Anweisungen liegen im selben Text, und nichts sagt der auswertenden Stelle, wo das eine aufhört. Die Lösung ist entsprechend auch immer dieselbe, nämlich beides getrennt zu übergeben.

Zum Mitnehmen

Der Fehler ist unsichtbar, solange niemand ihn sucht. Im Normalbetrieb läuft eine verkettete Abfrage tadellos, und genau deshalb steht sie nach zwanzig Jahren Warnung immer noch überall.

Jetzt du

Basis Konto, kostenlos

Zu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.

Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.

In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.

Was in dieser Lektion steckt

  • Artikel mit 3 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe, dein Code läuft auf einem Server

    Öffnet sich mit dem Basis Konto.