mitmario.dev

Vorbereitete Anweisungen

PHP Sandbox 4 Min Lesezeit 4 BeispieleLektion 4 von 9

Bisher standen alle deine Abfragen fest im Quelltext. In einem echten Programm ist das die Ausnahme: Da kommt ein Suchwort aus einem Formular, eine Nummer aus der Adresszeile, ein Name aus einem Cookie. Und in dem Moment, in dem so ein Wert in eine Abfrage soll, wird es ernst.

Der naheliegende Weg, und warum er nicht geht

Derselbe Name, zweimal gesucht
<?php

// Derselbe Name, zweimal gesucht. Der Verfasser heisst O'Brien, und
// dieser eine Apostroph ist alles, was es braucht.

require __DIR__ . "/vorbereiten.php";

$name = "O'Brien";

echo "Zusammengeklebt:\n";

try {
    $zeilen = $db->query("SELECT titel FROM notizen WHERE verfasser = '" . $name . "'")->fetchAll();
    echo "  ", implode(", ", array_column($zeilen, "titel")), "\n";
} catch (PDOException $e) {
    echo "  ", $e->getMessage(), "\n";
}

echo "\nMit Platzhalter:\n";

$abfrage = $db->prepare("SELECT titel FROM notizen WHERE verfasser = :name");
$abfrage->execute([":name" => $name]);

echo "  ", implode(", ", array_column($abfrage->fetchAll(), "titel")), "\n";

echo "\nUnd so sieht die zusammengeklebte Abfrage aus, wenn man sie ausschreibt:\n";
echo "  SELECT titel FROM notizen WHERE verfasser = '", $name, "'\n";

Der naheliegende Weg ist, den Wert in den Abfragetext zu kleben. Das kannst du seit Abschnitt 3, es sieht harmlos aus, und es funktioniert bei den meisten Eingaben auch.

Bei O’Brien funktioniert es nicht. Sieh dir die letzte Zeile des Beispiels an, dort steht die Abfrage ausgeschrieben: WHERE verfasser = 'O'Brien'. Für SQLite endet die Zeichenkette am zweiten Apostroph, direkt nach dem O, und danach steht dort ein Wort namens Brien, mit dem es nichts anfangen kann. Die Meldung sagt genau das: near "Brien": syntax error.

Der Punkt daran ist nicht der Apostroph. Der Punkt ist, dass die Datenbank deinen Wert und deinen Befehl nicht mehr auseinanderhalten kann, weil beides als ein einziger Text ankommt. Sie muss dann raten, wo das eine aufhört und das andere anfängt, und die Regeln dafür kennt dein Besucher genauso gut wie du.

Wo aus einem Wert ein Befehl wird
<?php

// Kein Angriff auf jemanden, sondern eine Vorfuehrung an der eigenen
// Datenbank: Was passiert, wenn ein Wert ein Befehl sein darf.

require __DIR__ . "/vorbereiten.php";

$eingabe = "' OR 1=1 --";

echo "Die Datenbank hat ", $db->query("SELECT count(*) FROM notizen")->fetchColumn(), " Notizen.\n";
echo "Gesucht wird nach einem Verfasser, den es nicht gibt:\n";
echo "  ", $eingabe, "\n\n";

$zusammengeklebt = "SELECT titel FROM notizen WHERE verfasser = '" . $eingabe . "'";

echo "Zusammengeklebt ergibt das diese Abfrage:\n";
echo "  ", $zusammengeklebt, "\n";
echo "  Treffer: ", count($db->query($zusammengeklebt)->fetchAll()), "\n\n";

$abfrage = $db->prepare("SELECT titel FROM notizen WHERE verfasser = :name");
$abfrage->execute([":name" => $eingabe]);

echo "Mit Platzhalter bleibt es eine Suche nach einem seltsamen Namen:\n";
echo "  Treffer: ", count($abfrage->fetchAll()), "\n";

