PHP

PHP 5.3.7, eine Analyse

Das PHP-Entwicklerteam veröffentlichte vor wenigen Tagen die Version 5.3.7, nur um schon wenige Tage später vor deren Verwendung zu warnen . Der Grund dafür war ein Fehler in der Funktion crypt(), welche bei bestimmten Hash-Verfahren lediglich das Salt zurückliefert. Das kann dazu führen, dass nach einem Update auf PHP 5.3.7 keine Benutzer sich mehr auf einem Webauftritt einloggen können oder sich bei einer Passwortänderung nach einem Update auf eine spätere PHP-Version nicht mehr einloggen können. In PHP 5.3.8 wurde der Fehler wieder behoben.

Dieser Artikel ist der Versuch einer Analyse, wie es zu dem Fehler kam und warum er erst nach der Release bemerkt wurde.

Die PHP-Funktion crypt() ist in der Datei php_crypt_r.c implementiert. Folgender Codeausschnitt baut dort den Passwort-Hash zusammen:

memcpy(passwd, MD5_MAGIC, MD5_MAGIC_LEN);
strlcpy(passwd + MD5_MAGIC_LEN, sp, sl + 1);
strcat(passwd, "$");

strcat() fügt eine Zeichenkette an das Ende eines Puffers. Die Funktion gilt als unsicher, da sie nicht prüft, ob der Zielpuffer genügend Speicherplatz zur Verfügung stellt. Wird die Zeichenkette zu lang, wird der nachfolgende Speicherbereich beschädigt - ein typisches Problem bei C-Sprachen.

Aus dem Grund wurde der Aufruf durch eine sicherere Funktion ersetzt. Leider ist aber gut gemeint das Gegenteil von gut gemacht.

memcpy(passwd, MD5_MAGIC, MD5_MAGIC_LEN);
strlcpy(passwd + MD5_MAGIC_LEN, sp, sl + 1);
strlcat(passwd, "$", 1);

strlcat() stellt sicher, dass der Puffer nicht über sein Ende hinaus beschrieben wird. Dazu wird dessen Größe übergeben. Und genau hier lag das Problem, denn statt der Größe von passwd wurde anscheinend die Größe des zu kopierenden Textes übergeben, nämlich 1. Da passwd zu dem Zeitpunkt bereits deutlich mehr als ein Zeichen enthält, tut strlcat() genau das, was es tun soll, nämlich gar nichts. Das “$"-Zeichen wird nicht angehängt, das tatsächliche Ergebnis weicht damit von dem gewünschten Ergebnis ab.

Solch ein Fehler ist eigentlich ein Lehrbuchbeispiel für Unit-Tests, und der Kommentar zum Bugfix (Revision 315218) deutet auch an, dass ein solcher existiert:

