Blog

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;
}
SQL-Injections

Dieser Artikel geht über SQL-Injections, und wie man sie verhindern kann.

Was sind SQL-Injections?

Bei SQL-Injections macht ein Angreifer sich zu Nutze, dass Parameter an das Script ungeprüft an das SQL-Statement weitergereicht werden. Durch geschickte Manipulation des Parameters erreicht er so, dass das SQL-Statement eine völlig andere Bedeutung erhält.

Ein Beispiel: Wir haben ein Login-Script, welches einen Usernamen und ein Passwort übergeben bekommt. Das Script sucht nun in der users-Tabelle nach einem passenden User mit dem gleichen Passwort, und liefert im Erfolgsfall die User-ID zurück. Der Einfachheit halber gehen wir davon aus, dass der übergebene Username in $user und das Passwort in $pwd abgelegt ist.

Folgendes SELECT würde sich für die Aufgabe anbieten:

mysql_query("SELECT id FROM users WHERE name=$name AND pwd=$pwd");

Der Angreifer könnte nun jedoch als Passwort $pwd = "foo OR 1=1" an das Script übergeben. Dadurch sähe der fertige SELECT-Statement so aus:

SELECT id FROM users
  WHERE name=meier
    AND pwd=foo OR 1=1

Der Angreifer wäre damit trotz falschem Passwort erfolgreich als meier eingeloggt worden.

Abwehrmaßname 1: Hochkommata

Um dies abzuwehren, sollten grundsätzlich alle Variablen im SQL-Statement in einfache Hochkommata gesetzt werden. 1

mysql_query("SELECT id FROM users WHERE name='$name' AND pwd='$pwd'");

Der ursprüngliche Angriff mit $pwd = "foo OR 1=1" würde dann folgendes SELECT-Statement ergeben:

SELECT id FROM users
  WHERE name='meier'
    AND pwd='foo OR 1=1'

Die Datenbank nimmt in dem Fall an, dass das Passwort "foo OR 1=1" lautet. Der Angriff wäre damit wirkungslos. Allerdings kann der Angreifer selbst Hochkommata einsetzen. Mit folgendem Passwort wäre er wieder erfolgreich: $pwd = "foo' OR '1'='1". Dies würde das folgende SELECT-Statement ergeben:

SELECT id FROM users
  WHERE name='meier'
    AND pwd='foo' OR '1'='1'

Und schon würde der User meier erneut trotz falschem Passwort eingeloggt werden.

Abwehrmaßnahme 2: addslashes()

Um auch diesen Angriff abzuwehren, muss man sicher stellen, dass Hochkommata im Parameter ordentlich mit einem Backslash escaped werden. Dafür sorgt PHP mit den Magic Quotes automatisch, sofern sie aktiviert sind. Man sollte sich aber nicht darauf verlassen, sondern den Magic Quotes-Modus abschalten und sich selbst mit der Funktion addslashes() um ordentliches Escapen kümmern.

Der oben genannte Angriff lässt sich abwehren, indem man die Parameter $user und $pwd mit addslashes() sichert:

$user = addslashes($user);
$pwd  = addslashes($pwd);
mysql_query("SELECT id FROM users WHERE name='$name' AND pwd='$pwd'");

addslashes() würde im Angriffs-Passwort ein Backslash vor die Hochkommata stellen, was dann folgendes SELECT-Statement ergibt:

SELECT id FROM users
  WHERE name='meier'
    AND pwd='foo\' OR \'1\'=\'1'

Damit wäre die Abfrage wieder sicher. Der Angreifer hat nun keine Möglichkeit mehr, die Abfrage zu manipulieren. SQL-Injections sind nun nicht mehr möglich.

Weitere Sicherungsmaßnahmen

Zunächst sollten alle Parameter an das Script auf Plausibilität geprüft werden. Wenn man zum Beispiel eine Zahl erwartet, sollte man den Parameter mit intval() in eine Zahl wandeln und anschließend eine Bereichsprüfung durchführen.

Weiterhin gilt grundsätzlich, wirklich niemals ganze SQL-Kommandos per Parameter an das nächste Script weiter zu reichen. Dem Angreifer wird es damit ermöglicht, direkt SQL-Kommandos an die Datenbank zu schicken. Die Palette reicht von kleinen Spionage-SELECTs bis hin zu einem katastrophalen DROP DATABASE.

