PHP: Wie verhindert man SQL Injection?

Wenn Benutzereingaben unverändert in einen SQL-Query wandern, ist deine Anwendung anfällig für SQL Injection. Die Lösung sind Prepared Statements. Wie das genau funktioniert und warum Escaping nicht die richtige Antwort ist, schauen wir uns in diesem Artikel an.

Das Problem

Nehmen wir an, du suchst einen Benutzer anhand seines Namens. Der Name kommt aus einem Formular:

<?php
  $name = $_POST['name'];
  $pdo->exec("SELECT id, name FROM users WHERE name = '$name'");
?>

Das sieht harmlos aus. Der Punkt ist aber: Was der Benutzer eintippt, wird Teil des SQL-Statements. Gibt er x'; DROP TABLE users; -- ein, dann schickt deine Anwendung folgendes an die Datenbank:

SELECT id, name FROM users WHERE name = 'x'; DROP TABLE users; -- '

Das einfache Anführungszeichen beendet den String, das Semikolon beendet das Statement, danach folgt ein zweites Statement. Die beiden Bindestriche am Ende kommentieren den Rest der Zeile weg, damit kein Syntaxfehler entsteht. Das Ergebnis: Deine Tabelle ist weg.

Ich habe das gegen eine echte Datenbank ausprobiert (PHP 8.3, MariaDB 11.4). Die Ausgabe des Testskripts:

   before : 3 row(s): Alice, Bob, Carol
   sql    : SELECT id, name FROM users WHERE name = 'x'; DROP TABLE users; -- '
   after  : TABLE GONE (HY000)

Datenverlust ist dabei nicht einmal der häufigste Fall. Viel öfter geht es darum, an Daten zu kommen, die einen nichts angehen. Gibt der Benutzer ' OR '1'='1 ein, sieht der Query so aus:

SELECT name FROM users WHERE name = '' OR '1'='1'

Die Bedingung ist damit immer wahr:

   result : 3 row(s) returned -> Alice, Bob, Carol

Wäre das eine Loginabfrage, käme jeder rein.

Die Lösung: Prepared Statements

Der entscheidende Gedanke ist, Daten und SQL voneinander zu trennen. Bei einem Prepared Statement schickst du zuerst das Statement mit Platzhaltern an die Datenbank. Diese parst und kompiliert es. Erst danach schickst du die Werte. Die Werte werden dann mit dem bereits kompilierten Statement kombiniert, nicht mit einem SQL-String. Der SQL-Parser sieht sie also nie.

Mit PDO

<?php
  $stmt = $pdo->prepare('SELECT id, name FROM users WHERE name = :name');
  $stmt->execute(['name' => $name]);

  foreach ($stmt as $row) {
    // mach etwas mit $row
  }
?>

Genau dieselbe Eingabe wie oben, diesmal als Parameter übergeben:

   input  : x'; DROP TABLE users; --
   result : 0 row(s) -- die Eingabe wurde als Text gesucht
   after  : 3 row(s): Alice, Bob, Carol

Die Tabelle steht noch. Die Datenbank hat schlicht nach einem Benutzer mit dem Namen x'; DROP TABLE users; -- gesucht und keinen gefunden.

Mit MySQLi

<?php
  $stmt = $mysqli->prepare('SELECT id, name FROM users WHERE name = ?');
  $stmt->bind_param('s', $name); // 's' steht für den Typ string
  $stmt->execute();
  $result = $stmt->get_result();
?>

Seit PHP 8.2 geht das auch in einem Aufruf:

<?php
  $result = $mysqli->execute_query('SELECT id, name FROM users WHERE name = ?', [$name]);
?>

Die Verbindung richtig aufsetzen

Ein Detail, das gerne übersehen wird: PDO emuliert Prepared Statements bei MySQL standardmäßig. Das heißt, PHP baut den Query doch wieder selbst zusammen, bevor er an den Server geht. Willst du echte Prepared Statements, musst du das explizit abschalten:

