Introduction
Ce document présente une solution possible au challenge SSTIC 2017.
Il s’agissait cette année de trouver la coutumière adresse mail en <...@sstic.org>
dans une trace d’analyseur logique.
Une fois les données extraites de la trace, on découvrait une page HTML embarquant un émulateur OpenRisc écrit en Javascript, et faisant tourner un système Linux modifié pour l’occasion. Le Javascript fournissait également le moyen de valider des clés d'épreuves par hashage cryptographique.
Depuis la page HTML, on accédait à trois épreuves:
-
"Don’t let him escape" : Un script Python installant un filtre eBPF (extended Berkeley Packet Filter) dans le noyau Linux. Le filtre vérifie certaines conditions sur les paquets réseau arrivant sur l’interface locale; puis valide une clé cryptographique contenue dans un paquet. Il fallait analyser l’algorithme de validation pour en dériver la clé.
-
"Riscy zones" : Un binaire ELF OpenRisc déchiffrant des messages à l’aide de primitives fournies par une "TrustZone" (périmètre de confiance), elle-même implantée en partie en Javascript et dans un autre binaire ELF d’architecture Risc-V. En analysant les liens entre le binaire OpenRisc, le noyau Linux, l'émulateur OpenRisc en Javascript, un second émulateur Risc-V en Javascript, et le binaire Risc-V, on en déduisait l’algorithme et le mode de chiffrement d’un fichier secret. On pouvait ensuite retrouvrer la clé protégeant le fichier secret par le biais d’une attaque hybride, à texte clair partiellement connu et par force brute.
-
"Unstable machines" (accessible après résolution des épreuves précédentes) : Un binaire PE Windows x86 validant l’entrée d’une boîte de dialogue. L’entrée est chiffrée par un algorithme cryptographique reversible, puis le résultat est comparé à la sortie attendue. La difficulté provient du code obfusqué de l’algorithme de génération de clé et de chiffrement, qui emploie une double-machine virtuelle, ainsi que des contre-mesures anti-débuggeurs.
La validation du niveau "Unstable machines" donnait accès à une dernière petite épreuve consistant en un labyrinthe formé dans un QR code. Sa résolution fournissait l’adresse finale.
IDA Pro est utilisé tout au long de la résolution du challenge, ainsi que des outils en source ouverte.
Electronic Flash
Le lien sur la page du challenge nour propose de télécharger un fichier Bittendo_email_leak_by_shadowbrokers.zip, dont on vérifie l’intégrité et dont on extrait un email firmware.eml
:
C:\sstic\2017>\tools\sha256sum.exe Bittendo_email_leak_by_shadowbrokers.zip
c25222bbeb0c59d4f8ba6f5abe543be9d079b36cf1e629cafee347dea087dbb6 *Bittendo_email _leak_by_shadowbrokers.zip
C:\sstic\2017>unzip Bittendo_email_leak_by_shadowbrokers.zip
Archive: Bittendo_email_leak_by_shadowbrokers.zip
creating: Bittendo_email_leak_by_shadowbrokers/
inflating: Bittendo_email_leak_by_shadowbrokers/firmware.eml
On peut ouvrir l’email avec Thunderbird
:

On découvre deux pièces jointes, un schéma d’une mémoire flash nand, et un fichier .sr
. Le contenu du message explique qu’il s’agit d’une trace d’analyseur logique obtenue lors du flashage de la puce, et précise que le logiciel en source ouverte sigrok permet d’ouvrir le fichier .sr
.
Voici le schéma de la flash:

On télécharge donc sigrok, et l’outil graphique associé, PulseView, pour se faire une idée du contenu de la trace. Le fichier .sr
complet est un peu gros pour PulseView, mais on se rend compte que c’est une simple archive zip, et on peut alors n’en conserver que le début. Il suffit pour cela d’effacer de l’archive les fichiers logic-1-%i
dont l’index est supérieur à 100. Voici la visualisation du début de la trace:

