Abschnitt 15 · Lektion 2
XSS
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.
<?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
<?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, <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.
<?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.
<?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 " " 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 </script>, aber der Wert ist danach trotzdem falsch: Ein Browser
übersetzt in einem Skriptblock keine Entities zurück, aus " 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 < 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, kostenlosZu 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.