mitmario.dev

SQL-Injection

PHP Sandbox 4 Min Lesezeit 4 BeispieleLektion 1 von 7

In Lektion 14.4 hast du gelernt, wie man einen Wert von außen sicher in eine Abfrage bringt: als Platzhalter, nie geklebt. Der Artikel damals hat den Angriff nur angedeutet und dann weitergedacht: „Dieselbe Lücke in einer Anmeldung heißt, dass jemand ohne Passwort hereinkommt.” Dieser Satz ist die Lektion. Jetzt sehen wir ihn in Aktion.

Die berühmteste Zeile der Websicherheit

Die Anmeldung von 2009
<?php

// Eine Anmeldung, wie sie in unzaehligen alten Anleitungen steht:
// Name und Passwort wandern direkt in den Abfragetext. Sie hat zwei
// Fehler auf einmal. Um den ersten geht es hier.

$db = new PDO("sqlite:" . __DIR__ . "/daten.db", null, null, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

$db->exec("CREATE TABLE IF NOT EXISTS benutzer (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL UNIQUE,
    passwort TEXT NOT NULL
)");

$db->exec("INSERT OR IGNORE INTO benutzer (name, passwort) VALUES ('admin', 's3hr-geheim')");
$db->exec("INSERT OR IGNORE INTO benutzer (name, passwort) VALUES ('mia', 'miamia')");

function e(?string $wert): string
{
    return htmlspecialchars($wert ?? "", ENT_QUOTES, "UTF-8");
}

$name = $_POST["name"] ?? "";
$passwort = $_POST["passwort"] ?? "";
$abfrage = null;
$meldung = null;

if ($_SERVER["REQUEST_METHOD"] === "POST") {
    $abfrage = "SELECT id, name FROM benutzer WHERE name = '" . $name . "' AND passwort = '" . $passwort . "'";
    $zeile = $db->query($abfrage)->fetch();
    $meldung = $zeile === false ? "Anmeldung fehlgeschlagen" : "Angemeldet als " . $zeile["name"];
}

?>
<!doctype html>
<html lang="de">
<head>
    <meta charset="utf-8">
    <title>Anmeldung</title>
    <style>
        body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; }
        pre { background: #f2f2f2; padding: 0.6rem; overflow-x: auto; }
        .meldung { font-weight: bold; }
    </style>
</head>
<body>
    <h1>Anmeldung</h1>

    <form method="post" action="/">
        <p><label>Name: <input type="text" name="name" size="34" value="<?= e($name) ?>"></label></p>
        <p><label>Passwort: <input type="text" name="passwort" size="34" value="<?= e($passwort) ?>"></label></p>
        <button type="submit">Anmelden</button>
    </form>

<?php if ($abfrage !== null) { ?>
    <p>Daraus wird diese Abfrage:</p>
    <pre><?= e($abfrage) ?></pre>
    <p class="meldung"><?= e($meldung) ?></p>
<?php } ?>

</body>
</html>

Im Reiter Browser steht eine Anmeldung, wie sie in tausend alten Anleitungen stand. Name und Passwort werden in den Abfragetext geklebt, und darunter zeigt die Seite dir, was daraus wird. Melde dich erst richtig an: admin mit dem Passwort s3hr-geheim. Dann sieh dir die Abfrage an, die dabei entsteht.

Und jetzt der Angriff. Tipp ins Namensfeld admin' OR '1'='1 und ins Passwortfeld irgendetwas. Sieh dir die Abfrage an, die daraus wird, und die Meldung darunter: Angemeldet als admin, ohne dass du das Passwort kanntest.

Was ist passiert? Der Apostroph in deiner Eingabe schließt die Zeichenkette, in der eigentlich der Name stehen sollte. Danach steht in der Abfrage OR '1'='1', und das ist eine Bedingung, die immer wahr ist. Das AND passwort = ... dahinter fällt zwar nicht weg, aber OR bindet lockerer als AND: Es reicht, dass die eine Seite wahr ist. Die Datenbank findet die erste Zeile, admin, und gibt sie zurück. Genau das ist SQL-Injection: Ein Wert, den du für einen Namen gehalten hast, war in Wirklichkeit ein Stück Befehl.

Diese Anmeldung hat zwei Fehler

Bevor wir die Lücke schließen, ein ehrliches Wort. Die Anmeldung oben macht zwei Dinge falsch, und nur eines davon ist SQL-Injection. Das andere kennst du schon: Sie vergleicht das Passwort im Klartext, statt es zu hashen. Das hast du in Lektion 12.5 abgestellt, und deine Anmeldung von damals macht es richtig.

Man könnte deshalb denken, mit gehashten Passwörtern sei das Problem erledigt. Ist es nicht.

Auch mit password_verify bleibt sie offen
<?php

// Dieselbe geklebte Abfrage, aber diesmal mit ordentlich gehashten
// Passwoertern. Der Klassiker prallt jetzt ab. Offen ist sie
// trotzdem, nur mit einem anderen Trick.

require __DIR__ . "/vorbereiten.php";

function anmelden(PDO $db, string $name, string $passwort): string
{
    $abfrage = "SELECT id, name, hash FROM benutzer WHERE name = '" . $name . "'";

    try {
        $zeile = $db->query($abfrage)->fetch();
    } catch (PDOException $fehler) {
        return "Abfrage gescheitert: " . $fehler->getMessage();
    }

    if ($zeile !== false && password_verify($passwort, $zeile["hash"])) {
        return "Angemeldet als " . $zeile["name"];
    }

    return "Anmeldung fehlgeschlagen";
}

// Der Hash von "knacken", von einem Angreifer auf dem eigenen
// Rechner erzeugt. Er kennt sein Passwort, nur nicht deins.
$fremderHash = '$2y$12$j04v.IOsk1BMeLqA2YRvGemDfEtHzt7znKE/Ci4iNfkdC5pZNhRvS';

echo "1. Richtiges Passwort\n";
echo "   ", anmelden($db, "admin", "s3hr-geheim"), "\n\n";

echo "2. Der Klassiker aus dem letzten Beispiel\n";
echo "   Eingabe im Namensfeld: admin' OR '1'='1\n";
echo "   ", anmelden($db, "admin' OR '1'='1", "egal"), "\n\n";

echo "3. Eine Zeile, die es in der Tabelle gar nicht gibt\n";
echo "   Eingabe: gibtesnicht' UNION SELECT 9, 'admin', '<hash von knacken>' -- \n";
echo "   ", anmelden($db, "gibtesnicht' UNION SELECT 9, 'admin', '" . $fremderHash . "' -- ", "knacken"), "\n";

Dasselbe noch einmal, diesmal mit ordentlich gehashten Passwörtern und password_verify(). Der Klassiker admin' OR '1'='1 prallt jetzt ab: Die Abfrage findet zwar wieder die Zeile admin, aber das mitgegebene Passwort passt nicht zu ihrem Hash, und password_verify sagt Nein.

Nur ist die Abfrage immer noch geklebt, und wer das weiß, hängt einfach eine eigene Zeile an. Das UNION SELECT im dritten Fall erfindet eine Zeile, die es in der Tabelle gar nicht gibt: mit dem Namen admin und einem Hash, den der Angreifer selbst erzeugt hat, zu einem Passwort, das er kennt. Die echte Abfrage findet nichts, die angehängte liefert die erfundene Zeile, und password_verify prüft das Passwort des Angreifers gegen dessen eigenen Hash. Es passt. Angemeldet als admin. Im Reiter Debug siehst du die erfundene Zeile mit eigenen Augen: Beim dritten Fall steht abfrage auf dem zusammengeklebten Text mit dem UNION darin, und zeile danach auf {id: 9, name: "admin", hash: "$2y$12$j04v…"}, einer Zeile, die es in der Tabelle nie gab.

Der Punkt: Das Passwort zu hashen ist richtig und wichtig, aber es ist keine Antwort auf SQL-Injection. Die beiden Fehler sind unabhängig, und beide müssen weg.

Was ein Angreifer sonst noch erfährt

Was eine Fehlermeldung ausplaudert
<?php

// Eine Anmeldung, die ihre Fehlermeldung an den Besucher weiterreicht,
// so wie man es in Anleitungen oft sieht: catch, und dann getMessage()
// auf die Seite. Fuer einen Angreifer ist das ein Werkzeug.

require __DIR__ . "/vorbereiten.php";

function frage(PDO $db, string $eingabe): void
{
    echo "Eingabe: ", $eingabe, "\n";
    try {
        $db->query("SELECT id, name, hash FROM benutzer WHERE name = '" . $eingabe . "'")->fetchAll();
        echo "  (kein Fehler)\n\n";
    } catch (PDOException $fehler) {
        echo "  Fehler: ", $fehler->getMessage(), "\n\n";
    }
}

// Jede Antwort sagt dem Angreifer etwas ueber die Tabelle, ohne dass
// er sie je zu sehen bekommt.
frage($db, "x' AND passwort = 1 -- ");
frage($db, "x' AND hash = 1 -- ");
frage($db, "x' UNION SELECT 1 -- ");

Um ein UNION SELECT zu bauen, muss der Angreifer die Tabelle kennen: wie die Spalten heißen, wie viele es sind. Er sieht sie nicht, aber er kann fragen, und die Datenbank antwortet, wenn man sie lässt. Das dritte Beispiel tastet sich vor: x' AND passwort = 1 liefert no such column: passwort, also gibt es diese Spalte nicht. x' AND hash = 1 liefert keinen Fehler, also gibt es hash. Und x' UNION SELECT 1 klagt, dass links und rechts nicht gleich viele Spalten stehen, was verrät, dass es mehr als eine ist.

Jede dieser Meldungen ist harmlos für sich und ein Bauplan in der Summe. Deshalb war Lektion 7.5 so deutlich: Auf einem öffentlichen Server bekommt der Besucher einen ruhigen Satz, nie die Fehlermeldung selbst. Und LIMIT 1 in der Abfrage ist übrigens keine Sicherheitsmaßnahme: Es begrenzt, wie viele Zeilen zurückkommen, nicht, welche der Angreifer sich aussucht.

Der eine Fall, für den es keinen Platzhalter gibt

Der eine Fall ohne Platzhalter
<?php

// Ein Platzhalter steht fuer einen Wert. Fuer einen Spalten- oder
// Tabellennamen gibt es keinen, und genau diese Stelle braucht eine
// andere Loesung.

require __DIR__ . "/vorbereiten.php";

echo "Ein Platzhalter fuer die Spalte sortiert nicht nach der Spalte,\n";
echo "sondern nach der Zeichenkette, und die ist bei jeder Zeile gleich:\n";

$sortiert = $db->prepare("SELECT name FROM benutzer ORDER BY ?");
$sortiert->execute(["name"]);
echo "  ORDER BY ? mit dem Wert name  ->  ", implode(", ", array_column($sortiert->fetchAll(), "name")), "\n";

$db->exec("INSERT INTO benutzer (name, hash) VALUES ('zoe', 'x'), ('ben', 'y')");
$richtig = $db->query("SELECT name FROM benutzer ORDER BY name");
echo "  ORDER BY name ausgeschrieben ->  ", implode(", ", array_column($richtig->fetchAll(), "name")), "\n\n";

echo "Kommt die Sortierspalte von aussen, ist die Antwort eine Positivliste.\n";
echo "Nur ein Name, der darin steht, wird eingesetzt:\n";

$erlaubt = ["name", "id"];

foreach (["name", "hash gestohlen"] as $wunsch) {
    $spalte = in_array($wunsch, $erlaubt, true) ? $wunsch : "id";
    echo "  Wunsch \"$wunsch\"  ->  sortiert nach $spalte\n";
}

Ein Platzhalter steht für einen Wert, nie für ein Stück Struktur. Das letzte Beispiel zeigt, wo das wehtut: ORDER BY ? mit dem Wert name sortiert nicht nach der Spalte name, sondern nach der Zeichenkette "name", die bei jeder Zeile gleich ist. Es kommt keine Fehlermeldung, es passiert nur nichts.

Wenn also eine Spalte oder ein Tabellenname von außen kommen soll, etwa eine wählbare Sortierung, gibt es keinen Platzhalter. Die Antwort ist eine Positivliste: Du schreibst die erlaubten Namen selbst in ein Array und setzt nur ein, was darin vorkommt. Alles andere fällt auf einen festen Standardwert zurück. Der Unterschied zur geklebten Abfrage ist entscheidend: Nicht der Besucher bestimmt den Spaltennamen, sondern du. Er darf nur noch aus deiner Liste wählen.

Und die gefürchtete gestapelte Anweisung? '; DROP TABLE benutzer hinter einer Abfrage? Nachgemessen in diesem Kurs: An einer lesenden Abfrage mit query() läuft das angehängte DROP nicht, nur die erste Anweisung wird ausgeführt. An einer schreibenden mit exec() sehr wohl, dann ist die Tabelle weg. Die ehrliche Fassung ist also: An einer geklebten Leseabfrage kommt ein Angreifer an deine Daten, an einer geklebten Schreibabfrage auch an die Tabelle. Beides ist schlimm genug, und gegen beides hilft dasselbe: der Platzhalter, und für Struktur die Positivliste.

Zum Mitnehmen

Das Mittel dagegen kennst du seit Lektion 14.4: die vorbereitete Anweisung. Dieser Abschnitt dreht die Kamera um und zeigt, wogegen sie eigentlich hilft. Denn eine Abwehr versteht man erst, wenn man den Angriff einmal gesehen hat.

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.