mitmario.dev

XSS

PHP Sandbox 4 Min Lesezeit 4 BeispieleLektion 2 von 7

In Lektion 11.4 hast du gelernt, dass ein Wort, das ein Besucher getippt hat, in deiner Seite kein Text ist, sondern Markup, und dass htmlspecialchars() es wieder zu Text macht. Das war die Abwehr. Hier siehst du den Angriff, gegen den sie hilft, und eine gefährlichere Form davon.

Reflektiert gegen gespeichert

Der Angriff aus 11.4 war reflektiert: Der Besucher schickt einen Payload, und die Seite spiegelt sie ihm in derselben Antwort zurück. Damit trifft sie nur den, der sie abschickt, und ein Angreifer muss sein Opfer erst dazu bringen, einen präparierten Link anzuklicken.

Der Payload, der liegen bleibt
<?php

// Eine Notizliste ohne jeden Schutz. Sie nimmt Text entgegen, legt
// ihn in die Datenbank und zeigt ihn genau so wieder an. Genau das
// macht sie angreifbar, und zwar dauerhaft.

$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 notizen (id INTEGER PRIMARY KEY, text TEXT NOT NULL)");

if ($_SERVER["REQUEST_METHOD"] === "POST") {
    $einf = $db->prepare("INSERT INTO notizen (text) VALUES (:t)");
    $einf->execute([":t" => $_POST["text"] ?? ""]);
    header("Location: /", true, 302);
    exit;
}

$notizen = $db->query("SELECT id, text FROM notizen ORDER BY id DESC")->fetchAll();

?>
<!doctype html>
<html lang="de">
<head>
    <meta charset="utf-8">
    <title>Notizen</title>
    <style>body { font-family: system-ui, sans-serif; max-width: 32rem; margin: 2rem auto; }</style>
</head>
<body>
    <h1>Notizen</h1>
    <form method="post" action="/">
        <p><input type="text" name="text" size="40"> <button type="submit">Anlegen</button></p>
    </form>
    <ul>
<?php foreach ($notizen as $n) { ?>
        <li><?= $n["text"] ?></li>
<?php } ?>
    </ul>
</body>
</html>

Die gespeicherte Form ist schlimmer, und du kannst sie nur an einer Seite mit Datenbank sehen, deshalb steht sie hier und nicht in 11.4. Im Reiter Browser läuft die Notizliste ohne jeden Schutz. Schick eine Notiz mit diesem Text ab:

<img src=x onerror="document.body.style.background='crimson'">

Das ist ein Bild, dessen Quelle es nicht gibt. Genau deshalb löst es sein onerror aus, und das ist ein Stück JavaScript. Die Seite färbt sich rot. Was der Browser für das Bild bekommt, zeigt dir das Terminal: curl -si localhost:3000/x | head -1 antwortet mit 200 OK, und dahinter kommt die Startseite, denn der Server kennt keine Datei x und nimmt die index.php. Als Bild taugt eine HTML-Seite nicht, also feuert onerror. Jetzt lade neu, ohne etwas abzuschicken: Sie färbt sich wieder rot. Der Payload liegt in der Datenbank, und ab jetzt bekommt ihn jeder Aufruf ausgeliefert, auch der von jemandem, der nie ein Formular gesehen hat.

Der Schaden entsteht also nicht beim Angreifer, sondern beim nächsten Besucher. Und wenn dieser Besucher angemeldet ist, kann das fremde Skript in seinem Namen handeln oder sein Sitzungscookie mitnehmen, sofern es nicht httponly ist. Jetzt zahlt sich die Angabe aus Lektion 12.2 aus: Ein Cookie, an das JavaScript nicht herankommt, ist auch für diesen Payload unerreichbar.

Warum kein alert()? Weil die Seite in diesem Kurs in einem Rahmen läuft, der keine Dialoge zulässt. Ein alert(1) öffnet hier nichts; der Reiter Console meldet stattdessen eine Zeile, dass der Dialog ausgeblieben ist, und was er gesagt hätte. Ausgerechnet hier sollst du aber etwas in der Seite selbst sehen, und ein umgefärbter Hintergrund tut genau das. Der Schaden bleibt dabei in dem Rahmen, in dem er entstehen soll: Er hat einen eigenen Ursprung und kommt an die Seite drumherum nicht heran.

Eine Zeile Unterschied

Dieselbe Liste, maskiert
<?php

// Dieselbe Liste, ein Zeichen anders: Die Ausgabe geht durch
// htmlspecialchars. Der Text landet weiterhin roh in der Datenbank,
// maskiert wird erst beim Anzeigen.

$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 notizen (id INTEGER PRIMARY KEY, text TEXT NOT NULL)");

if ($_SERVER["REQUEST_METHOD"] === "POST") {
    $einf = $db->prepare("INSERT INTO notizen (text) VALUES (:t)");
    $einf->execute([":t" => $_POST["text"] ?? ""]);
    header("Location: /", true, 302);
    exit;
}

$notizen = $db->query("SELECT id, text FROM notizen ORDER BY id DESC")->fetchAll();

?>
<!doctype html>
<html lang="de">
<head>
    <meta charset="utf-8">
    <title>Notizen</title>
    <style>body { font-family: system-ui, sans-serif; max-width: 32rem; margin: 2rem auto; }</style>
</head>
<body>
    <h1>Notizen</h1>
    <form method="post" action="/">
        <p><input type="text" name="text" size="40"> <button type="submit">Anlegen</button></p>
    </form>
    <ul>