Um ein SQL-Kommando von einem Script zum nächsten zu transportieren, sollte man Sessions verwenden. In dem Fall bleibt das Kommando auf dem Server und kann vom Benutzer weder eingesehen noch verändert werden.

Zusammenfassung

Um SQL-Injections erfolgreich zu vermeiden, sollte man also:

  • alle übergebenen Parameter auf Plausibilität prüfen.
  • sämtliche Variablen in SQL-Statements mit Hochkommata einschließen.
  • Magic Quotes abschalten, und stattdessen
  • alle Parameter vor der Übergabe an das SQL-Statement mit addslashes() sauber escapen.
  • zum Weiterreichen eines SQL-Statements an das nächste Script niemals Parameter, sondern nur Sessions verwenden.

  1. Dies erlaubt MySQL auch bei numerischen Spalten. Andere Datenbanken können darauf allerdings mit einer Fehlermeldung reagieren. ↩︎

Accept-Language

Das folgende Snippet parst den Accept-Language HTTP-Header und liefert ein Array mit den Sprachen.

Das zurückgelieferte Array enthält den Vorzugsgrad der Sprache als Key, der Value ist das Kürzel der jeweiligen Sprache. Das Array ist absteigend von der bevorzugten zu der am wenigsten bevorzugten Sprache sortiert.

/**
 * Parse the Accept-Language HTTP header sent by the browser. It
 * will return an array with the languages the user accepts, sorted
 * from most preferred to least preferred.
 *
 * @return  Array: key is the importance, value is the language code.
 */
function parseAcceptLanguage() {
  $ayLang = array();
  $aySeen = array();
  if(isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
    foreach(explode(',',$_SERVER['HTTP_ACCEPT_LANGUAGE']) as $llang) {
      preg_match("#^(.*?)([-_].*?)?(\;q\=(.*))?$#i", $llang, $ayM);
      $q = isset($ayM[4]) ? $ayM[4] : '1.0';
      $lang = strtolower(trim($ayM[1]));
      if(!in_array($lang, $aySeen)) {
        $ayLang[$q] = $lang;
        $aySeen[] = $lang;
      }
    }
    uksort($ayLang, create_function('$a,$b','return ($a>$b) ? -1 : 1;'));
  }
  return $ayLang;
}

Im Prinzip braucht man jetzt nichts weiter mehr zu machen, als das Array in der Reihenfolge zu durchlaufen, bis man eine Sprachkennung findet, die man auf der Seite unterstützt.

RFC-2047-Wandler

Dieser Codeschnippsel wandelt einen String nach RFC-2047 um.

Die RFC-2047 wird verwendet, um Umlaute in Mail- und News-Headern darzustellen. Die folgenden beiden Funktionen wandeln einen String entsprechend um.

/**
 * Converts the passed string to an RFC-2047 encoded word.
 * 
 * The string will always be converted. This function also takes care of
 * proper wrapping.
 *
 * @param   $str      String to be encoded
 * @param   $encode   The character set to be used (e.g. "ISO-8859-1")
 * @return  Converted string
 */
function rfc2047conv($str, $encode) {
  if(!strlen($str)) return "";
  $result  = "=?${encode}?Q?";
  $intro   = strlen($result);
  $charcnt = $intro;
  for($ix=0; $ix < strlen($str); $ix++) {
    if($charcnt > 70) {
      $result .= "?=\r\n =?${encode}Q?";
      $charcnt = $intro;
    }
    $chr = $str{$ix};
    if($chr=='?' || $chr=='_' || $chr=='=' || $chr<' ' || $chr>'~' ) {
      $result .= sprintf("=%02X", ord($chr));
      $charcnt+=3;
    }else {
      $result .= ($chr==' ' ? "_" : $chr);
      $charcnt++;
    }
  }
  $result .= "?=";
  return $result;
}

/**
 * This function will only encode the shortest required section of
 * the string. If there are no characters in the string that need to
 * be encoded, the string will be returned unaltered.
 * 
 * WARNING: Even though most mail clients can handle the result, it
 * is not RFC compliant!
 *
 * @param   $str      String to be encoded
 * @param   $encode   The character set to be used
 * @return  Converted string
 */
function rfc2047short($str, $encode) {
  return preg_replace(
    '/^([ -~]*)(.*?)([ -~]*)$/e',
    '"$1".rfc2047conv("$2",$encode)."$3"',
    $str
  );
}