Étude d'une épreuve du Capture The Flag (CTF) de la DEFCON 2008

Tue 28 October 2008 by christophe

Cet article détaille l'étude et l'exploitation d'une épreuve du CTF de la DEFCON 2008, le wargame le plus connu, organisé par les Kenshoto. Ce challenge se déroule chaque année et réunit huit équipes pendant trois jours (et deux nuits) autour d'un même but : exploiter le plus vite possible les services qui tournent sur les serveurs des autres équipes.

Présentation du Capture The Flag

Après une phase de qualification mondiale qui dure un week-end, sept équipes sont autorisées à venir à Las Vegas pour s'affronter, la dernière place étant réservée à l'équipe qui a remporté le challenge l'année précédente. Chaque équipe se voit remettre un accès à son serveur sur lequel tournent une vingtaine de services, tous inconnus, développés spécialement par les Kenshoto. Le système d'exploitation n'est pas connu à l'avance mais il s'agit généralement de FreeBSD. Chacun des services est accessible en écriture seulement à l'utilisateur root, possède son propre répertoire avec son propre utilisateur associé, et perd ses privilèges afin qu'une fois exploité, on ne puisse pas rebondir sur les autres épreuves.

Dans chacun des répertoires des épreuves, on trouve un fichier key. Ce fichier contient une chaine de caractères, qu'il faut récupérer et envoyer à un serveur central pour marquer un point de lecture (Steal). De plus, chaque équipe possède aussi une clé d'équipe, et gagne un autre point (d'écriture cette fois), si elle remplace la clé du fichier key par sa clé d'équipe (Overwrite). Périodiquement, un système vérifie si une clé d'équipe est dans le fichier key et génère une nouvelle clé qu'elle place dans le fichier key, prête à donner un nouveau point aux équipes y ayant accès.

Une autre note importante concerne les breakthrough (BT) : La première équipe qui valide une épreuve (que ce soit en marquant un point de lecture ou d'écriture) remporte un bonus de points, dépendant de la difficulté de l'épreuve (facile, normal, difficile, kenshoto). La seconde à valider remporte la moitié de ce bonus, la troisième le quart. Il faut donc être très rapide sur le reverse et la conception de l'exploit.

Après l'attaque et le reverse, il faut aussi penser à la défense. Il est possible de filtrer le réseau en plaçant un IDS/IPS et les bonnes règles de filtrage. Mais attention à ne pas rendre inaccessible les services, car le pourcentage de disponibilité de ceux-ci (SLA) est un paramètre très important dans le score final. De même, étant root sur notre propre serveur, il est possible de patcher le service pour corriger la faille, toujours sans modifier les fonctionnalités normales du service. Les Kenshoto ont un système de polling qui vérifie périodiquement l'état des services, sans jamais exploiter la faille en elle même bien sûr.

Le trafic réseau est anonymisé, ce qui empêche de filtrer uniquement les IPs des autres équipes (le polling des Kenshoto pouvant donc prendre les IPs des équipes adverses).

Le calcul du score final est donc: SCORE=(BT+(Steal+Overwrite)-PEN) x SLA, avec PEN représentant les pénalités possibles.

Concernant les épreuves elles-même, on trouve beaucoup de chose : de l'obfuscation, de la crypto, des scripts pythons transformés en ELF, une épreuve web PHP, etc. Il s'agit souvent de comprendre le binaire, de trouver une faille et de l'exploiter, ou parfois de simplement comprendre un protocole assez verbeux pour en tirer parti. Les Kenshoto prennent souvent un malin plaisir à inclure plusieurs failles dans certains binaires, en n'en laissant qu'une de réellement exploitable.

C'est donc dans ce cadre que mon équipe, les Routards, composée de huit personnes, est arrivée à Las Vegas. Après cette introduction, je vais maintenant vous présenter l'étude d'une des épreuves.

Étude de l'épreuve Krypto

Krypto est un des services qui m'a été attribué. Il s'agit d'un binaire ELF, non statique et non strippé. Il écoute sur le port 20020 ; une connexion sur ce port ne nous renvoie aucune information. Après avoir mis de côté les fonctions sans intérêt (création du daemon, drop des privilèges, fork à chaque connexion, etc.), on arrive au code à étudier :

krypto1.png

Le programme commence par lire le fameux fichier key et garde son contenu en mémoire. Suit ensuite la création d'une chaine de 32 caractères, constituée uniquement de "A". Il lit ensuite sur la socket un buffer maximal de 63 caractères (ou s'arrête s'il rencontre un '\'). L'adresse de ce buffer est conservée dans le registre ebx.

krypto2.png

L'instruction repne scasb qui suit va rechercher le caractère '\\0', le registre eax étant mis à 0 via xor eax, eax, dans la chaine pointée par le registre edi (celle-ci étant à ce moment le buffer lu sur la socket, grâce à mov edi, ebx). Suite à cette instruction, le registre ecx contient la taille de la chaine, et edi pointe vers le caractère qui suit la chaine.

Un appel à la fonction RC4_set_key() de OpenSSL est ensuite effectué, avec pour paramètre la chaine qui a été lue sur la socket et la taille calculée précédemment. Le fonctionnement de l'algorithme RC4 est simple : la clé permet de générer une suite de bits pseudo-aléatoires appelée keystream, qui sert ensuite à chiffrer les données via un simple XOR. La fonction de chiffrement est par conséquent la même que la fonction de déchiffrement. Ici, la clé correspond donc à l'entrée donnée par l'utilisateur.

Voici les prototypages des fonctions RC4 d'OpenSSL :

void RC4_set_key(RC4_KEY *key, int len, const unsigned char *data);
void RC4(RC4_KEY *key, unsigned long len, const unsigned char *indata, unsigned char *outdata);

La fonction RC4_set_key() nous donne donc une variable RC4_KEY créée à partir de la clé, qui est ensuite passée à la fonction RC4(). Cette fonction chiffre la chaine de 32 "A" générée précédemment. À la suite de cet appel, le registre esi pointe vers le buffer chiffré.

La fonction alarm() est appelée pour tuer le processus au bout de 2 secondes. On tombe ensuite sur un call esi, esi contenant donc le pointer vers notre buffer chiffré. Évidemment, ça plante, mais comme le processus a forké à la connexion, rien n'est visible, et le service tourne toujours. La fonction alarm() est de nouveau appelée pour annuler l'action du premier appel. S'en suit un appel à une fonction qui envoie au client une chaine de 4 caractères sur la socket, puis un appel à exit().

En résumé, on a :

  • Chaine_à_chiffrer = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
  • Clé_du_RC4 = notre buffer de 63 caractères maximum
  • Chiffrement RC4 de la chaîne avec la clé
  • Exécution du résultat du chiffrement

Où se trouve la faille qui nous procure un shell ?

On ne peut évidemment pas bruteforcer le RC4 pour forger un shellcode valide. La faille se situe dans le calcul de la taille de la clé. En effet, celle-ci se fonde sur l'idée que la clé se termine par un '\\0'; on peut donc utiliser une toute petite clé, de quelques octets, la faire suivre par un '\\0', et mettre ce que l'on veut dans le reste du buffer. En quoi cela nous avance-t-il ? Il faut aussi se rappeler qu'à la suite de ce repne scasb, le registre edi contient un pointeur vers le caractère qui suit le '\\0', donc ici le buffer qui reste et qui ne sert pas à la clé RC4. L'idée est ensuite de faire en sorte que le chiffrement RC4 génère un buffer qui a comme premiers octets un saut vers ce registre edi. Ici j'ai choisi de faire un "push edi ret" ; ceci se résume à seulement deux opcodes : 0x57 0xC3.

Il faut donc bruteforcer une petite clé pour que le chiffrement RC4 de la chaine de "A" commence par ces deux opcodes. J'ai choisi d'utiliser une clé de 4 octets. Cette clé ne doit contenir ni de '\\0' ni de '\' (0x0a). La clé n'est pas toujours la même pour éviter un blacklistage trop facile de l'exploit par un IDS.

Voici le code du bruteforceur pour trouver la clé RC4, en C (bruteforcer en python est une très mauvaise idée :p) :

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <time.h>
#include <openssl/rc4.h>

int             main(int ac, char **av)
{
    unsigned short a, b, c, d;
    RC4_KEY          key;
    unsigned char  data[4];
    unsigned char  outdata[32];
    srand(time(NULL));

    while (42) {
        a = 1 + (int) (255.0 * (rand() / (RAND_MAX + 1.0)));
        if (a == 0x0a)
            continue;
        for (b = 1; b <= 0xff; b++) {
            for (c = 1; c <= 0xff; c++) {
                for (d = 1; d <= 0xff; d++) {
                    data[0] = d;
                    data[1] = c;
                    data[2] = b;
                    data[3] = a;
                    data[4] = 0;
                    RC4_set_key(&amp;key, 4, data);
                    RC4(&key, 32, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", outdata);
                    if (outdata[0] == 0x57 && outdata[1] == 0xc3
                        && b != 0x0a && c != 0x0a && d != 0x0a)
                    {
                        printf("rc4key = \"\\x%02x\\x%02x\\x%02x\\x%02x\"\n", d, c, b, a);
                        return 1;
                    }
                }
            }
        }
    }
    return 0;
}

À ce stade, le paquet à envoyer est de la forme [clé RC4]['\0'][shellcode]['\n'].

Il faut maintenant penser à alarm(). Pour contourner cette "protection", qui tuera notre shell au bout de 2 secondes, il suffit d'envoyer un premier shellcode qui va forker. Un quinzaine d'octets sont nécessaires pour cela, et un nouveau problème pointe son nez : il nous reste très peu de place pour écrire notre véritable shellcode.

L'astuce consiste maintenant à utiliser un stager. Ce shellcode, beaucoup plus petit, retrouve la socket utilisée pour la connexion, et écoute ensuite dessus, en attente d'un nouveau shellcode identifié par un tag particulier. Ici, notre second shellcode est un simple bindshell, polymorphique, toujours pour éviter les blacklistages trop faciles. On peut cependant noter que les shellcodes fork et stager ne sont pas polymorphiques, par manque de place.

Le paquet final à envoyer est donc de la forme [clé RC4]['\0'][shellcode fork][shellcode stager]['\n'] puis [tag][shellcode réel].

Le shellcode fork est placé avant le shellcode stager car lors du wargame, le réseau est souvent saturé du fait des nombreuses attaques qui transitent. Il peut donc se dérouler plus de deux secondes avant que le stager ne reçoive par le réseau le véritable shellcode.

L'exploit se code en quelques lignes de python grâce à une librairie privée qui génère les shellcodes automatiquement. Et ça marche bien :

christophe@soukee ~/defcon/2008/krypto % ./exp_krypto_bind.py 172.16.85.129 7777
[*] rc4key: 394b01c3
[-] size: 4

[*] stager: 31c099b61029d499528[...]5fc3
[-] size: 42

[*] x86/shikata_ga_nai succeeded, final size 105

[*] shellcode: 29c9b114dad3d974[...]d245
[-] size: 105

[*] Final size: 63 + 109

Connection to 172.16.85.129 : 7777 successful
[*] Waiting for your order

id
uid=1006(krypto) gid=1006(krypto) groups=1006(krypto)

Bien sûr, dans le cadre du challenge, un simple bindshell ne nous suffit pas : il faut automatiser le vol de clés et l'écriture de notre clé d'équipe, et donc avoir des shellcodes plus spécialisés pour ceci. Mais c'est une autre histoire :)

Cette épreuve n'est pas vraiment représentative des autres épreuves dans le sens où il n'y a pas de faille à exploiter réellement. Pas de stack/heap/integer overflow, ou de format string à exploiter, mais uniquement un programme à analyser et un suite d'astuces à enchainer pour arriver à nos fins.