Das zweite Beispiel zeigt, wohin das führt. Die Eingabe ist kein Name, sondern ein Stück SQL, und zusammengeklebt ergibt sie eine Abfrage, die nach nichts sucht und trotzdem alle sechs Notizen liefert. Das -- am Ende ist in SQL ein Kommentarzeichen, damit fällt der Rest der ursprünglichen Abfrage einfach weg.

Sechs Notizen sind harmlos. Dieselbe Lücke in einer Anmeldung heißt, dass jemand ohne Passwort hereinkommt, und in einer Tabelle mit Kundendaten heißt sie, dass er sie mitnimmt. Das Ganze hat einen Namen, SQL-Injection, und Lektion 15.1 nimmt es sich vor. Hier reicht die Einsicht: Es ist kein exotischer Angriff, sondern die unmittelbare Folge davon, Text zusammenzukleben.

Der richtige Weg hat zwei Schritte

Statt einer fertigen Abfrage schickst du eine mit Lücken los, und die Werte kommen getrennt hinterher.

$db->prepare($sql) gibt die Abfrage an die Datenbank, aber ohne sie auszuführen. Wo ein Wert hingehört, steht ein Platzhalter: entweder ein Name mit Doppelpunkt davor wie :name oder ein schlichtes Fragezeichen. Die Datenbank sieht sich die Abfrage an, versteht sie und merkt sich den fertigen Plan. Zurück bekommst du ein PDOStatement.

$stmt->execute([":name" => $wert]) liefert die Werte nach und führt aus. Und jetzt kommt das Entscheidende: Die Abfrage steht zu diesem Zeitpunkt schon fest. Der Wert wandert in eine Lücke, die vorher als Lücke für einen Wert festgelegt wurde. Er kann kein Befehl mehr werden, weil an dieser Stelle gar kein Befehl mehr erwartet wird. Ob dort O'Brien steht oder ' OR 1=1 -- oder ein Absatz Shakespeare, macht keinen Unterschied: Es ist ein Wert.

Deshalb ist das auch kein Escaping. Escaping heißt, gefährliche Zeichen unschädlich zu machen, und es hängt daran, dass man alle kennt. Hier wird nichts unschädlich gemacht, es kommt schlicht nicht in die Nähe des Befehls.

Einmal vorbereiten, mehrmals ausführen
<?php

// Einmal vorbereiten, dreimal ausfuehren. Und zwei Schreibweisen
// fuer Platzhalter.

require __DIR__ . "/vorbereiten.php";

$abfrage = $db->prepare("SELECT titel FROM notizen WHERE verfasser = :name ORDER BY id");

foreach (["Mia", "Ben", "O'Brien"] as $name) {
    $abfrage->execute([":name" => $name]);

    echo str_pad($name . ":", 10), implode(", ", array_column($abfrage->fetchAll(), "titel")), "\n";
}

echo "\nDer Doppelpunkt im Array darf auch weg:\n";

$abfrage->execute(["name" => "Mia"]);
echo "  ", implode(", ", array_column($abfrage->fetchAll(), "titel")), "\n";

echo "\nUnd statt Namen gehen Fragezeichen, dann zaehlt die Reihenfolge:\n";

$zwei = $db->prepare("SELECT titel FROM notizen WHERE verfasser = ? AND titel = ?");
$zwei->execute(["Mia", "Kaffee"]);

echo "  ", implode(", ", array_column($zwei->fetchAll(), "titel")), "\n";

Der Name der Sache erklärt sich hier nebenbei. Eine vorbereitete Anweisung ist wirklich vorbereitet: Du kannst sie mehrmals ausführen, jedes Mal mit anderen Werten, und die Datenbank muss den Plan nur einmal machen. Bei drei Aufrufen merkst du davon nichts. Bei tausend schon.

Zwei Kleinigkeiten aus dem Beispiel: Der Doppelpunkt im Array darf weg, ["name" => $wert] funktioniert genauso. Und wer Fragezeichen statt Namen nimmt, übergibt eine einfache Liste, bei der die Reihenfolge zählt. Bei einer Handvoll Werte sind Namen lesbarer, gerade wenn beim Anpassen später einer dazukommt.

