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.
__ http://www.php.net/language.variables.predefined
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!
.. code-block:: php
$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
.. code-block:: mysql
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:
__ http://www.php.net/mysql_real_escape_string
__ http://www.php.net/mysql_connect
.. code-block:: php
$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:
.. code-block:: php
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=Fetter
Text`` 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.
.. code-block:: html
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:
__ http://www.php.net/htmlspecialchars
__ http://www.php.net/htmlentities
.. code-block:: php
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.
__ 0c.html
Es ist zwar praktisch einzelne Inhalte so ein ein Seitengerüst einzubinden:
.. code-block:: php
/* 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.
.. code-block:: php
$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:
.. code-block:: php
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.
__ http://www.php.net/str_replace
.. code-block:: php
$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.
__ http://www.php.net/serialize
__ http://www.php.net/unserialize
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!