Introduction

Cette étape, orientée rétroconception, repose sur un binaire (step.elf) et un fichier flag, à priori chiffré. Une première analyse du point d’entrée du binaire nous donne la taille du mot de passe attendu (16 charactères alpha numérique) et surtout la chaine :

Hi my good wanderer °/ That is damn movfuscated\n
— step.elf

Et effectivement, après avoir mappé le fichier d’entrée à l’adresse 0xCAFE0000 et le fichier de sortie à l’adresse 0x42420000 le programme appelle la fonction 4014FD. Après une courte phase d’initialisation (on y reviendra) la fonction ne contiendra plus (de 0x40152D à 0x477909) que des instructions MOV, et se termine par deux instructions invalides.

Ce qui est caractéristique d’un packer public relativement connu M/o/Vfuscator, publié à la recon 2015 par Christopher Domas. Une analyse du packer, et un dépacker, a ensuite été publiée par Julian Kirsch et Clemens Jonischkeit à REcon 2016. Cet outil a cependant été uniquement conçu pour une architecture 32bits, le challenge étant en 64bits.

M/o/Vfuscator 101

Je vais paraphraser massivement les deux papiers précédents, mais reprenons l’étude de la fonction en 0x4014FD pour comprendre les concepts de bases de ce packer :

signaux

.text:00000000004014FD
.text:00000000004014FD                 mov     rdi, SIGSEGV
.text:0000000000401504                 lea     rsi, MOVF__EXIT
.text:000000000040150C                 call    sys_rt_sigaction_
.text:0000000000401511                 mov     g_movf__ssp, rsp
.text:0000000000401519                 mov     rdi, SIGILL
.text:0000000000401520                 lea     rsi, gMainLoop
.text:0000000000401528                 call    sys_rt_sigaction_

Le programme commence par initialiser deux handlers d’exceptions:

  • MOVF_EXIT, pour les erreurs de segmentation. Dans le packer original, il servirait d’interface pour l’ensemble des appels externes, mais ici il ne sert qu’à terminer l’exécution de la section packée en revenant à la fonction principale.

  • gMainLoop, pour les instructions illégales. On l’a dit, la suite d’instructions MOV se termine en 0x47790D par deux octets qui ne sont pas des instructions x64 valides. Ce qui lance un signal SIGIL et transfère le flot d’exécution en début de boucle. Le registre RIP ne pouvant être manipulé à l’aide de MOV, l’auteur original a choisi ce moyen pour créer sa boucle d’exécution.

R15 et sélecteurs mémoire

En continuant avec le bloc d’instruction suivant :

.text:000000000040152D                 mov     rsp, g_movf__ssp
.text:0000000000401535                 mov     eax, 1
.text:000000000040153A                 mov     r8, offset gSelec__Part1__Success
.text:0000000000401541                 mov     [r8+r15*8], rax

Le mov rsp ne sert à rien dans notre cas, mais les trois instructions suivantes permettent d’aborder deux concepts forts :

  • le registre R15 agit comme un sélecteur de bloc:

    • si R15 est égal à 0 le bloc est actif

    • s’il est égal à 1 le bloc est inactif

Ici R15 a été initialisé, avant l’appel à la section packée, à 0.

  • Le code va manipuler des emplacements mémoire (que j’appelle sélecteur). Chaque sélecteur est en fait un couple d’emplacements.

    • Sel.<0> est "le vrai" bloc de travail

    • Sel.<1> est un bloc poubelle

Comme les sélecteurs sont systématiquement indexés à l’aide de R15 (plus rarement directement, un exemple vient), un bloc actif manipulera la partie "active" du sélecteur (ou Sel.<0>). Un bloc inactif travaillera sur la partie poubelle. Ce mécanisme, fondamental, permet de créer le graphe de contrôle de la fonction.

Le bloc d’instructions précédentes peut se synthétiser en :

Part1__Success.<sel> = 1

linéarisation du code

Pour plus de clarté, je vais utiliser le bloc situé en 0x402D2E, strictement identique à celui en 0x401545 (à l’exception de la première instruction)

