Programmiertechnik/Kontextwechsel

Aus SELFHTML-Wiki
Wechseln zu: Navigation, Suche

Dieser Artikel befasst sich mit der Problematik des Kontextwechsels. „Problematik“ deshalb, weil die Nichtbeachtung des Kontextwechsels eine der häufigsten Quellen für verschiedene Probleme ist. Ziel ist es, die häufigsten Kontextwechsel im Web-Umfeld aufzuzeigen und wie diese behandelt werden. Das soll vorwiegend mit Beispielen für PHP und MySQL und textbasierenden Daten erklärt werden. Prinzipiell betrifft das Thema jedoch auch andere Sprachen und andere Datentypen.

Der Artikel bezieht sich auf das Zeichen als kleinste Einheit. Auf welche Weisen Zeichen im Computer durch Bits und Bytes dargestellt werden, soll hier nicht weiter thematisiert werden. Dazu sei auf die Seite Zeichencodierung und geschriebene Sprache verwiesen. Unterschiedliche Zeichencodierungen beim Datenaustausch zwischen mehreren Systemen können zwar ebenfalls als Kontextwechsel angesehen werden, doch diese Thematik und deren Probleme ist zu umfangreich, um sie auch noch in diesem Artikel unterzubringen.

Die in diesem Artikel enthaltenen Links zum PHP-Handbuch verweisen generell auf die englische Dokumentation, da dies das Original ist und die Übersetzungen mitunter nicht aktuell genug oder nicht vollständig sind. Im Kopfteil jeder Handbuchseite kann allerdings bei Bedarf und „auf eigenes Risiko“ eine andere Sprache gewählt werden.

Einleitung

Im Anfang war das Wort … und kurz darauf bekam ein Informatiker die Aufgabe dieses Wort mit Hilfe eines Rechners zu verarbeiten. Er musste dem Computer Anweisungen zum Verarbeiten des Wortes geben, aber auch das Wort selbst musste irgendwie in den Arbeitsspeicher gelangen. Er stand vor dem Problem, Programmcode und das Wort in ein Dokument zu bringen, sodass der Computer dieses Dokument lesen und beides eindeutig voneinander unterscheiden kann. In der Literatur tritt ein vergleichbarer Fall auf, wenn wörtliche Rede in eine Erzählung eingefügt werden soll. Man schließt dabei das Gesagte in Anführungszeichen ein. Auch in den Programmiersprachen hat man meist diese Vorgehensweise übernommen.

Programmcode und Daten

Beispiel
Der Computer sagte: "Meine Batterie ist alle." Dann schaltete er sich selbst aus.
echo "Meine Batterie ist alle."; shutdown();

Das erste Beispiel beginnt zunächst im Erzählmodus. Das erste Anführungszeichen wechselt zur wörtlichen Rede, das zweite beendet diese und die Erzählung wird fortgesetzt.

Das zweite Beispiel stellt die gleiche Situation in Form von PHP Programmcode dar. Der Code-Parser liest die Zeichen und versucht, sie gemäß der PHP-Syntaxregeln zu interpretieren. Code und Daten verwenden jedoch zu großen Teilen die gleichen Zeichen (Buchstaben und Satzzeichen). Wann aber gehört ein Zeichen zum Code und wann zu den Daten? Das erste Anführungszeichen leitet einen Kontextwechsel ein. Alle folgenden Zeichen werden nun als Daten gelesen und nicht als Code interpretiert. Das zweite Anführungszeichen kennzeichnet das Ende des Datenkontextes. Der Rest wird wieder als Code interpretiert.

Dieses Beispiel ist eindeutig interpretierbar. Anders sieht die Sachlage aus, wenn ein " (Anführungszeichen) in den Daten enthalten ist.

Beispiel
$text = "Der Computer sagte: "Meine Batterie ist alle." Dann schaltete er sich selbst aus.";

