Von mysql_ und prozeduralem mysqli zu Prepared Statements
Verstreute mysql_-Aufrufe und String-verkettete SQL-Abfragen sind eines der häufigsten und gefährlichsten Erbstücke alter PHP-Projekte. Dieser Artikel zeigt, wie man solche Legacy-Datenbankzugriffe systematisch durch eine einheitliche PDO-Schicht mit Prepared Statements ersetzt, ohne den Betrieb zu unterbrechen.
Inhaltsverzeichnis
- 1. Warum Legacy-Datenbankzugriffe so gefährlich sind
- 2. Bestandsaufnahme: alle Zugriffsmuster im Code finden
- 3. Eine Adapterschicht als sicheren Übergang bauen
- 4. Prepared Statements statt String-Verkettung
- 5. Fetch-Modi und Rückgabetypen vereinheitlichen
- 6. Transaktionen korrekt nachrüsten
- 7. Fehlerbehandlung: von Rückgabewerten zu Exceptions
- 8. Priorisierung: welche Abfragen zuerst migrieren
- 9. mysql_/mysqli-prozedural vs. PDO im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum Legacy-Datenbankzugriffe so gefährlich sind
Legacy-Datenbankzugriffe über mysql_*-Funktionen oder prozedurales mysqli mit direkt in SQL-Strings eingebetteten Variablen gehören zu den häufigsten und gefährlichsten Mustern in alten PHP-Projekten. Das Problem ist nicht nur, dass mysql_* seit PHP 7.0 vollständig entfernt ist, sondern dass String-verkettete Abfragen ohne Prepared Statements praktisch immer eine SQL-Injection-Lücke bedeuten, sobald Benutzereingaben ungefiltert in die Abfrage gelangen.
Der typische Fund in einem Legacy-Audit sieht so aus: eine Funktion, die eine Benutzer-ID per $_GET['id'] entgegennimmt und direkt in einen SQL-String einfügt, ohne jegliche Escaping- oder Bindungslogik. Diese Muster sind über Jahre gewachsen, oft von mehreren Entwicklergenerationen kopiert, und finden sich typischerweise in Dutzenden bis Hunderten Stellen im Code verteilt, was eine punktuelle Behebung unpraktikabel macht.
Die Migration auf PDO löst zwei Probleme gleichzeitig: Sie stellt die technische Voraussetzung für einen PHP-Versionssprung her, weil mysql_* nicht mehr existiert, und sie beseitigt strukturell das SQL-Injection-Risiko, sofern konsequent Prepared Statements statt String-Verkettung verwendet werden. Beide Ziele erfordern denselben Migrationsweg, weshalb sich dieser Artikel auf eine gemeinsame Strategie konzentriert.
2. Bestandsaufnahme: alle Zugriffsmuster im Code finden
Bevor eine einzige Zeile Code geändert wird, braucht die Migration eine vollständige Bestandsaufnahme aller Datenbankzugriffsmuster im Projekt. Ein einfaches Grep nach mysql_query, mysql_connect und mysqli_query liefert einen ersten Überblick, deckt aber nicht die noch gefährlicheren Fälle ab, in denen Variablen direkt in SQL-Strings interpoliert werden, selbst wenn bereits mysqli im objektorientierten Stil verwendet wird.
Das folgende Skript sucht systematisch nach SQL-Strings mit direkter Variableninterpolation, einem der zuverlässigsten Indikatoren für eine SQL-Injection-Lücke, unabhängig davon, welche Datenbank-API im Einsatz ist.
#!/usr/bin/env bash
# find-sql-injection-risks.sh — locate string-interpolated SQL queries
set -euo pipefail
# Matches SELECT/INSERT/UPDATE/DELETE strings containing a $variable
# directly inside the quotes — a strong signal of unescaped user input
grep -rnE '"(SELECT|INSERT|UPDATE|DELETE)[^"]*\$[a-zA-Z_]' \
--include='*.php' \
app/code/ \
| tee /tmp/sql-injection-candidates.txt
echo "Found $(wc -l < /tmp/sql-injection-candidates.txt) candidate locations"
Diese Liste ist der Ausgangspunkt für die gesamte Migration. Jeder Treffer wird nach Risiko bewertet, etwa danach, ob die interpolierte Variable aus Benutzereingaben stammt oder aus einer intern kontrollierten Quelle, und in eine priorisierte Reihenfolge gebracht, die im achten Abschnitt dieses Artikels vertieft wird.
3. Eine Adapterschicht als sicheren Übergang bauen
Eine Migration, die alle Zugriffsstellen gleichzeitig umbaut, ist bei mehreren hundert Fundstellen nicht praktikabel. Der bewährte Ansatz ist eine dünne Adapterklasse, die die neue PDO-Verbindung kapselt und eine API bereitstellt, die der alten mysql_*-Nutzung ähnelt genug ist, um bestehenden Code mit minimalen Änderungen zu migrieren, aber intern bereits vollständig auf Prepared Statements setzt.
<?php
declare(strict_types=1);
// Adapter layer: familiar call shape, safe PDO implementation underneath
final class Database
{
private \PDO $connection;
public function __construct(string $dsn, string $user, string $password)
{
$this->connection = new \PDO($dsn, $user, $password, [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
\PDO::ATTR_EMULATE_PREPARES => false,
]);
}
/**
* @param array<string, mixed> $params
* @return array<int, array<string, mixed>>
*/
public function fetchAll(string $sql, array $params = []): array
{
$stmt = $this->connection->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
/** @param array<string, mixed> $params */
public function fetchOne(string $sql, array $params = []): ?array
{
$stmt = $this->connection->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch();
return $row === false ? null : $row;
}
/** @param array<string, mixed> $params */
public function execute(string $sql, array $params = []): int
{
$stmt = $this->connection->prepare($sql);
$stmt->execute($params);
return $stmt->rowCount();
}
}
// Legacy call site — old: mysql_query("SELECT * FROM users WHERE id = $id")
// New: named placeholders replace direct string interpolation entirely
$db = new Database('mysql:host=localhost;dbname=shop;charset=utf8mb4', 'app_user', getenv('DB_PASSWORD') ?: '');
$user = $db->fetchOne('SELECT * FROM users WHERE id = :id', ['id' => $id]);
Der entscheidende Vorteil dieser Adapterschicht: Jede Methode erzwingt strukturell die Nutzung von Platzhaltern statt String-Verkettung, weil $sql und $params getrennte Parameter sind. Ein Entwickler kann in dieser API kaum noch versehentlich eine unsichere Abfrage schreiben, ohne die Methode bewusst zu umgehen.
4. Prepared Statements statt String-Verkettung
Der Kern der gesamten Migration ist der Übergang von String-verketteten SQL-Abfragen zu Prepared Statements mit gebundenen Parametern. Der Unterschied ist nicht nur syntaktisch, sondern fundamental: Bei einer Prepared Statement wird die SQL-Struktur getrennt vom Datenbanktreiber kompiliert, bevor Werte eingesetzt werden, sodass ein Wert niemals als Teil der SQL-Syntax interpretiert werden kann, unabhängig von seinem Inhalt.
Benannte Platzhalter wie :id statt positionsbasierter ?-Platzhalter erhöhen zusätzlich die Lesbarkeit bei Abfragen mit vielen Parametern, weil die Zuordnung zwischen Platzhalter und Wert im Code explizit sichtbar bleibt, statt sich aus der Reihenfolge der Werte im Array zu ergeben. Bei der Migration von Legacy-Code ist es sinnvoll, systematisch auf benannte Platzhalter zu setzen, auch wenn positionale Platzhalter funktional gleichwertig sind, weil sie bei späteren Änderungen an der Abfrage weniger fehleranfällig sind.
5. Fetch-Modi und Rückgabetypen vereinheitlichen
Ein oft übersehener Aspekt der Migration betrifft die Konsistenz der Rückgabetypen. mysql_fetch_array() lieferte historisch sowohl numerisch indizierte als auch assoziative Schlüssel im selben Array, was in Legacy-Code zu einer wilden Mischung aus $row[0] und $row['name'] im selben Projekt führte. PDO erlaubt, den Fetch-Modus explizit und projektweit einheitlich festzulegen, etwa ausschließlich PDO::FETCH_ASSOC, was Code vorhersehbarer und leichter überprüfbar macht.
Bei der Migration empfiehlt es sich, diesen einheitlichen Fetch-Modus bereits als Standardeinstellung in der Verbindungskonfiguration zu setzen, wie im Adapterbeispiel aus Abschnitt drei gezeigt, statt ihn bei jedem einzelnen Aufruf explizit anzugeben. Das reduziert die Menge an Code, die während der Migration angepasst werden muss, und verhindert, dass neue, noch nicht migrierte Aufrufer versehentlich einen inkonsistenten Fetch-Modus verwenden.
6. Transaktionen korrekt nachrüsten
Viele Legacy-Systeme mit mysql_*-Zugriffen verzichten komplett auf Transaktionen, weil die alte API sie zwar technisch unterstützte, aber selten konsequent genutzt wurde. Bei der Migration auf PDO ist es sinnvoll, gleichzeitig zu prüfen, welche mehrstufigen Datenbankoperationen tatsächlich atomar ablaufen müssen, etwa eine Bestellung, die sowohl eine Zeile in der Bestelltabelle als auch mehrere Zeilen in der Positionstabelle anlegt.
PDO bietet mit beginTransaction(), commit() und rollBack() eine einfache, konsistente Transaktions-API über alle unterstützten Datenbanktreiber hinweg. Ein bewährtes Muster ist, kritische mehrstufige Operationen in eine Methode zu kapseln, die im Fehlerfall automatisch zurückrollt, statt die Transaktionslogik über den aufrufenden Code zu verteilen, wo sie leicht vergessen werden kann.
<?php
declare(strict_types=1);
// Multi-step operation wrapped in a transaction with automatic rollback
final class OrderCreator
{
public function __construct(private readonly \PDO $connection)
{
}
/** @param array<int, array{price: float, quantity: int}> $lines */
public function create(int $customerId, array $lines): int
{
$this->connection->beginTransaction();
try {
$stmt = $this->connection->prepare(
'INSERT INTO orders (customer_id, status) VALUES (:customer_id, :status)'
);
$stmt->execute(['customer_id' => $customerId, 'status' => 'new']);
$orderId = (int) $this->connection->lastInsertId();
$lineStmt = $this->connection->prepare(
'INSERT INTO order_lines (order_id, price, quantity) VALUES (:order_id, :price, :quantity)'
);
foreach ($lines as $line) {
$lineStmt->execute([
'order_id' => $orderId,
'price' => $line['price'],
'quantity' => $line['quantity'],
]);
}
$this->connection->commit();
return $orderId;
} catch (\PDOException $e) {
$this->connection->rollBack();
throw $e;
}
}
}
7. Fehlerbehandlung: von Rückgabewerten zu Exceptions
mysql_query() gab bei einem Fehler false zurück, das Skript musste anschließend jeden Rückgabewert explizit prüfen, um Fehler zu erkennen, ein Muster, das in der Praxis regelmäßig vergessen wurde. PDO löst dieses Problem strukturell, wenn PDO::ATTR_ERRMODE auf PDO::ERRMODE_EXCEPTION gesetzt wird, wie im Adapterbeispiel gezeigt: Ein Datenbankfehler wirft dann automatisch eine PDOException, statt einen leicht übersehbaren Rückgabewert zu liefern.
Diese Umstellung erfordert, bestehenden Code zu überprüfen, der bisher explizit auf false-Rückgabewerte geprüft hat, weil solche Prüfungen nach der Migration nie mehr greifen, der Fehler aber trotzdem geworfen wird, wenn er nicht abgefangen wird. Ein zentraler try-catch-Block an einer sinnvollen Stelle der Anwendung, etwa im Controller oder Command-Handler, fängt unerwartete PDOException-Instanzen ab und verhindert, dass Datenbankfehler unkontrolliert Details wie Tabellennamen oder Verbindungsdaten an den Benutzer durchreichen.
<?php
declare(strict_types=1);
// Central boundary: catches PDOException, never leaks internals to users
final class ErrorHandlingController
{
public function __construct(private readonly OrderCreator $orderCreator)
{
}
public function handle(int $customerId, array $lines): array
{
try {
$orderId = $this->orderCreator->create($customerId, $lines);
return ['success' => true, 'order_id' => $orderId];
} catch (\PDOException $e) {
// Log the real error internally, return a generic message
error_log('Order creation failed: ' . $e->getMessage());
return ['success' => false, 'error' => 'Order could not be created'];
}
}
}
8. Priorisierung: welche Abfragen zuerst migrieren
Bei Hunderten zu migrierenden Fundstellen aus der Bestandsaufnahme in Abschnitt zwei braucht die Migration eine Reihenfolge. Der wichtigste Faktor ist nicht die Häufigkeit der Nutzung, sondern die Herkunft der interpolierten Variable: Abfragen, die direkt Benutzereingaben aus $_GET, $_POST oder $_COOKIE verarbeiten, stellen ein akutes Sicherheitsrisiko dar und sollten vor allen anderen migriert werden, unabhängig von ihrer sonstigen Komplexität.
Abfragen mit intern kontrollierten Werten, etwa aus Konfigurationsdateien oder festen Konstanten, haben ein geringeres unmittelbares Risiko und können in der zweiten Priorisierungswelle folgen. Eine dritte Kategorie, Abfragen ohne jegliche Variableninterpolation, benötigt aus Sicherheitssicht keine dringende Migration, sollte aber aus Kompatibilitätsgründen dennoch vor dem nächsten PHP-Versionssprung umgestellt werden, weil mysql_* schlicht nicht mehr existiert.
#!/usr/bin/env bash
# categorize-sql-findings.sh — tag each candidate by variable origin
set -euo pipefail
while IFS=: read -r file line content; do
if echo "$content" | grep -qE '\$_(GET|POST|COOKIE|REQUEST)'; then
echo "CRITICAL $file:$line"
elif echo "$content" | grep -qE '\$_(SESSION|SERVER)'; then
echo "MEDIUM $file:$line"
else
echo "LOW $file:$line"
fi
done < /tmp/sql-injection-candidates.txt | sort
9. mysql_/mysqli-prozedural vs. PDO im Vergleich
Die folgende Tabelle vergleicht die wichtigsten Unterschiede zwischen den alten Datenbankzugriffsmustern und der PDO-Zielarchitektur.
| Kriterium | mysql_ / mysqli prozedural | PDO mit Prepared Statements |
|---|---|---|
| SQL-Injection-Schutz | Manuelles Escaping, leicht vergessen | Strukturell durch getrennte Parameterbindung |
| Datenbankunterstützung | Nur MySQL, mysql_ seit PHP 7 entfernt | MySQL, PostgreSQL, SQLite über ein Interface |
| Fehlerbehandlung | Rückgabewert false, leicht übersehen | Exceptions über ERRMODE_EXCEPTION |
| Transaktionen | Vorhanden, aber selten konsequent genutzt | Einheitliche API, leicht in Methoden kapselbar |
Die Tabelle macht deutlich, dass der Umstieg auf PDO nicht nur eine formale Notwendigkeit für neuere PHP-Versionen ist, sondern strukturelle Sicherheits- und Wartbarkeitsvorteile mit sich bringt, die weit über die reine Kompatibilität hinausgehen.
Mironsoft
PHP-Legacy-Modernisierung und Magento-Entwicklung
Noch mysql_ oder ungesicherte SQL-Strings im Einsatz?
Wir identifizieren alle SQL-Injection-Risiken in eurer Codebasis, bauen eine sichere PDO-Adapterschicht und migrieren bestehende Abfragen priorisiert nach tatsächlichem Risiko.
Sicherheits-Audit
Alle string-interpolierten SQL-Abfragen im Projekt systematisch finden
PDO-Adapter
Adapterschicht mit Prepared Statements und einheitlichen Fetch-Modi
Priorisierte Migration
Kritische, benutzereingabegetriebene Abfragen zuerst absichern
10. Zusammenfassung
Legacy-Datenbankzugriffe über mysql_* oder prozedurales mysqli mit String-verketteten SQL-Abfragen gehören zu den dringendsten Modernisierungsaufgaben in alten PHP-Projekten, weil sie sowohl ein technisches Kompatibilitätsproblem als auch ein akutes Sicherheitsrisiko darstellen. Eine dünne PDO-Adapterschicht, die Prepared Statements strukturell erzwingt, ermöglicht eine schrittweise Migration ohne Big-Bang-Umbau, indem bestehende Aufrufer nach und nach auf die neue, sichere API umgestellt werden.
Die Priorisierung sollte sich am tatsächlichen Risiko orientieren, nicht an der Nutzungshäufigkeit: Abfragen mit direkten Benutzereingaben zuerst, intern kontrollierte Werte danach. Einheitliche Fetch-Modi, konsequente Exception-basierte Fehlerbehandlung und sauber gekapselte Transaktionen runden die Migration ab und liefern am Ende eine Datenbankschicht, die sowohl auf aktuellen PHP-Versionen läuft als auch strukturell gegen SQL-Injection abgesichert ist.
Legacy-Datenbankzugriffe auf PDO migrieren — Das Wichtigste auf einen Blick
Bestandsaufnahme
Grep nach mysql_query und string-interpolierten SQL-Statements liefert die vollständige Fundliste.
Adapterschicht
Eine dünne PDO-Klasse erzwingt strukturell Prepared Statements und ermöglicht schrittweise Migration.
Priorisierung
Abfragen mit direkten Benutzereingaben aus $_GET oder $_POST zuerst migrieren.
Fehlerbehandlung
ERRMODE_EXCEPTION ersetzt leicht übersehbare false-Rückgabewerte durch echte Exceptions.