.text:0000000000402D2E                 mov     rax, 1
.text:0000000000402D35                 mov     r8, offset input_idx
.text:0000000000402D3C                 mov     [r8+r15*8], rax
.text:0000000000402D40                 mov     r8, offset input_idx
.text:0000000000402D47                 mov     rax, 0
.text:0000000000402D4E                 mov     [r8+8], rax
.text:0000000000402D52                 mov     rax, [r8+r15*8]
.text:0000000000402D56                 mov     r8, offset qword_479330
.text:0000000000402D5D                 mov     [r8+r15*8], rax

Ce bloc peut se synthétiser en :

input_idx.<sel> = 1
input_idx.<1> = 0
qword_479330.<sel> = input_idx.<sel>

ou

if sel == 0: //bloc actif
    qword_479330 = 1
else:
    qword_479330 = 0

L’ensemble des instructions étant exécuté à chaque tour de boucle, le mécanisme de sélecteur permet de faire travailler les blocs inutiles sur des données tampons.

ALUs

L’ensemble des instructions entre 0x401578 et 0x40183D répondent à une nouvelle problématique : comment représenter avec des MOV une opération arithmétique ? Comment faire une addition ? L’instruction :

mov r10, r8+r9

n’existe pas. On a la solution en se focalisant sur les instructions suivantes :

.text:00000000004015B3                 mov     rsi, offset MOVF__ALU__ADD
.text:00000000004015BA                 mov     rsi, [rsi+rcx*8]
.text:00000000004015BE                 mov     al, [rsi+rax]

MOVF_ALU__ADD est en fait une table d’indirection de forme :

char tab[256][256]

Supposons que rcx = 1 et rax = 2, l’instruction 0x4015BA va récupérer la seconde table contenu dans MOVFALUADD (0x479E00)

data:0000000000479E00 byte_479E00     db 1, 2, 3, 4, 5, 6, 7, 8, 9, 0Ah, 0Bh

Le résultat de 1+2 est contenu à l’index 2 de cette table. 1+2 = 3…​

Le bloc complet fait une opération sur 8 octets, on y trouve également une table gérant la retenue. Un mécanisme similaire permet d’implémenter l’ensemble des opérations logiques, arithmétiques et la comparaison de deux valeurs.

On note au passage le prologue du bloc :

.text:0000000000401578                 mov     rax, 0
.text:000000000040157F                 mov     rbx, rax
.text:0000000000401582                 mov     rcx, rax
.text:0000000000401585                 mov     rdx, rax
.text:0000000000401588                 mov     r8, offset qword_479330
.text:000000000040158F                 mov     r9, offset qword_479330
.text:0000000000401596                 mov     r10, offset qword_479330

Je n’ai pas vraiment creusé s’il s’agit d’un artefact du packer original ou de l’outil utilisé pour ce challenge, mais on sent vraiment que ce code est en fait construit à partir de simples substitutions : une instruction assembleur étant représentée par un bloc fixe avec une structure assez rigide. Ici les registres R8 et R9 sont les entrées, R10 est la sortie, les registres de travail RAX, RBX, RCX et RDX sont le plus souvent mis à zéro en début de bloc. L’instruction mov rax, 0 va donc me servir de séparateur de bloc (il y a quelques faux positif où cette instruction fait partie d’un bloc mais ce n’est pas bien grave).

demov.py

Tout ça est fantastique, mais il y a un unpacker publique. Pourquoi ne pas l’utiliser ?

  • Parce que ce n’est pas drôle et qu’on nous a demandé de ré-inventer la roue

  • Parce qu’il ne gère pas le x64 et que son code est truffé de références hardcodées à des registres 32 bits

  • Parce que c’est du cpp et qu’il faut pas abuser non plus. Un peu de souffrance oui, mais pas trop.

Je décide donc de refaire un unpacker, en python, moche, non maintenable, truffé de références hardcodées à des registres 64bits, et autres trucs inavouables. Mais. C’est mon code.

Sur le papier je pars sur les mêmes bases que demovfuscator, le trio unicorn, keystone, capstone me permet d’émuler, décompiler et modifier le code du challenge.

hash de bloc