Das Anführungszeichen hat bereits die Bedeutung des Kontextwechsels zwischen Code und Daten. Das zweite Anführungszeichen vor dem Wort Meine würde den Datenkontext beenden und die nachfolgenden Zeichen werden daraufhin im Code-Kontext interpretiert. Gewöhnlich führt dies zu der Meldung des Code-Parsers, dass ein Syntaxfehler vorliegt. Es wäre ziemliches Pech, wenn Daten, die als Code interpretiert werden, einen Sinn ergeben und vom PHP Interpreter ausgeführt würden, denn sie waren ja als Daten gemeint und sollten niemals ausgeführt werden.

Abhilfe ist die kontextgerechte Behandlung des Anführungszeichen. Die PHP-Syntax-Regel[1] besagt: Wenn ein Anführungszeichen in einem mit Anführungszeichen eingefassten String vorkommt, muss ihm ein \ (Backslash) vorangestellt werden.

Beispiel
$text = "Der Computer sagte: \"Meine Batterie ist alle.\" Dann schaltete er sich selbst aus.";

Der Backslash maskiert das zweite Anführungszeichen. Es wird nun nicht mehr als Stringbegrenzer angesehen. Durch die Verwendung des Backslashs als Maskierzeichen hat dieser nun ebenfalls eine Sonderbedeutung bekommen und ist selbst zu maskieren, wenn er als Datenbestandteil vorkommt. Dies geschieht, indem man ihm einen weiteren Backslash voranstellt.

Maskierungen (englisch Escaping) sind nur für die richtige Interpretation von Zeichen beim Lesen eines Dokuments oder Datenstroms notwendig. Die interne Verarbeitung wird meistens mit den Rohdaten stattfinden, denn Maskierzeichen sind meist auch nur normale Zeichen und dürfen bei der Stringverarbeitung (zum Beispiel Zeichen zählen) nicht berücksichtigt werden. Die Maskierzeichen werden beim Lesevorgang entfernt. Im Speicher steht der Wert also wieder als:

Beispiel
Der Computer sagte: "Meine Batterie ist alle." Dann schaltete er sich selbst aus.

Daten ausgeben

Ebenso wie beim Einlesen von Daten ist bei der Ausgabe ein möglicher Kontextwechsel zu beachten. Denn die Ausgabedaten des einen Systems sind nicht selten die Eingabedaten eines anderen Systems. Auf einem textbasierenden Bildschirm muss einfach nur eine in Rohform vorliegende Zeichenkette Zeichen für Zeichen dargestellt werden. Um den String jedoch beispielsweise in einem SQL-basierenden Datenbanksystem abzulegen, muss er in ein SQL-Statement eingebettet werden, weil auch SQL vorsieht, Anweisung und Daten als eine gemeinsame Zeichenfolge zu notieren. Zu beachten sind hier wiederum die Zeichen, die im Datenkontext eine Sonderbedeutung haben, insbesondere die Begrenzungszeichen.

Alternative Betrachtungsweise: Literale

Ein Literal ist eine Zeichenfolge, die einen bestimmten Wert repräsentiert. Möchte man zum Beispiel im Programmcode einen Zahlenwert angeben, so kann man ihn nicht direkt als Bytewert(e) oder ähnliches einfügen, sondern muss sein Literal verwenden. Diese setzt sich aus den einzelnen Ziffern zusammen, für die das jeweilige Zeichen notiert wird. Um eine 42 zu notieren, notiert man das Zeichen 4 gefolgt vom Zeichen 2. true und false wären die Literale für die beiden booleschen Werte. Literale von Text notiert man in vielen Programmiersprachen innerhalb von Anführungszeichen. Das Literal von SELFHTML wäre demnach "SELFHTML". Das Literal eines " (Anführungszeichens) aber ist "\"", und von Bei"spiel"text wäre es "Bei\"spiel\"text". Da die Anführungszeichen zur Begrenzung des Literals dienen, müssen sie, wenn sie als Teil eines Wertes vorkommen, durch ein \" repräsentiert werden.

