Désobfuscation de VM, une méthode générale
Mise en oeuvre de désobfuscation de VM, en empruntant le chemin dur et long (mais général).
Quelle aproche un reverseur doit-il adopter face à un code obfusqué par la technique de la machine virtuelle (VM) ? Cette méthode d’obfuscation est parmi les plus avancées, et les plus difficiles à défaire, du moment. Ainsi, ce qui est généralement recommandé est autant que possible de refuser l’obstacle et de ne pas tenter de désobfusquer : vous éviterez de perdre des jours qui deviennent des semaines. Vous n’aurez alors plus à votre disposition que les méthodes d’analyse comportementales dont on espère qu’elles vous donneront les informations dont vous avez besoin.
Vous êtes encore là ? Nous en déduisons qu’une simple analyse comportementale n’a pas suffi à donner réponse à vos questions, et que vous êtes déterminés à faire une analyse approfondie.
Comment donc se lancer dans une analyse de VM approfondie ? L’objectif final consiste à revenir au problème du reverse engineering habituel, c’est à dire à se doter des outils usuels de reverse engineering :
- un désassembleur (pour lire le code)
- un assembleur (pour le modifier)
- un débugger (pour l’analyse dynamique).
Une fois le temps investi pour créer ces outils, l’analyse revient à faire ce dont on a l’habiture en reverse engineering, simplement sur une architecture nouvelle.
Voici une méthodologie globale :
- 1) Analyser statiquement la VM, Tim Blazytko fournit un exemple de cette étape dans cette vidéo, notamment :
- identifier la boucle Fetch-Decode-Execute
- décrire la représentation des registres, stack, pointeurs d’instructions virtuels, etc.
- énumérer les méthodes de hardening de VM appliquées (plus de détails à la fin de l’article)
- expliciter le jeu d’instructions (intruction set) implémenté par la VM, en particulier :
- identifier les OPcodes correspondant à chaque instruction
- reverser chaque handler d’instruction
- 2) écrire un assembleur et un désassembleur
- 3) écrire un émulateur, et y incorporer des éléments de débugger
Dans cet article, nous détaillerons les deux dernières étapes, en partant de l’exemple d’une obfuscation par VM simple conçue pour un challenge du stand Oppida à l’ECW.
Le cas d’étude : Challenge d’Oppida sur le stand de l’European Cyber Week.
A titre d’exemple d’implémentation des outils d’analyse, nous mettons en oeuvre la méthodologie décrite par l’article sur un challenge créé par Oppida. C’est un logiciel obfusqué par VM compilé pour une architecture ARM. Si vous voulez tenter de résoudre le challenge, vous pouvez télécharger le binaire ici : LIEN CHALLENGE OPPIDA ECW. Ce binaire est conçu pour s’exécuter sur une RaspberryPi branchée sur une installation physique : considérez simplement que le challenge est résolu si la fonction main renvoie 0.
Nous allons passer très rapidement sur l’étape 1) d’analyse initiale . Il s’agit de faire une analyse statique (puisque c’est la seule technique à notre disposition à cette étape) du fonctionnement interne de la VM.
Analyse initiale : identification des éléments de la VM et rétroconception des handlers
On identifie la fonction qui implémente la boucle Fetch-Decode-Execute (FDE) de la VM :
- Le Decode est implémenté en tant que switch-case sur la valeur pointée par
r3
, avec l’offset contenu en @[0x13228].r3
contient donc l’adresse du bytecode et @[0x13228] le pointeur d’instruction virtuel !
On identifie de la même manière les autres éléments de la VM (stack virtuelle, stack pointer virtuel, registres virtuels, etc.)
- Chaque handler d’instruction est contenu dans une fonction séparée, qui est appelée par la boucle FDE.
Après avoir reversé chaque handler, nous avons tous les éléments permettant de passer aux étapes d’implémentation des outils d’analyse.
Création de l’assembleur et désassembleur
analyse détaillée du jeu d’instructions
La première étape pour écrire un assembleur et désassembleur est d’avoir une connaissance aussi fine et complète possible du jeu d’instructions. Les informations minimales à obtenir lors du reverse des handlers sont l’OPcode, la sémantique (même approximative), et les arguments (leur taille, type et rôle) de chaque instruction. Nous avons représenté ces informations dans un dictionnaire python :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
opcodes = {
"call":{"op":0x0,"args":["line_nbr"]},
"jump":{"op":0x2,"args":["line_nbr"]},
"mov_reg_reg":{"op":0x3,"args":["reg", "reg"]},
"mov_reg_imm":{"op":0x9,"args":["reg", "dword"]},
"push":{"op":0xa,"args":["reg"]},
"pop":{"op":0xc,"args":["reg"]},
"ret":{"op":0xd,"args":[]},
"operation":{"op":0x19,"args":["operation", "reg", "reg"]},
"cmp_reg_reg":{"op":0x1c,"args":["reg","reg"]},
"random":{"op":0x1e,"args":[]},
"break":{"op":0x3d,"args":[]},
"je":{"op":0x3e,"args":["line_nbr"]},
"check_result":{"op":0xff-0x21, "args":[]}
}
operations = {
"or":0x7c,
"xor":0x5e,
"shr":0x3e,
"shl":0x3c,
"and":0x26
}
registers = {"r0":0, "r1":1, "r2":2, "r3":3, "r4":4, "r5":5, "r6":6, "r7":7}
Code 1
Comme toutes les architectures de VMs servant à l’obfuscation, celle-ci présente des aspects étranges. On peut noter :
- l’absence d’instructions permettant des opérations algébriques (+, -, *)
- l’utilisation d’une seule instruction pour toutes les opérations logiques (l’opération est précisée en argument)
- l’utilisation à la fois de registres et d’une stack (beaucoup de VMs logicielles n’utilisent qu’un des deux)
Il est aussi utile de créer les distionaires inverses (où la clé n’est pas le nom (ou mnémonique) de l’instruction mais son opcode).
1
2
3
4
5
6
7
8
9
10
11
dis_opcodes = {}
for mnemonic in opcodes.keys():
dis_opcodes[opcodes[mnemonic]["op"]] = {"mnemonic":mnemonic, "args":opcodes[mnemonic]["args"]}
dis_operations = {}
for operation_name in operations.keys():
dis_operations[operations[operation_name]] = operation_name
dis_registers = {}
for register_name in registers.keys():
dis_registers[registers[register_name]] = register_name
Code 2
Désassembleur
L’élément le plus essentiel à construire est le désassembleur. Il s’agit de parcourir le bytecode en identifiant chaque instruction et ses arguments.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def disassemble(bytecode):
cursor = 0
code_lines = []
while cursor < len(bytecode):
# lecture de l'instruction depuis l'OPcode
op = arch.dis_opcodes[int.from_bytes(bytecode[cursor:cursor+1], arch.endianness)]
code_lines.append({"offset":cursor, "op": op["mnemonic"], "args":[]})
cursor += 1
# lecture des arguments, selon leur type
for arg_type in op["args"]:
if arg_type == "reg":
reg_code = int.from_bytes(bytecode[cursor:cursor+1], arch.endianness)
code_lines[-1]["args"].append({"type":"reg", "value":arch.dis_registers[reg_code]})
cursor += 1
elif arg_type == "dword":
imm_value = int.from_bytes(bytecode[cursor:cursor+4], arch.endianness)
code_lines[-1]["args"].append({"type":"dword", "value":hex(imm_value)})
cursor += 4
elif arg_type == "operation":
operation_code = int.from_bytes(bytecode[cursor:cursor+1], arch.endianness)
code_lines[-1]["args"].append({"type":"operation", "value":arch.dis_operations[operation_code]})
cursor += 1
elif arg_type == "line_nbr":
jump_addr = int.from_bytes(bytecode[cursor:cursor+4], arch.endianness)
code_lines[-1]["args"].append({"type":"line_nbr", "value":jump_addr})
cursor += 4
return(code_lines)
Code 3
En appliquant ce désassembleur à un bytecode conçu pour cette VM (et qui est le programme que l’on cherche à analyser !), on obtient un bytecode désassemblé :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
line offset instruction
0 0 random
1 1 mov_reg_reg r1 r0
2 4 mov_reg_reg r2 r0
3 7 mov_reg_imm r3 0x10
4 13 mov_reg_imm r4 0xffff
5 19 operation shr r1 r3
6 23 operation and r1 r4
7 27 operation and r2 r4
8 31 call 70
9 36 mov_reg_reg r5 r0
10 39 call 175
[...]
80 288 cmp_reg_reg r3 r1
81 291 je 301
82 296 jump 209
83 301 pop r5
84 303 pop r4
85 305 pop r3
86 307 pop r2
87 309 pop r1
88 311 ret
Résultat 1
Le désassemblé est satisfaisant… à l’exception des instructions jump
et call
qui prennent comme arguments des offsets dans le code : il serait mieux d’avoir des labels afin de bien visualiser les destinations. Pour cela, on ajoute une simple fonction qui résout les adresses de destination des jump
et call
, et on insère les labels qui vont bien.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def resolve_jump_lines(disassembly):
labels = []
for line in disassembly:
for arg in line["args"]:
if arg["type"] == "line_nbr" and "label_resolved" not in arg:
# pour les lignes qui font référence à une autre adresse dans le code
for line_number, target_line in enumerate(disassembly):
# on trouve la ligne référée
if target_line["offset"] == arg["value"]:
label = "lab_" + str(line_number)
# ajout de l'indicateur de label
if (label, line_number) not in labels:
labels.append((label, line_number))
# modification de l'instruction jump ou call
arg["label_resolved"] = True
arg["value"] = "@"+label
break
for label, line_number in labels :
disassembly.insert(line_number-1, {"label":label, "offset": None, "args":[]})
def pretty_print(disassembly):
print("line offset instruction")
for line_number, line in enumerate(disassembly):
if "label" in line:
print("\n", end='')
print(f'lab {line["label"]}')
else:
print(f' {line_number} {line["offset"]} {line["op"]} ', end='')
for arg in line["args"]:
print(arg["value"] + " ", end='')
print("\n", end='')
Code 4
Cela permet d’avoir un désassemblé plus agréable à lire :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
line offset instruction
[...]
7 27 operation and r2 r4
8 31 call @lab_19
9 36 mov_reg_reg r5 r0
10 39 call @lab_50
11 44 mov_reg_reg r6 r0
12 47 mov_reg_reg r1 r6
13 50 call @lab_41
14 55 mov_reg_reg r7 r0
15 58 mov_reg_reg r1 r5
16 61 mov_reg_reg r2 r7
17 64 call @lab_19
lab lab_19
19 69 check_result
20 70 push r1
21 72 push r2
22 74 push r3
23 76 push r4
lab lab_25
25 78 mov_reg_imm r4 0x0
26 84 mov_reg_imm r0 0x0
[...]
34 120 cmp_reg_reg r2 r3
lab lab_35
36 123 je @lab_35
37 128 jump @lab_25
38 133 mov_reg_reg r0 r1
39 136 pop r4
40 138 pop r3
41 140 pop r2
[...]
Résultat 2
Une autre possibilité est d’implémenter un Processor IDA Pro pour intégrer la nouvelle architecture à l’outil. La tâche est compliquée mais quelques blogposts ont débroussaillé le travail. Cela permet de profiter de tous les avantages qu’offre IDA, et en premier lieu, de l’affichage en graphe des blocs du désassemblé.
Assembleur
Créer un assembleur pourrait a priori sembler superflu, puisque l’objectif du reverseur est la lecture du code et non son écriture. Toutefois, on se rend vite compte du fait qu’avoir la possibilité de modifier un code est primordiale pour l’analyse, en particulier lorsque des mécanismes de défense contre l’analyse dynamique ou comportementale sont présents. Réécrire le code permet alors de les contourner.
L’assembleur implémente le mécanisme opposé au désassembleur :
- on commence par enlever les commentaires, lignes vides, etc.
- on enlève les labels et on les remplace par des numéros de ligne
- on assemble chaque ligne de code
- on résout les adresses des jumps
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def assemble(assembly):
"""Prend un code assembleur, retourne le binaire assemblé."""
commented_lines = assembly.split("\n")
labeled_lines = remove_comments(commented_lines)
lines = resolve_labels(labeled_lines) # remplacement des labels par le numéro de ligne correspondant
# après cette étape, toutes les lignes de code restantes sont à compiler
binary_lines = []
for number, line in enumerate(lines):
tokens = [t for t in line.split(" ") if t != '']
opcode = arch.opcodes[tokens[0]]
bin_line += (opcode["op"] + 0x21).to_bytes(1, arch.endianness)
if len(tokens) -1 != len(opcode["args"]):
print(f"ERROR at line {number} : \n{line} , incorrect arguments")
exit()
for arg_type, arg in zip(opcode["args"], tokens[1:]):
if arg_type == "reg":
bin_line += arch.registers[arg].to_bytes(1,arch.endianness)
if arg_type == "dword":
bin_line += int(arg, 0).to_bytes(4, arch.endianness)
if arg_type == "line_nbr":
bin_line = (bin_line, arg) # l'offset correspondant à la ligne sera calculé plus tard
if arg_type == "operation":
bin_line += arch.operations[arg].to_bytes(1, arch.endianness)
binary_lines.append(bin_line)
# calcul des offsets pour les jumps et calls
binary_with_offsets = []
for line in binary_lines:
if type(line) == tuple:
# Si un argument est un tuple, c'est une adresse de jump à calculer
tokens = list(line)
jump_dest_line_number = int(tokens[1])
tokens[1] = resolve_addr(jump_dest_line_number, binary_lines).to_bytes(4, arch.endianness)
binary_with_offsets.append(b''.join(tokens))
else:
# Sinon, on garde la ligne sans modification.
binary_with_offsets.append(line)
# concaténation du binaire
raw = b''.join(binary_with_offsets)
return(raw)
Code 5
Comme pour le désassembleur, il faut gérer les labels :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def resolve_labels(lines):
"""Transforme les labels en numéros de lignes (syntaxe = @lab) en numéros de ligne"""
labels = {}
no_label_lines = []
for line, number in zip(lines, range(len(lines))):
tokens = [t for t in line.split(" ") if t != '']
if tokens[0] == "lab":
labels[tokens[1]] = str(number - len(labels)) # Un label occupe une ligne, donc il faut décaler
else :
no_label_lines.append(line)
for line, number in zip(no_label_lines, range(len(no_label_lines))):
tokens = [t for t in line.split(" ") if t != '']
if len(tokens)>1 and "@" in tokens[1]: # hypothèse : seul le premier argument peut être un label
tokens[1] = labels[tokens[1].replace('@', '')] # récupère le numéro de ligne correspondant
no_label_lines[number] = ' '.join(tokens) # modifie la ligne
return(no_label_lines)
def resolve_addr(line_nbr, binary):
"""Prend une liste de lignes binaires et numéro de ligne, renvoie l'offset du numéro de la ligne dans le binaire final."""
addr = 0
for i in range(line_nbr):
addr += len(binary[i]) # l'offset est la somme des tailles des instructions précédentes
return(addr)
Code 6
Pour un code assembleur dont voici un extrait :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# objective : calculate (h + l) - (h * l)
lab main
random
#r1 is high
mov_reg_reg r1 r0
#r2 is low
mov_reg_reg r2 r0
mov_reg_imm r3 0x10
mov_reg_imm r4 0xffff
# create high
operation shr r1 r3
operation and r1 r4
# create low
operation and r2 r4
call @plus
mov_reg_reg r5 r0
call @multiply
mov_reg_reg r6 r0
mov_reg_reg r1 r6
call @minus
mov_reg_reg r7 r0
mov_reg_reg r1 r5
mov_reg_reg r2 r7
call @plus
check_result
Résultat 3
On obtient un binaire, que l’on peut désassembler pour vérifier (cf Résultat 2).
Environnement d’exécution
A ce stade, nous disposons des outils nécessaires à l’analyse statique et comportementale du code obfusqué, mais manque à notre arsenal l’analyse dynamique, méthode incontournable !
Pour qu’un outil permette l’analyse dynamique, le minimum syndical est qu’il dispose des fonctionnalités suivantes :
- capture des traces d’exécution
- mise en place de breakpoints
- lecture / écriture de la mémoire et des registres
Notre VM d’exemple étant elle-même compilée pour une architecture ARM, nous allons émuler son exécution grâce à MIASM. La fonctionnalité Sandbox de MIASM permet d’éviter d’écrire une majorité du code boilerplate. La sandbox MIASM récupère les arguments passés au programme python : il faut le lancer avec python environnement_exec.py -a $adresse_initiale $binaire
où :
environnement_exec.py
est le programme python qui suit$adresse_initiale
a pour valeur le point d’entrée désiré (pour nous le début de la fonction implémentant la VM)binaire
est le binaire obfusqué.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pdb import pm
from miasm.analysis.sandbox import Sandbox_Linux_arml
from miasm.core.locationdb import LocationDB
from miasm.jitter.csts import PAGE_READ, PAGE_WRITE, EXCEPT_SYSCALL
from miasm.core.utils import decode_hex
from arch import opcodes
# Parse arguments
parser = Sandbox_Linux_arml.parser(description="""Sandbox an elf binary with arm
engine (ex: jit_arm.py samples/md5_arm -a A684)""")
parser.add_argument("filename", help="ELF Filename")
options = parser.parse_args()
# Create sandbox
loc_db = LocationDB()
sb = Sandbox_Linux_arml(loc_db, options.filename, options, globals())
Code 7
Il faut ensuite initialiser l’appel à la VM, en plaçant tous les éléments, et le bytecode en premier lieu, aux localisations appropriées.
1
2
3
4
5
6
7
8
9
10
11
12
13
# lecture du bytecode à exécuter
with open("program", 'rb') as f :
program = f.read()
# allouer une page pour le bytecode et l'y stocker
program_addr = 0x60000
sb.jitter.vm.add_memory_page(program_addr, PAGE_READ | PAGE_WRITE, program)
# R0 doit pointer vers le début du bytecode
sb.jitter.cpu.R0 = program_addr
# @[0x1322c] doit contenir la taille du bytecode : 0X400
sb.jitter.vm.set_mem(0x1322c, 0x400.to_bytes(4, "little"))
Code 8
On peut ensuite lancer l’exécution de la VM ! Un breakpoint MIASM stratégiquement placé dans la boucle Fetch-Decode-Execute (au moment du Fetch) permet de récupérer la main avant l’exécution de chaque instruction. La fonction do_each_loop(jitter)
sera exécutée à chaque levée de ce breakpoint, donc pour chaque exécution d’une instruction de la VM.
1
2
3
4
5
# break on start of vm loop
vm_loop_addr = 0x11ba0
sb.jitter.set_breakpoint(vm_loop_addr, do_each_loop)
sb.run()
Code 9
Lecure / écriture de la mémoire et des registres
Grâce à l’analyse réalisée au tout début du travail de désobfuscation, nous savons comment sont représentés les différents éléments de registre et mémoire de la VM. On peut donc écrire les fonctions d’accès à ces valeurs :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# les valeurs des adresses ont été obtenues au moment de l'analyse initiale
def get_virtual_stack_pointer(jitter):
return(jitter.vm.get_mem(0x13068, 4))
def set_virtual_stack_pointer(jitter, hex_value):
jitter.vm.set_mem(0x13068, decode_hex(hex_value))
def get_register_value(jitter, reg_number):
registers = jitter.vm.get_mem(0x13200, 32)
return(registers[reg_number*4:reg_number*4+4])
def set_register_value(jitter, reg_number, hex_value):
jitter.vm.set_mem(0x13200 + reg_number*4, decode_hex(hex_value))
# et ainsi du reste : virtual instruction pointer, virtual_eflags, virtual_stack...
Code 10
Capture de la trace d’exécution
Il suffit d’assembler (pun intented) ce qu’on a fait jusqu’ici : à chaque boucle FDE, on imprime l’état des registres, stack, etc. On désassemble aussi l’instruction en cours.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import disassembler
def do_each_loop(jitter):
return(print_state(jitter))
def print_state(jitter):
# print eflag
print(f"eflag: {get_flags(jitter)}")
# print vSP
vSP = int.from_bytes(get_virtual_stack_pointer(jitter), 'little')
print(f"vSP : {hex(vSP)}")
# print registers
print(f"registers :")
for i in range(8) :
val = get_register_value(jitter, i)
print(f"r{i} = {val}")
# print stack
print(f"stack : ")
for i in range(6):
if i == vSP:
print(f"vSP --> {hex(i*4)} : {get_stack(jitter, i*4)}")
else :
print(f" {hex(i*4)} : {get_stack(jitter, i*4)}")
print("\n", end='')
# print vIP
vIP = get_virtual_instruction_pointer(jitter)
instruction_bytes = jitter.vm.get_mem(program_addr + int.from_bytes(vIP, "little"), 10)
# désassemblage de l'instruction en cours d'exécution
disass = disassembler.disassemble_single_line(instruction_bytes)
print(f"vIP : {int.from_bytes(vIP, arch.endianness)} > {disass['op']} ", end='')
for arg in disass["args"]:
print(str(arg["value"]) + " ", end='')
print("\n", end='')
# on retourne True pour continuer l'exécution
return(True)
Code 11
Le résultat est une trace d’exécution austère mais fonctionnelle :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
vIP : 291 > je 301
eflag: b'\x01'
vSP : 0x5
registers :
r0 = b'\x00\x00\x00\x00'
r1 = b'\x00\x00\x00\x00'
r2 = b'\x00\x00\x00\x00'
r3 = b'\x00\x00\x00\x00'
r4 = b'\xff\xff\xff\xff'
r5 = b'\x00\x00\x00\x00'
r6 = b'\x00\x00\x00\x00'
r7 = b'\x00\x00\x00\x00'
stack :
0x0 : b',\x00\x00\x00'
0x4 : b'\x00\x00\x00\x00'
0x8 : b'\x00\x00\x00\x00'
0xc : b'\x10\x00\x00\x00'
0x10 : b'\xff\xff\x00\x00'
vSP --> 0x14 : b''
vIP : 301 > pop r5
eflag: b'\x01'
vSP : 0x4
registers :
r0 = b'\x00\x00\x00\x00'
r1 = b'\x00\x00\x00\x00'
r2 = b'\x00\x00\x00\x00'
r3 = b'\x00\x00\x00\x00'
r4 = b'\xff\xff\xff\xff'
r5 = b'\x00\x00\x00\x00'
r6 = b'\x00\x00\x00\x00'
r7 = b'\x00\x00\x00\x00'
stack :
0x0 : b',\x00\x00\x00'
0x4 : b'\x00\x00\x00\x00'
0x8 : b'\x00\x00\x00\x00'
0xc : b'\x10\x00\x00\x00'
vSP --> 0x10 : b'\xff\xff\x00\x00'
0x14 : b''
Résultat 4
Breakpoints
Reste à implémenter les breakpoints. On pourrait créer une instruction spéciale par laquelle on remplacerait celle sur laquelle on souhaite breaker (comme c’est le cas pour l’OPcode INT3
/ 0xCC
sur x86), mais puisque nous avons à disposition un émulateur, on peut faire mieux et plus simple :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
breakpoints = []
# avant de lancer l'émulation, l'utilisateur peut placer un bp
while True:
user_input = input("place breakpoint ? (give an address or say 'go')\n>")
if user_input == 'go':
break
breakpoints.append(int(user_input))
def do_each_loop(jitter):
vIP = get_virtual_instruction_pointer(jitter)
print_state(jitter)
if int(int.from_bytes(vIP, arch.endianness)) in breakpoints:
handle_breakpoint(jitter)
return(True)
Code 12
Avant le début de l’exécution, on demande à l’utilisateur de placer des breakpoints, puis à chaque instruction exécutée, si un breakpoint est placé, on passe en mode gestion de breakpoints avec handle_breakpoint
, qui est définie ainsi :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def handle_breakpoint(jitter):
print(f"\nReached breakpoint at {get_virtual_instruction_pointer(jitter)}")
print("add breakpoint (b), read value (r), write value(w), go (go) ?")
while True:
user_input = input(">")
if user_input == 'go':
print('\n', end='')
break
tokens = user_input.split(" ")
if tokens[0] == 'b':
breakpoints.append(int(tokens[1]))
continue
if tokens[0] == 'r' or 'w':
if tokens[1][0] == 'r': # il s'agit d'un registre
reg_number = tokens[1][1]
if tokens[0] == 'r':
print(f"r{reg_number} = {get_register_value(jitter, int(reg_number))}")
if tokens[0] == 'w':
set_register_value(jitter, int(reg_number), tokens[2])
continue
print("illegal instruction")
Code 13
Dans l’exemple d’exécution suivant, on place un breakpoint à l’adresse 4, on lit puis on écrit (avec la valeur b'ABCD'
) le registre virtuel r1
, et on place un autre breakpoint à l’instruction suivante (adresse 7) avant de reprendre l’exécution.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
place breakpoint ? (give an address or say 'go')
>4
place breakpoint ? (give an address or say 'go')
>go
[...]
vIP : 4 > mov_reg_reg r2 r0
Reached breakpoint at b'\x04\x00\x00\x00'
add breakpoint (b), read value (r), write value(w), go (go) ?
>b 7
>r r1
r1 = b'\x00\x00\x00\x00'
>w r1 41424344
>r r1
r1 = b'ABCD'
>go
eflag: b'\x00'
vSP : 0xffffffff
registers :
r0 = b'\x00\x00\x00\x00'
r1 = b'ABCD'
r2 = b'\x00\x00\x00\x00'
r3 = b'\x00\x00\x00\x00'
r4 = b'\x00\x00\x00\x00'
r5 = b'\x00\x00\x00\x00'
r6 = b'\x00\x00\x00\x00'
r7 = b'\x00\x00\x00\x00'
stack :
0x0 : b'\x00\x00\x00\x00'
0x4 : b'\x00\x00\x00\x00'
0x8 : b'\x00\x00\x00\x00'
0xc : b'\x00\x00\x00\x00'
0x10 : b'\x00\x00\x00\x00'
0x14 : b''
vIP : 7 > mov_reg_imm r3 0x10
reached breakpoint at b'\x07\x00\x00\x00'
add breakpoint (b), read value (r), write value(w), go (go) ?
>
Résultat 5
Conclusion
Avec ces outils, un reverseur peut déployer sa méthodologie habituelle pour analyser le bytecode de la VM, avec toutefois la difficulté supplémentaire de l’architecture nouvelle. Il peut lire, écrire le bytecode, et l’exécuter avec une instrumentation minimale.
Le travail pour la mise en place des outils est conséquent, mais cette méthode permet d’effectuer un réel travail de rétro-conception, et donc de défaire l’obfuscation par VM. Il faut donc être certain que l’analyse approfondie soit effectivement indispensable !
Pour aller plus loin : VMs hardenées
Vous vous en êtes rendus compte : la VM utilisée dans cette obfuscation est très simple. Dans la vraie vie, les obfuscations par VM sont bien plus méchantes. Pour finir, nous allons donc exposer certaintes méthodes de hardening de VM.
Ajout de difficultés dans le reverse de la VM
Cette catégorie de hardenings de VMs consiste à puiser dans d’autres méthodes d’obfuscation pour rendre difficile la première étape, l’analyse statique de la VM.
Parmi ces méthodes, on peut compter :
- la multiplication des instructions et des handlers (et donc la multiplication du travail de reverse)
- obfuscation des handlers
- obfuscation de la boucle FDE
Ces techniques ont pour objectif de ralentir l’analyse initiale de la VM, et bien qu’elles donnent beaucoup de travail au reverseur, elles ne remettent pas fondamentalement en question la méthodologie présentée ici : il faudra simplement utiliser d’autres méthodes de désobfuscation à l’étape d’analyse statique.
Inlining du décodeur ou threaded code ***
Le threaded code consiste à ne plus utiliser de boucle FDE qui centralise l’exécution de la VM, mais à ajouter une unique itération de FDE à la fin de chaque handler, c’est donc chaque handler qui appelle le suivant, sans repasser par une étape commune ! Autrement dit, le FDE est dupliqué autant de fois qu’il y a d’instructions.
Là ça se complique : on ne peut pas mettre notre breakpoint émulateur dans la boucle FDE. Malgré tout, l’adaptation reste simple, il suffit de mettre un breakpoint à la fin chaque instruction, dans l’unique itération du FDE. La méthode est brutale, mais on n’est pas limité en breakpoints dans un émulateur…
Chiffrement du bytecode dépendant du flot d’exécution
Cette méthode d’obfuscation n’est implémentée que dans les VMs les plus difficiles. Si vous la rencontrez, c’est que le développeur n’a vraiment, mais alors vraiment pas envie que vous rétroconceviez son code.
La méthode consiste à ce que le bytecode lui-même soit chiffré, et que le déchiffrement se fasse just-in-time. Chaque handler d’instruction contient alors à la fin de son code une routine de déchiffrement, dont il se servira pour déchiffrer uniquement l’instruction suivante, avec une clé qui dépend de l’exécution (l’instruction précédente, ses paramètres, etc.).
La bonne nouvelle, c’est que la réalisation d’une trace d’exécution telle qu’on l’a mise en place plus haut n’est pas ou peu affectée. C’est toutefois la seule bonne nouvelle. En effet :
- Le désassemblage devient très difficile, et a probablement besoin d’une trace d’exécution afin de posséder les clés de déchiffrement de chaque instruction dans le bytecode
- l’assemblage devient lui aussi très difficile car il faut chiffrer les instructions en respectant le mécanisme implémenté dans la VM
- la mise en place du débugging est quasiment impossible, puisque toute modification de l’exécution du bytecode entrave son déchiffrement.
Une approche envisageable serait un déchiffrement complet du bytecode (à l’aide de la trace), puis une modification de la VM pour supprimer le chiffrement.
Si vous tombez sur ce genre de VM-là, nous vous souhaitons bien du courage !