Le concept est ensuite assez simple, pour chaque instruction émulée :

  • le registre de destination est ajouté au hash de bloc courant

  • s’il s’agit d’un mov rax, 0 je considère qu’il s’agit d’une fin de bloc. Si le bloc est connu, je récupère dynamiquement ses arguments d’entrées et sortie (qui peuvent être affichées). Je peux le remplacer par une instruction équivalente. S’il n’est pas connu je l’ajoute à la liste des blocs à décrire.

Le premier bloc :

.text:0000000000401535                 mov     eax, 1
.text:000000000040153A                 mov     r8, offset gSelec__Part1__Success
.text:0000000000401541                 mov     [r8+r15*8], rax

Est donc représenté par le hash ''eax', 'r8', 'mw_r8+r15*8'' et par le descriptif suivant :

DEMOV_INSTR_MOV_CONST = {
    'mnem' : "Sout.st = dw_const",
    'in' : [ 'imm_cs'],
    'in_off' : 0,
    'out' : [ 'imm' ],
    'out_off' : 1,
    'hash' : "['eax', 'r8', 'mw_r8+r15*8']"
}

C’est une assignation de constante, la valeur assignée (immédiate) est récupérable dans l’instruction 0, la destination dans l’instruction 1. Pas besoin de remplacer ce bloc. Le bloc d’addition est représenté par :

DEMOV_INSTR_ADD = {
    'mnem' : "Sout.st  = S1.st + S2.st",
    'in' : ['r8', 'r9'],
    'out' : [ 'r10' ],
    'hash' : "['r8', 'r9', 'r10', 'al', 'bl', 'rsi', 'rsi', 'dl', 'rsi', 'rsi', 'al', 'rsi', 'rsi', 'cl', 'rsi', 'rsi', 'al', 'rsi', 'rsi', 'cl', 'mw_r10+r15*8', 'al', 'bl', 'rsi', 'rsi', 'dl', 'rsi', 'rsi', 'al', 'rsi', 'rsi', 'cl', 'rsi', 'rsi', 'al', 'rsi', 'rsi', 'cl', 'mw_r10+r15*8+1', 'al', 'bl', 'rsi', 'rsi', 'dl', 'rsi', 'rsi', 'al', 'rsi', 'rsi', 'cl', 'rsi', 'rsi', 'al', 'rsi', 'rsi', 'cl', 'mw_r10+r15*8+2', 'al', 'bl', 'rsi', 'rsi', 'dl', 'rsi', 'rsi', 'al', 'rsi', 'rsi', 'cl', 'rsi', 'rsi', 'al', 'rsi', 'rsi', 'cl', 'mw_r10+r15*8+3', 'al', 'bl', 'rsi', 'rsi', 'dl', 'rsi', 'rsi', 'al', 'rsi', 'rsi', 'cl', 'rsi', 'rsi', 'al', 'rsi', 'rsi', 'cl', 'mw_r10+r15*8+4', 'al', 'bl', 'rsi', 'rsi', 'dl', 'rsi', 'rsi', 'al', 'rsi', 'rsi', 'cl', 'rsi', 'rsi', 'al', 'rsi', 'rsi', 'cl', 'mw_r10+r15*8+5', 'al', 'bl', 'rsi', 'rsi', 'dl', 'rsi', 'rsi', 'al', 'rsi', 'rsi', 'cl', 'rsi', 'rsi', 'al', 'rsi', 'rsi', 'cl', 'mw_r10+r15*8+6', 'al', 'bl', 'rsi', 'rsi', 'dl', 'rsi', 'rsi', 'al', 'rsi', 'rsi', 'cl', 'rsi', 'rsi', 'al', 'rsi', 'rsi', 'cl', 'mw_r10+r15*8+7']",
    'replace_with' : ["mov rax, [r8+r15*8]; mov rbx, [r9+r15*8]; add rax, rbx; mov [r10+r15*8], rax", b'\xE9\x91\x02\x00\x00' ],
    'replace_skip' : 3
}

Le simple fait de remplacer les différentes indirections d’ALU par des instructions équivalentes accélère considérablement le programme. L’autre avantage c’est que le code devient tout à fait décompilable, et relativement lisible.