Unbreak crypt() (fix bug #55439)
# If you want to remove static analyser messages, be my guest,
# but please run unit tests after

Tatsächlich gibt es einen Test, der die crypt()-Funktion mit bestimmten Werten aufruft und das Ergebnis mit einem erwarteten Ergebnis vergleicht. Dieser Test schlägt Alarm, wenn er ausgeführt wird.

Dass PHP 5.3.7 dennoch veröffentlicht wurde, lässt eigentlich nur einen Schluss zu: Der Unit-Test wurde nicht ausgeführt oder der Alarm wurde schlichtweg ignoriert. Spätestens beim Bau der finalen Version unmitelbar vor der Veröffentlichung hätte dies aber stattfinden müssen. Alles andere wäre grob fahrlässig.

Zusammengefasst hatte der Fehler also folgende Ursachen:

  • die für C-Sprachen üblichen Probleme bei der sicheren Verarbeitung von Zeichenketten
  • eine missverständliche oder nicht verstandene Dokumentation der Funktion strlcat()
  • keine verbindlich vorgeschriebene fehlerfreie Ausführung der Unit-Tests vor der Freigabe einer Release

Insbesondere der letzte Punkt wiegt schwer und wirft ein schlechtes Licht auf die verantwortlichen PHP-Entwickler.

PHP-Angriff von EviLuTz

Gestern hat ein netter Zeitgenosse offenbar versucht, meine Websites zu hacken. Er klapperte auf gut Glück eine Liste von über 700 URLs ab, die alle versuchen, die URL http://glendalehills.am/photo.gif? einzubinden. Hierbei handelt es sich nicht um ein Bild, sondern um ein simples PHP-Script:

<?php
echo ("EviLuTz hacked you");
?>

Der Sinn hinter dieser Aktion wird sein, die angegriffenen Server auf ein bei Hackern sehr beliebtes PHP-“Feature” abzuklopfen. PHP erlaubt es beim include()-Kommando, URLs anzugeben, die PHP dann artig vom externen Server nachlädt und einfach mal ausführt. Idiotischerweise ist diese Funktionalität per Default erlaubt, und kann auch erst seit PHP 5.2.0 gezielt abgeschaltet werden. Wenn man nun einen Parameter ungeprüft übernimmt und in das include() füttert, um normalerweise eine lokale PHP-Datei einzubinden, ist die Sicherheitslücke auch schon da. Leider tappen viele PHP-Entwickler in diese Falle. Vermutlich auch, weil dieses Feature erst irgendwann nachträglich dazukam. Andererseits dürfen Parameter, die vom Besucher der Site kommen, sowieso niemals ungeprüft verwendet werden.

Aber zurück zum Thema: Wenn der Angreifer nach dem Aufruf eine Seite zurückbekommt, in der der Text “EviLuTz hacked you” enthalten ist, weiß er, dass die Site angreifbar ist. Ob er dann sofort ein Hack-Script nachschiebt oder erst mal nur die Server sondiert, kann ich nicht sagen.

Der Angriff ging gegen mehrere Domains, aber immer von der IP 91.121.31.184 aus. Sie gehört offenbar einem dedizierten Server eines französischen ISPs. Auffällig ist auch der User-Agent, der mit der Anfrage mitgeschickt wird: “Toata dragostea mea pentru diavola” (laut Google-Übersetzer ist das Rumänisch für “Alle meine Liebe für den Teufel”).

Die Domain glendalehills.am gehört laut Whois seit 2005 der Firma “Glandale Hills” (sic!) in Armenien.

Als schnelle Gegenmaßnahme empfiehlt sich, die IP und/oder den User-Agent zu sperren. Auf jeden Fall sollte bei PHP außerdem allow_url_include abgeschaltet werden, um sich generell gegen diese Angriffe zu wehren. (Leider erfordert manche PHP-Software dieses Feature, zum Beispiel um externe Plugins nachzuladen.) Und der übliche Rat: Wikis, Foren, CMSe und weitere Software, die auf PHP aufsetzt, sollten immer auf einem möglichst aktuellen Stand gehalten werden.

PS: allow_url_include = Off stoppt nur die schlimmste Möglichkeit, nämlich dass fremde PHP-Scripte auf dem eigenen Server ausgeführt werden. Wenn die verwendete PHP-Software aber grundsätzlich anfällig ist (also HTTP-Parameter ungeprüft included), ist weiterhin eine gefährliche Sicherheitslücke offen, da darüber auch lokale Dateien gelesen oder zum Beispiel Administrations-Skripte aufgerufen werden können.

Parameterprüfung

Hier wird beschrieben, wie du durch Parameterprüfung deine Seite absichern kannst.

Traue grundsätzlich niemandem

Wenn man PHP-Seiten entwirft, kommt man zwangsläufig mit Parametern in Kontakt, die dem Script über den Aufruf übergeben werden. Diese Parameter sind ein ganz simpler Angriffspunkt für Hacker, da sie sich sehr leicht manipulieren lassen.

Grundsatzregel ist also: misstraue immer allen Daten, die vom Benutzer kommen! Neben den Parametern, die man in $_GET und $_POST findet, gehören auch die Cookies dazu, da sie beim Benutzer lokal abgelegt werden und von ihm manipuliert werden können. PHP fasst alle Parameter, die grundsätzlich suspekt sind, in dem superglobalen Array $_REQUEST zusammen. Allerdings lässt sich auch der HTTP-Request vom Browser manipulieren, so zum Beispiel der HTTP_REFERER.

Die einfachste Art der Manipulation ist, bei einem GET-Request einfach die Parameterzeile im Browser zu verändern. Eine URL wie

posting.php?id=13&admin=no

schreit geradezu danach, mit den Werten herumzuspielen. Ich könnte zum Beispiel die ID verändern und so vielleicht an Postings kommen, die ich eigentlich gar nicht lesen dürfte. Oder ich setze admin auf yes und schaue, ob ich dann unerlaubt Admin-Rechte erhalte.

Man darf sich also grundsätzlich nicht darauf verlassen, dass die übergebenen Parameter vernünftig oder gültig sind. Selbst Einschränkungen zum Beispiel über eine begrenzte Auswahl in Select-Boxen oder eine Formularprüfung per JavaScript lassen sich auf diese Weise hervorragend aushebeln. Ebenso lassen sich eigentlich nicht änderbare Werte in hidden-Feldern vom Angreifer jederzeit verändern.

Es hilft nicht wirklich, die Parameter in der URL durch Frames oder POST-Requests zu verschleiern. Auch wenn eine Manipulation dann erschwert wird, ist sie dennoch weiterhin möglich. Frames lassen sich umgehen, und POST-Requests ganz leicht durch ein selbstgebautes kleines HTML-Formular nachbilden.

Also noch einmal: misstraue immer allen Daten, die vom Benutzer kommen!

Sensible Daten in Sessions auslagern

Eine Abhilfe ist schon mal, besonders sensible Daten nicht per Parameter an das nächste Script zu übergeben, sondern über die Session. Da die Session-Daten selbst auf dem Server gespeichert werden, hat der Benutzer keine Möglichkeit, diese zu lesen oder zu manipulieren.

So gehören also zum Beispiel die User-ID des eingeloggten Users oder etwa Rechte-Flags in die Session. Also einfach alles, wodurch sich der Benutzer durch Änderung der Werte Rechte erschleichen könnte, die er eigentlich nicht hat. Aber auch SQL-Queries sollten niemals als Parameter übergeben werden, sondern immer über die Session.

Permanente Daten gehören in eine Datenbank

Sessions leben nur so lange, bis der Browser geschlossen wird. Daten, die länger gespeichert werden sollen, kann man beim Benutzer als Cookie ablegen. Aber auch Cookies sind manipulierbar und damit stets suspekt.

Sensible Daten wie Benutzerrechte dürfen nicht im Cookie abgelegt werden. Eine Möglichkeit wäre, die Daten statt dessen auf dem Server in einer Datenbank zu speichern und dann lediglich eine Benutzerkennung im Cookie abzulegen. Der Benutzer darf allerdings keine Möglichkeit haben, durch Manipulation am Cookie eine andere Benutzerkennung zu erraten. Lege also bitte keine User-ID im Cookie ab. Stattdessen kannst du per Zufallszahlengenerator einen zufälligen Text erzeugen lassen, der durch md5() gehashed und dann mit in die Datenbank abgelegt wird. Dieser Hash-Code kann im Cookie abgelegt werden, denn es ist praktisch ausgeschlossen, den Hash-Code eines anderen Benutzers zu erraten.

Die sicherste Methode, Benutzerdaten permanent auf dem Server zu halten, ist allerdings nach wie vor, Benutzerkonten zu führen und jeden Benutzer zu bitten, sich am Anfang mit einem Usernamen und einem eigenen Passwort in das System einzuloggen.

Parameterprüfung

Ein weiterer wichtiger Schritt ist, die an das Script übergebenen Parameter auf plausibilität zu prüfen, also zu kontrollieren, ob der Wert auch sinnvoll ist. Diese Prüfung sollte ganz am Anfang erfolgen, noch bevor andere Programmteile auf die Parameter zugreifen. Ist ein Wert offensichtlich manipuliert worden, sollte das Script sich sofort beenden (zum Beispiel mit die()), um eine fehlerhafte Verarbeitung durch den falschen Wert zuverlässig zu verhindern. Da dieser Fehler nur durch einen gezielten Angriffsversuch ausgelöst werden kann, kann auf eine schmucke Fehlerseite verzichtet werden.

Ein Wert wurde zum Beispiel offensichtlich manipuliert, wenn man eine Selectbox im Formular hatte und nun einen Wert bekommt, der dort gar nicht zur Auswahl stand. Oder wenn man eine Datenbank-ID erhält, die es gar nicht in der Datenbank gibt.

Es liegt allerdings kein Manipulationsversuch vor, wenn der Benutzer schlicht und einfach eine falsche Eingabe gemacht hat. Wenn du also zum Beispiel ein einfaches Texteingabefeld für eine Jahreszahl hast, und du dort eine vierstellige Jahreszahl erwartest, der Benutzer aber nur “90” (für 1990) eingibt, ist das natürlich kein bösartiger Angriff, sondern nur eine Fehleingabe. Du solltest den Benutzer dann mit einer sprechenden Fehlermeldung höflich darauf hinweisen, was er falsch eingegeben hat und wie er eine richtige Eingabe tätigt, statt gleich das ganze Script abzubrechen und die Alarmglocken schrillen zu lassen.

Hilfreich für den Benutzer ist auch eine Formularprüfung per JavaScript, noch bevor die Daten zum Server geschickt werden. Das Formular sollte allerdings auch ohne JavaScript abzuschicken sein, und es sollte immer auch eine zusätzliche Überprüfung auf dem Server stattfinden! Ein Angriff lässt sich niemals durch geschickte JavaScript-Programmierung verhindern.

Ein Beispiel

Eine einfache Möglichkeit der Parameterprüfung ist, alle Parameter, die man erwartet, durch Prüffunktionen zunächst prüfen zu lassen. Diese Funktionen prüfen zumindest elementar die übergebenen Werte und kopieren sie im Erfolgsfall in ein eigenes, sichereres Parameter-Array um. Diese Funktionen können sich gleichzeitig um die Entfernung von Magic-Quotes kümmern.

Die grundlegendste Prüffunktion ist parText(). Sie erwartet den Namen des Parameters und optional einen Defaultwert, wenn der Parameter nicht übergeben wurde. Er liefert true zurück, wenn der Parameter übergeben wurde, sonst false.

function parText($name, $default=null) {
  global $PAR;
  $PAR[$name] = $default;
  if(isset($_REQUEST[$name])) {
    $val = $_REQUEST[$name];
    if(get_magic_quotes_gpc()) $val = stripslashes($val);
    $PAR[$name] = $val;
    return true;
  }
  return false;
}

Ein paar Beispiele, wie man so eine Parameterprüfung aufrufen kann:

require_once('parameter.php');      // die Funktion einbinden

// 1: Es gibt einen optionalen Parameter namens 'foo'.
parText('foo');

// 2: Es gibt einen optionalen Parameter 'foo'. Gibt es ihn nicht,
//    wird 'Hallo' als Defaultwert angenommen.
parText('foo', 'Hallo');

// 3: Parameter 'foo' ist ein Pflichtparameter. Gibt es ihn nicht,
//    liegt eine Manipulation vor.
if(!parText('foo')) die("Parameter 'foo' fehlt!");

// Der Wert von 'foo' liegt anschließend im Array $PAR
if(isset($PAR['foo'])) {
  print("foo ist: ".htmlspecialchars($PAR['foo']));
}else {
  print("foo ist nicht übergeben worden.");
}

Das Array $PAR ist natürlich keine superglobale Variable und muss in Funktionen weiterhin mit global $PAR eingebunden werden. Durch diese Trennung weiß man jedoch, dass die Werte in $PAR zumindest eine einfache Prüfung durchlaufen haben und dadurch ein wenig vertrauenswürdiger sind als die Werte in $_REQUEST.

Analog dazu kann man jetzt weitere Prüffunktionen schreiben. Für die meisten Eingabefelder a la input type="text" reicht es zum Beispiel, den Text auf 256 Zeichen zu begrenzen. Da die Eingabe nur eine Textzeile zulässt, können außerdem alle Zeilenumbruch-Steuerzeichen herausgefiltert werden. In Code gegossen sieht das dann so aus:

function parPlain($name, $default=null) {
  global $PAR;
  $PAR[$name] = $default;
  if(isset($_REQUEST[$name])) {
    $val = trim($_REQUEST[$name]);
    if(get_magic_quotes_gpc()) $val = stripslashes($val);
    if(strlen($val)>256) $val = substr($val,0,256);
    $PAR[$name] = preg_replace('/(\n|\r)*/', '', $val);
    return true;
  }
  return false;
}

Erwarte ich zum Beispiel eine natürliche Zahl als Eingabe, kann ich folgende Funktion verwenden:

function parInt($name, $default=null) {
  global $PAR;
  $PAR[$name] = $default;
  if(isset($_REQUEST[$name])) {
    $val = trim($_REQUEST[$name]);
    if(get_magic_quotes_gpc()) $val = stripslashes($val);
    $PAR[$name] = intval($val);
    return true;
  }
  return false;
}

Nachdem überflüssige Leerzeichen abgeschnitten wurden, wird mittels der Funktion intval() anschließend sicher gestellt, dass sich im umgesetzten Parameter in $PAR garantiert eine Zahl befindet. Im Anschluss könnte man noch prüfen, ob die Zahl innerhalb eines Gültigkeitsbereichs liegt.

Die Vorprüfung für ein Formular mit User-ID, Vor- und Nachname sowie einem optionalen Freitext sähe dann zum Beispiel so aus:

require_once('parameter.php');      // die Funktion einbinden

// User-ID (Pflichtparameter)
if(!parInt('id'))         die("Parameter 'id' fehlt!");

// Vor- und Nachname (Pflichtparameter)
if(!parPlain('vorname'))  die("Parameter 'vorname' fehlt!");
if(!parPlain('nachname')) die("Parameter 'nachname' fehlt!");

// Ein mehrzeiliger Freitext (optional)
parText('freitext');

Dies ist allerdings nur eine sehr grundlegende Prüfung. Es sollten nun weitere Prüfungen folgen, zum Beispiel ob die User-ID gültig ist.

Behandlung von Passwörtern

Wenn du in einem Formular ein Eingabefeld für Passwörter verwendet, solltest du stets das input type="password"-Eingabefeld verwenden. Dies schützt das Passwort vor neugierigen Blicken auf den Bildschirm.

Außerdem sollten Formulare mit Passwörtern stets ausschließlich per POST an den Server geschickt werden. So erscheint das Passwort niemals in der URL und wird zum Beispiel auch nicht in Proxies zwischengespeichert.

Entsprechend solltest du im Script das Passwort nur von dem $_POST-Array entgegennehmen, nicht jedoch von $_GET oder von $_REQUEST.

Dateiuploads

Dateiuploads lassen sich bei PHP über ein Hidden-Feld in dem Formular in der Größe beschränken. Aber auch dieses Feld ist manipulierbar. Du solltest also unbedingt auch noch im Script prüfen, ob die empfangene Datei das Größenlimit überschritten hat, und gegebenfalls die Ausführung abbrechen.

Mittlerweile sollte sich auch herumgesprochen haben, dass man für Dateiuploads stets das $_FILES-Array oder die is_uploaded_file()-Funktion zur Prüfung verwenden sollte. Gerade der Dateiupload bietet eine relativ einfache Möglichkeit für einen Angreifer, Dateien und Passwörter vom Server auszuspähen, wenn man hier nicht sorgfältig genug vorgeht.

Code-Injections

Größte Vorsicht ist geboten, wenn man Parameter vom Server als Programmteil ausführen lässt, wenn sie also ganz oder teilweise an system(), eval(), mysql_query() oder entsprechend andere Funktionen übergeben werden. Du gibst damit einem Angreifer die Chance, Daten auszuspähen oder massiven Schaden anzurichten.

Bei Funktionen wie include() oder require() erlaubt es PHP normalerweise, auch externe Quellen (per “http://”, “ftp://” etc) anzugeben. Wenn ein Parameter ungeprüft an include() übergeben wird, könnte ein Angreifer so sehr bequem ein beliebiges externes Script einbinden.

Solche Parameter lassen sich nur schwer auf bösartigen Code überprüfen, du solltest es also meiden wie der Teufel das Weihwasser! Wenn es sich absolut nicht vermeiden lässt, solltest du entsprechende Vorsichtsmaßnahmen ergreifen und darauf achten, dass sich diese Maßnahmen nicht aushebeln lassen. Siehe dazu auch den Artikel über SQL-Injections.

Zusammenfassung

Um dich gegen Angriffe durch manipulierte Parameter zu schützen, solltest du:

  • grundsätzlich allen Daten misstrauen, die vom User kommen (GET-Parameter, POST-Parameter, Cookies, HTTP-Header)!
  • sensitive Daten niemals per Request, sondern per Session an andere Scripte weitergeben.
  • alle Parameter vor ihrer Benutzung auf Plausibilität prüfen.
  • niemals Parameter an system(), eval(), mysql_query() oder andere Funktionen der Art übergeben, ohne sie gründlich zu prüfen und vor dem Aushebeln der Prüfmechanismen zu schützen.
  • Passwörter stets mit POST übertragen und nur vom $_POST-Array entgegennehmen.

Plausibilitätsprüfungen schließen ein:

  • Sind alle Pflichtparameter vorhanden?
  • Ist ein erwarteter numerischer Wert (Datenbank-ID) wirklich numerisch?
  • Existiert ein Datenbankeintrag mit der übergebenen ID?
  • Enthält ein einzeiliges Eingabefeld unerlaubte Zeichen (z.B. Zeilenumbrüche)?
  • Wurde von einer Selectbox ein Wert übermittelt, der gar nicht in der Selectbox stand?
  • Wurden Mehrfachparameter übergeben, obwohl nur eine Einfachauswahl erlaubt ist? Wurden beispielsweise mehrere Radio-Buttons einer Gruppe ausgewählt, oder mehrere Einträge einer Selectbox mit Einfachauswahl?
  • Wurde die Datei tatsächlich hochgeladen und übersteigt sie nicht die erlaubte Maximalgröße?
  • Hat der Benutzer tatsächlich das Recht, die Aktion auszuführen oder den Datenbankeintrag zu sehen?
Session schützen

Du findest hier, wie man Session davor schützen kann, gekapert zu werden.

Wie funktionieren Sessions?

PHP erlaubt es mit den Sessions, Daten für den Nutzer über die gesamte Sitzung (engl. Session) hinweg vorzuhalten und für die Scripte verfügbar zu machen. So ließe sich zum Beispiel ein Warenkorb ohne Datenbank alleine über die Sessions verwalten, indem die gewünschten Waren in der Session des Nutzers eingetragen werden.

Damit PHP die Session einem bestimmten Nutzer zuordnen kann, vergibt es jeder neuen Session eine sogenannte Session-ID. Diese recht lange Zahl muss jedem Script übergeben werden, damit es die zugehörige Session finden kann. Die Session-ID wird entweder in einem temporären Cookie abgelegt, welches beim Schließen des Browsers gelöscht wird, oder aber als Parameter von Script zu Script weitergegeben. Geht die Session-ID verloren, gibt es keine Möglichkeit mehr, an die dazugehörige Session heranzukommen.

PHP räumt gelegentlich die Sessions auf und löscht die Session-Daten, auf die seit einer bestimmten Zeit nicht mehr zugegriffen wurden. Eine Session taugt also nicht dazu, benutzerabhängige Daten längerfristig auf dem Server zu speichern.

Was bedeutet nun “Session kapern”?

Die Daten der Session selbst liegen auf dem Server und sind vor Missbrauch geschützt. Der Ansatzpunkt für das Kapern ist allerdings die Session-ID. Eine Session wurde gekapert, wenn es einem unberechtigten User gelingt, an diese Session-ID zu kommen und sie für sich zu verwenden. Für PHP ist er dann von dem eigentlichen User nicht mehr zu unterscheiden. Wenn der User besondere Rechte (zum Beispiel Admin-Rechte) hat, hat sie der Angreifer durch das Kapern der Session-ID ebenfalls.

Es gilt nun, dieses Kapern der Session zu erschweren oder praktisch ganz zu verhindern.

Datenlecks

PHP erzeugt die Session-ID automatisch, und verwürfelt die Zahl dabei so gründlich, dass es praktisch unmöglich ist, eine Session-ID einfach zu erraten oder von einer bekannten ID auf eine unbekannte zu schließen. Auch sonst ist es für einen Angreifer nicht ohne weiteres möglich, eine beliebige Session zu kapern. Er muss dazu schon bestimmte Voraussetzungen erfüllen. Im Allgemeinen sind Sessions also ausreichend sicher.

Dennoch gibt es zum Teil sehr einfache Angriffspunkte, eine fremde Session-ID auszuspähen.

Der use_trans_sid-Modus hängt zum Beispiel die Session-ID automatisch an jede URL, die auf den eigenen Server verweist. Das wird schnell zum Problem, da die Session-ID nun grundsätzlich an die URL angefügt wird. So könnte ein User eine aktive Session per Mail oder Instant Messenger weiterverschicken, wodurch der Empfänger mehr oder weniger unfreiwillig die Session gekapert hätte. Dieser Modus gehört also grundsätzlich abgeschaltet!

Eine weitere große Session-Tratsche ist der Browser des Users! Er überträgt in der Regel bei jedem Request, den er absetzt, im Referer-Feld des HTTP-Headers die URL der Seite, von der der Aufruf ausging. Und diese URL kann natürlich auch die Session-ID enthalten. In der Praxis bedeutet es, dass die Session-ID zum Beispiel an die Server von Werbebannern weitergegeben wird. Und auch wenn der User einem externen Link folgt, wird die Session-ID möglicherweise im Referer-Feld mit an den Server geschickt.

Ein mögliches Angriffsszenario wäre ein PHP-Forum, das es seinen Usern erlaubt, einen externen Link auf ein Avatar-Bild1 von sich anzugeben. Wenn nun ein anderer User auf eine Forums-Seite kommt, die das Avatar-Bild des Angreifers enthält, greift dessen Browser auf das externe Bild zu und übermittelt dem Angreifer die Session-ID des Users in dem Referer-Feld. Jener braucht nur noch sein Logfile auszuwerten, um die gekaperten Session-IDs zu “ernten”. Generell reicht es, wenn der Angreifer auf einer Webseite einen externen Link auf seinen Server setzen kann, und schon kommen die Session-IDs für ihn frei Haus.

Eines darf man dabei allerdings nicht vergessen: den Sessions ist ein relativ kurzes Leben beschert. Bereits etwa eine halbe Stunde nach dem letzten Zugriff werden sie von PHP für gewöhnlich geschlossen. Eine gekaperte Session-ID bringt dem Angreifer also nur dann etwas, wenn sie noch frisch ist.

Abwehr

Wie kann man sich nun davor schützen? Der beste Schutz greift eigentlich beim Surfer. Ironischerweise ist hier die sicherste Methode gleichzeitig die mit dem schlechtesten Ruf: er sollte Cookies akzeptieren.

Im Internet kursieren die merkwürdigsten Gerüchte, warum Cookies unsicher sind und ausgeschaltet werden müssen. Angeblich soll es mit Cookies sogar möglich sein, die E-Mailadresse des Surfers zu ermitteln oder ihm einen Virus unterzujubeln. Das ist hausgemachter Blödsinn! Wahr ist jedoch, dass man mit Cookies das Surfverhalten eines Surfers in einem bestimmten Rahmen analysieren kann.

Was das Session-Kapern angeht, ist der Surfer, der Cookies erlaubt, allerdings stets auf der sicheren Seite. Denn dann legt PHP die Session-ID im Cookie ab, und an die kommt ein Angreifer dann höchstens noch, wenn er den Datenverkehr zwischen Browser und Server gezielt abhört. Dies ist jedoch eine relativ hohe technische Hürde, und selbst dagegen kann man sich mit SSL schützen.

Alternativ kann der Surfer dafür sorgen, dass der Referer nicht mehr im HTTP-Header übertragen wird. Einige Browser bieten die Möglichkeit, die Übertragung abzuschalten oder zu verfälschen. Es gehört aber auch zum Standardrepertoire verschiedener Webwasher und Proxies. Leider haben einige Webseiten, die sich auf das Referer-Feld verlassen (zum Beispiel um Deep Linking zu verhindern), damit erhebliche Probleme.

Washer-Seiten

Als Programmierer kann man das Problem über sogenannte Washer-Seiten lösen. Anstatt externe Links direkt anzuspringen, stellt man diese Washer-Seite dazwischen. Sie wird stets ohne Session-ID in der URL aufgerufen (use_trans_sid muss dafür abgeschaltet sein) und leitet den Request dann direkt an die externe Seite weiter.

Dadurch findet der externe Server im Referer-Feld nicht mehr eine Seite mit Session-ID, sondern nur noch die URL der Washer-Seite. Und diese wurde ja ohne Session-ID aufgerufen.

Kleiner Haken

Einen kleinen Haken hat die Geschichte allerdings noch. Selbst wenn Cookies erlaubt sind, wird die Session-ID von dem ersten Request aus in der URL übertragen.

Der Grund ist: es gibt keine Möglichkeit herauszufinden, ob der Browser Cookies akzeptiert oder nicht. Man versucht einfach sein Glück, setzt das Cookie, und wenn es beim nächsten Request zurückkommt, hat es wohl geklappt. Bei der ersten Seite, die aufgerufen wird, ist es jedoch noch ungewiss, und PHP muss deshalb beide Wege gehen. Nach der Erzeugung der neuen Session wird die ID in einem Cookie abgelegt und gleichzeitig an die URLs angehängt. Nur so ist gewährleistet, dass die Session-ID an die nächste Seite weitergegeben wird, auch wenn Cookies nicht erlaubt waren. Die zweite Seite, die der User ansurft, wird also mit der Session-ID in der URL aufgerufen und dementsprechend wieder vom Browser im Referer weitergegeben.

Es ist nicht möglich, den Effekt vollkommen auszuschließen. Aber zumindest konnten wir die Wahrscheinlichkeit, dass die Session-ID gekapert werden kann, auf nur eine Seite reduzieren.

Seit PHP 4.3.32 bietet PHP nun die Möglichkeit, mit der Funktion session_regenerate_id() die in der URL veröffentlichte Session-ID nachträglich zu ändern. Wenn man also feststellt, dass die Session-ID als Cookie und in der URL übertragen wurde, kann man diese Funktion aufrufen, um anschließend im Cookie eine neue Session-ID zu speichern. Die ID, die ein Angreifer von der URL abgefangen haben könnte, ist dann wertlos.

Nummer Sicher

Um sich gegen einen Session-Klau zu schützen, empfiehlt sich eine Kombination aus folgenden Techniken:

  • Die Verwendung von Cookies zum Ablegen der Session-ID wird erzwungen. Dies geht, indem man use_trans_sid abschaltet, use_only_cookies aktiviert und selbst nirgendwo die Session-ID an eine URL anhängt. Allerdings muss hier der User mitspielen, wovon man eigentlich nur in Intranets ausgehen kann.
  • Es wird SSL verwendet, um ein Mitschneiden der Cookie-Daten zu verhindern.
  • Wenn eine HTTP-Authentifizierung verwendet wird, können die Authentifizierungsdaten mit in der Session gespeichert werden. Der Request ist nur dann gültig, wenn die Authentifizierungs-Kredentien vom Browser übertragen werden und mit den Daten in der Session übereinstimmen.
  • Eine in der URL veröffentlichte Session-ID wird mit session_regenerate_id() sofort invalidiert, sofern Cookies erlaubt sind.

Was man dagegen nicht (oder höchstens in einem Intranet) machen sollte, ist die Session-ID mit der IP-Adresse des Users zu verknüpfen. Was sich zunächst wie eine gute Idee anhört, geht in der Praxis böse nach Hinten los. Denn spätestens dann, wenn kaskadierte Proxies eingesetzt werden, kann jeder Request von einer anderen IP-Adresse aus kommen. Bei dem T-Online-Proxy ist das zum Beispiel der Fall. Hier würde dann auch der berechtige User wegen angeblichem Session-Klau abgewiesen werden, weil er plötzlich eine andere IP verwendet.

Zusammenfassung

User können sich davor schützen, dass die Session gekapert wird, indem sie:

  • zumindest temporäre Cookies erlauben, so dass die Session-ID im Cookie abgelegt wird.
  • sich vor dem Verlassen einer Site stets von ihr ausloggen, sofern es eine Möglichkeit dafür gibt.
  • eine Möglichkeit finden, das Referer-Feld zu unterdrücken oder zu verfälschen.

Entwickler können ihre User weitgehend davor schützen, indem sie:

  • ihre User ermutigen, Cookies für die Site zuzulassen. Wenn du erklärst, wozu du das Cookie brauchst, dürfte es einfach sein, deine User zu überzeugen.
  • Washer-Seiten vor externe Links schalten, die die Session-ID aus dem Referer-Feld entfernen.
  • in der URL veröffentlichte Session-IDs mit session_regenerate_id() umgehend invalidieren, wenn der User Cookies zulässt.

Nicht dagegen sollte die Session mit der IP-Adresse des Users verknüpft werden.


  1. Ein Avatar ist ein Abbild von dem jeweiligen User in der virtuellen Welt des Internet. Es kann ein Foto von dem User sein, aber oft ist es auch eine Comiczeichnung oder ein Symbol. ↩︎

  2. Eigentlich gibt es die Funktion bereits seit PHP 4.3.2, dort ändert sie aber den Cookie nicht ab und ist damit für unsere Zwecke wertlos. ↩︎

Erste Woche ermitteln

Dieses Script ermittelt den Timestamp der ersten Woche eines Jahres.

Die Wochen eines Jahres werden durchnummeriert. Das ist leider ein wenig komplizierter, als es auf den ersten Blick aussehen mag, da die erste Woche nicht mit dem 1. Januar beginnt.

Nach ISO 8601 ist die erste Woche des Jahres die Woche mit dem 4. Januar.

Das folgende Script ermittelt nun zu einem Jahr den Timestamp von Mitternacht des Montags der ersten Woche.

/** 
 * Get a timestamp of midnight of the first week's Monday of the given
 * year.
 *
 * @param   $year     Year to compute the timestamp for (4 digits)
 * @return  Timestamp of the first week's Monday
 */
function computeFirstWeek($year) {
  // Get the timestamp of January 4th of this year
  $jan4 = mktime(0,0,0,1,4,$year);

  // Get the weekday of that day (with Monday being 0)
  $wd = (date('w',$jan4) + 6) % 7;

  // Go back those number of days, to reach Monday
  $jan4 -= $wd*24*60*60;

  return $jan4;
}