On retrouve dans la trace les noms de certaines pattes du schéma:
-
I/O-0
àI/O-7
sont des données transmises en parallèle sur un bus 8-bit -
ALE
signifieAddress Latch Enable
et indique que la prochaine écriture est destinée au registre d’adresse de la nand (au lieu d’une donnée) -
CLE
signifieCommand Latch Enable
et indique que la prochaine écriture est une commande -
WE
signifieWrite Enable
et tient lieu d’horloge pour le transfert des données, des adresses et des commandes vers la flash -
RE
signifieRead Enable
Une note technique de Micron nous fournit les indications ci-dessus et nous en dit plus sur les bases de la programmation des flash nand. En particulier, on trouve aussi les numéros de commandes, par exemple:
-
0x60 débute une commande d’effacement d’un bloc de la flash
-
0x80 débute une commande d'écriture d’un bloc de la flash
Notre but est donc de démêler le signal de données des signaux d’adresses et de commandes, puis d’extraire du signal de données le contenu final de la flash, dans le bon ordre.
Décodage du signal
sigrok
propose un outil en ligne de commande nommé sigrok-cli
plus pratique que PulseView pour traiter un volume important de données. Si on lui fournit le fichier .sr
en entrée sans autre option, il nous donne les bits de signaux sur chacune des pattes:
libsigrok 0.3.0
Acquisition with 12/16 channels at 5 MHz
I/O-0:00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
I/O-1:00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
I/O-2:00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
I/O-3:00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
I/O-4:00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
I/O-5:00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
I/O-6:00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
I/O-7:00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
CLE:00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
ALE:00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
WE:00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
RE:00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
[...]
sigrok-cli
est aussi capable de décoder le signal en lui spécifiant un décodeur de protocole avec l’option -P
. La liste des décodeurs disponibles est fournie par l’option --version
:
[...]
Supported protocol decoders:
avr_isp AVR In-System Programming
can Controller Area Network
dcf77 DCF77 time protocol
[...]
parallel Parallel sync bus
[...]
Après quelques tâtonnements, le décodeur parallèle convient à la tâche d’extraction du signal, en lui passant les bons paramètres pour les sources des données et pour l’horloge (clk
):
$ sigrok-cli -i NAND_FLASH_writes_no_OOB_5MHz.sr -P parallel:clk=WE:d0=I/O-0:d1=I/O-1:d2=I/O-2:d3=I/O-3:d4=I/O-4:d5=I/O-5:d6=I/O-6:d7=I/O-7 | perl -ne "{ binmode STDOUT } print chr(hex($1)) if /(\S+)/" > bindump
Le dump binaire obtenu mélange les données, les adresses, et les commandes, puisqu’on a pas tenu compte des signaux ALE
et CLE
. Il serait plus propre de n’extraire que le signal de données. Cependant il est relativement aisé de séparer les données des autres signaux.
Voici le début du dump binaire:
00000000: 00ff 9000 0060 0000 00d0 0080 0000 0000 .....`..........
00000010: 00eb 5890 6d6b 6673 2e66 6174 0002 0120 ..X.mkfs.fat...
00000020: 0002 0000 0000 f800 003f 00ff 0000 0000 .........?......
00000030: 0000 0801 0008 0200 0000 0000 0002 0000 ................
00000040: 0001 0006 0000 0000 0000 0000 0000 0000 ................
00000050: 0080 0029 6b16 faf0 4e4f 204e 414d 4520 ...)k...NO NAME
00000060: 2020 2046 4154 3332 2020 200e 1fbe 777c FAT32 ...w|
00000070: ac22 c074 0b56 b40e bb07 00cd 105e ebf0 .".t.V.......^..
00000080: 32e4 cd16 cd19 ebfe 5468 6973 2069 7320 2.......This is
00000090: 6e6f 7420 6120 626f 6f74 6162 6c65 2064 not a bootable d
000000a0: 6973 6b2e 2020 506c 6561 7365 2069 6e73 isk. Please ins
000000b0: 6572 7420 6120 626f 6f74 6162 6c65 2066 ert a bootable f
000000c0: 6c6f 7070 7920 616e 640d 0a70 7265 7373 loppy and..press
000000d0: 2061 6e79 206b 6579 2074 6f20 7472 7920 any key to try
000000e0: 6167 6169 6e20 2e2e 2e20 0d0a 0000 0000 again ... ......
On reconnaît un secteur de démarrage d’une FAT32 à l’offset 17, mais il est précédé d’octets correspondant à des adresses dans la flash ou des commandes. Dans la suite des données, on repère des insertions de commandes ou adresses tous les 8k.
À partir de ces observations on peut ébaucher un petit décodeur qui va trier le bon grain (les données) de l’ivraie (les commandes ou adresses). Une première tentative produit un système de fichiers montable, mais dont le contenu est corrompu… L’erreur provient du fait que l’on a ignoré l’ordre d'écriture des blocs dans la flash. En effet ils ne sont pas écrits dans l’ordre; il faut relever l’index du bloc en court d'écriture à chaque commande 0x80.
En définitive, voici un décodeur qui fonctionne:
#include <stdio.h> #include <stdlib.h> typedef unsigned char byte; void dump(byte *buf, int n) { int i; for (i = 0; i < n; i++) { printf("%02x ", buf[i]); if ((i & 15) == 15) printf("\n"); } printf("\n"); } int main(int argc, char *argv[]) { int i = 0, n; FILE *hnd = fopen(argv[1], "rb"); FILE *out = fopen("dec.img", "wb"); byte buf[0x2000]; int idx, last = -1; n = fread(buf, 1, 9, hnd); printf("Header\n"); dump(buf, n); for (;;) { printf("At %lx\n", ftell(hnd)); n = fread(buf, 1, 8, hnd); if (0x60 == buf[2]) { printf("Erase\n"); dump(buf, 6); fseek(hnd, -2, SEEK_CUR); n = fread(buf, 1, 8, hnd); } idx = *(unsigned*)(buf+4); printf("Command %x\n", idx); dump(buf, n); if (0x80 != buf[2]) break; if (-1 != last && idx != last + 0x100) printf("Index %x -> %x\n", last, idx); last = idx; if (feof(hnd)) break; fseek(out, idx * 0x20, SEEK_SET); n = fread(buf, 1, 0x2000, hnd); fwrite(buf, 1, n, out); printf("Data (%d)\n", n); dump(buf, 16); printf("...\n"); dump(buf+n-16, 16); if (feof(hnd)) break; } fclose(out); fclose(hnd); return 0; }
On obtient une image disque dont le SHA256 vaut:
1b16b726db4650d61662448547e325034597a172855fb89a350c4e9e5d0b1a4c
que l’on peut monter, et dont le contenu a le bon MD5:
flkn@flkn-VirtualBox:~/sstic/2017$ sudo mount dec.img ./partoche/ flkn@flkn-VirtualBox:~/sstic/2017$ cd partoche/ flkn@flkn-VirtualBox:~/sstic/2017/partoche$ ls -l total 14435 -rwxr-xr-x 1 root root 14780383 févr. 17 11:23 challenges.zip -rwxr-xr-x 1 root root 49 févr. 17 11:23 challenges.zip.md5 flkn@flkn-VirtualBox:~/sstic/2017/partoche$ md5sum challenges.zip 3977e2084331bb1c52abb115a332c6cc challenges.zip flkn@flkn-VirtualBox:~/sstic/2017/partoche$ cat challenges.zip.md5 3977e2084331bb1c52abb115a332c6cc challenges.zip
Interface du challenge
On trouve dans l’archive challenges.zip
une page .html
contenant l’interface graphique du challenge:

Un Linux modifié est embarqué dans la page et boot sur un CPU OpenRisc émulé en Javascript. Dans le répertoire home
, on trouve des instructions sur la suite du challenge:
Bonjour,
Voici la suite du challenge, les épreuves suivantes se dérouleront dans un
navigateur. Pour fonctionner, ce dossier doit être propulsé par un serveur
web, par exemple avec python SimpleHTTPServer :
$ python -m SimpleHTTPServer 8000
Pour travailler plus confortablement tu peux te connecter à la machine
virtuelle fonctionnant dans ton navigateur en SSH.
Une passerelle Websocket<->Interface TAP est nécessaire pour fournir du réseau
à la machine virtuelle. Le projet «go-websockproxy» [1] permet de créer cette
passerelle, il remplit également la fonction de serveur web.
Le binaire nécessite des privilèges élevés (les capabilities CAP_NET_RAW et
CAP_NET_ADMIN ainsi que le droit d'exécuter la commande "ip" avec ces
capabilities) pour créer une interface TAP :
sudo go-websockproxy --listen-address=127.0.0.1:8090 --static-directory=$CHALLENGE_DIR --tap-ipv4=10.42.42.1/30
Ensuite il ne reste plus qu'à ouvrir ton navigateur à l'adresse
http://127.0.0.1:8090/main.html. Lorsque la machine virtuelle a fini de
démarrer, il est possible de se connecter en SSH :
ssh user@10.42.42.2, le mot de passe est "sstic".
[1] https://github.com/gdm85/go-websockproxy
Dans le répertoire /challenges
du Linux se trouvent les épreuves à résoudre et des outils de validation des LUMs et des clés, add_lum
et add_key
. La validation utilise une bibliothèque tee_client.py
qui communique avec un périphérique /dev/sstic
, qui lui-même atteint le Javascript où les hashs des bonnes clés sont stockées. Ce noyau fait des choses louches…
Attaquons les épreuves.
Don’t let him escape
L'épreuve "Don’t let him escape" nous confronte à un script Python "server.py" installant un programme eBPF
sur une socket réseau. En voici une version légèrement modifiée (le programme eBPF
a été raccourci pour faciliter la lecture):
#!/usr/bin/python2 import base64 import zlib import sys import os from bpf import * interface="lo" bpf_prog = "eNrlW [...] QvEw==" sock = create_raw_socket(interface) # decompress bpf program bpf_prog = base64.b64decode(bpf_prog) bpf_prog = zlib.decompress(bpf_prog) # create_bpf_table table_fd = bpf_map_create() print("table_fd = %d" % (table_fd)) if table_fd < 0: print("can't create map") sys.exit(1) bpf_patched_prog=bpf_patcher(bpf_prog,table_fd) # load bpf program bpf_fd = load_bpf_program(bpf_patched_prog, debug=True) if bpf_fd > 0: print("bpf successfully loaded") else: print("bpf load error") sys.exit(1) # attach the bpf program to the raw socket bpf_attach_socket(sock.fileno(), bpf_fd) # empty the recv queue sock.setblocking(0) while 1: try: d = os.read(sock.fileno(),2048) except: break # start the main loop sock.setblocking(True) while 1: packet_str = os.read(sock.fileno(),2048) packet_bytearray = bytearray(packet_str) ETH_HLEN = 2*6 + 2 ip_header_length = (packet_bytearray[ETH_HLEN] & 0xF) << 2 udp_header_length = 8 udp_offset = ETH_HLEN + ip_header_length payload_size = struct.unpack(">H",packet_str[udp_offset+4:udp_offset+6])[0] - 8 payload_offset = ETH_HLEN + ip_header_length + udp_header_length payload = packet_str[payload_offset:payload_offset+payload_size] state = bpf_map_lookup(table_fd) if state == 1: lum = payload print("Good job!, you find a LUM : "+lum) try: sys.path.append("/challenges/tools/") from tee_client import add_lum add_lum(lum) except: pass elif state == 2: key = payload[4:36] print("Good job!, key is : "+key) try: sys.path.append("/challenges/tools/") from tee_client import add_key add_key(key) except: pass else: print("Strange state !")
Les commentaires sont dans le script d’origine. Son fonctionnement est assez clair:
-
le script crée une socket réseau sur l’interface
loopback
-
il décode et décompresse un programme
eBPF
, et le patche pour faire référence à une table, puis l’attache à la socket -
enfin, il accepte en boucle des datagrammes
UDP
et consulte un état interne dans la table, en fonction duquel il annonce la découverte d’un LUM ou d’une clé
eBPF
est une évolution de BPF
(Berkeley Packet Filter) qui permet, entre autres, de filtrer des paquets réseau. L’idée générale est de fournir un langage flexible, rapide, et sûr pour accepter ou rejeter des paquets en fonction de leur contenu.
La flexibilité provient d’une machine virtuelle qui permet de faire des calculs arbitraires sur le contenu des paquets; la rapidité découle d’un format d’instruction simple, permettant de compiler à la volée (JITer) le code facilement; la sûreté est assurée par la restriction des accès mémoire à certaines zones précises (paquet en cours de contrôle, tables associées au filtre) et par un vérificateur de bytecode interdisant les boucles (pour éviter les boucles infinies).
Les noyaux Linux "récents" (> 3.18) implantent un JIT eBPF
et fournissent des appels systèmes de gestion des filtres eBPF
. Ceux-ci peuvent être associés non seulement à des sockets, mais aussi à d’autres entités. Par exemple, le filtrage de la sandbox seccomp
repose en partie sur des filtres eBPF
.
Dans le cas qui nous intéresse, le programme eBPF
est utilisé dans le cadre traditionnel du filtrage de paquets. Le but est donc de comprendre les calculs effectués par le programme, et de lui envoyer les bons paquets pour le faire passer dans les états 1 (LUM trouvé) et 2 (clé trouvée).
(Dans la suite, on utilisera indifféremment les termes BPF
ou eBPF
, sachant qu’on regarde bien un programme eBPF
.)
Obtention du code à analyser
On commence par extraire le binaire BPF
du script en conservant uniquement le code python faisant le décodage et la décompression. On garde aussi le code de patchage de l’identifiant de table (situé dans la bibliothèque bpf.py
) et on choisit un identifiant arbitraire de table (1234) qui se reconnaîtra facilement.
#!/usr/bin/python2 import base64 import zlib import sys import os import struct def bpf_patcher(prog, table_fd): new="" endian="big" if struct.pack("=I",1) == "\x01\x00\x00\x00": print "little endian" endian="little" else: print "big endian" for i in range(0,len(prog),8): instru = prog[i:i+8] opcode = instru[0] registers = ord(instru[1]) off = instru[2:4] imm = instru[4:8] if opcode == "\x18" and ((registers & 0xF0) >> 4) != 0: print "patch table fd" imm = struct.pack("<I",table_fd) if endian == "big": new += opcode + chr( (registers&0xF)<<4 | (registers&0xF0)>>4 ) + off[::-1] + imm[::-1] else: new += opcode + chr(registers) + off + imm return new bpf_prog = "eNrlWk9 [...] vDyQvEw==" # decompress bpf program bpf_prog = base64.b64decode(bpf_prog) bpf_prog = zlib.decompress(bpf_prog) bpf_prog=bpf_patcher(bpf_prog,1234) with open("bpf.bin", "wb") as f: f.write(bpf_prog)
On cherche ensuite un désassembleur eBPF
pour l’appliquer à bpf.bin
. Le projet ubpf
, une implantation de la VM eBPF
en Python, inclut un petit désassembleur qui semble faire l’affaire.
Il faut ajouter quelques instructions manquantes, liées aux accès mémoire:
@@ -145,6 +145,18 @@ def disassemble_one(data, offset):
return "%s %s, %s" % (class_name + size_name, M(R(dst_reg), off), I(imm))
elif cls == BPF_CLASS_STX:
return "%s %s, %s" % (class_name + size_name, M(R(dst_reg), off), R(src_reg))
+ elif cls == BPF_CLASS_LD and mode_name == "abs" and size_name == 'b':
+ return "ldabsb %s" % (I(imm))
+ elif cls == BPF_CLASS_LD and mode_name == "abs" and size_name == 'w':
+ return "ldabsw %s" % (I(imm))
+ elif cls == BPF_CLASS_LD and mode_name == "abs" and size_name == 'h':
+ return "ldabsh %s" % (I(imm))
+ elif cls == BPF_CLASS_LD and mode_name == "ind" and size_name == 'b':
+ return "ldindb [%s+%s]" % (R(src_reg), I(imm))
+ elif cls == BPF_CLASS_LD and mode_name == "ind" and size_name == 'w':
+ return "ldindw [%s+%s]" % (R(src_reg), I(imm))
+ elif cls == BPF_CLASS_LD and mode_name == "ind" and size_name == 'h':
+ return "ldindh [%s+%s]" % (R(src_reg), I(imm))
else:
return "unknown mem instruction %#x" % code
else:
Ensuite, le programme se désassemble entièrement (cf fichier bpf.raw_disas
). En voici le début:
mov r6, r1
mov r7, 0x0
ldabsh 0xc ; offset 12
jne r0, 0x800, +1232 ; 0x800 = IPv4
ldabsb 0x17 ; offset 23
jne r0, 0x11, +1230 ; 17 = UDP
ldabsh 0x10
mov r8, r0
ldabsb 0xe
mov r9, r0
ldabsh 0x24 ; offset 36
jne r0, 0x539, +1224 ; 1337 = dst port
lddw r1, 0xfffffff8
add r8, r1
lsh r9, 0x2
and r9, 0x3c
sub r8, r9
stxdw [r10-24], r9
add r9, 0x16
mov r1, 0x0
stxdw [r10-8], r1
lddw r1, 0x4d2 ; 1234 = table id
mov r2, r10
add r2, 0xfffffff8
call 0x1
jne r0, 0x0, +1
ja +2
ldxdw r1, [r0]
jne r1, 0x0, +91
[...]
On remarque certaines constantes intéressantes, dont les offsets sont consistants avec la lecture depuis une trame ethernet. Ainsi, à l’offset 12, on trouve le champ protocole, et la valeur 0x800 correspond à IPv4. À l’offset 23 de la trame, c’est-à-dire l’offset 9 du paquet IP (car l’en-tête MAC fait 14 octets), on trouve le champ du protocole sur IP, et la valeur 17 correspond à UDP. À l’offset 36 de la trame, offset 2 dans l’en-tête UDP, se trouve le port de destination, qui doit donc valoir 1337.
On remarque aussi des instructions call
avec un paramètre 1 ou 2. Il s’agit d’appel à des fonctions utilitaires externes au programme eBPF
. La documentation nous indique qu’il s’agit des fonctions BPF_MAP_LOOKUP_ELEM
et BPF_MAP_UPDATE_ELEM
, ce qui est cohérent avec le mode de fonctionnement attendu du programme, qui doit consulter et mettre à jour un état interne dans une table. On retrouve aussi notre identifiant de table 1234, passé dans r1
à ces fonctions.
Un coup d’oeil aux sources du noyau nous donne les prototypes des fonctions natives correspondantes, et la nature des arguments. (En particulier, l’argument fd
est le handle identifiant la table associée au BPF
, ce qui explique pourquoi il fallait patcher le programme avant de le charger.)
int bpf_map_lookup_elem(int fd, void *key, void *value); int bpf_map_update_elem(int fd, void *key, void *value, __u64 flags);
Le code BPF
est lisible, mais le jeu d’instructions ne nous est pas très familier. Il pourrait être intéressant de récupérer un code équivalent sur une architecture plus classique. Or il est facile de récupérer le code JITé par le noyau lorsqu’un filtre eBPF
est compilé. Il suffit d'éditer une variable du noyau accessible à travers /proc/sys
.
On se met donc sur un Linux x86_64, et on tape dans un shell root:
echo 2 > /proc/sys/net/core/bpf_jit_enable
On lance ensuite server.py
et on peut récupérer un dump hexa du code JITé avec dmesg
, qu’on décode en un binaire dmesg.bin
, et qu’on ouvre dans IDA:
seg000:0000000000000000 push rbp seg000:0000000000000001 mov rbp, rsp seg000:0000000000000004 sub rsp, 228h seg000:000000000000000B mov [rbp+var_228], rbx seg000:0000000000000012 mov [rbp+var_220], r13 seg000:0000000000000019 mov [rbp+var_218], r14 seg000:0000000000000020 mov [rbp+var_210], r15 seg000:0000000000000027 xor eax, eax seg000:0000000000000029 mov [rbp+var_208], rax seg000:0000000000000030 mov r9d, [rdi+80h] seg000:0000000000000037 sub r9d, [rdi+84h] seg000:000000000000003E mov r10, [rdi+0D8h] seg000:0000000000000045 mov rbx, rdi seg000:0000000000000048 xor r13d, r13d seg000:000000000000004B mov esi, 0Ch seg000:0000000000000050 call near ptr 0FFFFFFFFDBFBF191h ; ldabsh seg000:0000000000000055 cmp rax, 800h seg000:000000000000005C jnz loc_1B88 seg000:0000000000000062 mov esi, 17h seg000:0000000000000067 call near ptr 0FFFFFFFFDBFBF1ADh ; ldabsb seg000:000000000000006C cmp rax, 11h seg000:0000000000000070 jnz loc_1B88 seg000:0000000000000076 mov esi, 10h seg000:000000000000007B call near ptr 0FFFFFFFFDBFBF191h seg000:0000000000000080 mov r14, rax seg000:0000000000000083 mov esi, 0Eh seg000:0000000000000088 call near ptr 0FFFFFFFFDBFBF1ADh seg000:000000000000008D mov r15, rax seg000:0000000000000090 mov esi, 24h ; '$' seg000:0000000000000095 call near ptr 0FFFFFFFFDBFBF191h seg000:000000000000009A cmp rax, 1337 seg000:00000000000000A1 jnz loc_1B88 seg000:00000000000000A7 mov rdi, 0FFFFFFF8h seg000:00000000000000B1 add r14, rdi seg000:00000000000000B4 shl r15, 2 seg000:00000000000000B8 and r15, 3Ch seg000:00000000000000BC sub r14, r15 seg000:00000000000000BF mov [rbp+ip_hdr_len], r15 seg000:00000000000000C3 add r15, 16h seg000:00000000000000C7 xor edi, edi seg000:00000000000000C9 mov [rbp+var_8], rdi seg000:00000000000000CD mov rdi, 0FFFF91EAB5471240h seg000:00000000000000D7 mov rsi, rbp seg000:00000000000000DA add rsi, 0FFFFFFFFFFFFFFF8h seg000:00000000000000DE push r10 seg000:00000000000000E0 push r9 seg000:00000000000000E2 call near ptr 0FFFFFFFFDC0C89F0h ; call 1 seg000:00000000000000E7 pop r9 seg000:00000000000000E9 pop r10 seg000:00000000000000EB cmp rax, 0 seg000:00000000000000EF jnz short loc_F3 seg000:00000000000000F1 jmp short loc_101 [...]
Certains opcodes BPF
(ldabsb
, ldabsh
, …) sont JITés en appels à des fonctions natives du noyau. C’est aussi le cas pour l’opcode call
, comme on s’y attendait.
Pour faciliter encore la lecture de certaines parties, on peut utiliser le décompilateur HexRays. Pour cela, il faut choisir un compilateur dans Options
/Compiler
. On choisit GCC
en première approximation; le code a été JITé par le noyau donc c’est inexact, mais on a au moins la bonne ABI.
Grandes étapes du BPF
On peut diviser le programme BPF
en deux sous-parties:
-
La vérification des conditions sur l’en-tête du paquet, déjà décrite ci-dessus: il doit s’agir d’un datagramme UDP destiné au port 1337, sans quoi le paquet est ignoré
-
La vérification du contenu de la charge utile UDP
Cette dernière sous-partie dépend elle-même de l'état courant, stocké à l’index 0 de la table:
-
Dans l'état 0: la charge utile est comparée à
LUM{BvWQEdCrMfA}
et, si la comparaison réussit, les entrées 1 et 2 de la table sont initialisées avec des valeurs dérivées du LUM et on passe dans l'état 1 -
Dans l'état 1: la charge utile est comparée à
KEY{...}
où la partie entre accolades est une clé de 32 caractères hexadécimaux décrivant une clé. Si la clé est bonne, on passe dans l'état 2
Vérification du LUM
Voici le code de vérification du LUM, simplifié depuis la sortie de HexRays pour en faciliter la lecture:
udp_payload_len = ip_total_len + 0xFFFFFFF8LL - ip_hdr_len; key = 0LL; LODWORD(v17) = map_lookup_elem(TABLE_HND, &key);// &map[0] if ( !v17 || (v16 = *v17) == 0 ) { if ( (unsigned int)udp_payload_len != 16LL ) return 0; // check LUM cL = load_byte(v16, ip_hdr_len + 22); cU = load_byte(v16, ip_hdr_len + 23); if ( cL != 'L' ) return 0; if ( cU != 'U' ) return 0; LODWORD(v23) = load_dword(ip_hdr_len + 34); v24 = v23; LODWORD(v25) = load_dword(ip_hdr_len + 30); v26 = v25; if ( (v24 | (v26 << 32)) != 'EdCrMfA}' || load_dword(ip_hdr_len + 26) != 'BvWQ' || load_word (ip_hdr_len + 24) != 'M{' ) return 0; // enter state 1 = found a LUM value = 1LL; key = 0LL; map_update_elem(TABLE_HND, &key, &value, 0LL);// map[0] = 1 // map[1] = ~'BvWQ' LODWORD(v33) = load_dword(ip_hdr_len + 26); value = ~v33 & 0xFFFFFFFFLL; key = 1LL; map_update_elem(TABLE_HND, &key, &value, 0LL); // map[2] = ~'EdCr' LODWORD(v39) = load_dword(ip_hdr_len + 30); value = ~v39 & 0xFFFFFFFFLL; key = 2LL; map_update_elem(TABLE_HND, &key, &value, 0LL); return 0xFFFFFFFFLL; }
Le code vérifie que la longueur de la charge utile UDP est exactement 16 octets, puis la compare avec le LUM. L’offset de départ, ip_hdr_len + 22
, correspond bien à l’offset du datagramme, car 22 est la somme de la taille du header MAC (14 octets) plus la taille du header UDP (8 octets.)
Les entrées de la table situées aux index 1 et 2 sont initialisées avec le non
bit-à-bit de deux dwords extraits du LUM.
On peut envoyer le bon paquet avec netcat pour passer dans l'état 1 avant le poursuivre nos recherches sur la clé.
falkn$ nc -u 127.0.0.1 1337 < lum.txt
Le serveur confirme le LUM:
Good job!, you find a LUM : LUM{BvWQEdCrMfA}
Vérification de la clé
Le code de vérification de la clé commence d’une manière similaire à celui du LUM:
if ( (unsigned int)udp_payload_len != 37LL || current_state != 1 ) return 0; dgram = ip_hdr_len + 22; if ( load_byte(dgram) != 'K' || load_byte(dgram | 1) != 'E' || load_byte(dgram + 2) != 'Y' || load_byte(dgram + 3) != '{' || load_byte(ip_hdr_len + 58) != '}' ) { // revert to state 0 value = 0LL; key = 0LL; map_update_elem(TABLE_HND, &key, &value, 0LL); return 0; } key = 1LL; LODWORD(v44) = map_lookup_elem(TABLE_HND, &key); LUM1 = 0LL; if ( v44 ) LUM1 = *v44; key = 2LL; LODWORD(v46) = map_lookup_elem(TABLE_HND, &key); LUM2 = 0LL; if ( v46 ) LUM2 = *v46; LODWORD(h2) = load_dword(TABLE_HND, ip_hdr_len + 34); k89AB = h2; LODWORD(h4) = load_dword(TABLE_HND, ip_hdr_len + 42); kGHIJ = h4; LODWORD(h6) = load_dword(TABLE_HND, ip_hdr_len + 50); kOPQR = h6; LODWORD(h0) = load_dword(TABLE_HND, ip_hdr_len + 26); k0123 = h0; LODWORD(h3) = load_dword(TABLE_HND, ip_hdr_len + 38); kCDEF = h3; LODWORD(h5) = load_dword(TABLE_HND, ip_hdr_len + 46); kKLMN = h5; LODWORD(h7) = load_dword(TABLE_HND, ip_hdr_len + 54); kSTUV = h7; LODWORD(k4567) = load_dword(TABLE_HND, ip_hdr_len + 30);
Le code vérifie donc que la charge utile UDP fait exactement 37 octets, que l’on se trouve bien dans l'état 1 (LUM déjà envoyé), et que la chaîne KEY{...}
apparaît dans la charge avec 32 octets de clé entre les accolades.
Si ces conditions sont vérifiées, le code relit dans la table les valeurs LUM1
et LUM2
dérivées du LUM, puis lit la clé sous la forme de huit dwords k0123
à kSTUV
.
S’en suit une assez longue portion de code, très répétitive, manipulant les octets de clé. En voici le début:
if ( (unsigned __int8)(k4567 - '0') > 9uLL ) { if ( (unsigned __int8)(k4567 - 'A') > 5uLL ) { H0 = (unsigned __int8)k4567 - 0x57LL; if ( (unsigned __int8)(k4567 - 'a') >= 6uLL ) H0 = 0LL; } else { H0 = (unsigned __int8)k4567 - 0x37LL; } } else { H0 = (unsigned __int8)k4567 - 0x30LL; } if ( (unsigned __int8)(BYTE1(k4567) - 48) > 9uLL ) { if ( (unsigned __int8)(BYTE1(k4567) - 65) > 5uLL ) { v63 = 0LL; if ( (unsigned __int8)(BYTE1(k4567) - 97) > 5uLL ) goto LABEL_38; v62 = 16LL * BYTE1(k4567) - 0x570; } else { v62 = 16LL * BYTE1(k4567) - 0x370; } } else { v62 = 16LL * BYTE1(k4567) - 0x300; } v63 = v62; [...]
Il s’agit d’une série de conversions de chiffres hexadécimaux en binaire, avec un éventuel décalage vers la gauche du nibble binaire résultant. Chaque octet de la clé textuelle en entrée est donc un caractère représentant un chiffre hexadécimal. Autrement dit, on cherche une clé de 16 octets binaires.
À l’issue des conversions des chiffres hexadécimaux, les nibbles binaires sont réassemblés en quatre dwords binaires, qui subissent les tests de validation. (On peut tester l’ordre de réassemblage en modifiant et en recompilant le code produit par HexRays: on extrait le code des conversions, on pré-initialise k0123
, …, kSTUV
ci-dessus à un vecteur de test, par exemple 112233445566...
, et on affiche les valeurs de sortie des variables subissant la validation k01234567
, …, kOPQRSTUV
ci-dessous.)
udp_len = (unsigned __int8)read_word(38LL); // offset 38 = UDP length (incl. hdr) = 37 + 8 = 45 // validate first dword if ( ((0x706F6C79 * udp_len ^ (LUM1 ^ k01234567)) & 0xFFFFFFFF) != 0x53535449 ) goto not_good; if ( !udp_len ) return 0LL; // validate second dword if ( (((LUM2 ^ k89ABCDEF) + 0x6D6F7266 / udp_len) & 0xFFFFFFFF) != 0x43204348 ) goto not_good; // validate third dword v160 = (LUM1 ^ kGHIJKLMN) & 0xFFFFFFFF; v161 = v160 * v160 % 2319885613 * (v160 * v160 % 2319885613) % 2319885613; v162 = v161 * v161 % 2319885613 * (v161 * v161 % 2319885613); v163 = v162 % 2319885613 * (v162 % 2319885613) % 2319885613 * (v162 % 2319885613 * (v162 % 2319885613) % 2319885613); if ( v163 % 2319885613 * (v160 % 2319885613) % 2319885613 != 0x204C4C41 ) goto not_good; // validate fourth dword v164 = ((LUM2 ^ kOPQRSTUV) & 0xFFFFFFFF) * ((LUM2 ^ kOPQRSTUV) & 0xFFFFFFFF); v165 = v164 % 3698100449 * (v164 % 3698100449) % 3698100449 * (v164 % 3698100449 * (v164 % 3698100449) % 3698100449); v166 = v165 % 3698100449 * (v165 % 3698100449) % 3698100449 * (v165 % 3698100449 * (v165 % 3698100449) % 3698100449); v167 = v166 % 3698100449 * (v166 % 3698100449) % 3698100449 * (v166 % 3698100449 * (v166 % 3698100449) % 3698100449); if ( v167 % 3698100449 * (v167 % 3698100449) % 3698100449 * (((LUM2 ^ kOPQRSTUV) & 0xFFFFFFFF) % 3698100449) % 3698100449 != 0x37313032 ) goto not_good; // good key, set state = 2 value = 2LL; key = 0LL; map_update_elem(TABLE_HND, &key, &value, 0LL); return 0xFFFFFFFFLL;
Les deux valeurs dérivées du LUM, LUM1
et LUM2
, interviennent dans la validation. Ce sont des constantes connues. La longueur du datagramme UDP intervient aussi. On peut la supposer égale à 45 dans un premier temps, puisqu’on connaît la longueur de la charge utile (37), mais on garde à l’esprit qu’il pourrait s’agir d’un paquet forgé, donc on n’a pas de certitude sur la cohérence entre les différents champs des en-têtes (en fait, c’est bien 45).
Les deux premières parties de la clé sont calculables directement en inversant les opérations appliquées dessus.
Les deux dernières parties subissent une transformation plus complexe avec des multiplications et réductions modulaires, dont le résultat est comparé à une constante. Il est facile de lancer une attaque par force brute sur chaque partie de 32 bits.
J’ai écrit deux brute-force similaires fondés sur le code HexRays. Curieusement, l’un d’eux n’a pas fonctionné, peut-être est-ce dû à un bug dans la sortie de HexRays… (je n’ai pas eu le temps de confirmer si c’est le cas). En transcrivant le code BPF
directement en C, le brute-force marche.
Voici les deux brute-force:
#include <stdio.h> #include <stdint.h> #define LUM1 0xbd89a8ae #define LUM2 0xba9bbc8d int main(void) { uint64_t v164, v165, v166, v167, OPQ; for (OPQ = 0; OPQ <= 0xffffffff; OPQ++) { v164 = ((LUM2 ^ OPQ) & 0xFFFFFFFF) * ((LUM2 ^ OPQ) & 0xFFFFFFFF); v165 = v164 % 3698100449 * (v164 % 3698100449) % 3698100449 * (v164 % 3698100449 * (v164 % 3698100449) % 3698100449); v166 = v165 % 3698100449 * (v165 % 3698100449) % 3698100449 * (v165 % 3698100449 * (v165 % 3698100449) % 3698100449); v167 = v166 % 3698100449 * (v166 % 3698100449) % 3698100449 * (v166 % 3698100449 * (v166 % 3698100449) % 3698100449); if ( v167 % 3698100449 * (v167 % 3698100449) % 3698100449 * (((LUM2 ^ OPQ) & 0xFFFFFFFF) % 3698100449) % 3698100449 == 0x37313032 ) printf("%llx\n", OPQ); } return 0; }
Un seul antécédent est possible pour cette partie de clé (la derniere):
~/sstic2017/challenges/dont_let_him_escape falkn$ ./bf449 291de7c9
#include <stdio.h> #include <stdint.h> int main(void) { uint64_t seed, r1 = 0xf7905d0c, r2 = 0x8a46a52d, r3, r8 = 0xbd89a8ae; for (seed = 0; seed <= 0xffffffff; seed++) { r1 = seed; r2 = 0x8a46a52d; r8 = 0xbd89a8ae; r8 ^= r1; //xor r8, r1 r8 <<= 20; //lsh r8, 0x20 r8 >>= 20; //rsh r8, 0x20 r1 = r8; //mov r1, r8 r1 *= r1; //r1 *= r1; //lddw r2, 0x8a46a52d r3 = r1; r3 /= r2; r3 *= r2; r1 -= r3; r1 *= r1; r3 = r1; r3 /= r2; r3 *= r2; r1 -= r3; r3 = r8; //mov r3, r8 r3 /= r2; r3 *= r2; r8 -= r3; //sub r8, r3 r1 *= r1; r3 = r1; r3 /= r2; r3 *= r2; r1 -= r3; r1 *= r1; r3 = r1; r3 /= r2; r3 *= r2; r1 -= r3; r1 *= r1; r3 = r1; r3 /= r2; r3 *= r2; r1 -= r3; r1 *= r1; r3 = r1; r3 /= r2; r3 *= r2; r1 -= r3; r1 *= r1; r3 = r1; r3 /= r2; r3 *= r2; r1 -= r3; r1 *= r1; r3 = r1; r3 /= r2; r3 *= r2; r1 -= r3; r1 *= r8; //mul r1, r8 r3 = r1; r3 /= r2; r3 *= r2; r1 -= r3; //jne r1, 0x204c4c41, +71 if (r1 == 0x204c4c41) printf("%llx\n", seed); } }
Pour le troisième dword de clé, il y a deux solutions possibles (seule la première est acceptée par l’outil add_key
)
~/sstic2017/challenges/dont_let_him_escape falkn$ ./check3 718fe186 fc360b55
On envoie la clé avec netcat:
falkn$ echo KEY{2d4ceda2fa2a0e08fc360b55291de7c9} > key.txt falkn$ nc -u 127.0.0.1 1337 < key.txt
Et le serveur affiche:
Good job!, key is : 2d4ceda2fa2a0e08718fe186291de7c9
add_key
accepte la clé, et zou, monde validé! :)
Riscy zones
L'épreuve "Riscy zones" se présente sous la forme de quatre fichiers, deux exécutables ELF, et deux fichiers de données:
~/sstic2017 falkn$ ls challenges/sys/fs/challenges/riscy_zones/ TA.elf.signed password_is_00112233445566778899AABBCCDDEEFF.txt.encrypted secret.lzma.encrypted trustzone_decrypt
trustzone_decrypt
est un binaire OpenRisc; on peut le lancer sur la VM du challenge et il nous fournit ses paramètres d’entrée:
/challenges/riscy_zones $ ./trustzone_decrypt usage: ./trustzone_decrypt [password] [encrypted file] [destination file]
Il nous faut donc un mot de passe et un fichier chiffré pour teste le programme, or nous disposons justement du fichier password_is_00112233445566778899AABBCCDDEEFF.txt.encrypted
. On tente donc le test:
/challenges/riscy_zones $ ./trustzone_decrypt 00112233445566778899aabbccddeeff password_is_00112233445566778899AABBCCDDEEFF.txt.encrypted test.decr load TA.elf.signed in TrustedOS OS return code = 0x00000000, TA return code = 0x00000000 Send command to Trusted App CMD_GET_TA_VERSION OS return code = 0x00000000, TA return code = 0x00000000 retreived version : SSTIC Trusted APP v0.0.1 check password in TEE OS return code = 0x00000000, TA return code = 0x00000000 Good password ! Send command to Trusted App CMD_DECRYPT_BLOCK OS return code = 0x00000000, TA return code = 0x00000000 [... 16 répétitions des 2 lignes précédentes ] Unload TA form TrustedOS OS return code = 0x00000000, TA return code = 0x00000000 /challenges/riscy_zones $ xxd test.decr 0000000: 4465 6372 7970 7465 6420 626c 6f63 6b0a Decrypted block. 0000010: 4465 6372 7970 7465 6420 626c 6f63 6b0a Decrypted block. [...] 00000f0: 4465 6372 7970 7465 6420 626c 6f63 6b0a Decrypted block. 0000100: 1010 1010 1010 1010 1010 1010 1010 1010 ................
Par ailleurs, simultanément à l’exécution du programme, on voit apparaître dans la deuxième console de l’interface une série de messages:

De cette première étape, on peut déjà déduire quelques éléments intéressants:
-
le second fichier exécutable
TA.elf.signed
intervient probablement dans le déchiffrement; il est chargé au début dans la "TrustZone" et en est déchargé à la fin -
on peut envoyer des commandes à la TrustZone pour réaliser des opérations comme requêter un numéro de version, soumettre un mot de passe, ou déchiffrer un bloc de données
-
le mot de passe soumis est validé par la TrustZone avant d'être utilisé
-
la "TrustZone" semble fournir une primitive de chiffrement d’un bloc individuel, mais c’est le client qui semble bâtir un algorithme de chiffrement complet; on s’attend donc à trouver le mode de chiffrement employé dans le client
Outre ces considérations générales, on remarque aussi quelques éléments spécifiques à notre fichier de test:
-
le fichier déchiffré ne fait que 272 octets; il est plus petit que le fichier chiffré qui en fait 400, reste donc à voir le rôle des octets surnuméraires dans le fichier chiffré
-
chaque bloc de 16 octets du fichier déchiffré semble donner lieu à une commande
CMD_DECRYPT_BLOCK
(il y en 17 en tout); a priori, la TrustZone fournit donc une primitive de chiffrement opérant sur des blocs de 16 octets -
le dernier bloc du fichier déchiffré semble être du padding PKCS#7 (ou "bourrage", pour les puristes)
Dès lors, le but de l'épreuve semble clair: il faut trouver le mot de passe du fichier secret.lzma.encrypted
, et pour cela il faut analyser le fonctionnement précis du chiffrement et y trouver une faille.
Analyse du client trustzone_decrypt
Choix de la tactique
Le jeu d’instructions OpenRisc n’est pas compris par IDA. Cependant, l’outil objdump
est capable de désassembler les instructions OpenRisc, nous pourrions nous appuyer là-dessus. Plusieurs approches viennent à l’esprit pour procéder à la rétro-ingénierie du programme:
-
on peut travailler sur la sortie d'
objdump
à la main. Très bourrin, mais on est sûr que ça marche, et c’est probablement la méthode la plus rapide quand le volume de code est petit -
on peut coder un CPU IDA (ou voyager dans le temps pour prendre celui de Deva). Élégant, mais il faut refaire le boulot de décodage des instructions
-
on peut tenter de traduire la sortie d'
objdump
en quelque chose de plus utilisable
La troisième approche semble intéressante. On pourrait traduire dans du C, et se ramener à nos outils favoris (compilateurs, etc). Cependant certains idiomes de bas niveau comme les déréférencements de pointeurs ou les manipulations de pile sont un peu ardus à traduire en C. En revanche, la traduction en assembleur (x86 par exemple) serait quasi-directe. Elle présenterait les mêmes avantages qu’un CPU IDA, peut-être même d’autres, comme de pouvoir utiliser HexRays. Il n’y aurait qu’une trentaine d’instructions à traduire pour convertir notre programme cible.
Nous partons donc sur l’idée d’une traduction binaire du code OpenRisc vers du x86. Deux éléments essentiels au reverse doivent impérativement être conservés: les chaînes et les symboles (internes et importés). La tactique va être de traduire la sortie de objdump vers un .asm
, assemblé ensuite avec nasm
au format .obj
, et qu’on ouvrira dans IDA.
Traduction en x86
On construit un dictionnaire associant les point d’entrées de la .plt
avec les imports. On emploie pour cela les deux dernières colonnes de la sortie de readelf
décrivant la section rela.plt
:
Relocation section '.rela.plt' at offset 0x344 contains 15 entries:
Offset Info Type Sym.Value Sym. Name + Addend
0000596c 00000114 R_OR1K_JMP_SLOT 00002430 ioctl + 0
00005970 00000214 R_OR1K_JMP_SLOT 00002444 printf + 0
00005974 00000314 R_OR1K_JMP_SLOT 00002458 memcpy + 0
00005978 00000414 R_OR1K_JMP_SLOT 0000246c puts + 0
0000597c 00000514 R_OR1K_JMP_SLOT 00002480 malloc + 0
00005980 00000614 R_OR1K_JMP_SLOT 00002494 mmap + 0
00005984 00000814 R_OR1K_JMP_SLOT 000024a8 write + 0
00005988 00000914 R_OR1K_JMP_SLOT 000024bc fstat + 0
0000598c 00000a14 R_OR1K_JMP_SLOT 000024d0 read + 0
00005990 00000b14 R_OR1K_JMP_SLOT 000024e4 memset + 0
00005994 00000d14 R_OR1K_JMP_SLOT 000024f8 exit + 0
00005998 00000e14 R_OR1K_JMP_SLOT 0000250c __libc_start_main + 0
0000599c 00000f14 R_OR1K_JMP_SLOT 00002520 strlen + 0
000059a0 00001014 R_OR1K_JMP_SLOT 00002534 open + 0
000059a4 00001114 R_OR1K_JMP_SLOT 00002548 close + 0
Pour les chaînes de caractères, on copie en brut la portion du binaire à partir de l’offset 0x1400 (adresse virtuelle 0x3400), et on l’inclut dans le .asm
sous forme de dump hexa. Dans la traduction, à chaque référence dans la petite plage d’adresse correspondant aux chaînes, on changera le contenu du registre destination pour référencer un offset dans notre "blob" de chaînes.
On récupère le code assembleur brut avec:
$ objdump -d trustzone_decrypt > objd_raw $ cat objd_raw trustzone_decrypt: file format elf32-or1k Disassembly of section .init: 000023f8 <.init>: 23f8: 9c 21 ff fc l.addi r1,r1,-4 23fc: d4 01 48 00 l.sw 0(r1),r9 2400: 04 00 01 3e l.jal 28f8 <_start_c+0x1f4> 2404: 15 00 00 00 l.nop 0x0 2408: 04 00 03 dd l.jal 337c <decode_hex+0x9e8> 240c: 15 00 00 00 l.nop 0x0 2410: 85 21 00 00 l.lwz r9,0(r1) 2414: 44 00 48 00 l.jr r9 2418: 9c 21 00 04 l.addi r1,r1,4 Disassembly of section .plt: 0000241c <.plt>: 241c: 19 80 00 00 l.movhi r12,0x0 2420: a9 8c 59 64 l.ori r12,r12,0x5964 2424: 85 ec 00 04 l.lwz r15,4(r12) 2428: 44 00 78 00 l.jr r15 242c: 85 8c 00 00 l.lwz r12,0(r12) [...]
On édite le code pour se débarrasser des sections .init
, .plt
, et .fini
, des lignes Disassembly
, etc., et ne garder que le code efficace.
Avant de se lancer dans la traduction, on se fait une idée du jeu d’instructions avec la doc du CPU, et en regardant la sortie d'objdump
.
En général, nous traduirons les instructions OpenRisc dans des instructions x86 équivalentes, et les registres OpenRisc dans des variables globales.
Néanmoins, certains points méritent une attention particulière:
-
le registre
r0
doit être traduit en la constante 0 -
le registre
r1
, servant de pointeur de pile et comme base des variables locales, doit être traduit enesp
-
contrairement à x86, l’architecture OpenRisc utilise un delay slot, les branches doivent donc être décalées d’une instruction vers le bas pour obtenir un code équivalent
-
le mode de contrôle des sauts conditionnels est un peu différent du x86: au lieu d’un ensemble de flags codant diverses conditions (égal à zéro, négatif, etc.) toutes mises à jour par un
cmp
, il existe un unique flag et de multiples instructions de comparaison qui l’initialisent à une condition ou une autre. On "bidouille" ™ pour adapter… -
les paramètres de fonctions sont passés par
r3
,r4
,r5
, etc. on les pousse donc sur la pile avant chaquecall
pour simuler un passage d’arguments__cdecl
standard
On écrit tout ça dans un script Perl conv.pl
(hmm les belles regex)
%plt = ( "2430" => "ioctl", "2444" => "printf", "2458" => "memcpy", "246c" => "puts", "2480" => "malloc", "2494" => "mmap", "24a8" => "write", "24bc" => "fstat", "24d0" => "read", "24e4" => "memset", "24f8" => "exit", "250c" => "_libc_start_main", "2520" => "strlen", "2534" => "open", "2548" => "close", ); %sym = ( "255c" => "main", "2994" => "decode_hex", ); %not = ("e" => "ne", "ne" => "e", "ge" => "l", "g" => "le", "be" => "a", "b" => "ae", "a" => "be"); print "bits 32\n"; print "extern $imp\n" while ($addr, $imp) = each %plt; print "global $s\n" while ($addr, $s) = each %sym; print "%define _r0 0\n"; print "%define _r1 esp\n"; print "%define _r$_ [__r$_]\n" for (2..32); print "section .text\n"; print "L_0:\n"; sub ldaddr { my ($r, $haddr) = @_; my $a = hex($haddr); if ($a > 0x3400 && $a < 0x3800) { return "lea eax, [(strings + $a - 3400h)]\nmov _$r, eax\n"; } else { return "mov word _$r, 0${haddr}h\n"; } } sub call { my $f = shift; return "push dword _r5\npush dword _r4\npush dword _r3\ncall $f\nadd esp, 12\n"; } while (<>) { s/\t/ /; s/ <.*>$//; next unless /^ (.{4}): .. .. .. .. (.*)/; $addr = $1; $insn = $2; print "\n;$addr $insn\n\n"; print "L_$addr:\n"; if (exists $sym{$addr}) { print "$sym{$addr}:\n" } $branch = undef; if ($insn =~ /l.movhi (r\d+),0x(.+)/) { print "mov word [__$1+2], 0$2h\n" } elsif ($insn =~ /l.ori (r\d+),\1,0x(.+)/) { print ldaddr($1, $2) } elsif ($insn =~ /l.ori (r\d+),(r\d+),0x0/) { print "mov eax, _$2\nmov _$1, eax\n" } elsif ($insn =~ /l.sw (-?\d+)\(r1\),(r\d+)/) { print "mov eax, _$2\nmov [esp+($1)], eax\n" } elsif ($insn =~ /l.sw (-?\d+)\((r\d+)\),(r\d+)/) { print "mov eax, _$2\nmov ecx, _$3\nmov [eax+($1)], ecx\n" } elsif ($insn =~ /l.sfeqi (r\d+),(.+)/) { print "mov ebx, _$1\n"; $cond = "e"; $cmp = "cmp ebx, $2" } elsif ($insn =~ /l.sfnei (r\d+),(.+)/) { print "mov ebx, _$1\n"; $cond = "ne"; $cmp = "cmp ebx, $2" } elsif ($insn =~ /l.sfgesi (r\d+),(.+)/) { print "mov ebx, _$1\n"; $cond = "ge"; $cmp = "cmp ebx, $2" } elsif ($insn =~ /l.sfgtsi (r\d+),(.+)/) { print "mov ebx, _$1\n"; $cond = "g"; $cmp = "cmp ebx, $2" } elsif ($insn =~ /l.sfleui (r\d+),(.+)/) { print "mov ebx, _$1\n"; $cond = "be"; $cmp = "cmp ebx, $2" } elsif ($insn =~ /l.sfgtui (r\d+),(.+)/) { print "mov ebx, _$1\n"; $cond = "a"; $cmp = "cmp ebx, $2" } elsif ($insn =~ /l.sfne (r\d+),(r\d+)/) { print "mov ebx, _$1\nmov edx, _$2"; $cond = "ne"; $cmp = "cmp ebx, edx" } elsif ($insn =~ /l.sfltu (r\d+),(r\d+)/) { print "mov ebx, _$1\nmov edx, _$2"; $cond = "b"; $cmp = "cmp ebx, edx" } elsif ($insn =~ /l.addi r1,r1,(-?.+)/) { print "add esp, $1\n" } elsif ($insn =~ /l.addi (r\d+),(r\d+),(-?.+)/) { print "mov eax, _$2\nadd eax, $3\nmov _$1, eax\n" } elsif ($insn =~ /l.add (r\d+),(r\d+),(r\d+)/) { print "mov eax, _$2\nadd eax, _$3\nmov _$1, eax\n" } elsif ($insn =~ /l.or (r\d+),(r\d+),(r\d+)/) { print "mov eax, _$2\nor eax, _$3\nmov _$1, eax\n" } elsif ($insn =~ /l.and (r\d+),(r\d+),(r\d+)/) { print "mov eax, _$2\nand eax, _$3\nmov _$1, eax\n" } elsif ($insn =~ /l.andi (r\d+),(r\d+),0x(.+)/) { print "mov eax, _$2\nand eax, 0$3h\nmov _$1, eax\n" } elsif ($insn =~ /l.xor (r\d+),(r\d+),(r\d+)/) { print "mov eax, _$2\nxor eax, _$3\nmov _$1, eax\n" } elsif ($insn =~ /l.sub (r\d+),(r\d+),(r\d+)/) { print "mov eax, _$2\nsub eax, _$3\nmov _$1, eax\n" } elsif ($insn =~ /l.srai (r\d+),\1,0x(.+)/) { print "sar dword _$1, 0$2h\n" } elsif ($insn =~ /l.srli (r\d+),(r\d+),0x(.+)/) { print "mov eax, _$2\nshr eax, 0$3h\nmov _$1, eax\n" } elsif ($insn =~ /l.slli (r\d+),(r\d+),0x(.+)/) { print "mov eax, _$2\nshl eax, 0$3h\nmov _$1, eax\n" } elsif ($insn =~ /l.bf (.*)/) { $branch = "$cmp\nj$cond L_$1\n" } elsif ($insn =~ /l.bnf (.*)/) { $branch = "$cmp\nj$not{$cond} L_$1\n" } elsif ($insn =~ /l.lwz (r\d+),(-?.+)\(r1\)/) { print "mov eax, [esp+($2)]\nmov _$1, eax\n" } elsif ($insn =~ /l.lwz (r\d+),(-?.+)\((r\d+)\)/) { print "mov eax, _$3\nmov eax, [eax+($2)]\nmov _$1, eax\n" } elsif ($insn =~ /l.lbz (r\d+),(-?.+)\((r\d+)\)/) { print "mov eax, _$3\nmovzx eax, byte [eax+($2)]\nmov _$1, eax\n" } elsif ($insn =~ /l.sb (-?.+)\((r\d+)\),(r\d+)/) { print "mov al, _$3\nmov ecx, _$2\nmov [ecx+($1)], al\n" } elsif ($insn =~ /l.sh (-?.+)\((r\d+)\),(r\d+)/) { print "mov ax, _$3\nmov ecx, _$2\nmov [ecx+($1)], ax\n" } elsif ($insn =~ /l.jal (.*)/) { if (exists $plt{$1}) { $f = $plt{$1} } else { $f = "L_$1" } $branch = call($f) } elsif ($insn =~ /l.jalr (r\d+)/) { $branch = "call dword _$1\n" } elsif ($insn =~ /l.jr r9/) { $branch = "mov eax, _r11\nret\n" } elsif ($insn =~ /l.nop 0x0/) {} elsif ($insn =~ /l.j (.*)/) { if (exists $plt{$1}) { $f = $plt{$1} } else { $f = "L_$1" } $branch = "jmp $f\n"; } else { print ";???\n" } print $delay if ($delay); $delay = $branch; } print "section .data\n"; print "__r$_: dd 0\n" for (2..32); print "strings:\n"; print <<EOS; db 44h, 00h, 48h, 00h, 9ch, 21h, 00h, 04h, 62h, 61h, 64h, 20h, 63h, 68h, 61h, 72h, db 00h, 43h, 61h, 6eh, 27h, 74h, 20h, 6fh, 70h, 65h, 6eh, 20h, 2fh, 64h, 65h, 76h, [...] db 69h, 62h, 2fh, 6ch, 64h, 2dh, 6dh, 75h, 73h, 6ch, 2dh, 6fh, 72h, 31h, 6bh, 2eh, db 73h, 6fh, 2eh, 31h, 00h, 00h, 00h, 00h, 01h, 1bh, 03h, 3bh, 00h, 00h, 00h, 48h, EOS
La sortie ressemble à ça:
;255c l.sw -12(r1),r2 L_255c: main: mov eax, _r2 mov [esp+(-12)], eax ;2560 l.sw -4(r1),r9 L_2560: mov eax, _r9 mov [esp+(-4)], eax ;2564 l.sw -16(r1),r1 L_2564: mov eax, _r1 mov [esp+(-16)], eax ;2568 l.sw -8(r1),r14 L_2568: mov eax, _r14 mov [esp+(-8)], eax ;256c l.sfeqi r3,4 L_256c: mov ebx, _r3 ;2570 l.addi r1,r1,-148 L_2570: add esp, -148 ;2574 l.bnf 263c L_2574: ;2578 l.ori r2,r4,0x0 L_2578: mov eax, _r4 mov _r2, eax cmp ebx, 4 jne L_263c
On l’assemble avec:
$ nasm -f win32 convd.asm
Et on désassemble le .obj
produit avec IDA:

Grâce aux chaînes de caractères, on retrouve facilement la signification des fonctions et on les renomme. Voici la fonction main
à l’issue du renommage:
int __cdecl main(int argc, const char **argv, const char **envp) { int v4; // [sp+4h] [bp-90h]@5 int v5; // [sp+14h] [bp-80h]@8 void **v6; // [sp+84h] [bp-10h]@1 int v7; // [sp+88h] [bp-Ch]@1 int v8; // [sp+8Ch] [bp-8h]@1 int v9; // [sp+90h] [bp-4h]@1 void *retaddr; // [sp+94h] [bp+0h]@1 v7 = _r2; v9 = _r9; v6 = &retaddr; v8 = _r14; _r2 = (int)_r4; if ( _r3 == (_BYTE *)main + 4 ) { _r14 = *((_DWORD *)_r4 + 1); _r3 = (char *)_r14; strlen((const char *)_r14); HIWORD(_r3) = 0; if ( _r11 == 32 ) { _r3 = (char *)_r14; _r4 = &v4; _r5 = 16; decode_hex(); HIWORD(_r3) = 0; if ( _r11 ) { _r3 = aCanTDecodePass; puts(aCanTDecodePass); _r11 = 1; } else { load_TA(); _r14 = _r11; if ( _r11 ) { _r3 = aCanTLoadTa; puts(aCanTLoadTa); _r11 = 1; } else { get_version(); _r4 = (void *)_r14; _r3 = *(char **)(_r2 + 8); open(_r3, _r14, _r5); _r14 = _r11; if ( _r11 < 0 ) { _r2 = *(_DWORD *)(_r2 + 8); _r3 = aCanTOpenSForRe; printf(aCanTOpenSForRe, _r4, _r5); _r11 = 1; } else { _r3 = (char *)_r11; _r4 = &v5; _r5 = 112; read(_r11, &v5, 112); HIWORD(_r3) = 0; if ( _r11 == 112 ) { _r3 = (char *)&v4; _r4 = &v5; check_pwd(); if ( !_r11 ) { _r3 = (char *)_r14; _r4 = *(void **)(_r2 + 12); decrypt_file(); _r3 = (char *)_r14; close(_r14, _r4, _r5); } unload_TA(); _r11 = 0; } else { _r2 = *(_DWORD *)(_r2 + 8); _r3 = aCanTReadEncryp; printf(aCanTReadEncryp, _r4, _r5); _r11 = 1; } } } } } else { _r3 = aBadPasswordLen; puts(aBadPasswordLen); _r11 = 1; } } else { _r2 = *(_DWORD *)_r4; _r3 = aUsageSPassword; printf(aUsageSPassword, _r4, _r5); _r11 = 1; } _r9 = v9; _r2 = v7; _r14 = v8; return _r11; }
Le fonctionnement de main
correspond donc bien à la logique que nous avions inférée des messages affichés.
La fonction load_TA()
mappe l’exécutable TA.elf.signed
en mémoire et fait une requête au périphérique /dev/sstic
pour charger l’exécutable en TrustZone.
La fonction check_pwd
travaille sur les premiers 0x70 octets du fichier chiffré, où elle lit un HMAC du mot de passe. Cela explique la différence de taille entre les fichiers chiffré et clair.
Il n’est pas nécessaire d’entrer dans les détails de toutes les fonctions. Un aperçu des chaînes et du schéma général est suffisant pour comprendre grossièrement la plupart des fonctions.
Il va falloir nous concentrer plus particulièrement sur la fonction decrypt_file
, et sur une sous-fonction query_dev_sstic
qui envoie des requêtes ioctl
au device /dev/sstic
pour communiquer avec la TrustZone.
Voici la sortie de HexRays sur decrypt_file
:
int decrypt_file() { int v1; // [sp+4h] [bp-D8h]@2 int v2; // [sp+8h] [bp-D4h]@6 [ ... variables locales ] int v41; // [sp+D8h] [bp-4h]@1 void *retaddr; // [sp+DCh] [bp+0h]@1 v32 = _r2; v38 = _r26; v39 = _r28; v41 = _r9; v31 = &retaddr; v33 = _r14; v34 = _r18; v35 = _r20; v36 = _r22; v37 = _r24; v40 = _r30; _r26 = (int)_r3; _r2 = (int)_r4; _r3 = (char *)_r4; _r4 = &L_257c + 1; open(_r3, 65, _r5); _r28 = _r11; if ( _r11 < 0 ) { _r3 = aCanTOpenSForWr; printf(aCanTOpenSForWr, _r4, _r5); _r11 = 1; } else { _r3 = (char *)_r26; _r4 = &v30; fstat(_r26, &v30, _r5); _r4 = &v1; if ( _r11 < 0 ) { _r3 = aCanTStat; puts(aCanTStat); _r11 = 1; } else { _r3 = (char *)_r26; _r5 = 16; read(_r26, _r4, 16); _r18 = 0; if ( _r11 == 16 ) { HIWORD(_r30) = 0x300; while ( 1 ) { _r3 = (char *)_r26; _r4 = &v5; _r5 = 16; read(_r26, &v5, 16); _r20 = _r11; if ( !_r11 ) { _r3 = (char *)_r28; close(_r28, _r4, _r5); goto done; } if ( _r11 != 16 ) break; _r3 = (_BYTE *)(&loc_100 + 4); _r24 = 280; malloc(260u); _r3 = (char *)_r24; _r2 = _r11; malloc(_r24); _r22 = 260; _r14 = _r11; memset((void *)_r11, 0, _r24); _r5 = _r22; _r4 = 0; memset((void *)_r2, 0, _r22); _r3 = a341mi0mSendComma; puts(a341mi0mSendComma); _r4 = (void *)((unsigned int)_r18 >> 24); _r5 = (unsigned __int64)(unsigned int)_r18 >> 8; _r6 = _r18 << 24; _r3 = (char *)(_r18 << 8); *(_DWORD *)_r14 = _r30; _r6 |= (unsigned int)_r4; _r4 = (void *)(_r5 & 0xFF00); HIWORD(_r5) = 255; _r4 = (void *)((unsigned int)_r4 | _r6); _r3 = (char *)(_r5 & (unsigned int)_r3); _r5 = 3; _r4 = (void *)((unsigned int)_r3 | (unsigned int)_r4); _r3 = (char *)&v25; *(_DWORD *)(_r14 + 4) = _r4; v25 = _r5; *(_DWORD *)(_r14 + 8) = v5; v26 = _r24; *(_DWORD *)(_r14 + 12) = v6; v27 = _r14; *(_DWORD *)(_r14 + 16) = v7; _r4 = v8; v28 = _r22; *(_DWORD *)(_r14 + 20) = v8; v29 = _r2; query_dev_sstic(); _r25 = *(_BYTE *)(_r2 + 8); _r23 = *(_BYTE *)(_r2 + 10); _r21 = *(_BYTE *)(_r2 + 12); _r19 = *(_BYTE *)(_r2 + 13); _r17 = *(_BYTE *)(_r2 + 14); _r15 = *(_BYTE *)(_r2 + 15); _r24 = *(_BYTE *)(_r2 + 9); _r22 = *(_BYTE *)(_r2 + 11); _r13 = BYTE3(v4); _r12 = BYTE2(v4); _r11 = BYTE1(v4); _r8 = (unsigned __int8)v4; _r7 = BYTE3(v3); _r6 = BYTE2(v3); _r5 = BYTE1(v3); _r4 = (void *)(unsigned __int8)v3; _r14 = *(_BYTE *)(_r2 + 16); _r13 = BYTE3(v4) ^ _r25; _r11 = BYTE1(v4) ^ _r23; _r7 = BYTE3(v3) ^ _r21; _r6 = BYTE2(v3) ^ _r19; _r5 = BYTE1(v3) ^ _r17; _r4 = (void *)((unsigned __int8)v3 ^ _r15); _r12 = BYTE2(v4) ^ _r24; _r8 = (unsigned __int8)v4 ^ _r22; _r3 = (char *)(BYTE3(v2) ^ _r14); v9 = BYTE3(v4) ^ _r25; v10 = BYTE2(v4) ^ _r24; v11 = BYTE1(v4) ^ _r23; v12 = (unsigned __int8)v4 ^ _r22; v13 = BYTE3(v3) ^ _r21; v14 = BYTE2(v3) ^ _r19; v15 = BYTE1(v3) ^ _r17; v16 = v3 ^ _r15; _r19 = *(_BYTE *)(_r2 + 17); _r17 = *(_BYTE *)(_r2 + 18); _r15 = *(_BYTE *)(_r2 + 19); _r8 = *(_BYTE *)(_r2 + 20); _r7 = *(_BYTE *)(_r2 + 21); _r6 = *(_BYTE *)(_r2 + 22); _r14 = *(_BYTE *)(_r2 + 23); v17 = (char)_r3; _r8 ^= BYTE3(v1); _r7 ^= BYTE2(v1); _r6 ^= BYTE1(v1); _r13 = BYTE2(v2) ^ _r19; _r12 = BYTE1(v2) ^ _r17; _r11 = (unsigned __int8)v2 ^ _r15; _r2 = (unsigned __int8)v1 ^ _r14; _r3 = (char *)_r28; _r4 = &v9; _r5 = _r20; v24 = v1 ^ _r14; v18 = BYTE2(v2) ^ _r19; v19 = BYTE1(v2) ^ _r17; v20 = v2 ^ _r15; v21 = _r8; v22 = _r7; v23 = _r6; write(_r28, &v9, _r20); ++_r18; v1 = v5; v2 = v6; v3 = v7; _r2 = (int)v8; v4 = v8; } _r3 = aBadBlockSize; printf(aBadBlockSize, _r4, _r5); _r3 = (char *)_r28; close(_r28, _r4, _r5); } else { _r3 = aCanTReadIv; puts(aCanTReadIv); _r11 = 1; } } } done: _r9 = v41; _r2 = v32; _r14 = v33; _r18 = v34; _r20 = v35; _r22 = v36; _r24 = v37; _r26 = v38; _r28 = v39; _r30 = v40; return _r11; }
Moyennement lisible tel quel… refactorisons un peu:
struct command { int cmd; int data_in_len; char *data_in; int data_out_len; char *data_out; }; struct message { // int fields are little-endian int cmd; int blockno; unsigned __int8 buf[16]; }; int decrypt_file(int in_hnd) { unsigned __int8 previous[16]; // initialized with IV unsigned __int8 ciphertxt[16]; unsigned __int8 plaintxt[16]; struct command cmd; out_hnd = open(out_fname, 'A'); if ( out_hnd < 0 ) { printf("Can't open %s for writing", out_fname); retcode = 1; } else { status = fstat(in_hnd, &stat_buf); if ( status < 0 ) { puts("Can't stat"); retcode = 1; } else { n = read(in_hnd, previous, 16); blockno = 0; if ( n != 16 ) { puts("Can't read IV"); retcode = 1; } else { while ( 1 ) { n = read(in_hnd, &ciphertxt, 16); if ( 0 == n ) { close(out_hnd); goto done; } if ( n != 16 ) { printf("Bad block size"); close(out_hnd); goto done; } msg_out = (struct message*) malloc(260); msg_in = (struct message*) malloc(280); memset(msg_in, 0, 280); memset(msg_out, 0, 260); puts("Send command to Trusted App CMD_DECRYPT_BLOCK"); msg_in.cmd = little_endian(CMD_DECRYPT_BLOCK); msg_in.blockno = bswap(blockno); cmd.cmd = CMD_TA_MESSAGE; memcpy(msg_in.buf, ciphertxt, 16); cmd.data_in_len = 280; cmd.data_in = msg_in; cmd.data_out_len = 260; cmd.data_out = msg_out; query_dev_sstic(&cmd); plaintxt[0] = previous[15] ^ msg_out.buf[0]; plaintxt[1] = previous[14] ^ msg_out.buf[1]; plaintxt[2] = previous[13] ^ msg_out.buf[2]; plaintxt[3] = previous[12] ^ msg_out.buf[3]; plaintxt[4] = previous[11] ^ msg_out.buf[4]; plaintxt[5] = previous[10] ^ msg_out.buf[5]; plaintxt[6] = previous[9] ^ msg_out.buf[6]; plaintxt[7] = previous[8] ^ msg_out.buf[7]; plaintxt[8] = previous[7] ^ msg_out.buf[8]; plaintxt[9] = previous[6] ^ msg_out.buf[9]; plaintxt[10] = previous[5] ^ msg_out.buf[10]; plaintxt[11] = previous[4] ^ msg_out.buf[11]; plaintxt[12] = previous[3] ^ msg_out.buf[12]; plaintxt[13] = previous[2] ^ msg_out.buf[13]; plaintxt[14] = previous[1] ^ msg_out.buf[14]; plaintxt[15] = previous[0] ^ msg_out.buf[15]; write(out_hnd, plaintxt, n); ++blockno; memcpy(previous, ciphertxt); } } } } done: return retcode; }
C’est beaucoup plus clair maintenant: decrypt_file
déchiffre le fichier dans un mode CBC inversé, où les données chiffrées du bloc précédent sont renversées (cf les indices décroissants dans le tableau previous
) puis xorées avec le bloc clair. Pour le premier bloc, un IV est utilisé.
Le format de commande passé au périphérique /dev/sstic
est celui décrit dans la bibliothèque tee_client.py
(fournissant les primitives de vérification des LUMs et des clés).
Pour la commande CMD_TA_MESSAGE
, on découvre le format du buffer data_in
: il s’agit d’un pointeur vers un message, dont le premier champ est une sous-commande (ici CMD_DECRYPT_BLOCK
). Le code de sous-commande est passé en little endian.
Dans le cas de la commande CMD_DECRYPT_BLOCK
, le second champ est le numéro de bloc (démarrant à 0) sur lequel opère le déchiffrement. Ce numéro de bloc est également communiqué en little endian.
Après le numéro de bloc viennent les données elles-même, sur 16 octets. Le message de sortie, passé via le champ data_out
de la commande, contient un buffer similaire contenant les données déchiffrées.
Jetons un coup d’oeil à la fonction query_dev_sstic
:
struct command { int cmd; int data_in_len; char *data_in; int data_out_len; char *data_out; }; struct message { // int fields are little-endian int status; }; signed int query_dev_sstic(struct command *cmd) { int hnd; struct message *msg_out; hnd = open("/dev/sstic", 0); if ( hnd < 0 ) { puts("Can't open /dev/sstic"); return 1; } else { status = ioctl(hnd, 0xC0145300, cmd); msg_out = (struct message*) cmd.data_out; if ( status ) { [ ... error handling ] } printf("OS return code = 0x%08x, TA return code = 0x%08x", _r4, _r5); close_hnd: close(hnd); _r3 = bswap(msg_out.status); _r11 = (int)&_r3[_r2]; return _r11; } }
Sans suprise, celle-ci se contente d’ouvrir le périphérique /dev/sstic
et de lui transmettre la commande. On reconnaît le même code ioctl
(0xc0145300) employé dans la bibliothèque tee_client.py
pour communiquer avec la TrustZone.
La prochaîne étape se déroule dans le noyau. Il nous faut trouver le point d’entrée du module noyau qui a créé le périphérique /dev/sstic
. Heureusement nous disposons de deux pistes pour nous y aider: le code ioctl
et les chaînes.
Physiquement, on trouve le fichier du noyau Linux dans challenges/sys/or1k/vmlinux.bin.bz2
. On le décompresse avec bzip2
et on obtient une image mémoire, qui est essentiellement un ELF sans en-tête.
On trouve rapidement les offsets des chaînes de caractères contenant sstic
:
~/sstic2017/challenges/riscy_zones falkn$ strings -t x vmlinux.bin | grep sstic 335a0c sstic 335a1b 6sstic: ioctl() error copy message struct 335a47 6sstic: ioctl() error bad data_in_len 335a6f 6sstic: can't allocate input_buffer 335a95 6sstic: ioctl() error bad data_out_len 335abe 6sstic: can't allocate output_buffer 335ae5 6sstic: ioctl() error copy_from_user (input_buffer) 335b1b 6sstic: ioctl() error copy_to_user (output_buffer) 3a6159 sstic-vm
On désassemble tout le noyau comme du code avec objdump -D
(les instructions font toutes 4 octets; ça nous arrange il n’y aura pas de problèmes de recouvrement, alignement du code, etc.) et on y cherche d’abord la partie basse de l’ioctl:
~/sstic2017/challenges/riscy_zones falkn$ grep "l.ori .*0x5300" vmlinux.disas 20b2e0: a8 42 53 00 l.ori r2,r2,0x5300
Bingo! un seul résultat, et on vérifie que les chaînes de caractères sstic
sont bien référencées autour du même point:
~/sstic2017/challenges/riscy_zones falkn$ grep "l.ori .*0x5a.." vmlinux.disas dac: ab de 5a ec l.ori r30,r30,0x5aec [...] 20b34c: a8 63 5a 1a l.ori r3,r3,0x5a1a 20b394: a8 63 5a 46 l.ori r3,r3,0x5a46 20b3b4: a8 63 5a 6e l.ori r3,r3,0x5a6e 20b3d4: a8 63 5a 94 l.ori r3,r3,0x5a94 20b3f4: a8 63 5a bd l.ori r3,r3,0x5abd 20b464: a8 63 5a e4 l.ori r3,r3,0x5ae4 [...]
Là on analyse le objdump rapidement, comme un gros bourrin:
check_ioctl:
20b2d8: 18 40 c0 14 l.movhi r2,0xc014
20b2dc: d7 e1 4f fc l.sw -4(r1),r9 ; save link register r9
20b2e0: a8 42 53 00 l.ori r2,r2,0x5300 ; c0145300 = ioctl #
20b2e4: d7 e1 0f e8 l.sw -24(r1),r1 ; store single word (source is rhs)
20b2e8: d7 e1 77 f0 l.sw -16(r1),r14
20b2ec: d7 e1 97 f4 l.sw -12(r1),r18
20b2f0: d7 e1 a7 f8 l.sw -8(r1),r20
20b2f4: e4 24 10 00 l.sfne r4,r2 ; set flag if not equal
20b2f8: 9c 21 ff d4 l.addi r1,r1,-44 ; reserve 44 bytes of stack
; first 20 bytes = copy of message header
; next come 6 32-bit words for saved regs r1, r2, r14, r18, r20, r9
20b2fc: 10 00 00 9e l.bf 20b574 <_binary_vmlinux_bin_start+0x20b574> ; branch if flag
20b300: 9d 60 fd fd l.addi r11,r0,-515 ; r11 = -515
20b304: 84 6a 00 10 l.lwz r3,16(r10) ; r3 = [r10 + 16]
20b308: bc a3 00 13 l.sfleui r3,19 ; set flag if less or equal to unsigned imm
; [r10 + 16] <=? 19
20b30c: 10 00 00 0b l.bf 20b338 <_binary_vmlinux_bin_start+0x20b338>
20b310: a8 45 00 00 l.ori r2,r5,0x0 ; r2 = r5
20b314: 9c 63 ff ec l.addi r3,r3,-20 ; r3 -= 20 ; [buf+16] - 20 (message payload sz?)
20b318: e4 45 18 00 l.sfgtu r5,r3 ; r5 > [buf+16] - 20
20b31c: 10 00 00 07 l.bf 20b338 <_binary_vmlinux_bin_start+0x20b338>
20b320: a8 85 00 00 l.ori r4,r5,0x0 ; r4 = r5
20b324: a8 61 00 00 l.ori r3,r1,0x0 ; r3 = r1
20b328: 07 f7 de a1 l.jal 2dac <_binary_vmlinux_bin_start+0x2dac> ; memcpy(stack msg, ioctl 3rd parm, 20)
20b32c: 9c a0 00 14 l.addi r5,r0,20 ; r5 = 20 (delay slot)
20b330: 00 00 00 11 l.j 20b374 <_binary_vmlinux_bin_start+0x20b374> ; continue_afer_memcpy below
20b334: bc 2b 00 00 l.sfnei r11,0 ; r11 = return value != 0 ?
xxx_less_or_eq_19: ; message too short -> error
20b338: bd 62 00 00 l.sfgesi r2,0 ; r2 >= 0 ? (errors on)
20b33c: 10 00 00 05 l.bf 20b350 <_binary_vmlinux_bin_start+0x20b350>
20b340: 18 a0 80 00 l.movhi r5,0x8000
memcpy_failed:
20b344: 18 60 c0 33 l.movhi r3,0xc033
20b348: 00 00 00 6b l.j 20b4f4 <_binary_vmlinux_bin_start+0x20b4f4>
20b34c: a8 63 5a 1a l.ori r3,r3,0x5a1a ; "sstic: ioctl() error copy message struct"
no_err_message:
20b350: a8 61 00 00 l.ori r3,r1,0x0
20b354: a8 82 00 00 l.ori r4,r2,0x0
20b358: 07 f7 de 95 l.jal 2dac <_binary_vmlinux_bin_start+0x2dac> ; jump and link
20b35c: e0 a5 10 02 l.sub r5,r5,r2
20b360: 18 80 80 00 l.movhi r4,0x8000
20b364: a8 84 00 14 l.ori r4,r4,0x14
20b368: e0 62 20 00 l.add r3,r2,r4
20b36c: e1 63 58 00 l.add r11,r3,r11
20b370: bc 2b 00 00 l.sfnei r11,0
continue_after_memcpy:
20b374: 13 ff ff f4 l.bf 20b344 <_binary_vmlinux_bin_start+0x20b344> ; handle memcpy() error code
20b378: 18 c0 00 01 l.movhi r6,0x1 ; r6 = 0x10000
20b37c: 84 61 00 04 l.lwz r3,4(r1) ; r3 = data_in_len from tee message
20b380: e4 a3 30 00 l.sfleu r3,r6 ; data_in_len < 64k ?
20b384: 10 00 00 05 l.bf 20b398 <_binary_vmlinux_bin_start+0x20b398>
20b388: 1a 40 02 40 l.movhi r18,0x240 ; r18 = 0x2400000
20b38c: 18 60 c0 33 l.movhi r3,0xc033
20b390: 00 00 00 59 l.j 20b4f4 <_binary_vmlinux_bin_start+0x20b4f4>
20b394: a8 63 5a 46 l.ori r3,r3,0x5a46 ; "sstic: ioctl() error bad data_in_len"
data_in_len_small_enough:
20b398: 07 fa a5 16 l.jal b47f0 <_binary_vmlinux_bin_start+0xb47f0> ; kmalloc(data_in_len, 0x24000c0)
20b39c: a8 92 00 c0 l.ori r4,r18,0xc0 ; r4 = 0x24000c0 kmalloc flags
20b3a0: bc 2b 00 00 l.sfnei r11,0
20b3a4: 10 00 00 05 l.bf 20b3b8 <_binary_vmlinux_bin_start+0x20b3b8> ; jmp if not NULL
20b3a8: a9 cb 00 00 l.ori r14,r11,0x0 ; r14 = alloc'd block for input data
20b3ac: 18 60 c0 33 l.movhi r3,0xc033
20b3b0: 00 00 00 51 l.j 20b4f4 <_binary_vmlinux_bin_start+0x20b4f4>
20b3b4: a8 63 5a 6e l.ori r3,r3,0x5a6e ; "sstic: can't allocate input_buffer"
alloc_ok:
20b3b8: 84 61 00 0c l.lwz r3,12(r1) ; r3 = data_out_len
20b3bc: 18 80 00 01 l.movhi r4,0x1
20b3c0: e4 a3 20 00 l.sfleu r3,r4 ; data_out_len < 64k?
20b3c4: 10 00 00 05 l.bf 20b3d8 <_binary_vmlinux_bin_start+0x20b3d8>
20b3c8: 15 00 00 00 l.nop 0x0
20b3cc: 18 60 c0 33 l.movhi r3,0xc033
20b3d0: 00 00 00 49 l.j 20b4f4 <_binary_vmlinux_bin_start+0x20b4f4>
20b3d4: a8 63 5a 94 l.ori r3,r3,0x5a94 ; sstic: ioctl() error bad data_out_len
data_out_len_ok:
20b3d8: 07 fa a5 06 l.jal b47f0 <_binary_vmlinux_bin_start+0xb47f0> ; kmalloc(data_out_len, 0x24000c0)
20b3dc: a8 92 00 c0 l.ori r4,r18,0xc0 ; (delay slot)
20b3e0: bc 2b 00 00 l.sfnei r11,0
20b3e4: 10 00 00 05 l.bf 20b3f8 <_binary_vmlinux_bin_start+0x20b3f8> ; jmp if not NULL
20b3e8: aa 8b 00 00 l.ori r20,r11,0x0 ; r20 = kblk_out for output data
20b3ec: 18 60 c0 33 l.movhi r3,0xc033
20b3f0: 00 00 00 41 l.j 20b4f4 <_binary_vmlinux_bin_start+0x20b4f4>
20b3f4: a8 63 5a bd l.ori r3,r3,0x5abd ; "sstic: can't allocate output_buffer"
20b3f8: 84 a1 00 04 l.lwz r5,4(r1) ; r5 = data_in_len (again but from kernel copy of message on stack)
20b3fc: 84 6a 00 10 l.lwz r3,16(r10) ; r3 = address limit?
20b400: e4 45 18 00 l.sfgtu r5,r3 ; data_in_len > address limit?
20b404: 10 00 00 0a l.bf 20b42c <_binary_vmlinux_bin_start+0x20b42c> ; jmp if data_in_len too big
20b408: 84 81 00 08 l.lwz r4,8(r1) ; (delay) r4 = data_in
20b40c: e0 63 28 02 l.sub r3,r3,r5 ; r3 = limit - data_in_len = max data_in ptr
20b410: e4 44 18 00 l.sfgtu r4,r3 ; data_in_ptr > max ?
20b414: 10 00 00 07 l.bf 20b430 <_binary_vmlinux_bin_start+0x20b430>
20b418: bd 84 00 00 l.sfltsi r4,0 ; data_in < 0 signed ? (delay)
20b41c: 07 f7 de 64 l.jal 2dac <_binary_vmlinux_bin_start+0x2dac> ; memcpy(kblk_in, data_in, data_in_len)
20b420: a8 6e 00 00 l.ori r3,r14,0x0 ; dst = r14 = alloc'd block for input data
20b424: 00 00 00 0b l.j 20b450 <_binary_vmlinux_bin_start+0x20b450>
20b428: a8 ab 00 00 l.ori r5,r11,0x0 ; r5 = memcpy() result
; it looks like something special and weird happens when data_in is negative as a signed number...
data_in_gt_address_limit:
20b42c: bd 84 00 00 l.sfltsi r4,0 ; data_in < 0 (signed)
data_in_ptr_too_big:
20b430: 10 00 00 08 l.bf 20b450 <_binary_vmlinux_bin_start+0x20b450> ; jmp if data_in < 0 signed
20b434: 18 c0 80 00 l.movhi r6,0x8000 ; r6 = 0x80000000 (could this be assuming the wrong kernel limit?)
20b438: a8 6e 00 00 l.ori r3,r14,0x0 ; r3 = dst = kernel block for input data
20b43c: e2 45 30 00 l.add r18,r5,r6 ; r18 = 0x80000000 + data_in_len
20b440: e2 44 90 00 l.add r18,r4,r18 ; r18 = 0x80000000 + data_in + data_in_len
20b444: 07 f7 de 5a l.jal 2dac <_binary_vmlinux_bin_start+0x2dac> ; memcpy(kblk_in, data_in, <see below>)
20b448: e0 a5 90 02 l.sub r5,r5,r18 ; (delay) r5 = 0x80000000 - data_in
20b44c: e0 ab 90 00 l.add r5,r11,r18 ; r5 = memcpy() result + data_in_len
data_in_negative_or_memcpy_input_data_done:
20b450: bc 05 00 00 l.sfeqi r5,0 ; r5 == 0 ?
20b454: 10 00 00 05 l.bf 20b468 <_binary_vmlinux_bin_start+0x20b468> ; r5 = 0 means success
20b458: 18 c0 40 00 l.movhi r6,0x4000
20b45c: 18 60 c0 33 l.movhi r3,0xc033
20b460: 00 00 00 25 l.j 20b4f4 <_binary_vmlinux_bin_start+0x20b4f4> ; error handling
20b464: a8 63 5a e4 l.ori r3,r3,0x5ae4 ; (delay) "sstic: ioctl() error copy_from_user (input_buffer)"
memcpy_input_data_successful:
20b468: 18 80 40 00 l.movhi r4,0x4000 ; r4 = 0x40000000
20b46c: e0 6e 20 00 l.add r3,r14,r4 ; r3 = kblk_in + 0x40000000
20b470: e0 b4 30 00 l.add r5,r20,r6 ; r5 = kblk_out + 0x40000000
20b474: 85 61 00 00 l.lwz r11,0(r1) ; r11 = message.cmd
20b478: 84 81 00 04 l.lwz r4,4(r1) ; r4 = message.data_in_len
20b47c: 84 c1 00 0c l.lwz r6,12(r1) ; r6 = message.data_out_len
20b480: 20 00 00 00 l.sys 0x0 ; trustzone call
***************** IT HAPPENS HERE
20b484: 84 8a 00 10 l.lwz r4,16(r10) ; r4 = address limit?
20b488: d4 01 30 0c l.sw 12(r1),r6 ; msg.data_out_len = r6 (update message)
20b48c: e4 46 20 00 l.sfgtu r6,r4 ; new data_out_len > address limit?
20b490: a8 a6 00 00 l.ori r5,r6,0x0 ; r5 = r6 = new data_out_len
20b494: a9 cb 00 00 l.ori r14,r11,0x0 ; r14 = r11 = trustzone call result?
20b498: 10 00 00 0a l.bf 20b4c0 <_binary_vmlinux_bin_start+0x20b4c0> ; jmp if new data_out_len big
20b49c: 84 61 00 10 l.lwz r3,16(r1) ; (delay) r3 = data_out
20b4a0: e0 84 30 02 l.sub r4,r4,r6 ; r4 -= new data_out_len
20b4a4: e4 43 20 00 l.sfgtu r3,r4
20b4a8: 10 00 00 07 l.bf 20b4c4 <_binary_vmlinux_bin_start+0x20b4c4>
20b4ac: bd 83 00 00 l.sfltsi r3,0 ; test data_out negative
20b4b0: 07 f7 de 3f l.jal 2dac <_binary_vmlinux_bin_start+0x2dac> ; memcpy(data_out, kblk_out, new data_out_len)
20b4b4: a8 94 00 00 l.ori r4,r20,0x0 ; src = r20 = kblk_out
20b4b8: 00 00 00 0b l.j 20b4e4 <_binary_vmlinux_bin_start+0x20b4e4>
20b4bc: a8 ab 00 00 l.ori r5,r11,0x0 ; (delay)
handle_big_data_out_len:
20b4c0: bd 83 00 00 l.sfltsi r3,0
handle_another_weird_case:
20b4c4: 10 00 00 08 l.bf 20b4e4 <_binary_vmlinux_bin_start+0x20b4e4>
; memcpy() with diffent parms to handle corner cases
20b4c8: 18 80 80 00 l.movhi r4,0x8000
20b4cc: e2 45 20 00 l.add r18,r5,r4
20b4d0: a8 94 00 00 l.ori r4,r20,0x0
20b4d4: e2 43 90 00 l.add r18,r3,r18
20b4d8: 07 f7 de 35 l.jal 2dac <_binary_vmlinux_bin_start+0x2dac> memcpy(...)
20b4dc: e0 a5 90 02 l.sub r5,r5,r18
20b4e0: e0 ab 90 00 l.add r5,r11,r18
after_regular_copy_to_user:
20b4e4: bc 05 00 00 l.sfeqi r5,0
20b4e8: 10 00 00 07 l.bf 20b504 <_binary_vmlinux_bin_start+0x20b504> ; r5 == 0 => copy_to_user_succeeded
20b4ec: 18 60 c0 33 l.movhi r3,0xc033
20b4f0: a8 63 5b 1a l.ori r3,r3,0x5b1a ; "sstic: ioctl() error copy_to_user (output_buffer)"
log_error_message?:
20b4f4: 07 f9 88 cf l.jal 6d830 <_binary_vmlinux_bin_start+0x6d830>
20b4f8: 15 00 00 00 l.nop 0x0
20b4fc: 00 00 00 1e l.j 20b574 <_binary_vmlinux_bin_start+0x20b574>
20b500: 9d 60 ff f2 l.addi r11,r0,-14
copy_to_user_suceeded:
20b504: 84 6a 00 10 l.lwz r3,16(r10) ; again, wtf is this?
20b508: bc a3 00 13 l.sfleui r3,19
20b50c: 10 00 00 0c l.bf 20b53c <_binary_vmlinux_bin_start+0x20b53c>
20b510: bd 82 00 00 l.sfltsi r2,0
20b514: 9c 63 ff ec l.addi r3,r3,-20 ; r3 -= 20
20b518: e4 42 18 00 l.sfgtu r2,r3
20b51c: 10 00 00 07 l.bf 20b538 <_binary_vmlinux_bin_start+0x20b538>
20b520: a8 62 00 00 l.ori r3,r2,0x0 ; r3 = frame pointer?
20b524: a8 81 00 00 l.ori r4,r1,0x0
20b528: 07 f7 de 21 l.jal 2dac <_binary_vmlinux_bin_start+0x2dac> ; memcpy(, stack copy of message, 20)
20b52c: 9c a0 00 14 l.addi r5,r0,20 ; (delay) sz = 20
20b530: 00 00 00 0e l.j 20b568 <_binary_vmlinux_bin_start+0x20b568>
20b534: bc 2b 00 00 l.sfnei r11,0
20b538: bd 82 00 00 l.sfltsi r2,0
20b53c: 13 ff ff 82 l.bf 20b344 <_binary_vmlinux_bin_start+0x20b344> ; indirect branch to 4f4 error handling
20b540: 18 a0 80 00 l.movhi r5,0x8000
20b544: a8 62 00 00 l.ori r3,r2,0x0 ; dst = r2
20b548: e0 a5 10 02 l.sub r5,r5,r2 ; sz = 0x80000000 - r2
20b54c: 07 f7 de 18 l.jal 2dac <_binary_vmlinux_bin_start+0x2dac> memcpy(r2, stack msg, adjusted sz)
20b550: a8 81 00 00 l.ori r4,r1,0x0 ; (delay) src = r1
20b554: 18 c0 80 00 l.movhi r6,0x8000
20b558: a8 c6 00 14 l.ori r6,r6,0x14
20b55c: e0 42 30 00 l.add r2,r2,r6
20b560: e1 62 58 00 l.add r11,r2,r11
20b564: bc 2b 00 00 l.sfnei r11,0
handle_error_or_epilogue:
20b568: 13 ff ff 78 l.bf 20b348 <_binary_vmlinux_bin_start+0x20b348> ; indirect branch to 4f4 error handling
20b56c: 18 60 c0 33 l.movhi r3,0xc033
20b570: a9 6e 00 00 l.ori r11,r14,0x0
epilogue:
20b574: 9c 21 00 2c l.addi r1,r1,44
20b578: 85 21 ff fc l.lwz r9,-4(r1)
20b57c: 84 21 ff e8 l.lwz r1,-24(r1)
20b580: 84 41 ff ec l.lwz r2,-20(r1)
20b584: 85 c1 ff f0 l.lwz r14,-16(r1)
20b588: 86 41 ff f4 l.lwz r18,-12(r1)
20b58c: 44 00 48 00 l.jr r9
20b590: 86 81 ff f8 l.lwz r20,-8(r1) ; delay slot
En gros le noyau alloue deux buffers kernel pour les données d’entrée et de sortie, et recopie le paramètre data_in
contenant le message pour la TrustZone.
La magie se produit en 0x20b480
avec l’instruction l.sys
(c’est la seule occurence de l.sys
dans tout le noyau). Cet appel système n’en est pas vraiment un, c’est un appel au Javascript faisant tourner le Linux. (On peut aller voir le handler de syscalls Linux et se convaincre qu’il n’est pas modifié par rapport à l’original, mais de toute façon l’instruction l.sys
est attrapée par le Javascript avant.)
TrustZone Javascript
L’aventure continue dans jor1k-worker-min.js
, un Javascript "minifié" qu’on peut passer dans jsbeautifier pour en faciliter la lecture. On y trouve une fonction handleSyscall
très intéressante:
// handle l.sys call from openrisc kernel // (a, b, d, e, f) = (r11, r3, r4, r5, r6) d.prototype.handleSyscall = function(a, b, d, e, f) { this.PrintDebug("new command from normal world"); this.PrintDebug("CMD " + c.ToHex(a)); this.PrintDebug("INPUT BUFFER " + c.ToHex(b)); this.PrintDebug("INPUT LEN " + c.ToHex(d)); this.PrintDebug("OUTPUT BUFFER " + c.ToHex(e)); this.PrintDebug("OUTPUT LEN " + c.ToHex(f)); switch (a) { case 1: // CMD_GET_VERSION a = this.handleGetVersion(b, d, e, f); break; case 2: // CMD_LOAD_TA a = this.handleLoadTA(b, d, e, f); break; case 3: // CMD_TA_MESSAGE if (0 == this.elfLoaded) { a = 0xffffffff; break } a = this.handleTACommand(b, d, e, f); break; case 4: // CMD_UNLOAD_TA if (0 == this.elfLoaded) { a = 0xffffffff; break } a = this.handleUnLoadTA(b, d, e, f); break; case 5: // CMD_CHECK_LUM a = this.handleCheckLUM(b, d, e, f); break; case 6: // CMD_CHECK_KEY a = this.handleCheckKEY(b, d, e, f); break; default: this.PrintDebug("Unknown CMD:" + c.ToHex(a)), a = 0xffffffff } f = this.output_len; return [a, f] };
Selon la commande reçue à travers le registre r11
du CPU OpenRisc, elle dispatche vers plusieurs fonctions. Allons voir handleTACommand
dans laquelle devrait se trouver la crypto:
// handle CMD_TA_MESSAGE // (a, b, c, d) = (r3, r4, r5, r6) = (kblk_in + 0x40000000, msg.data_in_len, kblk_out + 0x40000000, msg.data_out_len) d.prototype.handleTACommand = function(data_in, data_in_len, data_out, data_out_len) { this.PrintDebug("in handleTACommand"); for (var e = 0; e < b; e++) { var f = this.ramdev.Read8(data_in + e); this.riscv_ram.Write8(0x200004 + e, f) } this.riscvcpu.SetRegister(10, 0x200004); // ptr to input buffer this.riscvcpu.SetRegister(11, 0x200000); // ptr to input buffer len this.riscvcpu.SetRAM(0x200000, data_in_len); this.riscvcpu.SetRegister(12, 0x500004); // ptr to output buffer this.riscvcpu.SetRegister(13, 0x500000); // ptr to output buffer len this.riscvcpu.SetRAM(0x500000, data_out_len); this.riscvcpu.SetSP(0x1800000); this.riscvcpu.SetStackH(0x1800000); this.riscvcpu.SetStackL(0x1000000); this.riscvcpu.SetPC(this.elf.getEntryPoint()); this.riscvcpu.SetSyscallHandler(this.syscallHandler.bind(this)); this.riscvcpu.SetExitBreakpoint(0, this.exitHandler.bind(this)); for (this.PrintDebug("RISCV emulator starting"); ret = this.riscvcpu.Step(this.riscv_timer.instructionsperloop, this.riscv_timer.timercyclesperinstruction), 0 == ret; ); for (e = 0; e < data_out_len; e++) f = this.riscv_ram.Read8(0x500004 + e), this.ramdev.Write8(data_out + e, f); this.output_len = data_out_len };
Ce handler copie donc les données du buffer noyau contenant le message d’entrée, lance un émulateur Risc-V au point d’entrée d’un ELF préalablement chargé, puis récupère le résultat dans la mémoire de l'émulateur Risc-V et le copie dans le buffer noyau du message de sortie.
Ceci explique les conversions d'endianness observée dans le code de trustzone_decrypt
puisque l’architecture OpenRisc est big endian alors que l’architecture Risc-V est elle little endian (Allez, disons "gros-boutien" et "petit-boutien" pour l’académie française (je crois qu’ils lisent les soluces du SSTIC)).
L’ELF Risc-V exécuté n’est autre que TA.elf.signed
, comme on peut le voir dans la fonction handleLoadTA
qui écrit dans la RAM du Risc-V:
d.prototype.handleLoadTA = function(a, b, c, d) { this.elfLoaded = !1; this.PrintDebug("in handleLoadTA"); c = new Uint8Array(e(this.ramdev, a, b - 64)); a = String.fromCharCode.apply(null, new Uint8Array(e(this.ramdev, a + (b - 64), 64))); b = new q("SHA-256", "ARRAYBUFFER"); b.setHMACKey(String.fromCharCode.apply(null, this.getKey("TA_HMAC_KEY")), "TEXT"); b.update(c); if (b.getHMAC("HEX") === a) this.PrintOk("Good TA signature"); else return this.PrintError("Bad TA signature"), 0xffffffff; this.elf = new k(c); if (this.elf.IsELF()) this.PrintDebug("ELF header found, extracting to secure memory"); else return this.PrintError("No an ELF file !"), 0xffffffff; this.elf.Extract(this.riscv_ram); this.elfLoaded = !0; this.PrintOk("TA successfully loaded"); this.output_len = 0; this.riscvcpu.SetRAM(0x200000, 0); this.riscvcpu.SetRAM(0x200004, 0); r11 = this.handleTACommand(0, 0, 0, 0) };
Tout cela est cohérent. En résumé, dans une première étape trustzone_decrypt
charge le fichier TA.elf.signed
dans la TrustZone, par l’intermédiaire du périphérique /dev/sstic
qui fait le lien avec le Javascript sous-jacent:

Par la suite trustzone_decrypt
itère sur les blocs du fichier à déchiffrer, et passe chaque bloc à la TrustZone par le biais d’une commande DECRYPT_BLOCK
. trustzone_decrypt
implante le mode de chiffrement, tandis que TA.elf.signed
implante la primitive de chiffrement d’un bloc seul:

Il nous faut maintenant analyser la logique du fichier TA.elf.signed
tournant en TrustZone.
Analyse du module TrustZone TA.elf.signed
Nous sommes confrontés au même problème que pour OpenRisc: IDA ne comprend pas le Risc-V. L’approche de la traduction binaire depuis la sortie de objdump
n’avait pas mal marché, donc on reprend la même. La première étape est d’obtenir un objdump
avec le support pour Risc-V.
On récupère donc sur github la source des binutils
pour l’architecture Risc-V, que l’on doit compiler sans oublier de préciser la plateforme cible lors de la configuration:
$ ./configure --target=riscv-elf32 $ make [...] $ objdump -d TA.elf.signed > disas
On travaille ensuite sur la sortie de objdump
, et à l’aide de la documentation du CPU, on traduit les instructions en x86.
On utilise la même technique qu’en OpenRisc pour récupérer les références croisées des chaînes, très utiles, et on ajoute un petit peu de code ad hoc pour un table de saut utilisée au point d’entrée du binaire qui dispatche en fonction du numéro de commande.
Le traducteur binaire, riscv.pl
, ressemble à ceci:
%sym = ( "113f8" => "TEE_HMAC", "113e0" => "TEE_writekey", "113c8" => "TEE_write", "113d4" => "TEE_readkey", "113ec" => "TEE_AES_decrypt", "11590" => "start", ); print "bits 32\n"; print "global $s\n" while ($addr, $s) = each %sym; print "%define _ra [__ra]\n"; print "%define _a$_ [__a$_]\n" for (0..7); print "%define _s$_ [__s$_]\n" for (0..7); print "%define _t$_ [__t$_]\n" for (0..7); print "section .text\n"; sub call { my $f = shift; return "push dword _a2\npush dword _a1\npush dword _a0\ncall $f\nadd esp, 12\n"; } $ppp = $pp = $prev = ""; while (<>) { s/\t/ /; s/\s*<.*>$//; s/\s*[;#"].*$//; next unless /^ (11...): ........ +(\S.+)/; $addr = $1; $insn = $2; print "\n;$addr $insn\n\n"; print "L_$addr:\n"; if (exists $sym{$addr}) { print "$sym{$addr}:\n" } if ($insn =~ /^li ([ast]\d),(-?.+)/) { print "mov dword _$1, $2\n" } elsif ($insn =~ /ecall/) { print "sysenter\n" } elsif ($insn =~ /ret/) { print "ret\n" } elsif ($insn =~ /addi sp,sp,(-?.+)/) { print "add esp, $1\n" } elsif ($insn =~ /addi ([ast]\d),sp,(-?.+)/) { print "lea eax, [esp+($2)]\nmov _$1, eax\n" } elsif ($insn =~ /addi ([ast]\d),\1,(-?.+)/) { $r = $1; $o = $2; if (int($o) < 0x3d0 && ($ppp.$pp.$prev) =~ /lui.*$r,0x10/) { if (int($o) == 0x54) { print "lea eax, [jmptbl]\nmov _$r, eax\n" } else { print "lea eax, [ori + $o]\nmov _$r, eax\n" } } else { print "mov eax, _$r\nadd eax, $o\nmov _$r, eax\n" } } elsif ($insn =~ /addi ([ast]\d),([ast]\d),(-?.+)/) { print "mov eax, _$2\nadd eax, $3\nmov _$1, eax\n" } elsif ($insn =~ /xori ([ast]\d),([ast]\d),(-?.+)/) { print "mov eax, _$2\nxor eax, $3\nmov _$1, eax\n" } elsif ($insn =~ /add ([ast]\d),sp,([ast]\d)/) { print "mov eax, esp\nadd eax, _$2\nmov _$1, eax\n" } elsif ($insn =~ /add ([ast]\d),([ast]\d),([ast]\d)/) { print "mov eax, _$2\nadd eax, _$3\nmov _$1, eax\n" } elsif ($insn =~ /xor ([ast]\d),([ast]\d),([ast]\d)/) { print "mov eax, _$2\nxor eax, _$3\nmov _$1, eax\n" } elsif ($insn =~ /sub ([ast]\d),([ast]\d),([ast]\d)/) { print "mov eax, _$2\nsub eax, _$3\nmov _$1, eax\n" } elsif ($insn =~ /bgtz ([ast]\d),(.+)/) { print "cmp dword _$1, 0\njns L_$2\n" } elsif ($insn =~ /beqz ([ast]\d),(.+)/) { print "cmp dword _$1, 0\njz L_$2\n" } elsif ($insn =~ /bnez ([ast]\d),(.+)/) { print "cmp dword _$1, 0\njnz L_$2\n" } elsif ($insn =~ /bltu ([ast]\d),([ast]\d),(.+)/) { print "mov eax, _$1\ncmp eax, _$2\njb L_$3\n" } elsif ($insn =~ /bleu ([ast]\d),([ast]\d),(.+)/) { print "mov eax, _$1\ncmp eax, _$2\njbe L_$3\n" } elsif ($insn =~ /ble ([ast]\d),([ast]\d),(.+)/) { print "mov eax, _$1\ncmp eax, _$2\njle L_$3\n" } elsif ($insn =~ /beq ([ast]\d),([ast]\d),(.+)/) { print "mov eax, _$1\ncmp eax, _$2\njz L_$3\n" } elsif ($insn =~ /bne ([ast]\d),([ast]\d),(.+)/) { print "mov eax, _$1\ncmp eax, _$2\njnz L_$3\n" } elsif ($insn =~ /slli ([ast]\d),([ast]\d),0x(\d+)/) { print "mov eax, _$2\nshl eax, 0$3h\nmov _$1, eax\n" } elsif ($insn =~ /srli ([ast]\d),([ast]\d),0x(\d+)/) { print "mov eax, _$2\nshr eax, 0$3h\nmov _$1, eax\n" } elsif ($insn =~ /sll ([ast]\d),([ast]\d),([ast]\d)/) { print "mov eax, _$2\nmov cl, _$3\nshl eax, cl\nmov _$1, eax\n" } elsif ($insn =~ /srl ([ast]\d),([ast]\d),([ast]\d)/) { print "mov eax, _$2\nmov cl, _$3\nshr eax, cl\nmov _$1, eax\n" } elsif ($insn =~ /andi ([ast]\d),([ast]\d),(-?.+)/) { print "mov eax, _$2\nand eax, $3\nmov _$1, eax\n" } elsif ($insn =~ /and ([ast]\d),([ast]\d),([ast]\d)/) { print "mov eax, _$2\nand eax, _$3\nmov _$1, eax\n" } elsif ($insn =~ /or ([ast]\d),([ast]\d),([ast]\d)/) { print "mov eax, _$2\nor eax, _$3\nmov _$1, eax\n" } elsif ($insn =~ /neg ([ast]\d),([ast]\d)/) { print "mov eax, _$2\nneg eax\nmov _$1, eax\n" } elsif ($insn =~ /j (.+)/) { print "jmp L_$1\n" } elsif ($insn =~ /jr ([ast]\d)/) { print "mov eax, _$1\njmp eax\n" } elsif ($insn =~ /jal ra,(.+)/) { print call("L_$1") } elsif ($insn =~ /sb ([ast]\d),(-?\d+)\(sp\)/) { print "mov al, _$1\nmov [esp+($2)], al\n" } elsif ($insn =~ /sb ([ast]\d),(-?\d+)\(([ast]\d)\)/) { print "mov al, _$1\nmov ebx, _$3\nmov [ebx+($2)], al\n" } elsif ($insn =~ /sb zero,(-?\d+)\(sp\)/) { print "mov byte [esp+($1)], 0\n" } elsif ($insn =~ /sb zero,(-?\d+)\(([ast]\d)\)/) { print "mov ebx, _$2\nmov byte [ebx+($1)], 0\n" } elsif ($insn =~ /lbu ([ast]\d),(-?\d+)\(([ast]\d)\)/) { print "mov eax, _$3\nmovzx eax, byte [eax+($2)]\nmov _$1, eax\n" } elsif ($insn =~ /mv ([ast]\d),sp/) { print "mov _$1, esp\n" } elsif ($insn =~ /mv ([ast]\d),([ast]\d)/) { print "mov eax, _$2\nmov _$1, eax\n" } elsif ($insn =~ /lw ([ast]\d),(-?\d+)\(sp\)/) { print "mov eax, [esp+($2)]\nmov _$1, eax\n" } elsif ($insn =~ /lw ra,(-?\d+)\(sp\)/) { print "mov eax, [esp+($1)]\nmov _ra, eax\n" } elsif ($insn =~ /lw ([ast]\d),(-?\d+)\(([ast]\d)\)/) { print "mov eax, _$3\nmov eax, [eax+($2)]\nmov _$1, eax\n" } elsif ($insn =~ /lui ([ast]\d),0x(.+)/) { print "mov dword _$1, (0$2h << 12)\n" } elsif ($insn =~ /lhu ([ast]\d),(-?\d+)\(([ast]\d)\)/) { print "mov eax, _$3\nmovzx eax, word [eax+($2)]\nmov _$1, eax\n" } elsif ($insn =~ /sw ([ast]\d),(-?\d+)\(sp\)/) { print "mov eax, _$1\nmov [esp+($2)], eax\n" } elsif ($insn =~ /sw ra,(-?\d+)\(sp\)/) { print "mov eax, _ra\nmov [esp+($1)], eax\n" } elsif ($insn =~ /sw ([ast]\d),(-?\d+)\(([ast]\d)\)/) { print "mov eax, _$1\nmov ebx, _$3\nmov [ebx+($2)], eax\n" } elsif ($insn =~ /sw zero,(-?\d+)\(([ast]\d)\)/) { print "mov ebx, _$2\nmov dword [ebx+($1)], 0\n" } elsif ($insn =~ /sltu ([ast]\d),([ast]\d),([ast]\d)/) { print "mov eax, _$2\ncmp eax, _$3\nsetb al\nmovzx eax, al\nmov _$1, eax\n" } else { print "???\n" } $ppp = $pp; $pp = $prev; $prev = $insn; } print "section .data\n"; print "__ra: dd 0\n"; print "__a$_: dd 0\n" for (0..7); print "__s$_: dd 0\n" for (0..7); print "__t$_: dd 0\n" for (0..7); print "jmptbl:\n"; print " dd L_11b98\n"; print " dd L_11b00\n"; print " dd L_11a08\n"; print " dd L_11644\n"; print " dd L_1192c\n"; print "section .original\n"; print "ori:\n"; print "incbin \"TA.elf.signed\"\n";
et la sortie, rvconvd.asm
, ressemble à ceci:
[...] section .text ;113c8 li a7,1 L_113c8: TEE_write: mov dword _a7, 1 ;113cc ecall L_113cc: sysenter ;113d0 ret L_113d0: ret ;113d4 li a7,2 L_113d4: TEE_readkey: mov dword _a7, 2 ;113d8 ecall L_113d8: sysenter ;113dc ret L_113dc: ret [...] ;11404 addi a0,a0,1 L_11404: mov eax, _a0 add eax, 1 mov _a0, eax ;11408 add a7,a1,a2 L_11408: mov eax, _a1 add eax, _a2 mov _a7, eax ;1140c li a6,5 L_1140c: mov dword _a6, 5 ;11410 li t1,9 L_11410: mov dword _t1, 9 [...]
On peut assembler la sortie avec nasm
et ouvrir le .obj
dans IDA:

Commençons par le point d’entrée du programme. Nous connaissons les paramètres grace à l’analyse du code Javascript invoquant le point d’entrée. Il y a en quatre:
-
un pointeur sur le buffer d’entrée (contenant la commande)
-
un pointeur sur la longueur du buffer d’entrée
-
un pointeur sur le buffer de sortie
-
un pointeur sur la longueur du buffer de sortie
Ces arguments sont passés par les registres a0
à a3
.
int __cdecl handle_cmd(char *inbuf, int *pinlen, char *outbuf, int *poutlen) { [ ... local vars decls ... ] _a4 = *(_DWORD *)pinlen; [...] if ( (unsigned int)_a4 > 3067 ) { puts("bad input size\r\n"); } else { _a4 = *(_DWORD *)poutlen; if ( 3067 < (unsigned int)_a4 ) { puts("bad output size\r\n"); } else { _a5 = *(_DWORD *)inbuf; // command id _s0 = inbuf; _s3 = outbuf; if ( (unsigned int)_a5 <= 4 ) { return jmptbl[4 * _a5](); } puts("[DEBUG] Unknown CMD \r\n"); _a5 = -1; *(_DWORD *)outbuf = -1; } } [...] }
Le code vérifie que les buffers d’entrée et de sortie ne sont pas trop grands (> 3067 octets). Puis il déréférence le premier mot du buffer d’entrée, où se situe le numéro de commande. Le numéro est utilisé comme un index dans une table de sauts, que voici:
.data:00001DF8 jmptbl dd offset CMD_TA_INIT ; DATA XREF: handle_cmd:L_115eco .data:00001DFC dd offset CMD_GET_TA_VERSION .data:00001E00 dd offset CMD_CHECK_PASSWORD .data:00001E04 dd offset CMD_DECRYPT_BLOCK .data:00001E08 dd offset CMD_GET_TA_LUM
Grace aux messages de débuggage, les identifiants des commandes se retrouvent facilement.
Au passage, on va voir la commande GET_TA_LUM
. Son algorithme est un simple xor avec une clé fixe, qu’on recode en python:
>>> a = [ 4-i for i in range(16) ] >>> x = [ 0x48,0x56,0x4f,0x7a,0x67,0x9b,0xb0,0xc5,0xd2,0xbf,0xd0,0xb9,0xd3,0xa2,0xa0,0x88 ] >>> ''.join([ chr((u^v) & 0xff) for u,v in zip(a, x) ]) 'LUM{gdN8.D*@+UV}'
Voici la commande TA_INIT
:
void CMD_TA_INIT() { puts("[DEBUG] CMD CMD_TA_INIT\r\n"); TEE_writekey(&dword_10000[48], "___SSTIC_2017___", 16, 0); TEE_writekey(&dword_10000[57], &dword_10000[52], 16, 1); }
Elle fait appel à une fonction utilitaire puts
qui sort un message de debug sur la console, et qui repose elle-même sur la primitive TEE_write
exposée par Javascript. Les fonctions TEE_
sont implantées avec des instructions ecall
qui invoquent la méthode Javascript syscallHandler
du CPU Risc-V.
La primitive TEE_writekey
met à jour la clé utilisée ultérieurement pour déchiffrer des blocs de données. On peut sélectionner une clé par défaut codée en dur dans le Javascript, ou bien charger une clé "custom", à condition qu’elle possède un HMAC associé valide. Les clés de déchiffrement des fichiers par trustzone_decrypt
sont du type custom.
GET_TA_VERSION
est également simple:
void __cdecl CMD_GET_TA_VERSION() { char txt[] = "SSTIC Trusted APP v0.0.1"; puts("[DEBUG] CMD CMD_GET_TA_VERSION\r\n"); p = txt; q = &outbuf[4]; i = 0; while ( 1 ) { if ( i >= strlen(txt) ) break; c = *(_BYTE *)p; ++i; ++p; *(_BYTE *)q++ = c; } *(_BYTE *)outbuf[i + 4] = 0; *(_DWORD *)outbuf = 0; }
La fonction fait un strcpy
vers l’offset 4 du buffer de sortie, et met à 0 le premier dword du buffer de sortie, le statut, pour indiquer un succès.
La commande CHECK_PASSWORD
est plus compliquée, mais on infère qu’elle déchiffre en AES les 0x70 premiers octets du fichier cible pour en extraire un HMAC du bon mot de passe de chiffrement. Si le HMAC du mot de passe proposé par l’utilisateur est identique, le mot de passe est accepté et chargé avec la primitive TEE_writekey
pour servir de clé de déchiffrement.
Nous arrivons à la primitive cryptographique qui nous intéresse le plus, DECRYPT_BLOCK
. Là voici, sortie brute de HexRays:
void __cdecl CMD_DECRYPT_BLOCK() { int v0; // [sp+40h] [bp+40h]@0 int v1; // [sp+44h] [bp+44h]@0 int v2; // [sp+48h] [bp+48h]@0 int v3; // [sp+4Ch] [bp+4Ch]@0 _a0 = "[DEBUG] CMD CMD_DECRYPT_BLOCK\r\n"; puts("[DEBUG] CMD CMD_DECRYPT_BLOCK\r\n"); _a2 = 16; _a1 = (char *)&v0; _a0 = "SSTIC_CUSTOM_KEY"; _s1 = *(_DWORD *)(_s0 + 4); TEE_readkey("SSTIC_CUSTOM_KEY", (char *)&v0, 16); _a5 = (int (*)(void))((v3 & 0xADAAA9AA | v1 & 0x52555655) << -((_s1 + 23) & 0x1F)); _a4 = (unsigned int)_a5 | ((v3 & 0xADAAA9AA | v1 & 0x52555655) >> ((_s1 + 23) & 0x1F)); _t1 = (((unsigned int)_a5 | ((v3 & 0xADAAA9AA | v1 & 0x52555655) >> ((_s1 + 23) & 0x1F))) >> 24) + 65; _a5 = (int (*)(void))(((unsigned int)_a5 | ((v3 & 0xADAAA9AA | v1 & 0x52555655) >> ((_s1 + 23) & 0x1F))) >> 16); _a2 = (unsigned __int8)_a5 + (unsigned __int8)_t1 + 72; _a1 = (char *)(unsigned __int8)((_BYTE)_a5 + _t1 + 72); _a1 += BYTE1(_a4); _a1 += 79; _a4 = (unsigned __int8)_a4 + (unsigned __int8)_a1; _a6 = (v2 & 0xADAAA9AA | v0 & 0x52555655) << -((_s1 + 19) & 0x1F); _a5 = (int (*)(void))(_a6 | ((v2 & 0xADAAA9AA | v0 & 0x52555655) >> ((_s1 + 19) & 0x1F))); _a0 = (char *)(_a4 + 86); _a6 = (unsigned __int8)(_a4 + 86); _a6 += (unsigned int)_a5 >> 24; _a6 += 93; _a4 = (unsigned __int8)((unsigned int)_a5 >> 16); _a7 = _a4 + (unsigned __int8)_a6 + 100; _t3 = (unsigned __int8)(_a4 + _a6 + 100); _t3 += BYTE1(_a5); _t3 += 107; _a5 = (int (*)(void))((unsigned __int8)_a5 + (unsigned __int8)_t3); _a3 = (v3 & 0x52555655 | v1 & 0xADAAA9AA) << -((_s1 + 17) & 0x1F); _a4 = _a3 | ((v3 & 0x52555655 | v1 & 0xADAAA9AA) >> ((_s1 + 17) & 0x1F)); _a3 = (int)_a5 + 114; _t5 = (unsigned __int8)((_BYTE)_a5 + 114); _t5 += (unsigned int)_a4 >> 24; _t5 += 121; _a5 = (int (*)(void))(unsigned __int8)((unsigned int)_a4 >> 16); _t6 = (int)_a5 + (unsigned __int8)_t5 + 128; _s1 += 13; _s1 &= 0x1Fu; _s4 = BYTE1(_a4) + (unsigned __int8)_t6 + 135; _t4 = -_s1; _t0 = (unsigned __int8)(BYTE1(_a4) + _t6 - 121); _s1 = (v2 & 0x52555655 | v0 & 0xADAAA9AA) >> _s1; _a4 = (unsigned __int8)_a4 + _t0; _a5 = (int (*)(void))((v2 & 0x52555655 | v0 & 0xADAAA9AA) << _t4); _a5 = (int (*)(void))((unsigned int)_a5 | _s1); _t0 = _a4 + 142; _t2 = (unsigned __int8)(_a4 - 114); _t2 += (unsigned int)_a5 >> 24; _t2 += 149; _a4 = (unsigned __int8)((unsigned int)_a5 >> 16); _s1 = _a4 + (unsigned __int8)_t2 + 156; _s2 = BYTE1(_a5) + (unsigned __int8)(_a4 + _t2 - 100) + 163; _a4 = (unsigned __int8)(BYTE1(_a5) + _a4 + _t2 - 100 - 93); _a5 = (int (*)(void))(_a4 + (unsigned __int8)_a5); _s6 = _s0 + 8; _a4 = _s3 + 8; _a5 = (int (*)(void))((char *)_a5 + 170); _s5 = _s3 + 8 < (unsigned int)(_s0 + 12); _s7 = (unsigned __int8)_a3; _t1 = (unsigned __int8)_t1; _a2 = (unsigned __int8)_a2; _a1 = (char *)(unsigned __int8)_a1; _a0 = (char *)(unsigned __int8)_a0; _a6 = (unsigned __int8)_a6; _a7 = (unsigned __int8)_a7; _t3 = (unsigned __int8)_t3; _t5 = (unsigned __int8)_t5; _t6 = (unsigned __int8)_t6; _s4 = (unsigned __int8)_s4; _t0 = (unsigned __int8)_t0; _t2 = (unsigned __int8)_t2; _s1 = (unsigned __int8)_s1; _s2 = (unsigned __int8)_s2; _a5 = (int (*)(void))(unsigned __int8)_a5; _a3 = _s3 + 8 >= (unsigned int)(_s0 + 12); BYTE3(v3) = _t1; BYTE2(v3) = _a2; BYTE1(v3) = (_BYTE)_a1; LOBYTE(v3) = (_BYTE)_a0; BYTE3(v2) = _a6; BYTE2(v2) = _a7; BYTE1(v2) = _t3; LOBYTE(v2) = _s7; BYTE3(v1) = _t5; BYTE2(v1) = _t6; BYTE1(v1) = _s4; LOBYTE(v1) = _t0; BYTE3(v0) = _t2; BYTE2(v0) = _s1; BYTE1(v0) = _s2; LOBYTE(v0) = (_BYTE)_a5; _t4 = _a3 | (_s0 + 8 >= (unsigned int)(_s3 + 12)); if ( !_t4 || (_a4 |= _s6, (_a4 &= 3u) != 0) ) { _a4 = *(_BYTE *)(_s0 + 8); _a5 = (int (*)(void))(_a4 ^ (unsigned int)_a5); *(_BYTE *)(_s3 + 8) = (_BYTE)_a5; _a5 = (int (*)(void))*(_BYTE *)(_s0 + 9); _s2 ^= (unsigned int)_a5; *(_BYTE *)(_s3 + 9) = _s2; _a5 = (int (*)(void))*(_BYTE *)(_s0 + 10); _s1 ^= (unsigned int)_a5; *(_BYTE *)(_s3 + 10) = _s1; _a5 = (int (*)(void))*(_BYTE *)(_s0 + 11); _t2 ^= (unsigned int)_a5; *(_BYTE *)(_s3 + 11) = _t2; _a5 = (int (*)(void))*(_BYTE *)(_s0 + 12); _t0 ^= (unsigned int)_a5; *(_BYTE *)(_s3 + 12) = _t0; _a5 = (int (*)(void))*(_BYTE *)(_s0 + 13); _s4 ^= (unsigned int)_a5; *(_BYTE *)(_s3 + 13) = _s4; _a5 = (int (*)(void))*(_BYTE *)(_s0 + 14); _t6 ^= (unsigned int)_a5; *(_BYTE *)(_s3 + 14) = _t6; _a5 = (int (*)(void))*(_BYTE *)(_s0 + 15); _t5 ^= (unsigned int)_a5; *(_BYTE *)(_s3 + 15) = _t5; _a3 = *(_BYTE *)(_s0 + 16); _a3 ^= _s7; *(_BYTE *)(_s3 + 16) = _a3; _a5 = (int (*)(void))*(_BYTE *)(_s0 + 17); _t3 ^= (unsigned int)_a5; *(_BYTE *)(_s3 + 17) = _t3; _a5 = (int (*)(void))*(_BYTE *)(_s0 + 18); _a7 ^= (unsigned int)_a5; *(_BYTE *)(_s3 + 18) = _a7; _a5 = (int (*)(void))*(_BYTE *)(_s0 + 19); _a6 ^= (unsigned int)_a5; *(_BYTE *)(_s3 + 19) = _a6; _a5 = (int (*)(void))*(_BYTE *)(_s0 + 20); _a0 = (char *)((unsigned int)_a5 ^ (unsigned int)_a0); *(_BYTE *)(_s3 + 20) = (_BYTE)_a0; _a5 = (int (*)(void))*(_BYTE *)(_s0 + 21); _a1 = (char *)((unsigned int)_a5 ^ (unsigned int)_a1); *(_BYTE *)(_s3 + 21) = (_BYTE)_a1; _a5 = (int (*)(void))*(_BYTE *)(_s0 + 22); _a2 ^= (unsigned int)_a5; *(_BYTE *)(_s3 + 22) = _a2; _a5 = (int (*)(void))*(_BYTE *)(_s0 + 23); _t1 ^= (unsigned int)_a5; *(_BYTE *)(_s3 + 23) = _t1; } else { _a4 = *(_DWORD *)(_s0 + 8); _a5 = (int (*)(void))(_a4 ^ v0); *(_DWORD *)(_s3 + 8) = _a4 ^ v0; _a4 = *(_DWORD *)(_s0 + 12); _a5 = (int (*)(void))(_a4 ^ v1); *(_DWORD *)(_s3 + 12) = _a4 ^ v1; _a4 = *(_DWORD *)(_s0 + 16); _a5 = (int (*)(void))(_a4 ^ v2); *(_DWORD *)(_s3 + 16) = _a4 ^ v2; _a4 = *(_DWORD *)(_s0 + 20); _a5 = (int (*)(void))(_a4 ^ v3); *(_DWORD *)(_s3 + 20) = _a4 ^ v3; } *(_DWORD *)_s3 = 0; JUMPOUT(&L_11608); }
Dans un premier temps, étudions l’influence des paramètres en entrée de la fonction. Nous savons que handle_cmd
passe les pointeurs vers les buffers d’entrée et de sortie via s0
et s3
. s0
pointe donc vers un message dont le premier champ (déjà utilisé) contient la commande DECRYPT_BLOCK
, le second champ est le numéro de bloc, et le bloc chiffré suit à l’offset 8.
Un premier renommage nous donne:
void __cdecl CMD_DECRYPT_BLOCK() { int v0; // [sp+40h] [bp+40h]@0 int v1; // [sp+44h] [bp+44h]@0 int v2; // [sp+48h] [bp+48h]@0 int v3; // [sp+4Ch] [bp+4Ch]@0 puts("[DEBUG] CMD CMD_DECRYPT_BLOCK\r\n"); blockno = inbuf.blockno; TEE_readkey("SSTIC_CUSTOM_KEY", (char *)&v0, 16); _a5 = (int (*)(void))((v3 & 0xADAAA9AA | v1 & 0x52555655) << -((blockno + 23) & 0x1F)); _a4 = (unsigned int)_a5 | ((v3 & 0xADAAA9AA | v1 & 0x52555655) >> ((blockno + 23) & 0x1F)); _t1 = (((unsigned int)_a5 | ((v3 & 0xADAAA9AA | v1 & 0x52555655) >> ((blockno + 23) & 0x1F))) >> 24) + 65; _a5 = (int (*)(void))(((unsigned int)_a5 | ((v3 & 0xADAAA9AA | v1 & 0x52555655) >> ((blockno + 23) & 0x1F))) >> 16); _a2 = (unsigned __int8)_a5 + (unsigned __int8)_t1 + 72; _a1 = (char *)(unsigned __int8)((_BYTE)_a5 + _t1 + 72); _a1 += BYTE1(_a4); _a1 += 79; _a4 = (unsigned __int8)_a4 + (unsigned __int8)_a1; _a6 = (v2 & 0xADAAA9AA | v0 & 0x52555655) << -((blockno + 19) & 0x1F); _a5 = (int (*)(void))(_a6 | ((v2 & 0xADAAA9AA | v0 & 0x52555655) >> ((blockno + 19) & 0x1F))); _a0 = (char *)(_a4 + 86); _a6 = (unsigned __int8)(_a4 + 86); _a6 += (unsigned int)_a5 >> 24; _a6 += 93; _a4 = (unsigned __int8)((unsigned int)_a5 >> 16); _a7 = _a4 + (unsigned __int8)_a6 + 100; _t3 = (unsigned __int8)(_a4 + _a6 + 100); _t3 += BYTE1(_a5); _t3 += 107; _a5 = (int (*)(void))((unsigned __int8)_a5 + (unsigned __int8)_t3); _a3 = (v3 & 0x52555655 | v1 & 0xADAAA9AA) << -((blockno + 17) & 0x1F); _a4 = _a3 | ((v3 & 0x52555655 | v1 & 0xADAAA9AA) >> ((blockno + 17) & 0x1F)); _a3 = (int)_a5 + 114; _t5 = (unsigned __int8)((_BYTE)_a5 + 114); _t5 += (unsigned int)_a4 >> 24; _t5 += 121; _a5 = (int (*)(void))(unsigned __int8)((unsigned int)_a4 >> 16); _t6 = (int)_a5 + (unsigned __int8)_t5 + 128; _s1 = blockno + 13; _s1 &= 0x1Fu; _s4 = BYTE1(_a4) + (unsigned __int8)_t6 + 135; _t4 = -_s1; _t0 = (unsigned __int8)(BYTE1(_a4) + _t6 - 121); _s1 = (v2 & 0x52555655 | v0 & 0xADAAA9AA) >> _s1; _a4 = (unsigned __int8)_a4 + _t0; _a5 = (int (*)(void))((v2 & 0x52555655 | v0 & 0xADAAA9AA) << _t4); _a5 = (int (*)(void))((unsigned int)_a5 | _s1); _t0 = _a4 + 142; _t2 = (unsigned __int8)(_a4 - 114); _t2 += (unsigned int)_a5 >> 24; _t2 += 149; _a4 = (unsigned __int8)((unsigned int)_a5 >> 16); _s1 = _a4 + (unsigned __int8)_t2 + 156; _s2 = BYTE1(_a5) + (unsigned __int8)(_a4 + _t2 - 100) + 163; _a4 = (unsigned __int8)(BYTE1(_a5) + _a4 + _t2 - 100 - 93); _a5 = (int (*)(void))(_a4 + (unsigned __int8)_a5); _s6 = inbuf + 8; _a4 = outbuf + 8; _a5 = (int (*)(void))((char *)_a5 + 170); _s5 = outbuf + 8 < (unsigned int)(inbuf + 12); _s7 = (unsigned __int8)_a3; _t1 = (unsigned __int8)_t1; _a2 = (unsigned __int8)_a2; _a1 = (char *)(unsigned __int8)_a1; _a0 = (char *)(unsigned __int8)_a0; _a6 = (unsigned __int8)_a6; _a7 = (unsigned __int8)_a7; _t3 = (unsigned __int8)_t3; _t5 = (unsigned __int8)_t5; _t6 = (unsigned __int8)_t6; _s4 = (unsigned __int8)_s4; _t0 = (unsigned __int8)_t0; _t2 = (unsigned __int8)_t2; _s1 = (unsigned __int8)_s1; _s2 = (unsigned __int8)_s2; _a5 = (int (*)(void))(unsigned __int8)_a5; _a3 = outbuf + 8 >= (unsigned int)(inbuf + 12); BYTE3(v3) = _t1; BYTE2(v3) = _a2; BYTE1(v3) = (_BYTE)_a1; LOBYTE(v3) = (_BYTE)_a0; BYTE3(v2) = _a6; BYTE2(v2) = _a7; BYTE1(v2) = _t3; LOBYTE(v2) = _s7; BYTE3(v1) = _t5; BYTE2(v1) = _t6; BYTE1(v1) = _s4; LOBYTE(v1) = _t0; BYTE3(v0) = _t2; BYTE2(v0) = _s1; BYTE1(v0) = _s2; LOBYTE(v0) = (_BYTE)_a5; _t4 = _a3 | (inbuf + 8 >= (unsigned int)(outbuf + 12)); if ( !_t4 || (_a4 |= _s6, (_a4 &= 3u) != 0) ) { _a4 = *(_BYTE *)(inbuf + 8); _a5 = (int (*)(void))(_a4 ^ (unsigned int)_a5); *(_BYTE *)(outbuf + 8) = (_BYTE)_a5; _a5 = (int (*)(void))*(_BYTE *)(inbuf + 9); _s2 ^= (unsigned int)_a5; *(_BYTE *)(outbuf + 9) = _s2; _a5 = (int (*)(void))*(_BYTE *)(inbuf + 10); _s1 ^= (unsigned int)_a5; *(_BYTE *)(outbuf + 10) = _s1; _a5 = (int (*)(void))*(_BYTE *)(inbuf + 11); _t2 ^= (unsigned int)_a5; *(_BYTE *)(outbuf + 11) = _t2; _a5 = (int (*)(void))*(_BYTE *)(inbuf + 12); _t0 ^= (unsigned int)_a5; *(_BYTE *)(outbuf + 12) = _t0; _a5 = (int (*)(void))*(_BYTE *)(inbuf + 13); _s4 ^= (unsigned int)_a5; *(_BYTE *)(outbuf + 13) = _s4; _a5 = (int (*)(void))*(_BYTE *)(inbuf + 14); _t6 ^= (unsigned int)_a5; *(_BYTE *)(outbuf + 14) = _t6; _a5 = (int (*)(void))*(_BYTE *)(inbuf + 15); _t5 ^= (unsigned int)_a5; *(_BYTE *)(outbuf + 15) = _t5; _a3 = *(_BYTE *)(inbuf + 16); _a3 ^= _s7; *(_BYTE *)(outbuf + 16) = _a3; _a5 = (int (*)(void))*(_BYTE *)(inbuf + 17); _t3 ^= (unsigned int)_a5; *(_BYTE *)(outbuf + 17) = _t3; _a5 = (int (*)(void))*(_BYTE *)(inbuf + 18); _a7 ^= (unsigned int)_a5; *(_BYTE *)(outbuf + 18) = _a7; _a5 = (int (*)(void))*(_BYTE *)(inbuf + 19); _a6 ^= (unsigned int)_a5; *(_BYTE *)(outbuf + 19) = _a6; _a5 = (int (*)(void))*(_BYTE *)(inbuf + 20); _a0 = (char *)((unsigned int)_a5 ^ (unsigned int)_a0); *(_BYTE *)(outbuf + 20) = (_BYTE)_a0; _a5 = (int (*)(void))*(_BYTE *)(inbuf + 21); _a1 = (char *)((unsigned int)_a5 ^ (unsigned int)_a1); *(_BYTE *)(outbuf + 21) = (_BYTE)_a1; _a5 = (int (*)(void))*(_BYTE *)(inbuf + 22); _a2 ^= (unsigned int)_a5; *(_BYTE *)(outbuf + 22) = _a2; _a5 = (int (*)(void))*(_BYTE *)(inbuf + 23); _t1 ^= (unsigned int)_a5; *(_BYTE *)(outbuf + 23) = _t1; } else { _a4 = *(_DWORD *)(inbuf + 8); _a5 = (int (*)(void))(_a4 ^ v0); *(_DWORD *)(outbuf + 8) = _a4 ^ v0; _a4 = *(_DWORD *)(inbuf + 12); _a5 = (int (*)(void))(_a4 ^ v1); *(_DWORD *)(outbuf + 12) = _a4 ^ v1; _a4 = *(_DWORD *)(inbuf + 16); _a5 = (int (*)(void))(_a4 ^ v2); *(_DWORD *)(outbuf + 16) = _a4 ^ v2; _a4 = *(_DWORD *)(inbuf + 20); _a5 = (int (*)(void))(_a4 ^ v3); *(_DWORD *)(outbuf + 20) = _a4 ^ v3; } *(_DWORD *)outbuf = 0; }
Une structure commence à apparaître: la fonction lit d’abord la clé de déchiffrement dans v0
..v3
avec la primitive TEE_readkey
. S’en suivent une série de transformations sur la clé, où le numéro de bloc intervient. Il en resort une clé de bloc, stockée dans v0
..v3
, ainsi que dans des registres, mais là octet par octet.
Une condition complexe est ensuite testée, et le buffer de sortie est mis à jour octet par octet ou dword par dword, selon le résultat du test. Dans les deux cas, le résultat est un xor
entre le buffer d’entrée et un masque provenant soit des registres, soit des variables v0
..v3
.
A première vue, il semble même que les xor
octet par octet ou dword par dword sont équivalents. (La condition testée pourrait être un prédicat opaque simplement destiné à ralentir l’analyse…)
Il faudra sans doute simplifier encore cette fonction, mais nous en savons assez pour envisager les angles d’attaques possibles.
Attaque cryptographique
Nous possédons un indice précieux: le nom du fichier cible secret.lzma.encrypted
, dont l’extension .lzma
nous renseigne sur le texte clair. Serait-il alors possible de monter une attaque à clair connu?
Un coup d’oeil sur le format .lzma
et un test sur des fichiers divers nous confirment que 14 des 16 premiers octets de l’en-tête lzma sont fixes, en pratique. Un brute force sur les deux derniers est raisonnable.
En supposant que nous connaissions le contenu du premier bloc de 16 octets, serait-il possible d’en déduire le contenu du fichier entier? Cela supposerait que la clé de bloc dérivée par CMD_DECRYPT_BLOCK
pour le bloc 0 nous fournisse suffisamment d’information sur la clé complète. Idéalement, si la dérivation était bijective, nous pourrions l’inverser et retrouver la clé à partir de la clé du bloc 0.
Il nous faut donc répondre à la question de l’inversibilité du key schedule, et ce uniquement pour le bloc 0. Cette restriction simplifie un peu le problème, puisqu’on peut supposer que blockno
vaut 0 dans la fonction ci-dessus, et étudier uniquement ce cas.
On refactorise donc le déchiffrement du bloc 0, et en introduisant les bonnes macros, le code est plus lisible:
byte key[16]; byte input[16], output[16]; byte prev[16]; // previous encrypted block #define MIX(a,b) (((a) & 0xadaaa9aa) | ((b) & 0x52555655)) #define ROTL(a,n) (((a) << (n)) | ((a) >> (32-(n)))) #define ROTR(a,n) (((a) >> (n)) | ((a) << (32-(n)))) void decrypt_block0(void) { byte blkkey[16]; uint32_t k0123, k4567, k89ab, kcdef, m1, m2, m3, m4, r1, r2, r3, r4; byte b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, ba, bb, bc, bd, be, bf; int i; memcpy(blkkey, key, 16); k0123 = le32(&blkkey[0]); k89ab = le32(&blkkey[8]); k4567 = le32(&blkkey[4]); kcdef = le32(&blkkey[12]); m1 = MIX(kcdef, k4567); r1 = ROTL(m1, 9); b0 = (r1 >> 0x18) + 65; b1 = b0 + (r1 >> 0x10) + 72; b2 = b1 + (r1 >> 8) + 79; b3 = b2 + r1 + 86; m2 = MIX(k89ab, k0123); r2 = ROTL(m2, 13); b4 = b3 + (r2 >> 0x18) + 93; b5 = b4 + (r2 >> 0x10) + 100; b6 = b5 + (r2 >> 8) + 107; b7 = b6 + r2 + 114; m3 = MIX(k4567, kcdef); r3 = ROTL(m3, 15); b8 = b7 + (r3 >> 0x18) + 121; b9 = b8 + (r3 >> 0x10) + 128; ba = b9 + (r3 >> 8) + 135; bb = ba + r3 + 142; m4 = MIX(k0123, k89ab); r4 = ROTL(m4, 19); bc = bb + (r4 >> 0x18) + 149; bd = bc + (r4 >> 0x10) + 156; be = bd + (r4 >> 8) + 163; bf = be + r4 + 170; blkkey[15] = b0; blkkey[14] = b1; blkkey[13] = b2; blkkey[12] = b3; blkkey[11] = b4; blkkey[10] = b5; blkkey[9] = b6; blkkey[8] = b7; blkkey[7] = b8; blkkey[6] = b9; blkkey[5] = ba; blkkey[4] = bb; blkkey[3] = bc; blkkey[2] = bd; blkkey[1] = be; blkkey[0] = bf; for (i = 0; i < 16; i++) output[i] = input[i] ^ blkkey[i]; }
Le key schedule du bloc 0 est donc inversible; la clé de bloc est une permutation des bits de la clé. Nous pouvons l’inverser ainsi:
#define BYTES(a,b,c,d) (((byte)(a) << 24) | ((byte)(b) << 16) | ((byte)(c) << 8) | (byte)(d)) #define HA(a) (((a) & 0xadaaa9aa)) #define H5(a) (((a) & 0x52555655)) void recover_key(void) { byte blkkey[16]; uint32_t k0123, k4567, k89ab, kcdef, m1, m2, m3, m4, r1, r2, r3, r4; byte b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, ba, bb, bc, bd, be, bf; int i; // input is the ciphertext of the first block // output is the known plaintext (guessed) // prev is the IV for (i = 0; i < 16; i++) blkkey[i] = output[i] ^ input[i] ^ prev[15-i]; b0 = blkkey[15]; b1 = blkkey[14]; b2 = blkkey[13]; b3 = blkkey[12]; b4 = blkkey[11]; b5 = blkkey[10]; b6 = blkkey[9]; b7 = blkkey[8]; b8 = blkkey[7]; b9 = blkkey[6]; ba = blkkey[5]; bb = blkkey[4]; bc = blkkey[3]; bd = blkkey[2]; be = blkkey[1]; bf = blkkey[0]; k0123 = k4567 = k89ab = kcdef = 0; r1 = BYTES(b0-65, b1-b0-72, b2-b1-79, b3-b2-86); m1 = ROTR(r1, 9); kcdef |= HA(m1); k4567 |= H5(m1); r2 = BYTES(b4-b3-93, b5-b4-100, b6-b5-107, b7-b6-114); m2 = ROTR(r2, 13); k89ab |= HA(m2); k0123 |= H5(m2); r3 = BYTES(b8-b7-121, b9-b8-128, ba-b9-135, bb-ba-142); m3 = ROTR(r3, 15); k4567 |= HA(m3); kcdef |= H5(m3); r4 = BYTES(bc-bb-149, bd-bc-156, be-bd-163, bf-be-170); m4 = ROTR(r4, 19); k0123 |= HA(m4); k89ab |= H5(m4); *(uint32_t*)&key[0] = k0123; *(uint32_t*)&key[4] = k4567; *(uint32_t*)&key[8] = k89ab; *(uint32_t*)&key[12] = kcdef; }
À partir là, on énumère les premiers blocs clairs possibles, on retrouve la clé du bloc 0, on en déduit la clé maîtresse, on déchiffre le reste du fichier, et on utilise la présence ou l’absence d’un padding valide à la fin du fichier déchiffré pour considéré les candidats à la décompression lzma. Il n’y a qu’un seul candidat, et c’est le bon:
~/sstic2017/challenges/riscy_zones/bf falkn$ ls -l -rw-r--r-- 1 falkn staff 35552 Mar 23 00:15 secret.7fb6.lzma
Il se décompresse en un fichier .jpg
:

Au passage, on trouve dans l’image un LUM encodé en rot13:
~/sstic2017/challenges/riscy_zones/bf falkn$ strings good.jpg | head JFIF FExif Congratulations : YHZ{+g%Yi.vzG8Z} [...] ~/sstic2017/challenges/riscy_zones/bf falkn$ python >>> rot13 = string.maketrans( ... "ABCDEFGHIJKLMabcdefghijklmNOPQRSTUVWXYZnopqrstuvwxyz", ... "NOPQRSTUVWXYZnopqrstuvwxyzABCDEFGHIJKLMabcdefghijklm") >>> string.translate("YHZ{+g%Yi.vzG8Z}", rot13) 'LUM{+t%Lv.imT8M}'
On fournit la clé trouvée à add_key
et ça marche!
~ $ /challenges/tools/add_key 5921cd9fd3a82bd9244ece5328c6c95f OK !
Unstable machines
L'épreuve "Unstable machines" se présente sous la forme d’un fichier PE Windows x86 32-bit. On peut prendre connaissance de l’en-tête à l’aide d’un outil tel que CFF Explorer. On remarque que l’image est compatible NX mais que les relocations ont été enlevées, l’image sera donc chargée à une adresse mémoire fixe (nous verrons dans la suite pourquoi cette précaution était nécessaire.)
On voit aussi qu’il s’agit d’une application graphique (Windows GUI) qui importe quelques fonctions de fenêtrage et gestion de messages.
Après un rapide premier passage dans IDA Pro (on n’est jamais trop prudent), on se décide à lancer l’application dans une VM déconnectée du réseau, en l’occurrence un Windows 10 32-bit. On obtient un message d’erreur stipulant qu’un OS 64-bit est nécessaire pour utiliser l’application.

Il s’agit d’un des nombreux prérequis à l’utilisation vérifiés au démarrage; il faut:
-
2Go de RAM
-
un OS 64-bit
-
50Go de disque
-
un Windows Vista ou plus récent
-
un CPU à au moins 2 coeurs
À l’issue de ces vérifications l’application invoque OutputDebugString
avec en paramètre la chaîne "Real machine"
, ce qui suggère que les prérequis sont là pour détecter une tentative d’exécution sur une fausse machine: émulateur, sandbox minimaliste, etc. Quoi qu’il en soit, il est facile de s’affranchir des prérequis en sautant l’appel à la fonction de vérification.
![]() |
Sous le débuggeur d’IDA Pro, on exécute le programme jusqu'à l’adresse 0x401b96 en plaçant le curseur à cette adresse et en faisant F4 , puis on déplace le curseur sur la ligne suivante et on fait Ctrl-N pour changer eip . Puis on fait F9 pour continuer. |
On est alors gratifié d’une jolie fenêtre où figurent 7 boîtes à cocher (checkbox) alignées en diagonale, sur fond d’une image de boss monster qui annonce la couleur… Il y a aussi un gros bouton en bas qui affiche une apostrophe aléatoire ("Yo dude !", "Excellent!", etc.)

En jouant un peu avec les boîtes, on se rend compte qu’il semble y avoir un ordre pour les cocher toutes. Faute de le suivre, on retombe dans l'état où toutes les boîtes sont décochées. Il faut donc se lancer dans l’analyse du code pour découvrir le bon ordre.
Phase de cochage
La fonction WinMain
(en 0x401b90
), une fois les prérequis vérifiés, calcule une somme de contrôle sur un bloc de données initialisées, alloue et initialise quelques blocs mémoire au rôle encore flou, puis crée une boîte de dialogue et rentre dans une classique pompe à messages Windows. Le reste de la logique du programme se situe dans la fonction de gestion d'évènements de la boîte de dialogue (la DialogProc
) en 0x401430
.
Le prototype des fonctions DialogProc
et le rôle de leurs arguments est documenté par Microsoft:
INT_PTR CALLBACK DialogProc( _In_ HWND hwndDlg, _In_ UINT uMsg, _In_ WPARAM wParam, _In_ LPARAM lParam );
La gestion d'évènements de la DialogProc
peut se diviser en trois grandes parties:
-
l’initialisation (message
WM_INITDIALOG
) et la destruction (messagesWM_DESTROY
/WM_CLOSE
) de la boîte de dialogue, relativement sans suprise, mise à part la création d’un thread spécial lors de l’initialisation, sur lequel nous reviendrons -
la gestion du cochage/décochage des boîtes à cocher (message
WM_COMMAND
avec unwParam
identifiant un destinataire de la commande supérieur à 1000) -
la gestion de la "vérification", étape suivant celle des boîtes à cocher, sur laquelle nous reviendrons (message
WM_COMMAND
avec unwParam
valant 1)
En ce qui concerne la gestion des boîtes à cocher, on repère facilement la fonction en 0x4012f0
responsable de remettre à zéro l'état des boîtes en cas d'échec. Elle est suffisamment petite pour la reproduire in extenso; la voici décompilée par HexRays:
void uncheck_all_buttons_reset_state(HWND hDlg) { HWND v1; // edi@1 v1 = hDlg; CheckDlgButton(hDlg, 1005, 0); CheckDlgButton(v1, 1014, 0); CheckDlgButton(v1, 1015, 0); CheckDlgButton(v1, 1016, 0); CheckDlgButton(v1, 1017, 0); CheckDlgButton(v1, 1018, 0); CheckDlgButton(v1, 1019, 0); mask = -1; _mm_storeu_si128((__m128i *)&pool0, _mm_loadu_si128((const __m128i *)&initial_state)); }
L’appel de cette fonction est toujours accompagné de la remise à zéro de la variable en 0x416624
, qu’on peut nommer checkboxes_state
. C’est cette variable qui mesure notre progression dans le cochage et décochage des boîtes. Elle est progressivement incrémentée de 0 à 10 au fil des commandes, et retombe à 0 en cas d’erreur.
Par exemple, pour la faire passer de 0 à 1, on doit cocher la case dont l’identifiant est 1017 (c’est la 5ème case en partant du haut à gauche):
case 1017u: uncheck_all_buttons_reset_state(hWnd); CheckDlgButton(hWnd, 1017, 1u); mask ^= 0x78E631FAu; checkboxes_state = 1; set_rand_button_text(hWnd); result = 1; break;
Pour la faire passer de 1 à 2, on doit cocher la case identifiée par 1016 (la 4ème):
case 1016u: if ( checkboxes_state == 1 || checkboxes_state == 5 ) { mask ^= 0x664A215Fu; ++checkboxes_state; set_rand_button_text(hWnd); result = 1; } else { checkboxes_state = 0; uncheck_all_buttons_reset_state(hWnd); set_rand_button_text(hWnd); result = 1; } break;
Et ainsi de suite; la séquence complète pour progresser jusqu'à 10 est:
5, 4, 6, 2, 7, 4, 1, 3, 1, 7, 3
Une fois cette séquence effectuée, la DialogProc
appelle la fonction GOLDFIST
en 0x401230
, qui déclenche un changement dans l’interface graphique, et met à 1 la variable en 0x416610
, autorisant ainsi l’entrée d’un mot de passe. Dans l’interface une boîte d’entrée apparaît et le gros bouton en dessous nous propose de "Sauver le monde !". Waouh!

Avant de détailler l'étape de vérification, arrêtons-nous sur les autres variables manipulées lors du cochage des boîtes. Il s’agit de la variable nommée mask
en 0x4140f4
et des 16 octets situés en 0x416614
(dont le 1er dword
est nommé pool0
dans la fonction uncheck_all_buttons_reset_state
listée ci-dessus, les suivants étant pool1
, pool2
, et pool3
).
Ces variables évoluent d’une manière déterministe au fil du cochage, en partant d’une valeur initiale fixe (-1 pour mask
, un bloc de 16 octets fixes pour les poolX
). On connaît donc sans ambiguité leurs valeurs à l’issue de la phase de cochage. C’est important car le masque est pris en compte lors de la phase suivante de vérification. Quant aux poolX
, ils sont tout simplement déchiffrés en un LUM:
LUM{2KREDvn3OPf}
Phase de vérification du mot de passe
Après la phase de cochage, on peut taper un mot de passe dans la boîte d’entrée et clicker sur le gros bouton pour vérifier le mot de passe. Dans la DialogProc
cette dernière action correspond à l’envoi d’un message WM_COMMAND avec un wParam
mis à 1 - l’identifiant du bouton.
Voici le code de vérification:
v7 = GetDlgItem(hWnd, nIDDlgItem); //SetWindowTextA(v7, "VTrification..."); input_box = GetDlgItem(hWnd, 1); EnableWindow(input_box, 0); GetDlgItemTextA(hWnd, 1004, input, 33); v9 = (char *)vm_mem_segments_list_hd->next->next->contents + 0x1020; //
pinput_copy = v9; _mm_storeu_si128((__m128i *)v9, _mm_loadu_si128((const __m128i *)input)); _mm_storeu_si128((__m128i *)v9 + 1, _mm_loadu_si128((const __m128i *)&input[16])); run_vm(); //
v10 = pcompared_blk; //
ppwd = input; v12 = mask; left = 28; _mm_storeu_si128((__m128i *)pcompared_blk, _mm_loadu_si128((const __m128i *)&winning_hash)); _mm_storeu_si128((__m128i *)v10 + 1, _mm_loadu_si128((const __m128i *)&dword_4140D4)); *(_DWORD *)v10 ^= v12; *((_DWORD *)v10 + 1) ^= v12; *((_DWORD *)v10 + 2) ^= v12; *((_DWORD *)v10 + 3) ^= v12; *((_DWORD *)v10 + 4) ^= v12; *((_DWORD *)v10 + 5) ^= v12; *((_DWORD *)v10 + 6) ^= v12; *((_DWORD *)v10 + 7) ^= v12; _mm_storeu_si128((__m128i *)input, _mm_loadu_si128((const __m128i *)pinput_copy)); _mm_storeu_si128((__m128i *)&input[16], _mm_loadu_si128((const __m128i *)pinput_copy + 1)); while ( *(_DWORD *)ppwd == *(_DWORD *)v10 ) //
{ ppwd += 4; v10 = (char *)v10 + 4; v14 = left < 4; left -= 4; if ( v14 ) { MessageBoxA(hWnd, "Tu as rTussi ! Le Grand Protoon est sauvT !", "Le Magicien", 0); btn = GetDlgItem(hWnd, 1); SetWindowTextA(btn, "Quitter"); ++nIDDlgItem; v16 = GetDlgItem(hWnd, 1); EnableWindow(v16, 1); return 1; } }
![]() |
obtention du mot de passe, stocké dans input
|
![]() | copie du mot de passe |
![]() | fonction de transformation |
![]() | mise en place de la sortie attendue |
![]() | comparaison du mot passe transformé avec la sortie attendue |
Dans un premier temps, le mot de passe est récupéré dans la variable input
, un tableau de 33 caractères situé en 0x4165e8
. Ensuite les 32 premiers octets du mot de passe sont copiés dans un bloc du tas. Il s’avère que ce bloc est en réalité la mémoire virtuelle d’une VM, plus exactement le segment de données de cette VM.
Le coeur de la vérification s’effectue dans la fonction run_vm()
en 0x402910
, sur laquelle nous allons revenir longuement, mais il suffit pour l’instant de savoir qu’elle effectue une transformation sur la copie du mot de passe. (On entre brièvement dans la fonction, et au vu du graphe de contrôle et des opérations effectuées on reconnaît une schéma typique d’une VM, avec une boucle géante, un dispatcher opérant sur un opcode d’un octet, et des accès répétés aux mêmes zones mémoire tenant lieu de registres virtuels.)
Enfin, le winning_hash
, un bloc de 32 octets constants est xoré avec le masque calculé pendant la phase de cochage, et le résultat est comparé avec le mot de passe transformé. En cas d'égalité, une boîte à message nous annonce la victoire.
Il nous faudra donc comprendre la fonction run_vm()
, la nature de la transformation effectuée sur le mot de passe, puis inverser cette transformation et appliquer l’inverse au winning_hash
xoré avec le masque.
Cependant, un détail important mérite mention avant d’entrer la description plus précise de la VM: la copie du mot passe n’est pas modifiée uniquement par la fonction run_vm()
. Un thread furtif intervient aussi - celui lancé lors de l’initialisation de la boîte de dialogue. Nous allons voir que ce thread opère sur le mot de passe transformé au moment de la sortie de la VM. Pour résoudre l'épreuve nous devrons aussi inverser la transformation supplémentaire opérée par ce thread furtif.
Thread furtif
Il s’agit du thread lancé par la fonction en 0x4013e0
. Celle-ci commence par résoudre l’adresse de kernel32.dll
en mémoire par une technique de shellcode: elle récupère l’adresse du PEB
(Process Environment Block) en fs:[30h]
, puis celle du PEB_LDR_DATA
, et parcourt la liste des modules chargées (InMemoryOrderModuleList
) jusqu'à trouver le bon hash du nom de module.
La fonction résoud ensuite l’adresse de CreateThread
en parsant les exports de kernel32
, puis l’invoque en lui passant l’adresse du thread caché 0x401370
(adresse calculée dynamiquement pour éviter d’attirer l’attention dessus.)
Voici le résultat de la décompilation de la fonction principale du thread:
void __noreturn hidden_thread() { void **v0; // esi@2 void **v1; // esi@4 while ( 1 ) { if ( v2ip == -1 ) { v0 = &encrypted_ROP_chain; do { *v0 = (void *)(F4B02F4B() ^ (unsigned int)*v0); ++v0; } while ( (signed int)v0 < (signed int)&checksum_ro ); start_ROP(&encrypted_ROP_chain); v1 = &encrypted_ROP_chain; ++v2ip; do { *v1 = (void *)(F4B02F4B() ^ (unsigned int)*v1); ++v1; } while ( (signed int)v1 < (signed int)&checksum_ro ); } Sleep(100u); } }
Il s’agit donc d’une boucle infinie se répétant avec une période de 100ms. Le thread attend que la variable globale située en 0x41663c
(nommée v2ip
ci-dessus) prenne la valeur -1, puis déchiffre une chaîne ROP, la lance, et la rechiffre. La variable globale est également incrémentée pour que la chaîne ROP ne soit exécutée qu’une fois.
La fonction start_ROP
est extrêmement simple; elle se contente de faire pointer la pile vers son premier argument, et de retourner vers le sommet de cette nouvelle pile:
.text:00401360 start_ROP proc near ; CODE XREF: hidden_thread+37p .text:00401360 .text:00401360 arg_0 = dword ptr 8 .text:00401360 .text:00401360 55 push ebp .text:00401361 8B EC mov ebp, esp .text:00401363 8B 65 08 mov esp, [ebp+arg_0] .text:00401366 C3 retn .text:00401366 start_ROP endp
Et voici la chaîne ROP déchiffrée:
.data:00414100 D0 10 40 00 encrypted_ROP_chain dd offset locret_4010D0 .data:00414100 ; DATA XREF: hidden_thread+19o .data:00414100 ; hidden_thread+32o ... .data:00414104 8D 1C 40 00 dd offset ROP_retf .data:00414108 D0 10 40 00 dd offset locret_4010D0 .data:0041410C 33 00 00 00 dd 33h .data:00414110 1F 1D 40 00 dd offset loc_401D1F .data:00414114 00 00 00 00 dd 0 .data:00414118 4C 55 4D 7B 2B+aLumZhvqqjy03q db 'LUM{+zhVQqJy03q}' .data:00414128 1F 1D 40 00 dd offset loc_401D1F .data:0041412C 00 00 00 00 dd 0 .data:00414130 63 db 63h ; c .data:00414131 E2 db 0E2h ; G .data:00414132 FF db 0FFh .data:00414133 E1 db 0E1h ; ß .data:00414134 3D db 3Dh ; = .data:00414135 F8 db 0F8h ; ° .data:00414136 6A db 6Ah ; j .data:00414137 75 db 75h ; u .data:00414138 89 db 89h ; ë .data:00414139 7B db 7Bh ; { .data:0041413A 54 db 54h ; T .data:0041413B 02 db 2 .data:0041413C 10 db 10h .data:0041413D AE db 0AEh ; « .data:0041413E 34 db 34h ; 4 .data:0041413F FA db 0FAh ; · .data:00414140 1A 1D 40 00 dd offset loc_401D1A .data:00414144 00 00 00 00 dd 0 .data:00414148 0B 1D 40 00 dd offset loc_401D0B .data:0041414C 00 00 00 00 dd 0 .data:00414150 CE 34 40 00 dd offset loc_4034CE .data:00414154 00 00 00 00 dd 0 .data:00414158 C0 40 41 00 dd offset pinput_copy .data:0041415C 00 00 00 00 dd 0 .data:00414160 8F 1C 40 00 dd offset loc_401C8F .data:00414164 00 00 00 00 dd 0 .data:00414168 76 1C 40 00 dd offset loc_401C76 .data:0041416C 00 00 00 00 dd 0 .data:00414170 BA 1C 40 00 dd offset loc_401CBA .data:00414174 00 00 00 00 dd 0 .data:00414178 10 1D 40 00 dd offset unk_401D10 .data:0041417C 00 00 00 00 dd 0 .data:00414180 01 1D 40 00 dd offset F4B03F4B+1 .data:00414184 00 00 00 00 dd 0 .data:00414188 59 1A 40 00 dd offset loc_401A59 .data:0041418C 00 00 00 00 dd 0 .data:00414190 76 1C 40 00 dd offset loc_401C76 .data:00414194 00 00 00 00 dd 0 .data:00414198 B5 1C 40 00 dd offset loc_401CB4+1 .data:0041419C 00 00 00 00 dd 0 .data:004141A0 D6 1C 40 00 dd offset loc_401CD5+1 .data:004141A4 00 00 00 00 dd 0 .data:004141A8 06 1D 40 00 dd offset loc_401D05+1 .data:004141AC 00 00 00 00 dd 0 .data:004141B0 01 1D 40 00 dd offset F4B03F4B+1 .data:004141B4 00 00 00 00 dd 0 .data:004141B8 59 1A 40 00 dd offset loc_401A59 .data:004141BC 00 00 00 00 dd 0 .data:004141C0 76 1C 40 00 dd offset loc_401C76 .data:004141C4 00 00 00 00 dd 0 .data:004141C8 D6 1C 40 00 dd offset loc_401CD5+1 .data:004141CC 00 00 00 00 dd 0 .data:004141D0 5F 1A 40 00 dd offset unk_401A5F .data:004141D4 00 00 00 00 dd 0 .data:004141D8 80 1C 40 00 dd offset unk_401C80 .data:004141DC 00 00 00 00 dd 0 .data:004141E0 01 1D 40 00 dd offset F4B03F4B+1 .data:004141E4 00 00 00 00 dd 0 .data:004141E8 59 1A 40 00 dd offset loc_401A59 .data:004141EC 00 00 00 00 dd 0 .data:004141F0 76 1C 40 00 dd offset loc_401C76 .data:004141F4 00 00 00 00 dd 0 .data:004141F8 BA 1C 40 00 dd offset loc_401CBA .data:004141FC 00 00 00 00 dd 0 .data:00414200 A9 1A 40 00 dd offset unk_401AA9 .data:00414204 00 00 00 00 dd 0 .data:00414208 71 1C 40 00 dd offset F4B00F4B+1 .data:0041420C 00 00 00 00 dd 0 .data:00414210 80 1C 40 00 dd offset unk_401C80 .data:00414214 00 00 00 00 dd 0 .data:00414218 10 1D 40 00 dd offset unk_401D10 .data:0041421C 00 00 00 00 dd 0 .data:00414220 01 1D 40 00 dd offset F4B03F4B+1 .data:00414224 00 00 00 00 dd 0 .data:00414228 8D 1C 40 00 dd offset ROP_retf .data:0041422C 00 00 00 00 dd 0 .data:00414230 CD 10 40 00 dd offset loc_4010CD .data:00414234 23 00 00 00 dd 23h
On remarque de prime abord plusieurs éléments intéressants:
-
Un LUM est caché dans la chaîne chiffrée :)
-
Certains gadgets font partie des fonctions
F4BxxF4B
, dont l’autre rôle est de fournir certaines constantes obscurcies par "dépliage de constante" (constant unfolding) -
Une majorité des adresses de gadgets sont suivies d’un dword à 0, ce qui serait curieux pour une chaîne ROP 32-bit
L’explication du dernier point est simple: la chaîne ROP est en réalité composée en majorité de gadgets 64-bit. L’architecture Wow-64 de Windows autorise le passage de 32-bit en 64-bit et vice-versa par le biais d’un simple jmp far
, ou d’un ret far
comme c’est le cas ici. Le gadget en 0x40ac8d
effectue un retf
vers le selecteur de segment 33h, indexant le segment de code 64-bit sous Windows. À la fin de la chaîne, le même gadget est utilisé avec le selecteur de segment 23h pour repasser en 32-bit.
Il nous faut donc décoder les gadgets comme du code 64-bit. En ignorant le LUM, et en supprimant les retours situés à la fin de chaque gadget, on obtient la logique suivante:
mov rdx, 756AF83DE1FFE263 mov rcx, FA34AE1002547B89 not rdx bswap rcx mov rax, pinput_copy mov eax, [rax] mov r8, [rax] xor r8, rcx add r8, rdx mov [rax], r8 add rax, 8 mov r8, [rax] xor r8, rdx bswap r8 sub r8, rcx mov [rax], r8 add rax, 8 mov r8, [rax] bswap r8 rol rcx, 13 ror r8, cl mov [rax], r8 add rax, 8 mov r8, [rax] xor r8, rcx ror rdx, 21 xchg rdx, rcx ror r8, cl add r8, rdx mov [rax], r8
Il s’agit donc faire quatre petites transformations sur chaque qword de la copie du mot passe pointée par pinput_copy
.
Ces transformations sont facilement inversibles, par exemple en passant le code de la chaîne ROP manipulant rcx
et rdx
sous forme SSA (Static Single Assignment) puis en inversant les opérations effectuées sur les qwords. Voici le résultat en C:
void unROP() { uint64_t r8, a, b, c, d, e, f, g, h, i; a = 0x756AF83DE1FFE263; b = 0xFA34AE1002547B89; c = ~a; d = bswap(b); r8 = *(uint64_t *)&input[0]; r8 -= c; r8 ^= d; *(uint64_t *)&output[0] = r8; r8 = *(uint64_t *)&input[8]; r8 += d; r8 = bswap(r8); r8 ^= c; *(uint64_t *)&output[8] = r8; r8 = *(uint64_t *)&input[16]; e = rol(d, 13); r8 = rol(r8, e & 63); r8 = bswap(r8); *(uint64_t *)&output[16] = r8; r8 = *(uint64_t *)&input[24]; f = ror(c, 21); g = e; h = f; i = g; r8 -= i; r8 = rol(r8, h & 63); r8 ^= e; *(uint64_t *)&output[24] = r8; }
Notons également que les adresses de retour de cette chaîne ROP sont fixes, ce qui explique pour les relocations du binaire ont été strippées. En effet, l’ASLR empêcherait la chaîne ROP de fonctionner.
Il faut maintenant nous pencher sur le fonctionnement de la machine virtuelle.
Machine virtuelle
Intéressons-nous à la fonction run_vm
.
Registres et mémoire virtuels
La première sous-fonction invoquée par run_vm
, située en 0x402380
, consiste en l’initialisation des registres virtuels. Elle éclaire aussi les allocations et initialisations de blocs mémoire au début de WinMain
: il s’agit de l’allocation des zones de code, pile, et données de la VM.
En voici la décompilation:
void __cdecl init_vm_regs() { struct S1 *v0; // eax@1 v0 = vm_mem_segments_list_hd; vip = 0; v2ip = 0; _mm_storeu_si128((__m128i *)R, 0i64); _mm_storeu_si128((__m128i *)&R[4], 0i64); if ( vm_mem_segments_list_hd != (struct S1 *)-1 ) { do { if ( v0->segtype == 0x85u ) { vip = (mask ^ (mask + v0->vva)) - mask; } else if ( v0->segtype == 0xFEu ) { R[7] = (mask ^ (mask + v0->vva + 0x68)) - mask; } v0 = v0->next; } while ( v0 != (struct S1 *)-1 ); } }
Chaque zone contigue de mémoire virtuelle-virtuelle (c’est-à-dire la mémoire de la VM; nous prenons comme convention de doubler l’adjectif "virtuel" pour la distinguer de la mémoire virtuelle native) est associée à un identifiant de type de segment:
-
0x85 pour le segment de code
-
0xfe pour le segment de pile
-
0x61 pour le segment de données
La mémoire virtuelle-virtuelle est découpée en trois blocs du tas organisés en une liste chaînée, dont la tête de liste est la variable vm_mem_segments_list_hd
située en 0x416638
. Les noeuds de la liste chaînée pointent vers le code, la pile, et les données. Le tout prend la forme suivante:

Le registre vip
(virtual instruction pointer) de la VM est initialisé pour pointer au début du segment de code.
Il y a huit registres de données 32-bit, situés en 0x416640
; appelons-les R0
à R7
. Les sept premiers sont initialisés à zéro. Le dernier - R7
- tient lieu de pointeur de pile virtuelle, et il est initialisé pour pointer 0x68 octets à l’intérieur du segment de pile.
On remarque l’utilisation du masque (variable mask
, calculée pendant la phase de cochage des boîtes) pour chiffrer les registres virtuels. La valeur réelle X
contenue dans un registre est encodée en ((X + mask) ^ mask) - mask
. On retrouvera à chaque accès ultérieur à un registre le décodage pour obtenir la valeur effective du registre, suivi d’une opération quelconque (addition, soustraction, etc.), suivi du ré-encodage du résultat avant de le stocker de nouveau dans un registre de destination.
Notons en passant que l’encodage et le décodage sont identiques, la fonction d’encodage étant son propre inverse.
Pour simplifier la lecture du code de la VM, dans la suite, on pourra imaginer que le masque vaut 0 et faire abstraction du masque.
Lecture du bytecode
La seconde sous-fonction invoquée par run_vm
, située en 0x402180
, consiste en la récupération d’un octet de bytecode (le code exécuté par la VM) et l’incrémentation du pointeur d’instruction virtuel. Pour ce faire, elle parcourt la liste chaînée des segments de mémoire virtuelle-virtuelle jusqu'à trouver celui englobant l’adresse ciblée.
Cette fonction fetch_byte
(ci-dessous) est invoquée 14 fois par run_vm
, depuis des endroits variés. On peut supposer que, selon l’instruction en cours de décodage, certains opérandes encodés sur un octet seront récupérés par fetch_byte
.
char fetch_byte() { struct S1 *v0; // eax@1 unsigned int v1; // edx@1 struct S1 *v2; // ecx@3 char v4; // cl@9 v0 = vm_mem_segments_list_hd; v1 = (mask ^ (vip + mask)) - mask; if ( vm_mem_segments_list_hd == (struct S1 *)0xFFFFFFFF ) { LABEL_8: vip = (mask ^ ((mask ^ (vip + mask)) + 1)) - mask; return -1; } while ( 1 ) { if ( v0->vva > v1 ) { v0 = v0->next; goto LABEL_7; } v2 = v0->next; if ( v2 == (struct S1 *)-1 || v2->vva > v1 ) break; v0 = v0->next; LABEL_7: if ( v0 == (struct S1 *)-1 ) goto LABEL_8; } v4 = read_le_dword(v0, v1 - v0->vva); vip = (mask ^ ((mask ^ (vip + mask)) + 1)) - mask; return v4; }
Une autre fonction très similaire, fetch_dword
, située en 0x402210
, récupère le dword pointé par vip
et incrémente vip
de 4.
Bytecode de la VM
Suite à la récupération du premier octet de bytecode avec fetch_byte
, les trois bits de poids faible en sont masqués (and 0xf8
), et on entre dans le dispatcher, qui utilise donc les cinq bits de poids fort comme opcode principal. Selon l’opcode principal, certains des bits de poids faible peuvent intervenir dans le choix de l’instruction, et il peut éventuellement être nécessaire de lire un octet ou un dword supplémentaire pour décoder l’instruction complète.
Le format des instructions présente quelques caractéristiques intéressantes:
-
les indices de registres sont codés sur trois bits (ce qui confirme le nombre de registres: huit)
-
certains bits du bytecode sont ignorés (ceux marqués ? dans le tableau ci-dessous) et peuvent être mis à 0 ou 1 sans influencer l’instruction décodée; il est probable qu’il s’agisse d’un moyen d'éviter une trop grande régularité dans le jeu d’intructions, qui permettrait de distinguer visuellement les instructions les plus courantes
-
les sauts, sauts conditionnels, et appels sont encodés en relatif, et les offsets sont composés d’un bit de signe (bit de poids fort) suivi de bits décrivant la valeur absolue de l’offset
Voici un tableau récapitulatif du format des instructions; nous allons revenir sur les détails de son obtention, et sur le sens de certaines macro-instructions complexes:
opcode | bits 0-2 | octet ou dword suivant | signification |
---|---|---|---|
0x00 |
|
- |
v2ip += signed word R0 |
0x18 |
|
|
call relative |
0x28 |
|
- |
R0 = sse([R7], [R7+4]) |
0x30 |
|
|
jeq Rr, Rs, imm |
0x30 |
|
|
jeq Rr, Constant, imm |
0x48 |
|
- |
exit |
0x50 |
|
|
and Rr, imm |
0x58 |
|
- |
ret |
0x68 |
|
|
xor Rr, Rs |
0x68 |
|
|
xor Rr, imm |
0x78 |
|
- |
R0 = checksum of top R0 stack entries |
0x80 |
|
|
sub Rr, Rs |
0x80 |
|
|
sub Rr, imm |
0x90 |
|
- |
read R0 bytes from background image into R1-R[R0] |
0xa0 |
|
|
jmp relative |
0xa8 |
|
|
shr Rr, byte(Rs) |
0xb0 |
|
|
add Rr, Rs |
0xb0 |
|
|
add Rr, imm |
0xc8 |
|
|
push Rr |
0xc8 |
|
|
push imm |
0xe0 |
|
|
shl Rr, imm |
0xe8 |
|
|
mov Rr, imm |
0xe8 |
|
|
mov Rr, Rs |
0xe8 |
|
|
mov [Rr], Rs |
0xe8 |
|
|
mov Rr, [Rs] |
Décompilation sous IDA
Pour obtenir ce tableau, il faut regarder un par un les handlers d’opcodes. Ce travail relativement fastidieux est facilité par l’utilisation du décompilateur Hex Rays d’IDA. Cependant la fonction run_vm
(ainsi que quelques autres) utilise des astuces destinées à ralentir l’analyse en faisant perdre le fil à IDA.
Une première astuce classique est d’utiliser des sauts conditionnels consécutifs utilisant des conditions opposées mais pointant vers la même destination. La paire est équivalente à un saut non-conditionnel mais beaucoup de désassembleurs, dont celui d’IDA, poursuivent en priorité le décodage des instructions de manière linéaire à la suite de la paire de sauts. En plaçant à la suite des sauts une instruction incomplète (par exemple, un seul octet d’opcode) chevauchant la destination commune des sauts, d’une part on empêche le décodage de l’instruction légitime à la destination des sauts, d’autre part on peut faire croire à IDA que l’instruction jamais atteinte suivant les sauts redirige le flot de contrôle vers une instruction non-définie.
Voici un tel exemple:
.text:00402E20 jz short near ptr loc_402E24+1 .text:00402E22 jnz short near ptr loc_402E24+1 .text:00402E24 .text:00402E24 loc_402E24: ; CODE XREF: .text:loc_402E20j .text:00402E24 ; .text:00402E22j .text:00402E24 jnz short near ptr loc_402DB0+1
![]() |
Dans ce cas on peut patcher les cinq octets en 0x402e20 avec des nop 's (opcode 0x90), soit à travers le menu Edit /Patch program , soit avec la primitive PatchByte en IDA-python. |
Après quelques patchs de ce type, on est en mesure de définir une procédure run_vm
en appuyant sur la touche P
en 0x402910
. Néanmoins, si l’on fait F5
pour voir le code décompilé, IDA refuse en affichant: 402CA6: positive sp value has been found
.
Allons voir de quoi il retourne:
.text:00402C84 loc_402C84: ; CODE XREF: sub_402910+37j .text:00402C84 ; DATA XREF: .text:off_403120o .text:00402C84 push 0 ; jumptable 00402947 case 48 .text:00402C86 push 0 ; dwFlagsAndAttributes .text:00402C88 push 3 ; dwCreationDisposition .text:00402C8A push 0 ; lpSecurityAttributes .text:00402C8C push 0 ; dwShareMode .text:00402C8E push 80000000h ; dwDesiredAccess .text:00402C93 push offset aCRaymanRayman_ ; "C:\\Rayman\\Rayman.ini" .text:00402C98 call ds:CreateFileA .text:00402C9E cmp eax, 42h .text:00402CA1 jnz short loc_402CA7 .text:00402CA3 add esp, 42h .text:00402CA6 retn
Il s’agit donc d’une tentative d’ouverture d’un fichier, et si le résultat en est le HANDLE
0x42, esp
est incrémenté et la fonction effectue un retour. En réalité ce dernier code n’est jamais exécuté; en effet, en l’absence du fichier CreateFileA
renvoie INVALID_HANDLE_VALUE
= 0xffffffff, et même si le fichier existe, on devrait normalement recevoir une valeur de HANDLE
multiple de 4.
L’unique but de ce code est de plonger IDA dans la confusion en modifiant esp
inopinément. Dans le menu, Options
/General
, on peut cocher Stack pointer
pour voir la valeur émulée du pointeur de pile calculée par IDA:
.text:00402C84 loc_402C84: ; CODE XREF: sub_402910+37j .text:00402C84 ; DATA XREF: .text:off_403120o .text:00402C84 020 push 0 ; jumptable 00402947 case 48 .text:00402C86 024 push 0 ; dwFlagsAndAttributes .text:00402C88 028 push 3 ; dwCreationDisposition .text:00402C8A 02C push 0 ; lpSecurityAttributes .text:00402C8C 030 push 0 ; dwShareMode .text:00402C8E 034 push 80000000h ; dwDesiredAccess .text:00402C93 038 push offset aCRaymanRayman_ ; "C:\\Rayman\\Rayman.ini" .text:00402C98 03C call ds:CreateFileA .text:00402C9E 020 cmp eax, 42h .text:00402CA1 020 jnz short loc_402CA7 .text:00402CA3 020 add esp, 42h .text:00402CA6 -22 retn
![]() |
Il nous faut corriger l’effet de l’instruction add esp, 42h . Pour ce faire, on se place en 0x402ca3 et on appuie sur Alt-K , puis on rentre 0 dans la boîte de saisie. |
Une fois cette modification apportée, on peut faire F5
et obtenir le code décompilé.
Instructions simples
Voyons donc le code décompilé d’une instruction simple, le xor
entre deux registres (opcode 0x68, sous-opcode 1):
v15 = fetch_byte(); R[((unsigned int)(unsigned __int8)v15 >> 4) & 7] = (mask ^ (mask + (((mask ^ (mask + R[((unsigned int)(unsigned __int8)v15 >> 4) & 7])) - mask) ^ ((mask ^ (mask + R[((unsigned int)(unsigned __int8)v15 >> 1) & 7])) - mask)))) - mask;
Hormis le démasquage des valeurs des registres opérandes, et le remasquage du résultat, c’est un opcode très simple qui utilise trois des bits de v15
comme index de registre source, et trois autres bits comme index de registre destination.
Beaucoup d’autres instructions simples sont à l’avenant. Notons deux exceptions: shl
et and
, dont les handlers sont obscurcis. shl
procède au décalage vers la gauche bit par bit, au lieu de le faire d’un seul coup, et invoque un prédicat opaque (a priori toujours faux) dirigeant vers une branche morte de code. and
procède également bit par bit.
Macro-instructions
Parmi les instructions complexes, on trouve plusieurs opcodes manipulant la donnée en 0x41663c
. Son rôle est obscur, de prime abord, mais on remarque qu’elle est initialisée à 0 en même temps que le pointeur d’instruction virtuel de la VM, et qu’elle contrôle également le déclenchement de la chaîne ROP dans le thread furtif.
L’opcode 0x00 incrémente ou décrémente la donnée mystérieuse en fonction de R0
.
L’opcode 0x90 utilise la donnée mystérieuse pour calculer des coordonnées de départ dans l’image de fond de la boîte de dialogue, puis combine les bits de poids faible de certains pixels de l’image pour formet des octets, stockés dans les registres R1
, R2
, etc. La logique d’adressage de l’image est située dans la fonction en 0x4023f0
, qui invoque GetPixel
en boucle.
L’opcode 0x48, correspondant à l’instruction exit
de sortie de la VM, met la donnée mystérieuse à -1, attends trois secondes, puis sort de la VM. Ces trois secondes sont un délai qui permet à la chaîne ROP du thread furtif de s’exécuter avant que la fonction ne retourne! On sait donc que la transformation du mot passe implantée dans la chaîne ROP intervient après celle de la VM.
L’opcode 0x28 invoque une fonction compliquée employant des instructions SSE, située en 0x402490
. L’opcode passe en arguments à la fonction les deux valeurs au sommet de la pile. Dans un premier temps on peut tenter de traiter cette fonction en boîte noire, plutôt que de l’analyser en détails.
Enfin, l’opcode 0x78 correspond à une macro-instruction qui calcule une somme de contrôle sur les R0
valeurs au sommet de la pile. Il s’agit de la même somme de contrôle utilisée au début de WinMain
pour vérifier l’intégrité du contenu initial de la mémoire de la VM. La fonction est située en 0x401e80
.
Désassemblage
Nous avons suffisamment d’informations pour procéder au désassemblage du bytecode de la VM. Nous récupérons le bytecode depuis le segment de code de la VM. Voici sa forme initiale, un peu aérée pour faciliter la lecture:
0000 e8 mov R0, 5572100 0005 b0 add R0, 1000 000a 18 call 000e 000c a0 jmp 00ef 000e e9 mov R1, R0 0010 ca push 5b1c63ed 0015 c8 push 7ebb8eb3 001a c8 push b8ab7606 001f c8 push 8a06fff9 0024 c8 push f0a21668 0029 c9 push 962e5304 002e c9 push 36644553 0033 ca push 40375a0 0038 e8 mov R0, 8 003d 78 checksum stack 003e ee mov [R1], R0 0040 cb push e9b2165a 0045 cb push e3a7f8b 004a ca push f4c919ff 004f cb push fd067499 0054 c8 push 2868793e 0059 cb push e2a3e528 005e c8 push 7b1e58e8 0063 ca push 1951d388 0068 e8 mov R0, 8 006d 7c checksum stack 006e b1 add R1, 4 0073 ea mov [R1], R0 0075 c8 push 4b0b009c 007a ca push 32b16ace 007f ca push 4b0b565e 0084 c9 push 4b0b57c1 0089 cb push 4b0b57ce 008e cb push 4b0b5730 0093 c8 push a2e81d8a 0098 cb push 4b0b565e 009d c9 push 4b0b5146 00a2 ec mov R0, 9 00a7 7d checksum stack 00a8 b0 add R1, 4 00ad ea mov [R1], R0 00af c8 push 5b01ef00 00b4 cb push f4bb1 00b9 cb push 5c80eb85 00be cb push b602e003 00c3 c9 push 508fa8d5 00c8 c9 push c5fc8137 00cd c9 push 43f919c3 00d2 ca push d8aa82e8 00d7 e8 mov R0, 8 00dc 79 checksum stack 00dd b0 add R1, 4 00e2 ee mov [R1], R0 00e4 b0 add R7, 80 00e9 b5 add R7, 4 00ee 58 ret 00ef e8 mov R0, 3 00f4 97 vm2fetch 00f5 32 jeq R1, 78, 013b 00fa 32 jeq R1, fe, 014b 00ff 34 jeq R1, 33, 0169 0104 36 jeq R1, 12, 019b 0109 30 jeq R1, a1, 01a6 010e 34 jeq R1, af, 01c7 0113 32 jeq R1, 8e, 01e9 0118 32 jeq R1, 13, 020b 011d 32 jeq R1, bb, 022d 0122 30 jeq R1, 7c, 02d9 0127 36 jeq R1, 32, 0277 012c 30 jeq R1, 2f, 02a7 0131 30 jeq R1, dc, 02f8 0136 30 jeq R1, 59, 0252 013b ec mov R4, 5572100 0140 b4 add R4, 1040 0145 b2 add R4, R2 0147 ee mov [R4], R3 0149 a4 jmp 00ef 014b ec mov R4, 5572100 0150 b0 add R4, 1040 0155 b6 add R4, R2 0157 e8 mov R5, 5572100 015c b5 add R5, 1040 0161 b3 add R5, R3 0163 ef mov R6, [R5] 0165 ea mov [R4], R6 0167 a4 jmp 00ef 0169 ec mov R4, 5572100 016e b1 add R4, 1040 0173 b7 add R4, R2 0175 eb mov R4, [R4] 0177 e8 mov R5, 5572100 017c b4 add R5, 1040 0181 b0 add R5, 30 0186 e8 mov R6, 1 018b ee mov [R5], R6 018d 35 jeq R4, R3, 0199 0192 e8 mov R6, 0 0197 ea mov [R5], R6 0199 a4 jmp 00ef 019b ed mov R4, R3 019d e4 shl R4, 8 019f b6 add R4, R2 01a1 e9 mov R0, R4 01a3 07 v2jmp sword(R0) 01a4 a4 jmp 00ef 01a6 ec mov R4, 5572100 01ab b4 add R4, 1040 01b0 b0 add R4, 30 01b5 ef mov R5, [R4] 01b7 32 jeq R5, 0, 01c5 01bc ed mov R4, R3 01be e4 shl R4, 8 01c0 b7 add R4, R2 01c2 e9 mov R0, R4 01c4 00 v2jmp sword(R0) 01c5 a4 jmp 00ef 01c7 e8 mov R4, 5572100 01cc b4 add R4, 1040 01d1 b2 add R4, R3 01d3 eb mov R4, [R4] 01d5 ec mov R5, 5572100 01da b4 add R5, 1040 01df b6 add R5, R2 01e1 ef mov R6, [R5] 01e3 b3 add R6, R4 01e5 ee mov [R5], R6 01e7 a4 jmp 00ef 01e9 e8 mov R4, 5572100 01ee b5 add R4, 1040 01f3 b6 add R4, R3 01f5 ef mov R4, [R4] 01f7 e8 mov R5, 5572100 01fc b1 add R5, 1040 0201 b7 add R5, R2 0203 eb mov R6, [R5] 0205 87 sub R6, R4 0207 ea mov [R5], R6 0209 a5 jmp 00ef 020b e8 mov R4, 5572100 0210 b5 add R4, 1040 0215 b6 add R4, R3 0217 ef mov R4, [R4] 0219 ec mov R5, 5572100 021e b5 add R5, 1040 0223 b7 add R5, R2 0225 eb mov R6, [R5] 0227 6a xor R6, R4 0229 ee mov [R5], R6 022b a5 jmp 00ef 022d ec mov R4, 5572100 0232 b0 add R4, 1044 0237 eb mov R2, [R4] 0239 e2 shl R2, 2 023b 85 sub R4, 24 0240 b7 add R4, R2 0242 eb mov R2, [R4] 0244 ec mov R4, 5572100 0249 b5 add R4, 1040 024e ea mov [R4], R2 0250 a5 jmp 00ef 0252 e8 mov R4, 5572100 0257 b5 add R4, 1044 025c eb mov R2, [R4] 025e e2 shl R2, 2 0260 81 sub R4, 24 0265 b6 add R2, R4 0267 ec mov R4, 5572100 026c b1 add R4, 1048 0271 ef mov R3, [R4] 0273 ea mov [R2], R3 0275 a5 jmp 00ef 0277 ec mov R4, 5572100 027c b1 add R4, 1044 0281 eb mov R2, [R4] 0283 e8 mov R5, 5572100 0288 b0 add R5, 1048 028d eb mov R3, [R5] 028f cd push R2 0291 cf push R3 0293 2e sse 0294 b0 add R7, 8 0299 ec mov R5, 5572100 029e b1 add R5, 1040 02a3 ee mov [R5], R0 02a5 a5 jmp 00ef 02a7 ec mov R2, 5572100 02ac b0 add R2, 1044 02b1 eb mov R0, [R2] 02b3 ec mov R2, 5572100 02b8 b4 add R2, 1048 02bd ef mov R1, [R2] 02bf ec mov R2, 5572100 02c4 b4 add R2, 1000 02c9 19 call 03ed 02cb ec mov R5, 5572100 02d0 b5 add R5, 1040 02d5 ea mov [R5], R0 02d7 a5 jmp 00ef 02d9 e8 mov R0, 5572100 02de b1 add R0, 1000 02e3 19 call 03f8 02e5 b1 add R0, 2 02ea e8 mov R5, 5572100 02ef b0 add R5, 1040 02f4 ee mov [R5], R0 02f6 a6 jmp 00ef 02f8 4c exitvm (trigger ROP chain) 02f9 f2 f2 02fa e6 e6 02fb 66 66 ... (zone non-référencée) 03ea fc fc 03eb c5 c5 03ec d5 d5 03ed 6e xor R0, R1 03ef 50 and R0, 7 03f1 e0 shl R0, 1 03f3 b6 add R0, R2 03f5 eb mov R0, [R0] 03f7 5c ret 03f8 b1 add R0, 10 03fd ef mov R0, [R0] 03ff 5b ret
La structure globale du bytecode est donc la suivante: tout d’abord un appel à la fonction en 0xe, qui effectue des sommes de contrôle sur des mots de la pile et utilise le résultat pour initialiser 16 octets de données à l’adresse virtuelle-virtuelle 0x5573100
. Puis une boucle en 0xef, qui invoque le mystérieux opcode 0x90 pour récupérer trois octets depuis l’image de fond, et dispatche ensuite sur 14 blocs possibles en fonction du premier octet.
Cette grande boucle fait penser à un deuxième niveau de VM, et en s’y penchant de plus près cette hypothèse est confirmée. On comprend du coup que la donnée mystérieuse en 0x41663c
n’est autre que le pointeur d’instruction de cette deuxième machine virtuelle (appelons-le v2ip
), que l’opcode 0x00 est un saut de la deuxième VM, et l’opcode 0x90 correspond à un fetch d’instruction de la deuxième VM.
Notons l’existence d’un "trou" dans le bytecode entre 0x2f9 et 0x3ed. À la fin des 0x400 octets de code, on trouve deux petites fonctions intervenant dans la sémantique de deux opcodes de la deuxième VM. Nous allons voir que leur placement en fin du code n’est pas innocent…
Avant de détailler les instructions de cette deuxième machine virtuelle, arrêtons-nous sur la fonction en 0xe qui fait des sommes de contrôle, en effet son exécution a un incidence sur le reste du bytecode.
Débordement de pile et auto-modification du bytecode
La fonction bytecode en 0xe prend en argument une zone de destination dans le registre R0
. Cette zone est située à l’offset 0x1000 du début du segment de données, dont la base est 0x5572100 (voir schéma de le mémoire virtuelle-virtuelle ci-desus).
Les 16 octets écrits dans la zone de destination proviennent de quatre appels à la fonction de somme de contrôle. À chaque appel, R0
spécifie le nombre de dwords de la pile surlesquels la somme opère. Il s’agit donc de 8, 8, 9, puis 8 dwords, ce qui correspond bien aux nombres de push
précédant l’opcode checksum stack
.
À la toute fin, avant de retourner, la fonction corrige le pointeur de pile virtuelle R7
en y ajoutant 0x84 octets, ce qui correspond bien à (8 + 8 + 9 + 8) * 4
.
Rappelons-nous que R7
est initialisé pour pointer 0x68 octets à l’intérieur du segment de pile. Or la fonction pousse sur la pile 0x84 octets, en plus de sa propre adresse de retour poussée lors de son appel. Il se produit donc un débordement de pile. Le segment de code est adjacent au segment de pile en mémoire virtuelle-virtuelle, et le précède. Par conséquent les données poussées en dernier vont écraser la fin du segment de code!

Il nous faut donc récupérer le bon bytecode après l’exécution de la fonction en 0xe, par exemple à mettant un breakpoint sur l’opcode ret
et en attendant qu’il soit déclenché. On récupère ensuite le bytecode depuis le segment de code de la VM, et on constate la modification suivante:
03ed a8 shr R0, byte(R1) 03ef 50 and R0, 3 03f1 e0 shl R0, 2 03f3 b6 add R0, R2 03f5 eb mov R0, [R0] 03f7 5c ret 03f8 b1 add R0, f4b 03fd ef mov R0, [R0] 03ff 5b ret
Le coupable a signé son crime ignoble.
Deuxième machine virtuelle
Attachons-nous désormais à l’analyse de la deuxième VM. On remarque tout d’abord que les instructions sont des triplets d’octets, puisque l’opcode vm2fetch
est invoqué avec R0
valant toujours trois. R1
semble être l’opcode, et R2
et R3
les opérandes éventuels.
L’adresse virtuelle-virtuelle 0x5572100 revient sans arrêt - c’est la base du segment de données - et les accès mémoire sont localisés autour de l’offset 0x1000 du segment de données. Plus précisément, une zone à l’offset 0x1040 est accédée en lecture et en écriture par tous les opcodes, il s’agit des registres virtuels, qui font 32 bits. Appelons-les X0
, X1
, etc. pour les distinguer des registres de la première VM.
La série de 14 sauts jeq
nous fournit les valeurs des opcodes. Notons que l’opcode 0x78 est l’opcode par défaut: tout valeur d’opcode autre que celles explicitement comparées dans les jeq
aboutit au même handler en 0x13b, il s’agit donc d’un alias de l’opcode 0x78.
Cet opcode 0x78 est très simple, il place la valeur de R3
en R2 + 0x1040
. Il s’agit de l’initialization d’un registre Xi
avec une valeur immédiate.
On procède de même à l’analyse de tous les handlers. L’un d’eux invoque la macro-instruction sse
de la première VM. Pour le reste, il s’agit essentiellement d’opérations arithmétiques ou logiques simples, ou d’accès mémoire. Le saut conditionnel jtrue
utilise un flag booléen situé à l’offset 0x1070.
Après l'étude des handlers, on obtient le jeu d’instructions suivant:
opcode | signification |
---|---|
0x12 |
jmp |
0x13 |
Xi ^= Xj |
0x2f |
X0 = [1000 + 4 * ((X1 >> X2) & 3)] |
0x32 |
X0 = sse(X2, X1) |
0x33 |
set boolean flag to Xi == imm |
0x59 |
[1020 + 4 * X1] = X2 |
0x78 (et défaut) |
Xi = imm |
0x7c |
X0 = 2 + [1f4b] |
0x8e |
Xi -= Xj (non utilisé) |
0xa1 |
jtrue |
0xaf |
Xi += Xj |
0xbb |
X0 = [1020 + 4 * X1] |
0xdc |
exit |
0xfe |
Xi = Xj |
Les instructions référençant la mémoire le font à travers des offsets relatifs au début du segment de données.
Notons le curieux offset 0x1f4b (provenant du code auto-modifiant), à première vue situé en dehors de la zone initialisée du segment de données, qui ne fait que 0x1100 octets de long. En réalité, les segments mémoire sont alloués avec une granularité de 4k et initialisés immédiatement avec un padding dans la fonction en 0x401d70
. C’est de ce padding qu’est extraite la valeur [1f4b]
de l’opcode 0x7c.
On est en mesure d'écrire un petit désassembleur.
Bytecode de la 2ème VM
Nous devons récupérer le bytecode brut de la deuxième VM. Pour cela, plusieurs options sont possibles:
-
on peut répliquer la fonctionnalité de la fonction
v2fetch_byte
en0x4023f0
, extraire l’image de fond des ressources du binaire, et procéder au décodage du bytecode depuis l’image extraite -
on peut mettre un breakpoint conditionnel sur l’opcode
vm2fetch
(0x90) de la première VM et enregistrer dans un dictionnaire le couple duv2ip
courant et l’octet reçu en retour de la fonction -
on peut utiliser la fonctionnalité Appcall d’IDA pour invoquer la fonction
v2fetch_byte
, après avoir correctement défini son prototype C
Nous utilisons cette dernière technique. On fait Y
au sommet de la fonction et on entre le prototype C suivant:
unsigned __int8 __cdecl v2fetch_byte()
Puis on met v2ip
à zéro en patchant la mémoire, et on tape dans la ligne de commande IDA-python:
Python>v2 = [ Appcall.v2fetch_byte() for i in range(0x400) ] Python>v2 [120L, 28L, 0L, 51L, 28L, 4L, 161L, 156L, 0L, 254L, 4L, 28L, 175L, 4L, 4L, 187L, 86L, 71L, 254L, 12L, 0L, 120L, 32L, 1L, 175L, 4L, 32L, 187L, 237L, 17L, 254L, 16L, 0L, 120L, 20L, 0L, 120L, 24L, 0L, 51L, 24L, 64L, 161L, 87L, 0L, 254L, 4L, 16L, 133L, 8L, 4L, 50L, 0L, 229L, 254L, 32L, 0L, 175L, 32L, 16L, 254L, 4L, 20L, 91L, 8L, 0L, 47L, 150L, 72L, 254L, 36L, 0L, 175L, 36L, 20L, 19L, 32L, 36L, 175L, 12L, 32L, 124L, 136L, 75L, 175L, 20L, 0L, 254L, 4L, 12L, 71L, 8L, 4L, 50L, 190L, 102L, 254L, 32L, 0L, 175L, 32L, 12L, 254L, 4L, 20L, 120L, 8L, 11L, 47L, 174L, 88L, 254L, 36L, 0L, 175L, 36L, 20L, 19L, 32L, 36L, 175L, 16L, 32L, 120L, 32L, 1L, 175L, 24L, 32L, 18L, 93L, 128L, 254L, 8L, 12L, 254L, 4L, 28L, 175L, 4L, 4L, 89L, 214L, 211L, 254L, 8L, 16L, 120L, 32L, 1L, 175L, 4L, 32L, 89L, 208L, 214L, 120L, 32L, 1L, 175L, 28L, 32L, 18L, 162L, 128L, 220L, 132L, 214L, 76L, 85L, 77L, 123L, 67L, 49L, 85L, 65L, 105L, 100L, 118L, 95L, 112L, 122L, 74L, 125L, 77L, 246L, 176L, 8L, 142L, 138L, 175L, 213L, 113L, 245L, 53L, 120L, 153L, 62L, 250L, 226L, 222L, 119L, 154L, 204L, 181L, 137L, 129L, 78L, 121L, 185L, 148L, 138L, 138L, 103L, 90L, 52L, 209L, 125L, 208L, 33L, 125L, 165L, 189L, 156L, 53L, 55L, 118L, 253L, 207L, 99L, 194L, 181L, 85L, 47L, 15L, 244L, 156L, 22L, 244L, 62L, 224L, 44L, 65L, 189L, 173L, 111L, 111L, 135L, 87L, 186L, 121L, 70L, 44L, 153L, 197L, 194L, 83L, 100L, 131L, 88L, 232L, 38L, 250L, 140L, 162L, 64L, 106L, 186L, 242L, 197L, 41L, 100L, 198L, 134L, 126L, 199L, 33L, 142L, 117L, 227L, 224L, 254L, 32L, 197L, 56L, 144L, 66L, 71L, 82L, 110L, 40L, 19L, 147L, 80L, 3L, 224L, 150L, 67L, 125L, 231L, 154L, 219L, 76L, 195L, 236L, 23L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, 255L, ...]
On sauve le résultat dans un fichier inception.bin
. Par habitude, on fait un strings
dessus et on tombe sur un LUM. Chouette!
LUM{C1UAidv_pzJ}
Puis on désassemble le bytecode:
0000 78 X7 <- 00
outer_loop:
0003 33 X7 == 04
0006 a1 jtrue 00a5
0009 fe X1 <- X7
000c af X1 += X1
000f bb X0 <- [1020 + 4 * X1]
0012 fe X3 <- X0
0015 78 X8 <- 01
0018 af X1 += X8
001b bb X0 <- [1020 + 4 * X1]
001e fe X4 <- X0
0021 78 X5 <- 00
0024 78 X6 <- 00
inner_loop:
0027 33 X6 == 40
002a a1 jtrue 0084
002d fe X1 <- X4
0030 85 X2 <- 04
0033 32 X0 <- sse(X2, X1)
0036 fe X8 <- X0
0039 af X8 += X4
003c fe X1 <- X5
003f 5b X2 <- 00
0042 2f X0 <- [1000 + 4 * ((X1 >> X2) & 3)]
0045 fe X9 <- X0
0048 af X9 += X5
004b 13 X8 ^= X9
004e af X3 += X8
0051 7c X0 <- 2 + [1f4b]
0054 af X5 += X0
0057 fe X1 <- X3
005a 47 X2 <- 04
005d 32 X0 <- sse(X2, X1)
0060 fe X8 <- X0
0063 af X8 += X3
0066 fe X1 <- X5
0069 78 X2 <- 0b
006c 2f X0 <- [1000 + 4 * ((X1 >> X2) & 3)]
006f fe X9 <- X0
0072 af X9 += X5
0075 13 X8 ^= X9
0078 af X4 += X8
007b 78 X8 <- 01
007e af X6 += X8
0081 12 jmp 0027 ; inner_loop
0084 fe X2 <- X3
0087 fe X1 <- X7
008a af X1 += X1
008d 59 [1020 + 4 * X1] <- X2
0090 fe X2 <- X4
0093 78 X8 <- 01
0096 af X1 += X8
0099 59 [1020 + 4 * X1] <- X2
009c 78 X8 <- 01
009f af X7 += X8
00a2 12 jmp 0003 ; outer_loop
00a5 dc exitvm (trigger ROP chain)
Tout ça commence à prendre corps. Nous sommes face à un algorithme de chiffrement par bloc opérant sur les 32 octets à l’offset 0x1020 du segment de données, et dont la clé de 16 octets est située à l’offset 0x1000.
Au début du code de vérification, dans la DialogProc
, une copie du mot passe est faire précisément à l’offset 0x1020 du troisième segment mémoire de la VM. C’est donc bien une copie du mot de passe que la deuxième VM chiffre.
Le mot de passe est découpé en 4 blocs de 8 octets chacun, qui sont chiffrés de la même manière en utilisant la même clé. Chaque bloc de 8 octets est découpé en deux dwords qui subissent des transformations (mettant en jeu la fonction sse
) pendant 64 tours.
Si on exprime la logique d’un seul tour sous forme d’expressions, on obtient:
X3 += (sse(4, X4) + X4) ^ ([1000 + 4 * (X5 & 3)] + X5) X5 += 2 + [1f4b] X4 += (sse(4, X3) + X3) ^ ([1000 + 4 * ((X5 >> 11) & 3)] + X5)
X3
évolue donc en fonction de X4
, puis X4
évolue en fonction de X3
. Il s’agit d’un schéma de chiffrement classique: un réseau de Feistel. C’est un schéma facile à inverser; nous allons pouvoir déchiffrer la sortie attendue pour retrouver le bon mot de passe.
Les tailles de bloc (64-bit), de clé (128-bit), et la structure très simple d’un tour évoque l’algorithme TEA (Tiny Encryption Algorithm de Roger Needham et David Wheeler). On compare avec TEA mais ça ne colle pas. En revanche la variante XTEA semble coller parfaitement.
Voici le code de référence de XTEA sur Wikipédia:
void encipher(unsigned int num_rounds, uint32_t v[2], uint32_t const key[4]) { unsigned int i; uint32_t v0=v[0], v1=v[1], sum=0, delta=0x9E3779B9; for (i=0; i < num_rounds; i++) { v0 += (((v1 << 4) ^ (v1 >> 5)) + v1) ^ (sum + key[sum & 3]); sum += delta; v1 += (((v0 << 4) ^ (v0 >> 5)) + v0) ^ (sum + key[(sum>>11) & 3]); } v[0]=v0; v[1]=v1; }
On retrouve le décalage de 11 et les bonnes opérations arithmétiques et logiques. Le delta
est modifié par l’astuce du débordement de pile, et le nombre de tours est doublé, mais l’algorithme semble vraiment très proche. Reste à voir si la fonction sse
correspond. Si un doute subsiste on peut toujours ripper la fonction sse
et l’utiliser depuis notre code de déchiffrement.
Dans un premier temps, j’ai écrit un petit shellcode de déchiffrement, injectable en 0x401000
une fois le début du programme exécuté. Il appelle la fonction sse
dans le contexte du programme lui-même, pour aller vite et limiter les risques d’erreur, et il opère directement sur la clé stockée dans la mémoire de la VM. Le voici:
bits 32 mov ebx, 416638h ; vm memory segments list head mov ebx, [ebx] ; code node mov ebx, [ebx+10h] ; stack node mov ebx, [ebx+10h] ; data node mov ebx, [ebx+0ch] ; data page add ebx, 1000h ; key / input / vvregs sub esp, 64 mov ebp, esp mov dword [ebp], 402490h ; sse function call over wanted: ; unROP(mask ^ winning hash): db 073h, 07dh, 02fh, 01bh, 0b1h, 037h, 034h, 0c3h, db 020h, 084h, 03ch, 036h, 087h, 04ah, 024h, 051h, db 0b2h, 047h, 03dh, 032h, 0c6h, 0b3h, 062h, 071h, db 0ebh, 071h, 035h, 01eh, 049h, 03eh, 071h, 0fch, over: pop esi lea edi, [ebp+32] ; for the result mov ecx, 4 next_input: push ecx mov eax, [esi] ; x3 mov [ebp+4], eax mov eax, [esi + 4] ; x4 mov [ebp+8], eax mov dword [ebp+12], 0b504ecc0h ; x5 mov ecx, 64 next_round: push ecx ; reverse this: ; ; 004e af X3 += (sse(4, X4) + X4) ^ ([1000 + 4 * (X5 & 3)] + X5) ; 0054 af X5 += 2 + [1f4b] ; 0078 af X4 += (sse(4, X3) + X3) ^ ([1000 + 4 * ((X5 >> 11) & 3)] + X5) push ecx push edx mov cl, 4 mov edx, [ebp+4] ; x3 call [ebp] pop edx pop ecx add eax, [ebp+4] ; sse(...) + x3 mov ecx, [ebp+12] ; x5 shr ecx, 11 and ecx, 3 mov ecx, [ebx + 4 * ecx] add ecx, [ebp+12] xor eax, ecx ; delta sub [ebp+8], eax ; x4 -= delta ;sub dword [ebp+12], 9e3779bbh ; x5 -= 2 + key dword at 1010 sub dword [ebp+12], 0d2d413b3h ; x5 -= 2 + key dword at 1f4b push ecx push edx mov cl, 4 mov edx, [ebp+8] ; x4 call [ebp] ; sse() pop edx pop ecx add eax, [ebp+8] mov ecx, [ebp+12] ; x5 and ecx, 3 mov ecx, [ebx + 4 * ecx] add ecx, [ebp+12] xor eax, ecx ; delta sub [ebp+4], eax ; x3 -= delta pop ecx loop next_round ; x5 should be 0 here mov eax, [ebp+4] ; x3 mov [edi], eax mov eax, [ebp+8] ; x4 mov [edi+4], eax add esi, 8 ; next chunk of input add edi, 8 ; next chunk of output pop ecx ;loop next_input ; too far dec ecx jecxz here jmp next_input here: jmp here db 'END'
On injecte le shellcode, on place eip
en 0x401000
en faisant Ctrl-N
, on lance le déchiffrement, on espère voir apparaître une chaîne lisible en sortie du déchiffrement et… ça ne marche pas.
Sans doute un bug, on recode la crypto en C, on rip la fonction sse
, ça ne marche pas. On utilise le code standard XTEA, ça ne marche pas. On va voir s’il n’y a pas un anti-debug dans la fonction sse
mais elle semble juste implanter les décalages attendus dans du code obfusqué.
On fait des vecteurs de test pour l’inversion de la chaîne ROP, l’inversion du XTEA, tout est normal…
Désespoir.
Deuxième chaîne ROP
Après moult théories fumeuses (race condition, rayon cosmique, hallucinations), il s’avère que le problème est un anti-debug caché, qui provoque un changement de la clé de chiffrement lorsque la VM tourne sous un débuggeur.
À force faire du pas-à-pas, on finit par tomber sur un comportement très intéressant de la fonction de calcul des sommes de contrôle, située en 0x401e80
.
Voici le résultat de la décompilation de cette fonction par HexRays:
unsigned int __cdecl calc_checksum(void *src, SIZE_T dwBytes) { HANDLE v2; // eax@3 HANDLE v3; // eax@8 SIZE_T i; // [sp+0h] [bp-34h]@4 unsigned __int8 *lpMem; // [sp+8h] [bp-2Ch]@2 int v7; // [sp+Ch] [bp-28h]@1 unsigned int csum; // [sp+Ch] [bp-28h]@4 unsigned __int8 localbuf[36]; // [sp+10h] [bp-24h]@2 v7 = mask; if ( dwBytes > 0x24 ) { v2 = GetProcessHeap(); lpMem = (unsigned __int8 *)HeapAlloc(v2, 0, dwBytes); memmove(lpMem, src, dwBytes); } else { memmove(localbuf, src, dwBytes); lpMem = localbuf; } csum = v7 ^ F4B03F4B(); for ( i = 0; i < dwBytes; ++i ) { lpMem[i] ^= F4B00F4B(); csum = 32 * (csum ^ lpMem[i]) | ((csum ^ lpMem[i]) >> 27); } if ( lpMem != localbuf ) { v3 = GetProcessHeap(); HeapFree(v3, 0, lpMem); } return csum; }
La fonction accepte deux arguments: un buffer src
et sa taille dwBytes
. Selon la taille, elle copie le buffer source vers une variable locale ou vers un bloc alloué sur le tas. Puis elle calcule une somme de contrôle sur la copie, et renvoie le résultat.
Remarquons qu’elle modifie aussi la copie du buffer, pointée par lpMem
, en même temps qu’elle calcule la somme. (Chaque octet est xoré avec 0x4b). C’est un peu curieux, car on pourrait très bien implanter un algorithme de somme de contrôle ne modifiant pas son buffer d’entrée, un CRC32 par exemple. Mais pourquoi pas.
À première vue le code C décompilé semble correct mais il comporte un bug, un dépassement de tampon sur la pile, qui n’apparaît clairement qu’en regardant le code assembleur, dont voici le début et la fin:
.text:00401E80 ; unsigned int __cdecl calc_checksum(void *src, SIZE_T dwBytes) .text:00401E80 calc_checksum proc near ; CODE XREF: WinMain+15p .text:00401E80 ; run_vm+6C4p .text:00401E80 .text:00401E80 var_34 = dword ptr -34h .text:00401E80 var_2D = byte ptr -2Dh .text:00401E80 lpMem = dword ptr -2Ch .text:00401E80 var_28 = dword ptr -28h .text:00401E80 localbuf = byte ptr -24h .text:00401E80 src = dword ptr 8 .text:00401E80 dwBytes = dword ptr 0Ch .text:00401E80 .text:00401E80 55 push ebp .text:00401E81 8B EC mov ebp, esp .text:00401E83 83 EC 34 sub esp, 34h .text:00401E86 A1 00 30 41 00 mov eax, ___security_cookie .text:00401E8B 33 C5 xor eax, ebp .text:00401E8D 89 45 FC mov dword ptr [ebp+localbuf+20h], eax .text:00401E90 A1 F4 40 41 00 mov eax, mask .text:00401E95 89 45 D8 mov [ebp+var_28], eax .text:00401E98 83 7D 0C 24 cmp [ebp+dwBytes], 24h .text:00401E9C 77 1C ja short loc_401EBA .text:00401E9E 8B 4D 0C mov ecx, [ebp+dwBytes] .text:00401EA1 51 push ecx ; size_t .text:00401EA2 8B 55 08 mov edx, [ebp+src] .text:00401EA5 52 push edx ; void * .text:00401EA6 8D 45 DC lea eax, [ebp+localbuf] .text:00401EA9 50 push eax ; void * .text:00401EAA E8 91 4A 00 00 call _memmove .text:00401EAF 83 C4 0C add esp, 0Ch [...] .text:00401F6E loc_401F6E: ; CODE XREF: calc_checksum+D9j .text:00401F6E 8B 45 D8 mov eax, [ebp+var_28] .text:00401F71 8B 4D FC mov ecx, dword ptr [ebp+localbuf+20h] .text:00401F74 33 CD xor ecx, ebp .text:00401F76 E8 E9 12 00 00 call @__security_check_cookie@4 ; __security_check_cookie(x) .text:00401F7B 8B E5 mov esp, ebp .text:00401F7D 5D pop ebp .text:00401F7E C3 retn .text:00401F7E calc_checksum endp
Le code assembleur montre la mise en place du security cookie (biscuit de pile, en bon français) dans le prologue de la fonction, et sa vérification à l'épilogue de la fonction. En particulier, l’instruction du prologue en 0x4018ed
montre que le cookie est stocké en localbuf+20h
(c’est-à-dire en ebp-4
, ce qui est sa place normale). localbuf
ne fait donc que 0x20 octets de long.
Au début du bytecode, la troisième occurence de l’opcode checksum
invoque la fonction avec 9 dwords de la pile, c’est-à-dire 0x24 octets, dépassant ainsi la taille du buffer localbuf. Or la comparaison portant sur dwBytes
au début de la fonction n’alloue un bloc sur le tas que si la taille du buffer source est strictement supérieure à 0x24 octets. Les 9 dwords de la pile sont donc copiés dans localbuf
et le dernier dword écrase le cookie.
Dès lors, la fonction du run-time __security_check_cookie
devrait lever une exception et interrompre le programme. Ce n’est pas le cas, car elle a été modifiée! Voici le code de __security_check_cookie
:
.text:00403264 @__security_check_cookie@4 proc near ; CODE XREF: set_rand_button_text+65p .text:00403264 ; check_prereqs+1F0p ... .text:00403264 3B 0D 00 30 41+ cmp ecx, ___security_cookie .text:0040326A 75 02 jnz short $failure$26820 .text:0040326C F3 C3 rep retn .text:0040326E ; --------------------------------------------------------------------------- .text:0040326E .text:0040326E $failure$26820: ; CODE XREF: __security_check_cookie(x)+6j .text:0040326E E9 40 02 00 00 jmp sub_4034B3 .text:0040326E @__security_check_cookie@4 endp
La fonction sub_4034b3
devrait correspondre à la fonction du run-time nommée report_failure
qui met fin au programme avec un code de retour STATUS_STACK_BUFFER_OVERRUN
(0xC00000409). Mais sub_4034b3
est un peu différente de la fonction report_failure
normale; elle commence ainsi:
.text:004034B3 55 push ebp .text:004034B4 8B EC mov ebp, esp .text:004034B6 81 EC 24 03 00+sub esp, 324h .text:004034BC 6A 47 push 71 ; ProcessorFeature .text:004034BE E8 07 9D 00 00 call IsProcessorFeaturePresent .text:004034C3 85 C0 test eax, eax .text:004034C5 75 0A jnz short normal_report_failure .text:004034C7 89 EC mov esp, ebp .text:004034C9 5D pop ebp .text:004034CA 59 pop ecx .text:004034CB 83 C4 0C add esp, 0Ch .text:004034CE .text:004034CE loc_4034CE: ; DATA XREF: .data:00414150o .text:004034CE 58 pop eax .text:004034CF C3 retn
Cette fausse version de report_failure
invoque l’API Windows IsProcessorFeaturePresent
avec un paramètre (71) ne correspondant à aucune feature de processeur existante. L’API retourne donc faux (0) et le code en 0x4034c7
est exécuté.
Les instructions mov esp, ebp / pop ebp
libère le cadre de pile de la fausse report_failure
.
L’instruction pop ecx
récupère dans ecx
l’adresse de retour de __security_check_cookie
, située dans calc_checksum
.
À ce stade esp
pointe au début des variables locales de calc_checksum
, en ebp-34h
.
Les instructions add esp, 0Ch / pop eax
font pointer esp
en ebp-24h
, c’est-à-dire au début de localbuf
.
On remarque alors que localbuf
a été déchiffré en une nouvelle chaîne ROP lors du calcul de la somme de contrôle! La voici:
Stack[000008B8]:0014F9D4 0D 1A 40 00 dd offset loc_401A0D Stack[000008B8]:0014F9D8 15 1D 40 00 dd offset loc_401D15 Stack[000008B8]:0014F9DC C1 56 A3 E9 dd 0E9A356C1h Stack[000008B8]:0014F9E0 7B 1C 40 00 dd offset loc_401C7B Stack[000008B8]:0014F9E4 85 1C 40 00 dd offset loc_401C85 Stack[000008B8]:0014F9E8 8A 1C 40 00 dd offset loc_401C8A Stack[000008B8]:0014F9EC 15 1D 40 00 dd offset loc_401D15 Stack[000008B8]:0014F9F0 85 21 FA 79 dd 79FA2185h Stack[000008B8]:0014F9F4 D7 4B 40 00 dd offset loc_404BD7
Le ret
en 0x4034cf
lance cette petite chaîne ROP. Les gadgets sont:
.text:00401A0D 64 8B 1D 30 00+mov ebx, large fs:30h .text:00401A14 8B 1B mov ebx, [ebx] .text:00401A16 F7 D3 not ebx .text:00401A18 C3 retn .text:00401D15 5E pop esi .text:00401D16 01 F0 add eax, esi .text:00401D18 C3 retn .text:00401C7B 31 D8 xor eax, ebx .text:00401C7D C3 retn .text:00401C85 C1 C8 2A ror eax, 2Ah .text:00401C88 C3 retn .text:00401C8A 29 D8 sub eax, ebx .text:00401C8C C3 retn .text:00401D15 5E pop esi .text:00401D16 01 F0 add eax, esi .text:00401D18 C3 retn .text:00404BD7 51 push ecx .text:00404BD8 C3 retn
Le premier gadget prend l’adresse du PEB
à l’offet 0x30 du TEB
, puis lit le premier dword du PEB
dans ebx
. Le troisième octet du PEB
est un champ nommé BeingDebugged
qui vaut 1 si le processus est en train d'être débuggé, 0 sinon. (Les octets 0, 1, et 3 sont réservés par Microsoft et semblent valoir 0.)
S’en suivent quelques opérations arithmétiques et logiques. Le gadget en 0x401d15
place dans eax
la valeur qui sera retournée par calc_checksum
et utilisée comme le 3ème dword de la clé XTEA. Cette valeur vaut 0xa5b2cd1c en l’absence de débuggeur, ou 0xa5b3cd5c si un débuggeur est présent.
Le dernier gadget retourne vers calc_checksum
qui poursuit son exécution comme si de rien n'était.
Pour résumer, on pourrait dire que le code natif fournit au bytecode un test de présence du débuggeur par le biais d’un canal caché.
Déchiffrement avec la bonne clé
La bonne clé est donc finalement:
uint32_t key[4] = { 0x9AACC69B, // checksum of first 8 dwords pushed on VM 1 stack 0x89A31D8A, // checksum of next 8 dwords 0xa5b2cd1c, // not 0xA5B3CD5C, // ROP chain produced!! 0xB0A5CE54, // checksum of last 8 dwords };
On relance le déchiffrement:
~/sstic2017/challenges/unstable_machines falkn$ ./crypto 3366363931663364366562363062333433633933316332326530626161393266
Hmmm, ça ressemble à de l’ASCII :)
>>> '3366363931663364366562363062333433633933316332326530626161393266'.decode('hex') '3f691f3d6eb60b343c931c22e0baa92f'
Et l’outil add_key
nous confirme que la clé est bonne. Joie et allégresse!
LabyQRinth
L’ajout de la dernière clé débloque un fichier final.txt
, dont voici le contenu:
Une dernière petite étape!
cefec06 b a e 0 cb519c4
6 9 434 a4d 12 660e 4 4
4 d94 f 9 bf f 02 b a f 287 4
f 01d 1 2 65 0 0 65 7 183 6
a fd6 b 7 e7 5 a 5 d d4b c
f 6 7 a 6 e 6 3 3
c56b0b4 0 8 3 9 9 0 4 5 c d186a57
60 3 8c a 9
2 6 214 9c b 20 d e80 65f
422 f e95 b3 6 1 16e 8
a 49eb 157 2 d a 96d5 f
d 9 2d f4 5 a d 6d2
a b55 cf36d6b657658a8abeca0 fc
8 a 73 1 5 3 c 2c 7 5
0 3 a4 0 2c58 86 1 f 2 56
6 041 a e 8 3f 3791 7 4
6558bab a e13 d 4 16 0
f1 27 2 c 90 8829bb1 f8 431
4b3 7e c 9 26 5e5 70e c 9
35 d61 e 9 3 a c2f c f 7dc
26 ad 0 4d b f4 a938ac9a7 3
7 cc e 92 431 6 6 d 4c5
8 f a 0da9 a0f 23 6 a3 8d
deQRypt(a e6 8 8b 66 24 6 92 c e
ce80 5ba a c d 2 5bd 81e52f c 3
c 8f f 3d 6 d 2
228caa6 e2 90 6 8b3ab 5 4c973);
a 8 d 96f9 b e 2b7e
0 294 e 7 0 44 0 2061d2 9
0 40c 9 fb 44 10 91bcc4 2 44
3 829 5 1 c0 4 8 6e f 08d
e b 07 2b a6b 0 a8c7
fd0ca91 26ad 6 9 3 a 9
(Ouh là, quand on me parle de "dernière petite étape", je me méfie depuis un certain chall.)
On reconnaît le format général d’un QR code. On supprime la 1ère ligne et le deQRypt
, et pour améliorer le contraste et permettre la lecture du code, on remplace tous les caractères qui ne sont pas des espaces par le caractère unicode 0x2588, qui correspond à un bloc noir.
Voici le petit script Perl qui s’acquitte de cette tâche.
binmode(STDOUT, ":utf8"); while (<>) { s/\S/\x{2588}/g; print; }
En visualisant le résultat sous un shell bien configuré (caractères noirs sur fond blanc, interligne réduit), on obtient un QR code qui devrait être lisible:

On pointe notre tablette vers l'écran de notre ordinateur (on pourrait ensuite prendre une photo de la tablette avec le téléphone portable, mais nerd overdose), on lance une appli QR code, et ça marche. Le QR code contient un texte:
Please use this Nibble ADD key: 5571C2017
Un nibble désigne un demi-octet, cette clé s’applique donc sûrement sur les chiffres formant le code. Ça n’aurait pas beaucoup de sens d’ajouter la clé entière à un seul nibble, donc on peut supposer que la clé s’applique de manière circulaire, un nibble de clé étant ajouté (ou soustrait, pour déchiffrer) à un nibble de texte.
Le nom de l'épreuve évoque le mot "labyrinthe", donc on se demande où pourraient être l’entrée et la sortie d’un labyrinthe. Les parenthèses ouvrante et fermante du deQRypt
semblent de bons candidats. De plus elles sont effectivement joignables en suivant des chiffres du code.
On suit donc à la main le plus court chemin entre les parenthèses (et parmi les plus courts, celui qui présente le moins de "coudes"), et on écrit un petit programme de déchiffrement:
#include <stdio.h> #include <string.h> //char add_key[] = "5571C2017"; int key[9] = { 5, 5, 7, 1, 12, 2, 0, 1, 7 }; char path[] = "ace80e6fcca1776558babe2c51d6b657658a8abcf973d1bb5c938ac9da252fd4c973"; int main(void) { int i, p; char tmp[2] = {0}; for (i = 0; i < strlen(path); i++) { tmp[0] = path[i]; sscanf(tmp, "%x", &p); printf("%x", (p - key[i % 9]) & 15); } }
On le lance:
~/sstic2017/home/user falkn$ ./laby 57774c6e575a6b4541654d6a50616f4b4573354b37726c644073737469632e6f7267
Ça a une bonne tête d’ASCII, décodons ça en Python:
>>> '57774c6e575a6b4541654d6a50616f4b4573354b37726c644073737469632e6f7267'.decode('hex') 'WwLnWZkEAeMjPaoKEs5K7rld@sstic.org'
On écrit à l’adresse ci-dessus. Petite pointe d’angoisse en attendant la réponse… (et si le chemin du labyrinthe était plus compliqué…) Mais non c’est le bon. Youpi, chall fini!
Conclusion
Super-chall, merci aux concepteurs.