Part1 : secret[:13]

La première partie devient donc """relativement""" lisible, pour exemple la vérification du premier charactère donne :

  gSelec::Part1::Success[state] = 1LL;
  input_idx[state] = 0LL;
  input_idx[1] = 0LL;
  qword_479330[state] = input_idx[state];
  qword_479330[state] *= 2LL;
  qword_479340[state] = (__int64)gPdData1;
  qword_479330[state] += qword_479340[state];
  pwd_char_iter[state] = *(_BYTE *)(qword_479330[state] + state);
  gSelec::pad_data1_chr[state] = pwd_char_iter[state];
  input_idx[1] = 0LL;
  qword_479330[state] = input_idx[state];
  qword_479330[state] *= 2LL;
  qword_479340[state] = (__int64)gPdData2;
  qword_479330[state] += qword_479340[state];
  pwd_char_iter[state] = *(_BYTE *)(qword_479330[state] + state);
  gSelec::pad_data2_chr[state] = pwd_char_iter[state];
  input_idx[1] = 0LL;
  qword_479330[state] = input_idx[state];
  qword_479330[state] *= 2LL;
  qword_479340[state] = (__int64)gPassword;
  qword_479330[state] += qword_479340[state];
  pwd_char_iter[state] = *(_BYTE *)(qword_479330[state] + state);
  gSelec::pwd_add_data2[state] = pwd_char_iter[state];
  gSelec::pwd_add_data2[state] += gSelec::pad_data2_chr[state];
  input_idx[state] = 0xFFLL;
  gSelec::pwd_add_data2[state] &= input_idx[state];
  byte_4D0810[state] = gSelec::pwd_add_data2[state] == gSelec::pad_data1_chr[state];
  gSelec::Part1::Success[state] &= *(_QWORD *)&byte_4D0810[8 * state];

J’ai choisi de laisser apparent R15 (dans le code précédent state), pour garder le principe de boucle d’exécution, mais ça rend la lecture un peu lourde. Tout ça aurait pu s’écrire

bool gSelec::Part1::Success = 1;
gSelec::Part1::Success &= ( (pwd[0] + gPdData2[0])&0xFF == gPdData1[0] )

Et ça pour les 8 premiers caractères, les 5 suivants sont calculés par :

gSelec::Part1::Success &= ( (gPdData2[0] - pwd[0])&0xFF == gPdData1[0] )

Ce qui nous donne le fragment suivant : Reegh3meiXuvu. Si l’entrée utilisateur correspond :

  • l’adresse d’un bloc d’instruction (0x416FBA) sera mise dans la partie active du sélecteur situé en 0x479320. Ce sélecteur sera par la suite appelé selected_block.

  • l’état courant passe à 1 (inactif)

  • le programme déréférence la partie inactive du sélecteur situé en 0x479310. Ce sélecteur spécifique (un code de retour), provoquera un déréférencement de l’adresse 0xDEAD (et donc un segfault, et donc un appel au handler initialisé beaucoup plus tôt, et donc un retour dans la fonction main) si l’état est à 0, et rien sinon.

Part2 : un peu de crypto avec ça ?

We have a story to tell through this file and this is going to take forever …​.
— flag.dec

En entrant le mot de passe trouvé précédemment (suivi de 3 caractères aléatoires) on obtient un début de fichier chiffré. La suite du déchiffrement se passe lentement, très lentement, et de plus en plus lentement d’ailleurs.

Prélude de basicblock

Une nouvelle structure de code, non visible dans la première partie, est introduite à l’offset 0x416FBA (début de la boucle de déchiffrement) :

.text:0000000000416FBA MOVF__CryptoLoop proc near
.text:0000000000416FBA                 mov     rax, offset MOVF__CryptoLoop
.text:0000000000416FC1                 mov     r8, offset selected_block
.text:0000000000416FC8                 mov     rbx, [r8]
.text:0000000000416FCB                 mov     qword_479330, rax
.text:0000000000416FD3                 mov     qword_479340, rbx
.text:0000000000416FDB                 mov     rax, 0
.text:0000000000416FE2                 mov     rbx, rax
.text:0000000000416FE5                 mov     rdx, rax
.text:0000000000416FE8                 mov     r8, offset qword_479330
.text:0000000000416FEF                 mov     r9, offset qword_479340
.text:0000000000416FF6                 mov     rax, [r8]
.text:0000000000416FF9                 mov     rbx, [r9]
.text:0000000000416FFC                 sub     rax, rbx
.text:0000000000416FFF                 setnz   al
.text:0000000000417002                 and     r15, rax