<?php
  $dsn = 'mysql:host=db;dbname=shop;charset=utf8mb4';
  $pdo = new PDO($dsn, 'user', 'password', [
    PDO::ATTR_EMULATE_PREPARES => false,
    PDO::ATTR_ERRMODE          => PDO::ERRMODE_EXCEPTION,
  ]);
?>

Die zweite Zeile ist nicht zwingend notwendig, aber sehr zu empfehlen. Ohne sie schluckt PDO Fehler stillschweigend. Bei MySQLi erreichst du dasselbe mit:

<?php
  mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
?>

Den Zeichensatz solltest du in beiden Fällen setzen (charset=utf8mb4 im DSN bzw. $mysqli->set_charset('utf8mb4')).

Was Platzhalter nicht können

Ein Platzhalter steht immer für einen kompletten Wert. Tabellennamen, Spaltennamen oder die Sortierrichtung kannst du damit nicht ersetzen, weil diese Teil der Struktur des Statements sind. Der Versuch endet mit einem Syntaxfehler:

SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in
your SQL syntax; ... near '?' at line 1

Für solche Fälle brauchst du eine Positivliste (Whitelist). Du prüfst also gegen eine feste Liste erlaubter Werte:

<?php
  // $dir kann nur 'DESC' werden, alles andere wird zu 'ASC'
  if (empty($dir) || $dir !== 'DESC') {
    $dir = 'ASC';
  }
?>

Ein häufiger Stolperstein ist auch die Suche mit LIKE. Die Wildcards gehören zum Wert, nicht ins Statement:

<?php
  // Falsch, der Platzhalter muss den ganzen Wert ersetzen
  $stmt = $pdo->prepare("SELECT * FROM users WHERE name LIKE '%?%'");

  // Richtig
  $stmt = $pdo->prepare('SELECT * FROM users WHERE name LIKE ?');
  $stmt->execute(["%$name%"]);
?>

Warum nicht einfach escapen?

Man liest oft den Rat, Benutzereingaben mit mysql_real_escape_string() oder addslashes() zu entschärfen. Davon ist abzuraten. Die OWASP führt Escaping zwar als mögliche Verteidigung an, bezeichnet es aber ausdrücklich als “STRONGLY DISCOURAGED”. Der Ansatz ist fehleranfällig und hängt von Datenbank und Zeichensatz ab. Eine Garantie, dass er alle Fälle abdeckt, gibt es nicht.

Dazu kommt: mysql_real_escape_string() gehört zur Erweiterung ext/mysql, die es seit PHP 7.0 gar nicht mehr gibt. Warum du diese Funktionen ohnehin nicht mehr verwenden solltest, haben wir in einem eigenen Artikel beschrieben.

Prepared Statements lösen das Problem an der Wurzel, statt an den Symptomen herumzudoktern. Und sie haben noch einen angenehmen Nebeneffekt: Führst du dasselbe Statement in einer Session mehrfach aus, muss es die Datenbank nur einmal parsen und kompilieren.

Zusammenfassung

  • Baue Benutzereingaben niemals per String-Verkettung in einen Query ein.
  • Verwende Prepared Statements, mit PDO oder MySQLi.
  • Schalte bei PDO die Emulation ab (PDO::ATTR_EMULATE_PREPARES => false).
  • Schalte Exceptions ein, damit dir Fehler nicht entgehen.
  • Für Tabellen- und Spaltennamen brauchst du eine Positivliste, dafür gibt es keine Platzhalter.
  • Escaping ist keine Alternative zu Prepared Statements.

Hinweis zu Netcup (Werbung)

Der deutsche Hoster Netcup bietet unter anderem günstige und zugleich leistungsstarke Webhosting Pakete, KVM-basierte Root Server und dezidierte Server an. Mit unseren Gutscheincodes kannst du noch mehr Geld sparen (6€ bei deiner ersten Bestellung, 30% Rabatt auf alle KVM-basierten Root Server, ...).