Wofür ein Platzhalter nicht gedacht ist

Ein Platzhalter steht für einen Wert
<?php

// Ein Platzhalter steht fuer einen Wert. Fuer nichts sonst.

require __DIR__ . "/vorbereiten.php";

echo "Ein Prozentzeichen fuer LIKE gehoert in den Wert:\n";

$suche = $db->prepare("SELECT titel FROM notizen WHERE titel LIKE :teil ORDER BY id");
$suche->execute([":teil" => "%ee%"]);

echo "  LIKE :teil mit dem Wert %ee%  ->  ";
echo implode(", ", array_column($suche->fetchAll(), "titel")), "\n";

echo "\nSteht es dagegen im SQL, ist der Platzhalter keiner mehr:\n";

try {
    $falsch = $db->prepare("SELECT titel FROM notizen WHERE titel LIKE '%:teil%' ORDER BY id");
    $falsch->execute([":teil" => "ee"]);
    echo "  durchgelaufen\n";
} catch (PDOException $e) {
    echo "  ", $e->getMessage(), "\n";
}

echo "\nUnd ein Spaltenname ist kein Wert. Das hier sortiert nicht,\n";
echo "und es beschwert sich auch nicht:\n";

$sortiert = $db->prepare("SELECT titel FROM notizen ORDER BY ?");
$sortiert->execute(["titel"]);

echo "  ORDER BY ? mit dem Wert titel  ->  ";
echo implode(", ", array_column($sortiert->fetchAll(), "titel")), "\n";

echo "  ORDER BY titel ausgeschrieben  ->  ";
echo implode(", ", array_column($db->query("SELECT titel FROM notizen ORDER BY titel")->fetchAll(), "titel")), "\n";

Ein Platzhalter steht für einen Wert, also für das, was in einer Spalte stehen könnte. Für nichts sonst, und das kostet gelegentlich eine Viertelstunde.

Ein Spaltenname ist kein Wert. ORDER BY ? mit dem Wert titel sortiert deshalb nicht nach der Spalte titel, sondern nach der Zeichenkette titel, und die ist bei jeder Zeile gleich. Es kommt keine Fehlermeldung, es passiert nur nichts. Brauchst du eine wählbare Sortierung, prüfst du die Eingabe gegen eine Liste erlaubter Spaltennamen und setzt den geprüften Namen selbst ein.

Bei LIKE gehören die Prozentzeichen in den Wert und nicht in den Abfragetext. LIKE :teil mit dem Wert %ee% ist richtig. LIKE '%:teil%' ist falsch, denn dort steht der Platzhalter innerhalb einer Zeichenkette und ist damit gar keiner mehr. Die Meldung dazu ist wenig hilfreich und lautet column index out of range, was so viel heißt wie: Du hast mir einen Wert gegeben, aber ich habe keine Lücke dafür.

Die Regel für den Rest deines Lebens

Jeder Wert, der nicht im Quelltext steht, geht als Platzhalter.

Nicht „jeder Wert von einem Besucher”, nicht „jeder Wert, der gefährlich aussieht”. Jeder. Auch der aus deiner eigenen Konfigurationsdatei, auch die Nummer, von der du sicher bist, dass sie eine Nummer ist. Die Regel ist deshalb so schneidend formuliert, weil jede Ausnahme eine Stelle ist, an der jemand nachdenken muss, und Nachdenken geht schief.

Der Preis dafür ist eine Zeile mehr. Der Gewinn ist, dass diese ganze Sorte Fehler in deinem Code nicht vorkommen kann.

Zum Mitnehmen

Die wichtigste Lektion des ganzen Kurses, und sie steht hier und nicht im Sicherheitsabschnitt. Weil sie keine Zusatzmaßnahme ist, sondern die ganz normale Art, mit einer Datenbank zu arbeiten. Wer sie hier lernt, macht es nie anders.

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 4 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe, dein Code läuft auf einem Server

    Öffnet sich mit dem Basis Konto.