Une première passe d’unpacking est déjà visible sur le code précédent : les instructions sub, setnz et and ne sont pas des mov. Ce fragment de code va vérifier si le bloc courant (ici 0x416FBA) correspond au bloc contenu dans selected_bloc.<0>. Si c’est le cas, et que l’état n’était pas actif, on set R15 à 0 (on repasse en état actif). Si l’état était déjà actif, rien ne se passe.

Il s’agit du mécanisme permettant de déclarer la destination d’un jump. On trouve un exemple de déclenchement de ce type de saut en 0x43A065

.text:000000000043A065                 mov     rax, offset loc_417102
.text:000000000043A06C                 mov     r8, offset selected_block
.text:000000000043A073                 mov     [r8+r15*8], rax
.text:000000000043A077                 mov     rax, 1
.text:000000000043A07E                 mov     rbx, r15
.text:000000000043A081                 mov     rsi, offset gTable__OR
.text:000000000043A088                 mov     rsi, [rsi+rax*8]
.text:000000000043A08C                 mov     al, [rsi+rbx]
.text:000000000043A08F                 mov     r15b, al

Le code précédent ajoute dans selected_block.<State> la destination, et force R15 à 1 (en faisant R15 |= 1). Les instructions suivant ce type de blocs seront donc non actives, jusqu’à atteindre le bloc de destination. Ou les instructions illégales de fin de boucle, ce qui déroule une nouvelle boucle du programme jusqu’à atteindre le bloc de destination (cas du jump back). En implémentant une substitution pour ce bloc et pour un bloc implémentant un saut conditionnel on retrouve le graphe de contrôle de la fonction de déchiffrement.

Analyse du chiffrement

Le code devient, encore une fois, tout à fait décompilable et reste assez compréhensible. Le code est cependant truffé de boucles plus ou moins inutiles (visant à ralentir l’exécution). Et mes différents patterns d’instruction équivalentes mériteraient d’être re travaillés pour obtenir un code plus lisible.

J’identifie cependant assez rapidement que le code va manipuler trois tableaux de constantes (0x4CBF00, 0x4CFF00 et 0x4D0300), ainsi que 4 tableaux temporaires.

En cherchant les références aux adresses 0xCAFE0000 et 0x42420000 (les adresses auxquelles sont mappées les fichiers d’entrée et sortie), on trouve le bloc 46ED0A. Ce bloc fait partie d’une boucle de 16 octets, dans laquelle :

for i in range(16):
    ...
    outFile[i + offset] = inFile[i+offset] ^ pad2[i]
    ...

Il ne reste plus qu’à réimplémenter, progressivement, le code en python, ce qui donne au final un chiffrement très simple crypto.py. Le code original implémente également un mécanisme de scheduling, initialisé après le déchiffrement du premier bloc avec les constantes 0x3A4778F88D et 0x3968CB399E, mais il est en pratique inutile.

Le temps, impressionnant, mis par le binaire original pour déchiffrer le fichier s’explique aussi. En plus de boucles visant à ralentir l’exécution, le programme déchiffre, pour chaque nouvel octet, l’ensemble des octets précédents. On peut ceci dit obtenir des temps d’exécutions à peu près corrects en remplaçant l’ensemble des tables d’ALU par des opérations équivalentes, et en modifiant le nombre d’itération des boucles intermédiaires. Ce qui permet de valider l’implémentation python.

Un peu de bruteforce, le flag

Comme il ne nous reste plus que 3 caractères inconnus, une phase de bruteforce, permet de récupérer, en moins d’une heure, le mot de passe complet. Et le flag associé :

SSTIC{21c66b2c691438c8a99b33e2************************}

La seconde partie du flag devant être demandée au bot du client lourd.