NotPetya est un célèbre malware issu de la famille Petya, apparu en juin 2017. La partie s'exécutant depuis le MBR a souvent été étudiée en statique ou en dynamique grâce au débogueur Bochs pour IDA. Une autre approche d'analyse est-elle possible ? Nous proposons ici d'émuler pas à pas le bootloader de NotPetya en utilisant Miasm.
1. Rappel
NotPetya est un malware issu de la famille bien connue des ransomwares Petya. Cette variante est apparue pour la première fois au cours du mois de juin 2017 en Ukraine. D'après Mikko Hyppönen, Chief Research Officer chez F-Secure, le vecteur d'infection serait le mécanisme de mise à jour du logiciel de comptabilité M.E.Doc, largement déployé dans les pays de l'Est.
Cette famille de malware a la particularité d'écraser le bootloader de la machine compromise afin de chiffrer une partie du disque dur à son redémarrage. Cet article utilise ce bootloader comme prétexte à un tutoriel sur l'émulation et le reverse de ces petites bêtes grâce au framework de reverse engineering Miasm. Le code associé est disponible à cette adresse : https://github.com/aguinet/miasm-bootloader/. Il contient une implémentation en Python d'un sous-ensemble des interfaces d'un BIOS de PC x86 classique. Le code a été écrit de façon à ce qu'il soit réutilisable facilement pour d'autres cas, voire pour aider au développement/débogage de bootloaders.
1.1 Travaux associés
Des articles ainsi que de nombreux blogposts ont déjà étudié les mécanismes de compromission et d'infection du MBR de NotPetya, ainsi que l'étude de l'implémentation de ses différents mécanismes cryptographiques (et de leurs défauts). En voici quelques-uns notables :
- MISC n°86 : « Pleased to meet you, my name is Petya ! » écrit en juillet 2016 par Damien Schaeffer ;
- MISC n°93 : « Petya or Not Petya, that is the question » écrit en septembre 2017 par Teddy et Benjamin. Il aborde de manière précise les mécanismes de démarrage d'un système et propose une rétroconception du code du bootloader ;
- Crowstrike : « Full Decryption of Systems Encrypted by Petya/NotPetya » [1]. Étude d'une erreur d'implémentation de l'algorithme Salsa20 dans le bootloader.
1.2 NotPetya
Cette section n’aborde que d’une manière très généraliste le cycle de vie du malware. Cela permet de mettre en lumière la partie étudiée dans cet article.
Une fois que NotPetya s’est exécuté sur la machine, il génère une clé de chiffrement AES qui va être utilisée pour réaliser la première phase de chiffrement des données de l’utilisateur. Cette clé est elle-même chiffrée avec une clé publique RSA.
Le malware vérifie ensuite que le système utilise un schéma de partition classique et, s’il en a les droits, inscrit ses propres données sur les premiers secteurs du disque (de 1 à 18, puis 32 à 34), dont le MBR dans le premier secteur. Si le système utilise UEFI (avec donc un schéma de partition GPT), le malware ne continue pas les opérations. La machine est ensuite redémarrée et le bootloader de NotPetya est exécuté : une clé Salsa20 et un nonce sont générés. Ces secrets sont utilisés pour chiffrer la Master File Table (MFT)[2] du système de fichiers NTFS. Cette structure de donnée contient les métadonnées permettant de retrouver les données associées à chaque fichier. Cette opération prend l’allure d’un « chkdsk ». Une fois cette opération réalisée, la machine redémarre une dernière fois et s’affiche alors le message de demande de rançon.
1.3 Miasm
Miasm est un framework de reverse engineering développé en Python. Brièvement, il permet :
- d'ouvrir, modifier et générer des binaires au format PE, ELF 32, 64 LE, BE ;
- d’assembler/désassembler du code x86, ARM, MIPS, SH4, PPC et MSP430 ;
- représenter la sémantique des instructions via une représentation intermédiaire ;
- émuler du code avec un moteur de JIT (Just In Time compilation) ;
- simplifier des expressions, utilisées par exemple pour la désobfuscation de code obfusqué.
1.4 Pourquoi émuler NotPetya avec Miasm ? (dit autrement, pourquoi se faire autant de mal ?)
Il existe différentes façons d'émuler un bootloader. Une approche classique est d'utiliser QEMU (ou toute autre solution de virtualisation) en écrivant le bootloader sur un disque dur virtuel, mais cela permet difficilement d'instrumenter le code de ce bootloader. Une telle chose est cependant possible via le débogueur Bochs pour IDA. Cette approche a été adoptée par Teddy et Benjamin dans MISC n°93, mais aussi par Saurabh Sharma [3]. Cette méthode a fait ses preuves et permet de déboguer un bootloader assez facilement.
Dans l'article associé à la présentation de son outil Miasm à SSTIC en 2012 [4], Fabrice Desclaux en présentait les possibilités offertes. L'une des applications proposées était l'émulation d'un bootloader.
Pouvoir émuler complètement le bootloader (jusqu'aux interruptions du BIOS) avec un framework comme Miasm donne un contrôle plus fin sur ce qu'il se passe, permet éventuellement de le désobfusquer, et/ou d'utiliser tous les outils développés dans Miasm pour aider le reverseur dans cette tâche. Il devient par exemple très simple d'instrumenter le code afin de voir les données/lues écrites sur le disque, les secrets générés, etc.
Enfin, le code du bootloader de Petya étant succinct, non obfusqué et extrêmement simple (il tourne en mode réel, en 16 bits et ne fait appel qu'à quelques interruptions du BIOS), c'est un cas d'école de choix pour prendre en main Miasm !
2. Fonctionnement d'un bootloader PC/x86
2.1 Généralités
Nous n'abordons ici que le fonctionnement d'un bootloader avec un BIOS « à l'ancienne ». Nous n'aborderons pas ici les mécanismes de démarrage utilisant l’UEFI.
Sur un PC x86, lorsque la machine démarre, le BIOS va charger le premier secteur du disque (Master Boot Record ou MBR) à l'adresse 0x7C00, puis y transférer le flot d'exécution. Le MBR contient donc le code du bootloader. Pour le moment, le processeur ne supporte que les instructions 16 bits et ne peut adresser la mémoire qu'en mode réel [5].
Pour rappel, un secteur de disque contient 512 octets. Par conséquent, il n'est pas possible de stocker beaucoup de code sur celui-ci. C'est pourquoi les bootloaders sont généralement conçus en plusieurs parties, aussi appelées stages. Ainsi le bootloader stocké dans le MBR va, par exemple, charger et exécuter le stage 2 contenu dans le secteur 2.
2.2 Dans le cas de NotPetya
NotPetya fonctionne exactement de cette manière. Le bootstrap code correspond au code assembleur ci-dessous. La primitive à l'adresse 0x7C38, c’est-à-dire disk_read_stage2, va écrire le contenu des secteurs 2 à 34 (inclus) à l'adresse 0x8000 puis transférer l'exécution à cette adresse.
seg000:7C00 cli
seg000:7C01 xor ax, ax
seg000:7C03 mov ds, ax
seg000:7C05 mov ss, ax
seg000:7C07 mov es, ax
seg000:7C09 lea sp, start
seg000:7C0D sti
seg000:7C0E mov eax, 32
seg000:7C14 mov byte ptr ds:word_7C93, dl
seg000:7C18 mov ebx, 1
seg000:7C1E mov cx, 8000h
seg000:7C21 call disk_read_stage2
seg000:7C24 dec eax
seg000:7C26 cmp eax, 0
seg000:7C2A jnz short loc_7C21
seg000:7C2C mov eax, dword ptr ds:8000h
seg000:7C30 jmp far ptr 0:8000h
3. Émulation avec Miasm
3.1 Installation
Le système hôte utilisé pour ces tests est basé sur Linux. Les utilisateurs de Windows 10 devraient pouvoir faire fonctionner tout cela en utilisant le très pratique Windows Subsystem for Linux (WSL pour les intimes), en installant par exemple Ubuntu à travers le Windows Store [6].
Nous conseillons d'utiliser la version de Miasm spécifiée dans le fichier README du dépôt GitHub. À l'heure où sont écrites ces lignes, la version utilisée est associée à l'empreinte dadfaabc3fff5edb9bf4ef7e7e8c4cfc4baccb94. Pour récupérer cette version spécifique, faire :
$ git clone --depth=1 -odadfaabc3fff5edb9bf4ef7e7e8c4cfc4baccb94https://github.com/cea-sec/miasm/
Nous utilisons le moteur de JIT de Miasm basé sur LLVM. Pour cela, le paquet python llvmlite est nécessaire. D'autres dépendances sont nécessaires à Miasm et au projet en soi. Elles sont installables directement grâce au fichier requirements.txt fourni :
$ cd /path/to/src && pip install -r requirements.txt
Il suffit ensuite d'installer Miasm :
$ cd /path/to/miasm && python ./setup.py install
3.2 Implémentation
Toutes les techniques décrites ici peuvent être testées grâce au script src/emulate_mbr.py, présent dans le dépôt GitHub cité au début de cet article. Les différentes options disponibles sont accessibles en utilisant le drapeau --help.
3.3 Création d'un disque de test
Nous avons réalisé nos tests avec des machines virtuelles sous Windows XP et Windows 10. L'hyperviseur utilisé importe peu (VMWare, VirtualBox), tant que le disque créé est de taille fixe et au format VMDK. Ainsi, l'émulation du bootloader se fait directement sur le disque de la machine virtuelle. Un avantage à cette méthode est qu'il n'y a pas besoin d'extraire le bootloader de la DLL d'origine du malware ou bien depuis le disque à la main.
Fig. 1 : Scénario d'émulation.
Le scénario de test est le suivant :
- Infection volontaire de la machine virtuelle avec NotPetya ;
- Attente d'environ 10s (la machine ne doit pas redémarrer seule au risque que le chiffrement effectué par le bootloader soit lancé) ;
- Arrêt de la machine virtuelle : le MBR est maintenant infecté ;
- Lancement de l'émulation : chiffrement de la MFT par le bootloader puis affichage du message de rançon.
Si votre machine virtuelle n'est pas au bon format de disque, vous pouvez toujours le convertir au format RAW grâce à QEMU :
$ qemu-img convert -f vmdk mydisk.vmdk -O raw mydisk.raw
Nous fournissons de plus une image de test disponible dans le dépôt Git référencé en introduction (fichier disk.raw.bz2). Cette image décompressée fait environ 1Go, et contient une simple partition NTFS avec quelques fichiers de test.
Nous pouvons maintenant émuler le bootloader de NotPetya. Pour cela, il faut émuler un BIOS capable de :
- lecture/écriture sur les secteurs du disque ;
- l'affichage des caractères sur le moniteur ;
- la saisie des caractères au clavier ;
- démarrer sur un MBR (re/démarrage léger).
La suite de l'article décrit comment implémenter tout cela à travers Miasm.
3.4 Abstraction système
Nous implémentons une abstraction d'un système simple tel que vu par le BIOS. Il comprend :
- un disque dur virtuel (classe HardDrive) ;
- un affichage vidéo, qui passe par un terminal classique Unix, à travers le pipe stdout ;
- un clavier, qui utilise le pipe stdin pour récupérer les frappes clavier (fonctions de async_kb.py).
L'abstraction est soutenue par la classe System, dont une instance est utilisée tout au long de l'émulation. Celle-ci est initialisée en même temps que la VM Miasm.
3.5 Initialisation de la VM Miasm
Comme annoncé en introduction, le code du MBR est chargé et exécuté à l'adresse 0x7C00 par le BIOS, qui écrira et exécutera son stage 2 à l'adresse 0x8000. L'espace restant est dédié à la pile et commence à l'adresse 0x500 pour terminer à l'adresse 0x07C00 [7], c'est-à-dire l'espace [0x00000500:0x00007BFF].
Nous initialisons donc dans un premier temps la VM Miasm pour déclarer ces espaces mémoire, en mettant le premier secteur (MBR) à l'adresse 0x7C00 :
HD0 = HardDrive(hd_path)
sys_ = System([HD0])
mbr = HD0.read_sector(0)
stage1_addr = 0x07C00
stage2_addr = 0x08000
jitter.vm.add_memory_page(stage1_addr, PAGE_READ | PAGE_WRITE | PAGE_EXEC, mbr, "NotPetyaS1")
jitter.vm.add_memory_page(stage2_addr, PAGE_READ | PAGE_WRITE | PAGE_EXEC, "\x00"*SECTOR_LEN*32, "NotPetyaS2")
jitter.vm.add_memory_page(0x500, PAGE_READ | PAGE_WRITE, "\x00"*(0x7C00-0x500+1), "Stack")
# Affiche l’état de la mémoire
L’état de la mémoire au sein de la VM Miasm est ainsi le suivant :
Addr Size Access Comment
0x500 0x7700 RW_ Stack
0x7C00 0x200 RWX NotPetyaS1
0x8000 0x4000 RWX NotPetyaS2
NotPetya charge 32 secteurs du disque en mémoire lors de l'exécution du stage 1. C'est pourquoi l'espace mémoire alloué au stage 2 est de 32 secteurs.
3.6 Gestion des interruptions dans Miasm
Miasm permet de spécifier un gestionnaire d'interruption qu'il appellera à chaque fois que l'instruction INT est rencontrée. Pour cela, il suffit d'appeler la fonction add_exception_handler du jitter utilisé :
jitter.add_exception_handler(EXCEPT_INT_XX, lambda jitter: exception_int(jitter, sys_))
Nous pouvons ensuite appeler notre implémentation Python du BIOS à partir de la fonction exception_int.
3.7 Support des différentes interruptions
Il convient maintenant d'écrire les gestionnaires d'interruption du BIOS. Nous distinguons 4 familles d'interruptions à implémenter pour émuler NotPetya :
- INT 10h : accès au moniteur (écriture de caractères, couleurs…) ;
- INT 13h : accès au disque (lecture, écriture, géométrie…) ;
- INT 16h : accès au clavier (lecture des frappes) ;
- INT 19h : démarrage sur le MBR d'un disque.
3.7.1 INT 13h
Pour illustrer nos propos, voici une implémentation de l'interruption 13havec le code de fonction 0x43(Extended Read Sectors From Drive). Ce code correspond au chargement d'un ou plusieurs secteurs du disque vers la mémoire RAM. Le code Python présenté ici ne contient pas la gestion des erreurs pour des raisons de lisibilité. L'objet sys_correspond à l'abstraction du système proposée en section 3.4.
@func(disk_interrupts, 0x42)
def extended_read_sectors(jitter, sys_):
drive_idx = get_xl(jitter.cpu.DX)
print "Extended read sectors, drive idx 0x%x" % drive_idx
dap = jitter.vm.get_mem((jitter.cpu.DS << 4)+jitter.cpu.SI, 16)
dap_size, _, num_sect, buff_addr, abs_sect = struct.unpack("<BBHIQ", dap)
hd = sys_.hd(drive_idx)
print(" Read %d sectors from sector %d" % (num_sect, abs_sect))
size = num_sect * SECTOR_LEN
data = hd.read(abs_sect * SECTOR_LEN, size)
jitter.cpu.cf = 0 # No error
# AL is the number of sectors read
# AH is the return code, 0 = successful completion
jitter.cpu.AX = set_16bit_reg(low=int(len(data) / SECTOR_LEN), high=0)
jitter.vm.set_mem(buff_addr, data)
Il existe deux manières d'accéder à des données sur le disque, en utilisant deux adressages différents pour la même interruption 13h :
- L'adressage CHS (Cylinder, Head, Sector), utilisé par le code 02h/03h. Il permet de lire/écrire un ou plusieurs secteurs en spécifiant le numéro de cylindre et de tête.
- L'adressage LBA (Logical Bloc Adressing), utilisé par le code 42h/43h permet de lire/écrire un ou plusieurs secteurs en spécifiant le secteur de manière absolue, c'est-à-dire à partir du début du disque en faisant abstraction des têtes et des cylindres.
NotPetya utilise l'adressage LBA nécessitant l'usage d'un DAP (Disk Address Packet). Il s’agit d’une structure de données décrivant quels secteurs lire et où placer les données lues.
Nous noterons qu'il existe une structure étendue permettant de charger plus de secteurs (mode LBA étendu).
Offset |
Taille |
Description |
0 |
1 |
Taille du paquet |
1 |
1 |
Toujours à zéro |
2 |
2 |
Nombre de secteurs à charger |
4 |
4 |
Buffer où charger les données (seg:off) |
8 |
8 |
Numéro absolu du secteur à lire |
Pour résumer :
- Le DAP est parsé ;
- Les données sont lues depuis le disque virtuel ;
- Les données lues sont inscrites dans la page mémoire de la VM Miasm instanciée.
Le mécanisme d'écriture sur le disque est l'exact opposé : l'adresse mémoire spécifiée contient les données à écrire sur le disque.
3.7.2 INT 19h
Le second exemple choisi est l'interruption 19h (diskboot), qui permet de redémarrer la machine [8][9]. Elle est utilisée à deux endroits :
- dans la procédure à l'adresse 0x892E, appelée lorsqu'une erreur fatale survient ;
- au redémarrage après le chiffrement des entrées de la MFT (adresse 0x820D).
L'interruption 19h est exécutée après la procédure POST (Power On Self Test) par le BIOS. Le MBR est alors chargé depuis le disque et le BIOS donne le contrôle au code situé à l'adresse Ox7C00. Ce n'est donc pas un redémarrage complet, mais plutôt une instruction permettant de démarrer à partir d'un disque. Cette instruction fait partie du processus de boot après exécution du BIOS. Certains BIOS permettent de gérer les priorités de boot, tandis que d'autres bouclent sur les différents disques disponibles jusqu'à trouver sur lequel il peut démarrer.
Nous allons donc émuler cette instruction en chargeant à nouveau le MBR dans la page mémoire dédiée au stage 1, et en sautant ensuite sur l'adresse 0x7C00 :
diskboot_interrupts = FuncTable("INT 19h (diskboot)")
@func(diskboot_interrupts, 0x02)
def reboot(jitter, sys_):
# Here, we assume only one bootable disk (index 0)
hd = sys_.hd(0)
mbr = hd.read_sector(0)
jitter.vm.set_mem(0x7C00, mbr)
jitter.pc = 0x7C00
3.8 Et pour quelques hacks de plus...
L'instruction STI (Set Interrupt Flag) est utilisée à l'adresse 0x7C0D. Elle permet d'activer les interruptions masquables (flag IF, offset 9 du registre FLAGS). Ce flag n'a pas d'effet sur les interruptions non masquables. Les interruptions provenant du matériel étant entièrement émulées, Miasm ne contient pas (légitimement) de sémantique pour cette instruction.
Nous décidons donc de l'ignorer en posant un breakpoint à l'adresse spécifiée :
jitter.add_breakpoint(0x7C0D, handle_sti)
puis de faire passer PC à l'instruction suivante. Sachant que l'opcode de l'instruction STI (0xFB) ne fait qu'un octet, une simple incrémentation de PC suffit :
def handle_sti(jitter):
jitter.pc += 1
return True
3.9 Yippie kay yay motherfucker !
Maintenant que les différents gestionnaires ont été écrits, et que le code du MBR est chargé puis mappé dans Miasm, l'émulation peut commencer :
jitter.init_run(stage1_addr)
jitter.continue_run()
Avec le drapeau --verbose-bios-data (voir section 3.2), la sortie de notre programme affiche directement le contenu des lectures/écritures du disque. Par exemple, voici le contenu du second secteur (sur les 32 chargés par le bootloader à l'adresse 0x8000) :
Extended read sectors, drive idx 0x0
Read 1 sectors from sector 2
00000000: 50 FF 76 04 E8 91 0A 83 C4 0A E8 3B 07 CD 19 5E P.v........;...^
00000010: C9 C3 6A 0E E8 39 07 5B 68 70 9C E8 C0 03 5B C3 ..j..9.[hp....[.
00000020: C8 04 04 00 56 6A 00 6A 01 6A 00 6A 20 8D 86 FC ....Vj.j.j.j ...
00000030: FD 50 8A 46 06 50 E8 21 0A 83 C4 0C 6A 00 68 8E .P.F.P.!....j.h.
[...]
Le code chargé correspond à celui du stage 2. Nous observons aussi facilement le chargement du secteur 32 :
Extended read sectors, drive idx 0x80
Read 1 sectors from sector 32
00000000: 00 AA 92 E7 82 11 15 D3 20 96 A7 75 51 C0 36 08 ........ ..uQ.6.
00000010: E8 65 42 8C 73 9F 06 53 77 CB C5 95 60 C8 38 69 .eB.s..Sw...`.8i
00000020: 9B 0D A4 99 E0 13 12 30 79 31 4D 7A 37 31 35 33 .......0y1Mz7153
00000030: 48 4D 75 78 58 54 75 52 32 52 31 74 37 38 6D 47 HMuxXTuR2R1t78mG
00000040: 53 64 7A 61 41 74 4E 62 42 57 58 00 00 00 00 00 SdzaAtNbBWX.....
00000050: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000060: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000070: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000080: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000090: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
000000A0: 00 00 00 00 00 00 00 00 00 48 34 79 5A 73 77 56 .........H4yZswV
000000B0: 54 64 43 6B 43 77 55 68 72 31 4D 52 6D 4A 65 69 TdCkCwUhr1MRmJei
000000C0: 76 31 34 46 4B 39 6A 5A 6A 4D 36 36 4C 44 79 65 v14FK9jZjM66LDye
000000D0: 71 52 4C 64 6B 38 53 58 53 53 73 53 53 45 78 34 qRLdk8SXSSsSSEx4
000000E0: 44 51 57 4E 47 00 00 00 00 00 00 00 00 00 00 00 DQWNG...........
000000F0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
[...]
000001F0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ...............
D'après la description faite de ce secteur dans [MISC 93], nous pouvons en déduire :
- l'indicateur de chiffrement du disque : 0x00 ;
- la clé Salsa20 de 32 octets : AA 92 E7 82 11 15 D3 20 96 A7 75 51 C0 36 08 E8 65 42 8C 73 9F 06 53 77 CB C5 95 60 C8 38 69 ;
- un nonce de 8 octets : 0D A4 99 E0 13 12 30 79 ;
- la chaîne aléatoire générée par le malware lors de son lancement sous Windows, qui est affichée dans la rançon.
Après la phase de chiffrement réalisée par le bootloader, la clé et le nonce sont ensuite effacés du disque via 32 réécritures successives de zéros. En outre, nous observons bien le flag de chiffrement du disque passé à 0x01 après le chiffrement :
Extended write sectors, drive idx 0x80
Write 1 sectors at offset 32 (from memory at 0x776A)
00000000: 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000010: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000020: 00 0D A4 99 E0 13 12 30 79 31 4D 7A 37 31 35 33 .......0y1Mz7153
00000030: 48 4D 75 78 58 54 75 52 32 52 31 74 37 38 6D 47 HMuxXTuR2R1t78mG
00000040: 53 64 7A 61 41 74 4E 62 42 57 58 00 00 00 00 00 SdzaAtNbBWX.....
00000050: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000060: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000070: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000080: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000090: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
000000A0: 00 00 00 00 00 00 00 00 00 48 34 79 5A 73 77 56 .........H4yZswV
000000B0: 54 64 43 6B 43 77 55 68 72 31 4D 52 6D 4A 65 69 TdCkCwUhr1MRmJei
000000C0: 76 31 34 46 4B 39 6A 5A 6A 4D 36 36 4C 44 79 65 v14FK9jZjM66LDye
000000D0: 71 52 4C 64 6B 38 53 58 53 53 73 53 53 45 78 34 qRLdk8SXSSsSSEx4
000000E0: 44 51 57 4E 47 00 00 00 00 00 00 00 00 00 00 00 DQWNG...........
000001E0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
[...]
000001F0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
Nous remarquons aussi que le secteur 35 sert au stockage du nombre d'entrées de la MFT chiffrées, par exemple lors du chiffrement de l'entrée suivant celle de la MFT :
Extended write sectors, drive idx 0x80
Write 1 sectors at offset 35 (from memory at 0x5C74)
00000000: 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
[...]
3.10 Récupération de secrets en mémoire
L'avantage de l'émulation est qu'il est possible de facilement analyser les pages mémoires. Par exemple, il est possible de récupérer la clé de chiffrement Salsa20 utilisée, et ce, même après que la machine ait fini le chiffrement de la MFT (durant le « faux chkdsk », cf. introduction).
En effet, comme vu en section 3.7.2, après que le chiffrement ait fini, le bootloader « redémarre » grâce à l'interruption 19h. Cette interruption n'effectue pas un redémarrage complet de la machine, car le BIOS n'est pas exécuté comme dans le cas d'un reset ou d'un redémarrage complet. Ainsi, sur les BIOS testés, la mémoire actuelle du bootloader est conservée. Dans les cas où le BIOS s'exécuterait quand même, il y aurait malheureusement de grandes chances qu'il écrase la clé Salsa20 en mémoire par des données de sa propre pile.
Ceci étant dit, il se révèle intéressant de voir si la clé Salsa20 se trouve toujours en mémoire. Pour ce faire, nous lisons celle stockée dans le secteur 32 (voir section 3.9). Nous ajoutons ensuite un breakpoint au moment où le message de rançon est affiché, à l'adresse 0x85AF :
key = HD0.read(32*SECTOR_LEN + 1, 32)
jitter.add_breakpoint(0x85AF, functools.partial(find_key_in_mem, key=key))
La fonction find_key_in_mem parcourt la mémoire de la VM Miasm et cherche ladite clé :
def find_key_in_mem(jitter, key):
# Find if the salsa20 key is still in memory!
mem = jitter.vm.get_all_memory()
print >>sys.stderr, "\n[+] Looking for key %s in memory..." % key.encode("hex")
for addr,v in mem.iteritems():
idx = v['data'].find(key)
if idx == -1:
continue
print >>sys.stderr, "[+] Key found at address %s!" % hex(addr + idx)
break
else:
print >>sys.stderr, "[-] Key not found in memory!"
return True
Le drapeau --hook=find_key du programme emulate_mbr effectue cette manipulation.
En lançant ce bootloader instrumenté grâce à Miasm, nous obtenons :
$ python ./emulate-mbr.py --hook=find_key disk.raw
Repairing file system on C:
[... encryption happends here ...]
[+] Looking for key [your salsa20 key] in memory...
[+] Key found at address 0x674a!
Pour accélérer le processus, vous pouvez utiliser l'option --skip-encryption(voir section 3.1). À noter que même avec ce drapeau, l'octet de statut du chiffrement du disque sera quand même mis à vrai à la fin du chiffrement. Le drapeau --dry peut être utilisé pour éviter toute modification sur le disque original.
Pour savoir à quel moment la clé a été copiée sur la pile, nous pouvons mettre un point d'arrêt mémoire en écriture :
def print_ip(jitter):
print(hex(jitter.pc))
return False
jitter.exceptions_handler.callbacks[EXCEPT_BREAKPOINT_MEMORY] = []
jitter.add_exception_handler(EXCEPT_BREAKPOINT_MEMORY, print_ip)
jitter.vm.add_memory_breakpoint(0x674a, 1, PAGE_WRITE)
Miasm gère par défaut les exceptions mémoires en arrêtant le jitter. Il enregistre pour cela un callback sur l'exception EXCEPT_BREAKPOINT_MEMORY. Nous devons donc le supprimer pour le remplacer par notre propre fonction qui va afficher le pointeur d'instruction, puis mettre l'exécution en pause.
Nous trouvons ainsi que le code responsable de cette copie se trouve à l'adresse 0x816B. Il est important de noter que, lorsque la fonction print_ip est appelée, PC pointe vers la prochaine instruction à exécuter, soit 0x816F.
En analysant le code, nous voyons que l'instruction à l'adresse 0x816B fait partie d'une boucle qui copie 32 octets provenant du secteur 32 (où se trouve la clé Salsa20). En regardant le code complet de la fonction associée, nous pouvons voir que ce buffer sur la pile n'est en effet jamais effacé.
De plus, comme il n'y a aucun mécanisme de type ASLR sur le bootloader, cette adresse sera toujours la même.
3.11 Modification du bootloader pour déchiffrer la MFT
Pour quelqu'un disposant d'un mécanisme permettant d'écrire directement dans la mémoire de la machine (en utilisant par exemple une carte PCI Express [10] au d'autres interfaces comme FireWire ou Thunderbolt [11]), il serait donc possible de déchiffrer les données. L'attaque consiste à patcher le bootloader en mémoire de manière à ce qu'il utilise la clé restée sur la pile. Cette section simule cette attaque grâce à Miasm.
Pour cela, nous allons injecter du code à l'adresse 0x82A8. Cette fonction vérifie que la clé entrée est celle attendue. Étant donné qu'elle a été effacée du disque, et que le texte de demande rançon est complètement aléatoire [12], le bootloader n'a en théorie aucun moyen de savoir si la clé entrée est bonne. Cette fonction renverra toujours 0 (clé incorrecte). Le code injecté va copier la clé Salsa20 depuis l'adresse 0x674A vers un endroit spécifique de la pile, pour que la fonction de déchiffrement à l'adresse 0x835A utilise cette clé. Nous sauterons ensuite sur cette fonction.
Le code associé est le suivant :
; Sauvegarde des registres sur la pile
PUSHA
LEA DI, WORD PTR [BP-0x44]
LEA BX, WORD PTR [key_addr]
XOR CX,CX
loop:
MOV EAX, DWORD PTR [BX]
MOV DWORD PTR [DI], EAX
ADD DI, 4
ADD BX, 4
INC CX
CMP CX,8
JNZ loop
; Restauration des registres
POPA
; Saut sur la fonction de déchiffrement (avec une adresse absolue CS:OFFSET)
JMP 0000:0x835A
Nous utilisons Miasm pour l’assembler grâce à la fonction ci-dessous :
def asm_shellcode(asm, labels = None):
machine = Machine("x86_16")
symbol_pool = asmblock.AsmSymbolPool()
# Assemble
blocks, symbol_pool = parse_asm.parse_txt(machine.mn, 16, asm, symbol_pool)
# Set custom labels
if not labels is None:
for name,value in labels.iteritems():
sym = symbol_pool.getby_name(name)
symbol_pool.set_offset(sym, value)
# Resolve all the labels
patches = asmblock.asm_resolve_final(machine.mn,
blocks,
symbol_pool)
# Patch the final code with the label values
shellcode = StrPatchwork()
for offset, raw in patches.items():
shellcode[offset] = raw
return str(shellcode)
Attardons-nous un peu sur cette fonction. Le code est tout d’abord assemblé en 16 bits. Les labelspassés par argument sont ensuite associés à une valeur concrète avec la fonction symbol_pool.set_offset. Tous les labels restants (par exemple ici loop) sont résolus grâce à la fonction asmblock.asm_resolve_final, qui retourne le code assemblé par blocs. Nous utilisons finalement la classe utilitaire StrPatchwork pour assembler le shellcode final.
La fonction read_key_and_patch se charge de lire la clé en mémoire, l’afficher et d’écrire ce code fraîchement assemblé en mémoire :
def read_key_and_patch(jitter):
# Key is still in the stack, at 0x674A. You can find this value by activating the
# find_key_in_mem breakpoint!
key_addr = 0x674A
key = jitter.vm.get_mem(key_addr, 32)
print >>sys.stderr, "\n[+] Key from memory: %s" % key.encode("hex")
# Assemble our "shellcode" thanks to Miasm!
shellcode = """
...
"""
shellcode = asm_shellcode(shellcode, {"key_addr": key_addr})
# Patch the bootloader in memory to decrypt using the key
jitter.vm.set_mem(0x82A8, shellcode)
return True
Il ne reste plus qu’à poser un point d'arrêt à la même adresse qu’à la section ci-dessus (0x85AF) afin d’appeler cette fonction :
jitter.add_breakpoint(0x85AF, read_key_and_patch)
Tout est maintenant en place. Il suffira ensuite d'appuyer sur « Entrée » lorsque le bootloader demandera la clé pour lancer le processus de déchiffrement. Le drapeau --hook=patch_bootloader du script emulate_mbr permet d'activer cette attaque.
3.12 Étude du keystream lors du chiffrement
L'algorithme de chiffrement utilisé est Salsa20, un algorithme de chiffrement par flot (autrement appelé stream cipher). Le principe général est qu'un flux de données aléatoire basé sur une clé - appelé communément keystream - est généré, et ce flux est XORé avec les données à chiffrer. Un avantage des stream ciphers est que les données à chiffrer n'ont pas besoin d'être paddées. Il faut en revanche faire attention à ne pas utiliser deux fois les mêmes parties de ce flux de données aléatoires.
Fig. 2 : Chiffrement des données avec Salsa20.
Nous pouvons vérifier cela avec Miasm, en regardant les données avant et après chiffrement, et en affichant la différence (XOR).
Pour cela, nous savons déjà poser des breakpoints. Le début de la fonction de chiffrement est à l'adresse 0x9798, et la fin à l'adresse 0x9877. Nous allons poser le premier breakpoint juste après l'instruction enter, et le deuxième juste avant l'instruction leave, afin d'avoir la pile proprement alignée pour récupérer les données avant et après chiffrement. Le code associé est le suivant :
_last_buf = None
def encrypt_start(jitter, options):
global _last_buf
buf_ptr = upck16(jitter.vm.get_mem((jitter.cpu.SS << 4) + jitter.cpu.BP + 0xC, 2))
buf_size = upck16(jitter.vm.get_mem((jitter.cpu.SS << 4) + jitter.cpu.BP + 0xE, 2))
_last_buf = jitter.vm.get_mem((jitter.cpu.DS << 4) + buf_ptr, buf_size)
return True
def encrypt_end(jitter, options):
global _last_buf
buf_ptr = upck16(jitter.vm.get_mem((jitter.cpu.SS << 4) + jitter.cpu.BP + 0xC, 2))
buf_size = upck16(jitter.vm.get_mem((jitter.cpu.SS << 4) + jitter.cpu.BP + 0xE, 2))
encr_buf = jitter.vm.get_mem((jitter.cpu.DS << 4) + buf_ptr, buf_size)
keystream = ''.join(chr(ord(a)^ord(b)) for a,b in zip(_last_buf,encr_buf)).encode("hex")
keystream = ' '.join(keystream[i:i+4] for i in xrange(0,len(keystream),4))
print >>sys.stderr, "Keystream for next 2 sectors: %s" % keystream
return True
jitter.add_breakpoint(0x979C, functools.partial(encrypt_start, options=options))
jitter.add_breakpoint(0x9876, functools.partial(encrypt_end, options=options))
Le drapeau --dump-keystream du script emulate_mbr permet d'activer cette fonctionnalité.
En regardant la sortie, nous nous apercevons que, entre deux secteurs (2*512 octets), le keystream est seulement décalé de deux octets, au lieu des 2x512 octets normalement requis. Ce décalage est schématisé en figure 2.
Fig. 3 : Décalage du keystream.
Ainsi, des parties du keystream sont réutilisées entre plusieurs secteurs, ce qui peut permettre de récupérer certaines de ses composantes.
En effet, si l'on considère p le texte clair, k le keystream et c le texte chiffré, alors la fonction de chiffrement E se définie comme E(p) = p xor k = c. Une partie des structures de la MFT étant invariantes et connues, il est donc possible, sur deux secteurs, de retrouver une partie du keystream utilisé pour ces secteurs. Celui-ci étant réutilisé pour les deux secteurs suivants en étant simplement décalé de deux octets, une partie du clair de ces autres secteurs peut être retrouvée.
Cette vulnérabilité dans l'implémentation de Salsa20 du bootloader a été exploitée par CrowdStrike pour récupérer une grande partie des données d'origine de la MFT (entre 98.10% et 99.97% selon la méthode[13]).
Conclusion
L'émulation du code du bootloader NotPetya a permis de vérifier des hypothèses et de comprendre de manière très concrète les différentes étapes liées au chiffrement des entrées de la MFT. En outre, elle nous a permis de vérifier la validité de méthode de recouvrement de données, de trouver assez facilement le biais dans l'implémentation du keystream Salsa20 (sans avoir à reverser l'algorithme statiquement), ou encore de simuler la récupération de la clé restant en mémoire après le chiffrement.
Cet article ne fait que gratter la surface des possibilités de Miasm, et nous espérons que l'approche didactique adoptée dans les étapes d'émulation encouragera peut-être les lecteurs non initiés à Miasm à jouer avec :)
Références
[1] https://www.crowdstrike.com/blog/full-decryption-systems-encrypted-petya-notpetya/
[2] https://fr.wikipedia.org/wiki/Master_File_Table
[3] https://shasaurabh.blogspot.fr/2017/07/debugging-mbr-ida-pro-and-bochs-emulator.html
[4] https://www.sstic.org/2012/presentation/miasm_framework_de_reverse_engineering/
[5] https://fr.wikipedia.org/wiki/Mode_r%C3%A9el
[6] https://docs.microsoft.com/en-us/windows/wsl/install-win10
[7] https://wiki.osdev.org/Memory_Map_(x86)
[8] https://wiki.osdev.org/ATA_in_x86_RealMode_(BIOS))
[9] http://webpages.charter.net/danrollins/techhelp/0243.HTM
[11] https://github.com/carmaa/inception
[13] https://www.crowdstrike.com/blog/full-decryption-systems-encrypted-petya-notpetya/
Remerciements
Nous tenons à remercier gapz pour ses encouragements, ainsi que Camille Mougey et Fabrice Desclaux pour leur aide et la relecture de cet article ! Nous remercions également Thomas Chauchefoin et zerk pour leur relecture attentive.