<?php foreach ($notizen as $n) { ?>
        <li><?= htmlspecialchars($n["text"], ENT_QUOTES, "UTF-8") ?></li>
<?php } ?>
    </ul>
</body>
</html>

Dieselbe Liste, ein Zeichen anders: Die Ausgabe geht durch htmlspecialchars. Schick denselben Payload noch einmal ab. Diesmal steht er als Zeichen da, &lt;img ..., und passiert nichts. Aus der Anweisung ist wieder Text geworden.

Beachte, was sich nicht geändert hat: Der Text landet weiterhin roh in der Datenbank. Maskiert wird erst beim Anzeigen. Das ist kein Zufall, sondern die Regel aus 11.4, und weiter unten steht, warum sie so und nicht anders lautet.

Zwei Stellen, an denen htmlspecialchars nicht reicht

Die Funktion ist richtig, aber sie ist für einen Ort gemacht: für Text zwischen zwei Tags. An zwei anderen Orten führt sie in die Irre, und beide sind am eigenen Server nachgemessen.

Ein href ist kein Text
<?php

// htmlspecialchars ist das richtige Werkzeug fuer Text in einer
// Seite. In einem href ist es das falsche, und dieses Beispiel
// zeigt, warum.

$boese = "javascript:alert(1)";

echo "So sieht der Wert nach htmlspecialchars aus:\n";
echo "  ", htmlspecialchars($boese, ENT_QUOTES, "UTF-8"), "\n";
echo "  Unveraendert. Da ist keine spitze Klammer und kein\n";
echo "  Anfuehrungszeichen drin, das zu maskieren waere. Ein Link\n";
echo "  <a href=\"javascript:alert(1)\"> wird trotzdem ausgefuehrt.\n\n";

echo "Die richtige Antwort ist eine Pruefung des Schemas davor:\n";
$erlaubt = str_starts_with($boese, "http://") || str_starts_with($boese, "https://");
echo "  Faengt der Wert mit http an? ", $erlaubt ? "ja" : "nein", "\n";
$sicher = $erlaubt ? $boese : "#";
echo "  Also wird der href zu: ", $sicher, "\n";

Der erste ist ein href oder src. Ein Wert wie javascript:alert(1) enthält keine spitze Klammer und kein Anführungszeichen, es gibt für htmlspecialchars also nichts zu tun: Der Wert kommt Zeichen für Zeichen unverändert durch, und ein Link damit wird ausgeführt. Die Antwort ist hier keine Maskierung dahinter, sondern eine Prüfung des Schemas davor: Nur wer mit http:// oder https:// anfängt, darf überhaupt in ein href.

Ein Wert in einem Skriptblock
<?php

// Soll ein Wert in einen <script>-Block, damit JavaScript ihn
// weiterverwenden kann, ist htmlspecialchars ebenfalls falsch.

$name = 'Ben "der Chef" O\'Brien';

echo "Mit htmlspecialchars, so wie im sichtbaren HTML:\n";
echo "  const name = \"", htmlspecialchars($name, ENT_QUOTES, "UTF-8"), "\";\n";
echo "  Der Browser liest in einem Skriptblock keine Entities: aus\n";
echo "  &quot; wird dort kein Anfuehrungszeichen. Der Wert ist nicht\n";
echo "  gefaehrlich, sondern schlicht falsch.\n\n";

echo "Mit json_encode, dem richtigen Werkzeug fuer diesen Ort:\n";
echo "  const name = ", json_encode($name, JSON_HEX_TAG | JSON_UNESCAPED_UNICODE), ";\n";
echo "  Fertig eingepackt. Und ein </script> im Wert wuerde zu\n";
echo "  \\u003C\\/script, kann den Block also nicht vorzeitig beenden.\n";

Der zweite ist ein <script>-Block. Dort schützt htmlspecialchars zwar vor dem Ausbruch, </script> würde zu &lt;/script&gt;, aber der Wert ist danach trotzdem falsch: Ein Browser übersetzt in einem Skriptblock keine Entities zurück, aus &quot; wird dort kein Anführungszeichen. Der Wert ist nicht gefährlich, sondern kaputt. Das richtige Werkzeug ist json_encode($wert, JSON_HEX_TAG): Es packt den Wert fertig ein, mit Anführungszeichen, und verwandelt ein </script> in eine Form, die den Block nicht beenden kann.

Warum bei der Ausgabe und nicht bei der Eingabe

Damit ist auch die Regel aus 11.4 endlich begründet. Man könnte ja versucht sein, den Text schon beim Speichern zu maskieren, dann wäre er ein für alle Mal sicher. Ist er nicht. Derselbe Text kann morgen in einer E-Mail landen, in einer CSV-Datei, in einer JSON-Antwort an eine App, und in jedem dieser Zusammenhänge ist &lt; schlicht falsch. Erst bei der Ausgabe steht fest, in welchem Zusammenhang der Wert landet, und für jeden Zusammenhang gibt es ein anderes Werkzeug: htmlspecialchars für Text, eine Schemaprüfung für Links, json_encode für JavaScript. Deshalb wird bei der Ausgabe maskiert, und in der Datenbank steht der rohe Text.

Zum Mitnehmen

Die Abwehr kennst du seit Lektion 11.4: htmlspecialchars bei der Ausgabe. Hier siehst du zum ersten Mal, wogegen. Und du siehst den gefährlicheren Bruder des Angriffs von damals, den, der liegen bleibt und den Nächsten trifft.

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.