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:

email.png

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:

NAND_pinout.jpg

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:

pulseview.png

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 signifie Address 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 signifie Command Latch Enable et indique que la prochaine écriture est une commande

  • WE signifie Write Enable et tient lieu d’horloge pour le transfert des données, des adresses et des commandes vers la flash

  • RE signifie Read 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:

iface.png

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:

tee.png

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 en esp

  • 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 chaque call 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:

convd.png

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:

trustzone.png

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:

trustzone2.png

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:

rvconvd.png

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:

good.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.

um_os64.png

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.

Tip 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.)

um_cap1.png

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 (messages WM_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 un wParam 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 un wParam 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!

um_cap2.png

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); // 1
    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; // 2
    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(); // 3
    v10 = pcompared_blk; // 4
    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 ) // 5
    {
      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;
      }
    }
1 obtention du mot de passe, stocké dans input
2 copie du mot de passe
3 fonction de transformation
4 mise en place de la sortie attendue
5 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:

vm_mem.png
Figure 1. Mémoire virtuelle-virtuelle

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

iii

iiii iiii

call relative

0x28

???

-

R0 = sse([R7], [R7+4])

0x30

??1

rrr? ???? ???s ss?? iiii iiii ii?? ????

jeq Rr, Rs, imm

0x30

??0

rrr? ?ccc cccc c??? iiii iiii ii?? ????

jeq Rr, Constant, imm

0x48

???

-

exit

0x50

rrr

iiii iiii

and Rr, imm

0x58

???

-

ret

0x68

?1?

?rrr sss?

xor Rr, Rs

0x68

?0?

rrri iiii iiii iiii iiii iiii iiii iiii

xor Rr, imm

0x78

???

-

R0 = checksum of top R0 stack entries

0x80

?1?

?rrr sss?

sub Rr, Rs

0x80

?0?

rrri iiii iiii iiii iiii iiii iiii iiii

sub Rr, imm

0x90

???

-

read R0 bytes from background image into R1-R[R0]

0xa0

iii

iiii iiii

jmp relative

0xa8

rrr

??sss???

shr Rr, byte(Rs)

0xb0

?1?

?rrr sss?

add Rr, Rs

0xb0

?0?

rrri iiii iiii iiii iiii iiii iiii iiii

add Rr, imm

0xc8

1??

???? rrr?

push Rr

0xc8

0??

iiii iiii iiii iiii iiii iiii iiii iiii

push imm

0xe0

rrr

iiii iiii

shl Rr, imm

0xe8

?00

rrri iiii iiii iiii iiii iiii iiii iiii

mov Rr, imm

0xe8

?01

?rrr sss?

mov Rr, Rs

0xe8

?10

?rrr sss?

mov [Rr], Rs

0xe8

?11

?rrr sss?

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
Tip 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
Tip 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!

vm_stack_smash.png
Figure 2. débordement de pile

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 en 0x4023f0, 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 du v2ip 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:

qrshot.png

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.