Jede Progammiersprache – oder allgemeiner gesagt: jeder Kontext – definiert mehr oder weniger eigene Regeln, wie die Literale von bestimmten Werten aussehen müssen. Möchte man einen Wert notieren, so muss man im Prinzip nur das zugehörige Literal gemäß den Regeln des jeweiligen Kontextes bilden.

Fehler und deren Auswirkungen

Im PHP-Code wird eine Nichtbeachtung des Kontextwechsels recht schnell durch einen Syntax-Fehler auffallen.

Beispiel
$sql = "SELECT spalte1, spalte2, spalte3 FROM tabelle WHERE spalte1='$variable'";

In diesem Beispiel kommen zwei geschachtelte Kontextwechsel vor. Mit PHP-Code wird ein String in einer Variablen abgelegt, der im weiteren Verlauf als MySQL-Statement verwendet werden soll. Der innere Kontext ist dabei der interessantere. Wenn zur Laufzeit in $variable der Wert Beispiel steht, so ergibt sich ein SQL-Statement

Beispiel
SELECT spalte1, spalte2, spalte3 FROM tabelle WHERE spalte1='Beispiel'

Steht allerdings Beispiel mit ' drin darin, so ergibt sich

Beispiel
SELECT spalte1, spalte2, spalte3 FROM tabelle WHERE spalte1='Beispiel mit ' drin'

Richtig hingegen wäre

Beispiel
SELECT spalte1, spalte2, spalte3 FROM tabelle WHERE spalte1='Beispiel mit \' drin'

Auch MySQL verwendet den Backslash als Maskierungszeichen.[2]

Unmaskiert ist der String „nur“ syntaktisch inkorrekt. MySQL wird sich mit einer Fehlermeldung darüber beklagen. Gelingt es hingegen jemandem, $variable so zu füllen, dass das SQL-Statement am Ende wie folgt aussieht, so kann er beispielsweise an die Daten der Benutzertabelle kommen.

Beispiel
SELECT spalte1, spalte2, spalte3 FROM tabelle WHERE spalte1='Beispiel mit ' UNION SELECT userid, username, password FROM userdaten -- drin'

Somit hätte das PHP-Script eine SQL-Injection-Lücke.

Beim folgenden Beispiel gelingt das Anmelden als Administrator ohne dessen Passwort zu kennen.

Hier muss allerdings auch erwähnt werden, dass diese Art der Benutzerauthentifizierung grundsätzlich fehlerhaft ist! Korrekt wäre, das gehashte Passwort aus der Datenbank zu lesen und es dann wie hier gezeigt mit password_verify zu überprüfen. Aber leider wurden viele Systeme so wie in diesem Beispiel hier gebaut und besitzen deshalb die beschriebene Anfälligkeit gegen SQL-Injection.

Beispiel
$sql = "SELECT userid, username, userlevel FROM users " . 
       "WHERE username='$username' AND passwordHash='$hash'";

Als Benutzername wurde „admin' -- “ eingegeben. Das fertige SQL-Statement sieht nun so aus:

Beispiel
SELECT userid, username, userlevel FROM users WHERE username='admin' -- ' AND password=''

Die Sequenz Minus-Minus-Leerzeichen leitet einen Kommentar ein, sodass der Passwort-Hash gar nicht mehr verglichen wird.

Beachten Sie: Das Risiko eines Fehlers oder SQL-Injection besteht nicht nur bei Daten die mehr oder weniger direkt aus Benutzereingaben stammen. Einem SQL-Statement ist es egal, wie und aus welchen Quellen es entstanden ist. Am Ende muss sich eine gültige Syntax ergeben und die erzeugte Anweisung muss auch der Intention des Autors entsprechen. Der Kontextwechsel muss also in jedem Fall angemessen berücksichtigt werden.

Verhindernde Maßnahmen

Für den Kontextwechsel aus dem eben gezeigten Beispiel – String in ein MySQL-Statement einfügen – müsste man sämtliche relevante Syntax-Regeln des verwendeten Datenbanksystems berücksichtigen und vor dem Einfügen des Variableninhalts selbigen nach Zeichen mit Sonderbedeutung (Anführungszeichen und einige andere) durchsuchen und diese Zeichen durch ihr maskiertes Pendant austauschen.

