PHP-Object-Injection in Contao 2.11.13

Dies ist ein Gastbeitrag von Ruben Rögels.

Disclaimer

Der Artikel beschreibt einen möglichen Angriffsvektor für eine potentielle PHP-Object-Injection in Contao 2.11.13 und früher.
Er dient in keiner Weise als Aufforderung oder Anleitung.

Die Sicherheitslücke wurde darüber hinaus auch sehr schnell behoben:

Anlass

Am 03. Februar wurde Contao 2.11.14 veröffentlich (https://contao.org/de/news/contao_2-11-14.html).
In diesem Release wurde eine potentielle PHP-Object-Injection behoben, heißt es in der Meldung. Entdeckt wurde die Lücke von Pedro Ribeiro. Hier kann sein PoC eingesehen werden: https://github.com/pedrib/PoC/blob/master/contao-3.2.4.txt

Da ich selbst Contao verwende, habe ich mich mit der Problematik beschäftigt und dabei unabhängig von Pedro ein PoC implementiert.

serialize() und unserialize()

Bevor ich den konkreten Fall in Contao erläutere, möchte ich den Angriffsverktor generell kurz beschreiben.

PHP bietet die Möglichkeit Daten, und damit auch Objekte, in speicherbare Form zu überführen. Hierzu dient die serialize()-Funktion.

Beispiel

Klasse “User”

class User {
   public $name = '';

   public function setName($name) {
      $this->name = $name;
   }

   public function getName() {
      return $this->name;
   }
}

System “A”:

$user = new User();
$user->setName('Pedro');
$serialized = serialize($user);
file_put_contents('saved_object.txt',$serialized);

Um aus der serialisierten Repräsentation des Objekts wieder ein instanziertes Objekt zu erhalten, muss die unserialize()-Funktion genutzt werden:

System “B”:

$serialized = file_get_contents('saved_object.txt);
$user = unserialize($serialized);
echo $user->getName();

Und nun erscheint “Pedro”. Wir haben das Objekt also auf einem System A serialisiert, auf ein System B übertragen und können dort nach der Deserialisierung per getName() die gespeicherten Daten abrufen.

Schlussfolgerung

Die Datei “saved_object.txt” kann also zwischen zwei Applikationen ausgetauscht werden. Die Einzige Voraussetzungen ist, dass in beiden Applikationen die gleiche Klasse “User” existiert, da diese von der unserialize()-Funktion geladen werden muss.

Am Rande: PHP ist es egal, ob es tatsächlich die gleiche Klasse ist, wenn der Klassen-Name auf dem Ziel-System existiert, wird die Klasse geladen und mit den Daten der serialisierten Repräsentation befüllt.

Nun steht die Frage im Raum: Was geschieht, wenn eine Benutzereingabe Argument der unserialize()-Funktion ist.

Mechanik in Contao

Genau diese Konstellation existiert in Contao 2.11.13 (und vermutlich auch davor).
Bevor ich ins Detail gehe, beschreibe ich die Mechanik, die in Contao problematisch ist.

Contao besitzt eine Config-Klasse.
Diese ermöglicht das Lesen und Schreiben der System-Konfiguration, welche in der Datei “localconfig.php” liegt.
Diese Datei wird per __destruct()-Methode jedes mal geschrieben, wenn die Daten in der Config-Klasse verändert wurden. Dies wird mit der Eigenschaft “blnIsModified” überprüft.

Voraussetzung 1: Die Config-Klasse bietet also prinzipiell eine Möglichkeit Dinge in eine ausführbare PHP-Datei zu schreiben.

Eine weitere Wichtige Rolle spielt die Input-Klasse.
Diese säubert Benutzereingabe. Hierzu werden u.a. die Inhalte der Superglobals $_POST und $_GET in die Klasse kopiert und anschließend bereinigt (XSS, Tags, Entitäten, etc.).
Dies geschieht aber nur vollständig beim Aufruf von Input::post() oder Input::get(). Wird aber Input::postRaw() verwendet, finden viele der Bereinigungen nicht statt.

An dieser Stelle sei auch angemerkt, dass die serialisierte Repräsentation eines Objekts Nullbyte-Stings enthalten kann, die einen HTTP-Request ohne Maskierung in meinen Versuchen nicht überlebt haben.

Voraussetzung 2: Die serialisierte Repräsentation des Objekts überlebt die Input Sanitization.

Zudem besitzt Contao eine Widget-Basisklasse, von der alle Widgets, also Eingabefelder, abgeleitet werden. Die Widget-Klasse implementiert Validierungen der Eingaben. Im Zuge der Validierung wird die Eingabe durch die unserialize()-Funktion geschleift.

Voraussetzung 3: unserialize() wird auf eine Benutzereingabe angewendet

Hürden

Die einzigen Hürden waren für mich die Nullbyte-Strings und Zeilenumbrüche.
Diese entstehen beim Serialisieren, wenn das zu serialisierende Objekt “protected” oder “private” Eigenschaften besitzt.

Serialisierte Config-Objekte

Da sich die unserialize()-Funktion aber, wie bereits vorher beschrieben, nicht daran stört, dass in der serialisierten Repräsentation Eigenschaften public sind, die in der Klasse tatsächlich aber protected sind, können wir in unsere Config-Klasse die zu störenden Nullbyte-Strings führenden protected Deklarationen entfernen.

Anschließend wird der Config noch der eigentliche Schadcode hinzugefügt:

$cfg = Config::getInstance();
$cfg->add("throw new Exception('Opps!'); $dummy",null);

$serialized = serialize($cfg);
file_put_contents('evil_config.txt',$serialized);

Nun müssen aus der “evil_config.txt” noch die Zeilenumbrüche entfernt werden. Dabei ist zu beachten, dass die Längenangabe der Strings entsprechend angepasst wird:

Dieser String lässt sich nun in einem Eingabefeld abschicken:

<!DOCTYPE html>
<html>
   <head>
      <title>PoC</title>
   </head>
   <body>
      <form method="post">
         <textarea name="name"></textarea>
         <input name="submit" value="los" type="submit" />
      </form>
   </body>
</html>

<?php
define('TL_MODE','FE');
define('BYPASS_TOKEN_CHECK',true);

require_once('../../initialize.php');

$input = Input::getInstance();
if($input->post('submit')) {
   $w = new TextArea();
   $w->value = $input->postRaw('name');
   $w->validate();
}

In meinen Versuchen konnte ich unter Benutzung von Input::postRaw() die localconfig.php beliebig verändern und mit Input::post() zumindest leeren.

Advertisements