Diesen Aufwand kann man sich sparen, denn die diversen PHP-Datenbankschnittstellen stellen Funktionen bereit, die diese Arbeit übernehmen. Für MYSQL ist das mysqli_real_escape_string().[3] Die Funktion erwartet neben dem zu behandelnden String als ersten Parameter die Variable mit der Verbindung zur Datenbank, die mit mysqli_connect() aufgebaut wurde. In den folgenden Beispielen steht die Verbindung in der Variablen $dblink.

Wenn Sie mit PDO arbeiten, steht auf dem PDO-Objekt die Methode quote zur Verfügung.

Beispiel
$sql = "SELECT spalte1,spalte2,spalte3 FROM tabelle WHERE spalte1='" . mysqli_real_escape_string($dblink, $variable) . "'";

Damit man nicht immer den String für das Einfügen der Maskierfunktion verlassen muss, kann man die Platzhalter-Funktion sprintf() hinzunehmen.

Beispiel
$sql = sprintf("SELECT spalte1,spalte2,spalte3 FROM tabelle WHERE spalte1='%s' AND spalte2='%s'", mysqli_real_escape_string($dblink, $variable1), mysqli_real_escape_string($dblink, $variable2));

Der einfachste Anwendungsfall von sprintf() verwendet %s als Platzhalterzeichen für einen String. Pro Platzhalter ist der Funktion ein weiterer Parameter zu übergeben, der an dessen Stelle eingefügt wird.

Besonderheiten: Identifier, ORDER BY

Identifier (Bezeichner) sind die Namen für Datenbanken, Tabellen und Felder. Diese werden meist direkt notiert, sprich: sowohl als fester Statementbestandteil als auch ohne Begrenzungszeichen. Es gibt Situationen, in denen man nicht auf die Begrenzungszeichen verzichten kann oder will oder in denen der Identifier aus Daten stammend eingebaut werden soll. Für MySQL müssen dann die Identifier mit ` (Backticks) eingefasst werden[4]. Für Identifier gibt es nur eine eigene Regel aber keine vorgefertigte PHP-Funktion: Ein im Identifier enthaltener Backtick ist doppelt zu notieren.

Will man die Ausgabe einer Ergebnismenge vom Anwender sortieren lassen, muss man das Sortierfeld und die Richtung in das SQL-Statement einarbeiten. Der Feld- oder Aliasname ist ein Identifier und könnte nach der Identifier-Regel behandelt werden. Gibt allerdings jemand einen Namen eines nicht vorhandenen Feldes an, so gibt es aber trotzdem eine Fehlermeldung von MySQL. Besser ist es, den übergebenen Wert gegen eine Liste der erlaubten Sortierfeldnamen zu prüfen.

Die Sortierrichtungsangaben ASC und DESC sind Bestandteil der SQL-Syntax, also Code, der ohne Kontextwechsel eingefügt werden muss. Eine SQL-Injection-Lücke verhindert man hier ebenfalls nur mit einer Prüfung.

Beispiel
$sql = "SELECT ... FROM ... WHERE ..."; $orderNames = array('feld1', 'feld2', 'alias1'); if (in_array($_GET['orderBy'], $orderNames)) { $orderDir = (isset($_GET['orderDir']) and $_GET['orderDir'] == 'DESC') ? 'DESC' : 'ASC'; $sql .= sprintf(" ORDER BY `%s` %s", str_replace('`', '``', $_GET['orderBy']), $orderDir); }

Das im Vorfeld in $sql aufgebaute SQL-Statment hat noch keine ORDER-BY-Klausel. Der GET-Parameter orderBy wird auf Vorhandensein im Array $orderNames geprüft. Ist das der Fall wird orderDir definiert auf DESC oder ASC gesetzt. Anschließend wird die ORDER-BY-Klausel an das SQL-Statement angehängt. Ein ungültiger Richtungswert ergibt ASC, ein ungültiger Feldname führt in diesem Beispiel zum kompletten Weglassen der ORDER-BY-Klausel.

Besonderheit: der LIKE-Operator

Der LIKE-Operator gibt den beiden Zeichen % (Prozent) und _ (Unterstrich) eine Sonderbedeutung. Möchte man nicht, dass ein Anwender diese Zeichen als Platzhalter (wildcard character) in einem Suchmuster verwenden kann, so müssen diese mit einem vorangestellten \ (Backslash) maskiert werden. MySQL gestattet aber auch, ein beliebiges anderes Maskierzeichen (escape character) zu definieren.

Mit dem folgenden Beispiel soll dieses SQL-Statement gebildet werden:

Beispiel
SELECT ... FROM ... WHERE feld LIKE 'searchFor%'

Anstelle von searchFor soll eine Benutzereingabe eingefügt werden. Dabei sollen eventuell eingegebene % und _ genau so im Suchergebnis enthalten sein – denn als alleiniges Jokerzeichen soll das dem searchFor folgende % Verwendung finden.

Beispiel
$replacePairs = array('%' => '\%', '_' => '\_'); $sql = sprintf("SELECT ... FROM ... WHERE feld LIKE '%s%%'", strtr(mysqli_real_escape_string($dblink, $searchFor), $replacePairs));

Die Funktion sprintf() nimmt mit %s einen Platzhalter entgegen. An seiner Stelle wird das Ergebnis des nächsten Funktionsparameters eingefügt. Das nach dem Platzhalter folgende %% steht aufgrund des Kontextes „sprintf()-Format-String“ für ein % (Prozentzeichen) – welches als Jokerzeichen für den LIKE-Operator vorgesehen ist.

Der zu suchende Wert steht in der Variable $searchFor. Er wird zunächst mit mysqli_real_escape_string() allgemein für den String-Kontext innerhalb eines SQL-Statements vorbereitet. Die Zeichen % und _ werden dabei nicht beeinflusst. Sie werden anschließend mit der Funktion strtr() durch ihre maskierten Formen gemäß dem Mustervergleichskontext (pattern-matching context) des LIKE-Operators ausgetauscht. Dazu wurden vorher in $replacePairs die nötigen Zuordnungen erstellt.

Angenommen in $searchFor steht „25% von“, so ergibt sich nun dieses SQL-Statement:

Beispiel
SELECT ... FROM ... WHERE feld LIKE '25\% von%'

Alternative: Prepared Statement

Bei einem Prepared Statement schreibt man ähnlich wie bei der bereits erwähnten Funktion sprintf() zunächst das SQL-Statement mit Platzhaltern. Dieses SQL-Statement gelangt in einem Prepare (vorbereiten) genannten Schritt zum SQL-Server. An die Platzhalter werden Variablen gebunden. Mit einem Execute (ausführen) werden dann die Daten aus den Variablen an den SQL-Server gesendet. In diesem Fall werden die Daten nicht in den SQL-Statement-Kontext gebracht, denn das SQL-Statement und die Daten gehen getrennte Wege zum DBMS. Dem zufolge müssen und dürfen sie auch nicht dafür aufbereitet werden. Prepared Statements sind also schon vom Prinzip her unanfällig gegen fehlerhafte Maskierungen und SQL-Injection. Dem Nicht-Behandeln-Müssen der Daten steht auf der anderen Seite der etwas erhöhte Programmieraufwand gegenüber. Prepared Statements sind eigentlich dafür vorgesehen, ein Statement mit unterschiedlichen Daten mehrfach nutzen zu können. Dabei fällt das mehrfache Parsen des SQL-Statements weg. Wenn jedoch – wie bei PHP üblich – jeder Request eine eigene Datenbankverbindung aufbaut, ist dieser Vorteil nur dann gegeben, wenn während der Abarbeitung des Requests mehrere gleiche Statements aber mit unterschiedlichen Daten abgearbeitet werden sollen, wie es beim Verarbeiten von Massendaten der Fall ist. Der Vorteil der SQL-Injection-Immunität hingegen bleibt. Zusätzlich müssen die Besonderheiten von Identifiern und dem LIKE-Operator berücksichtigt werden, denn diese sind vom Prepared-Statements-Mechanismus nicht betroffen.

Beispiele zu Prepared Statements können den PHP-Handbuch-Kapiteln zu mysqli und PDO entnommen werden (siehe zum Beispiel bei mysqli_prepare() beziehungsweise PDO: Prepared statements and stored procedures). Eine wichtige Einschränkung hierbei ist, das PHP in den Versionen 5.3 - 7, den Prepare-Schritt zwischen PHP und dem SQL Server nur emuliert.

Zahlen im (My)SQL-Statement

Beachten Sie: Ein falsch behandelter Kontextwechsel kann ebenso fatale Folgen haben wie ein nicht behandelter. Zumindest kann es zu falschen Ergebnissen führen, wenn man für den einen Kontext maskiert, die Daten aber tatsächlich in einem anderen bringt.
Beispiel
SELECT feldliste FROM tabelle WHERE feld=42

Die 42 ist als Zahlenwert notiert. Dem Vergleichsoperator = folgt kein Anführungszeichen und somit wurde kein String-Kontext eingeleitet.

Wenn statt der 42 beispielsweise ein aus einer Nutzereingabe stammender Wert eingefügt werden soll (23 UNION SELECT …), so wird dieser sofort als Befehlsbestandteil angesehen. Die Funktion mysqli_real_escape_string() hilft an dieser Stelle nicht, denn sie maskiert nur und quotiert nicht.

Beispiel
$sql = sprintf('SELECT feldliste FROM tabelle WHERE feld=%s', intval($zahl));

Die Funktion intval() liefert garantiert eine Integerzahl zurück. Wenn $zahl keine gültige Stringdarstellung einer Zahl ist, so ist das Ergebnis zumindest 0. Für Fließkommazahlen gibt es floatval(). Auch ein Typecast nach int oder float garantiert einen Zahlenwert.

Alternativ kann (zumindest unter MySQL) die einzufügende Zahl auch in String-Begrenzern notiert und mit mysqli_real_escape_string() behandelt werden. Der Typecast wird dann von MySQL vorgenommen. (MySQL muss sowieso einen String in eine Zahl umwandeln, denn auch ohne Anführungszeichen ist eine Zahl in einem SQL-Statement-String in ihrer String-Darstellung notiert.)

Fußnoten

  1. Die genauen Syntax-Regeln können dem PHP-Handbuch-Kapitel zu Strings entnommen werden.
  2. Es ist alternativ möglich, das als Begrenzungszeichen genutzte Anführungszeichen doppelt zu notieren: 'Beispiel mit '' drin'. Die üblichere Vorgehensweise ist jedoch der vorangestellte Backslash. Auch wenn man für die Anführungszeichen die Verdopplungsmethode verwendet, muss der Backslash selbst immer noch durch Verdopplung maskiert werden.
  3. Bei einigen im asiatischen Raum verbreiten Zeichencodierungen muss diese unbedingt mittels mysqli_set_charset() eingestellt werden, sonst kann mysqli_real_escape_string() nicht richtig arbeiten, zerstört einige Zeichen und hinterlässt unter Umständen eine Injection-Lücke. Ein SET NAMES-Statement ist ebenfalls nicht geeignet, da es mysqli_real_escape_string() nicht beeinflusst. Für die Codierungen der ISO-8859-Familie und UTF-8, die unter anderem im deutschsprachigen Raum verbreitet sind, ist mysqli_set_charset() nicht erforderlich – jedenfalls nicht wegen sicherheitstechnischer Aspekte.
  4. Es gibt einen ANSI-Kompatibilitäts-Modus (Stichwort: ANSI_QUOTES), in dem werden String-Literale nur in einfachen Anführungszeichen notiert und doppelte Anführungszeichen stehen für Identifier.

Fortsetzung

Die Fortsetzung geht auf verschiedene konkrete Kontextwechsel-Situationen ein – unter anderem: SQL, HTML, URL, JavaScript, E-Mail.