Z2A Challenge 0x5 | BlackLotus UEFI

Analyse d'un bootkit BlackLotus profitant d'une vulnérabilité pour bypass le Secure Boot.

BlackLotus est un des premiers bootkits de sa génération permettant de bypasser le SecureBoot. C’est un malware qui se loge dans l’UEFI, démarrant donc avant le système d’exploitation, passant outre toute détection antivirale. Ce malware utilise une faille pourtant patchée pour s’ajouter à la liste des drivers fiables et donc se lancer même en présence de SecureBoot.

Objectifs

Z2A ne nous a pas donné d’objectifs clairs pour ce challenge (peut être dû à sa difficulté un peu plus prononcée que les challenges habituels). Nous allons donc tenter d’analyser ce malware le plus en profondeur possible (sans se perdre dans les menus détails) et voir ce que nous pouvons automatiser/comprendre à propos de celui-ci.

Première approche

En ouvrant ce binaire dans IDA Pro, nous pouvons apercevoir une structure de fonctions plutôt propre et compréhensible :

Fonction start

Nous nous rendons également compte que ce binaire ne contient aucun import ni export. Côté strings, rien non plus.

Cependant, on peut remarquer qu’une grande partie du binaire contient de la data non explorée, c’est à dire qu’IDA n’a pas réussi à trouver d’instructions dans ce code. Cela indique souvent la présence d’un autre binaire sous forme chiffrée :

En bleu, les instructions, en jaune, le binaire chiffré

Syscalls

En analysant la première fonction, on aperçoit une instruction assez peu commune : syscall

Pour faire bref, un syscall est un appel bas niveau utilisé par les APIs Windows Zw pour franchir la barrière entre le user mode et le kernel mode. Un syscall prend en entrée un code hexadécimal dans eax, qui sera ensuite comparée à une table pour rediriger cet appel vers la bonne fonction kernel.

Les sycalls sont souvent utilisés pour ne pas faire appel directement aux APIs Windows classiques, et donc éviter la détection par les antivirus.

Nous savons maintenant que le malware utilise des syscalls pour faire appel à des APIs bas niveau. Mes connaissances sur le sujet étant inexistantes, je décide donc de me renseigner grâce à l’excellente vidéo d’OAlabs sur le sujet :

https://www.youtube.com/watch?v=Uba3SQH2jNE

Mais un passage dans la vidéo ressemble fortement au code que je suis en train d’analyser. Dans cette vidéo, l’auteur utilise un projet nommé SyscallsWhisper2 qui lui permet d’utiliser des appels syscalls dans son programme.

https://github.com/jthuraisamy/SysWhispers2

Après lecture du code source et comparaison avec l’implémentation du malware, j’ai pu comprendre comment étaient résolues les APIs via syscalls :

  • Premièrement, le malware sauvegarde les arguments qu’il souhaite passer à la fonction.

  • Ensuite, un hash est passé à la fonction de résolution d’API.

  • La fonction de résolution va créer une table contenant le nom de l’API (ex ZwTerminateProcess, permettant de terminer un programme), son adresse, ainsi que le hash de son nom.

  • Les APIs sont ordonnées par ordre croissant d’adresses, et l’ordre correspond à l’identifiant de syscall (par exemple, si la fonction est troisième de la liste, son identifiant sera 0x3)

  • La fonction retourne donc l’identifiant correspondant au hash demandé.

  • Les arguments sont restaurés dans les registres x64

  • L’appel syscall se produit

Automatisation

Nous savons maintenant comment fonctionne la fonction de résolution des syscalls. Nous pouvons sûrement l’automatiser pour résoudre automatiquement les noms des APIs? Oui, mais avant il faut se procurer la table des syscalls. Cette table change à chaque nouvelle version de Windows (version build), et le plus fiable serait donc de créer notre propre table.

  • Premièrement, il faut récupérer la liste de toutes les APIs Zw. Celles-ci se trouvent dans ntdll.dll et peuvent être extraites à l’aide d’outils comme DllExportsViewer.

  • Une fois cette liste obtenue, nous allons réordonner les APIs par adresse dans l’ordre ascendant.

  • Enfin, nous allons hasher le nom de l’API avec SDBM.

Voici la fonction de hash qu’utilise ce malware pour hasher le nom des APIs :

La première fois que j’ai rencontré cet algorithme de hash, c’était en 2020 en analysant Emotet. Le nombre 65599 correspond généralement à du SDBM.

A noter que l’algo de hash n’est pas le même que dans SyscallWhispers, qui lui utilise du XOR mixé à du ROR8.

Voici un extrait de la table des syscalls pour mon système :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
a4ee04c7 ZwAccessCheck 0x0
043e0bb6 ZwWorkerFactoryWorkerReady 0x1
8c1368e6 ZwAcceptConnectPort 0x2
cc3699bb ZwMapUserPhysicalPagesScatter 0x3
6f2b783e ZwWaitForSingleObject 0x4
4935e4d2 ZwCallbackReturn 0x5
1036250f ZwReadFile 0x6
e6aef440 ZwDeviceIoControlFile 0x7
79a42dfe ZwWriteFile 0x8
9ea474a3 ZwRemoveIoCompletion 0x9
684676ba ZwReleaseSemaphore 0xa
2b7d1802 ZwReplyWaitReceivePort 0xb
5ebcdeee ZwReplyPort 0xc
2ed76231 ZwSetInformationThread 0xd
d64473b5 ZwSetEvent 0xe
3891d11b ZwClose 0xf
f21f14ca ZwQueryObject 0x10
2f4cb39d ZwQueryInformationFile 0x11
be736318 ZwOpenKey 0x12
761eaf95 ZwEnumerateValueKey 0x13
011a11e7 ZwFindAtom 0x14
21680c50 ZwQueryDefaultLocale 0x15
1580d5b4 ZwQueryKey 0x16

Une fois cette table créée, nous pouvons renommer chaque fonction par son nom d’API grâce à un simple script IDAPython :

 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
import idautils
import idaapi
import idc

hash_table = [...]
def getXrefsAddr(addr):
    """Gets all xrefs of a function
    Args:
        addr (ea): address of the function to get xrefs
    Returns:
        list: xrefs addresses
    """
    return [xref.frm for xref in idautils.XrefsTo(addr)]

xrefs = getXrefsAddr(0x000000140002DCC)

for x in xrefs:
    val = hex(idc.get_operand_value(x - 7,1))[-8:]
    for i in hash_table:
        if val in i['hash']:
            if idaapi.get_func(x) != None:
                func_addr = idaapi.get_func(x).start_ea
                idaapi.set_name(func_addr, i['api'].replace('Zw', 'Nt'))
            else:
                print(f"Erreur dans la fonction {hex(x)}, l'API est : {i['api'].replace('Zw', 'Nt')}")
            
            addr = x
            while idc.print_insn_mnem(addr) != 'syscall':
                addr = idc.next_head(addr)
            idc.set_cmt(addr, i['api'].replace('Zw', 'Nt'), 0)

Anti debugging

L’anti debugging est une technique utilisée par quasiment tous les malwares afin de crash ou de changer de comportement si celui-ci détecte un débuggeur.

Ce malware utilise une dizaine de techniques mixant l’anti debugging et l’antiVM afin de s’assurer que le programme n’est pas analysé ou ne tourne pas sous une sandbox.

Voici toutes les fonctions permettant de déceler un débugger ou une VM. L’instruction MEMORY[Ø] = 20108 permet de faire crash un débugger. Ainsi, si une seule de ces fonctions retourne True, le programme crash.

Dans l’ordre, un résumé des différentes techniques :

  • NtSetInformationThread avec le paramètre 0x11 permet de cacher le thread d’un débugger, ainsi, il est possible que des breakpoints ne soient pas déclenchés et que du code s’exécute sans pouvoir l’analyser.

  • IsBeingDebugged utilise le PEB (ProcessEnvironmentBlock) pour voir si le processus est en cours de débug.

  • CheckURSSLocale : Cette fonction est particulière, elle vérifie la langue utilisée sur l’ordinateur et la compare aux langues de l’est. Si le malware est exécuté sur un ordinateur de ces pays, celui-ci s’arrêtera.

  • NtGlobalFlags est une valeur du PEB qui est mise à 0x70 lorsque le malware est débuggé.

  • NtQueryInformationProcess et NtQuerySystemInformation permettent également de savoir si le malware est débuggé.

  • RtlAddVectoredExceptionHandler permet d’exécuter du code lorsqu’un breakpoint est exécuté (je n’ai pas identifié de comportement particulier dans ce sample)

  • L’instruction Int 2D agit comme un breakpoint et permet de détecter un débugger.

  • Le malware va ensuite vérifier si une des DLL qui lui sont attachées correspond à une DLL connue d’un éditeur d’antivirus.

  • Vérification du nom du programme et comparaison avec certains noms

  • Compare les programmes en cours d’exécution à une liste, détecte les outils d’analyse et les outils comme vmtools.

  • Vérifie ensuite des clés de registre pour détecter une VM :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
L"\\Registry\\Machine\\SOFTWARE\\Microsoft\\Virtual Machine\\Guest\\Parameters"
L"\\Registry\\Machine\\SYSTEM\\ControlSet001\\Services\\vioscsi"
L"\\Registry\\Machine\\SYSTEM\\ControlSet001\\Services\\VirtIO-FS Service"
L"\\Registry\\Machine\\SYSTEM\\ControlSet001\\Services\\VirtioSerial"
L"\\Registry\\Machine\\SYSTEM\\ControlSet001\\Services\\BALLOON"
L"\\Registry\\Machine\\SYSTEM\\ControlSet001\\Services\\BalloonService"
L"\\Registry\\Machine\\SYSTEM\\ControlSet001\\Services\\netkvm"
L"\\Registry\\Machine\\SOFTWARE\\VMware, Inc.\\VMware Tools"
L"\\Registry\\Machine\\HARDWARE\\ACPI\\DSDT\\VBOX__"
L"\\Registry\\Machine\\HARDWARE\\ACPI\\FADT\\VBOX__"
L"\\Registry\\Machine\\HARDWARE\\ACPI\\RSDT\\VBOX__"
L"\\Registry\\Machine\\SOFTWARE\\Oracle\\VirtualBox Guest Additions"
L"\\Registry\\Machine\\SYSTEM\\ControlSet001\\Services\\VBoxGuest"
L"\\Registry\\Machine\\SYSTEM\\ControlSet001\\Services\\VBoxMouse"
L"\\Registry\\Machine\\SYSTEM\\ControlSet001\\Services\\VBoxService"
L"\\Registry\\Machine\\SYSTEM\\ControlSet001\\Services\\VBoxSF"
L"\\Registry\\Machine\\SYSTEM\\ControlSet001\\Services\\VBoxVideo"
  • Vérification de clés de registre liées au BIOS (pour détecter une VM)
1
2
3
L"\\Registry\\Machine\\HARDWARE\\DEVICEMAP\\Scsi\\Scsi Port 0\\Scsi Bus 0\\Target Id 0\\Logical Unit Id 0"
L"\\Registry\\Machine\\SYSTEM\\ControlSet001\\Control\\SystemInformation"
L"\\Registry\\Machine\\HARDWARE\\Description\\System"
  • Vérification de frameworks d’emulation connus

  • Vérification de la présence d’un driver (lequel?)

  • Enfin, utilise l’instruction rtdsc pour mesurer le temps passé entre chaque instruction et détecter un débugger.

Ce programme utilise beaucoup de techniques, ce qui peut rendre l’analyse beaucoup plus complexe. En revanche, il existe un simple patch permettant de passer outre ces détections qui consiste à changer l’instruction de condition “test eax,eax” par “xor eax, eax” afin d'ignorer complètement le retour des foncitons.

Résolution d’APIs

Lorsque l’on cherche à évader à la détection, il ne faut en général pas mettre ses oeufs dans le même panier.
Ici, les APIs étaient appelées par le biais de syscalls. Malgré cela, il restait beaucoup d’APIs non résolues, ce qui porte à croire que le sample utilise une autre méthode de résolution.

Cette fonction est très simple, elle utilise de nouveau la même méthode que lors de la résolution de syscalls.

Elle charge dans un premier temps une DLL via son hash SDBM, sauvegarde son adresse, puis parcourt chacun de ses exports et retrouve les APIs via leurs hashs.

Il est possible de résoudre ces APIs grâce à un script 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import idautils
import idaapi
import idc
import csv

apis = []
path = "C:\\Users\\lordtmk\\Desktop\\"

def hash(export_name):
    result = 0
    if export_name:
        for byte in export_name:
            result = (result * 65599) + ord(byte)
    return hex(result)[-8:]
    
with open(f"{path}apis.csv", "r") as f:
    reader = csv.reader(f)
    for row in reader:
        dicts = {}
        dicts['api'] = row[0].strip()
        dicts['hash'] = hash(row[0].strip())
        apis.append(dicts)

def getXrefsAddr(addr):
    """Gets all xrefs of a function
    Args:
        addr (ea): address of the function to get xrefs
    Returns:
        list: xrefs addresses
    """
    return [xref.frm for xref in idautils.XrefsTo(addr)]

xrefs = getXrefsAddr(0x0000001400028F4)

print('-----------------------')
for x in xrefs:
    caddr = x
    while idc.get_operand_type(caddr, 1) != 0x5:
        caddr = idc.prev_head(caddr)
    val = hex(idc.get_operand_value(caddr, 1))[-8:]
    for i in apis:
        if val in i['hash']:
            caddr = x
            while idc.get_operand_type(caddr,0) != 0x2:
                caddr = idc.next_head(caddr)
            qword = idc.get_operand_value(caddr,0)
            idc.set_name(qword, i['api'])
print('end')

Voici le détail de son fonctionnement :

  • Récupère les exports des DLL choisies

  • Créée une liste de couples “Nom DLL; Hash”

  • Pour chaque appel à la fonction “resolve_function_by_hash”, récupérer le hash en argument et le comparer avec la liste

  • Renomme le qword qui contiendra l’adresse par le nom de l’API

Fonction principale

Le malware prendra ensuite 2 chemins, si il est lancé ou non en tant qu’administrateur. Ici, j’ai uniquement analysé le comportement si lancé en tant qu’admin.

Blocage de l’extinction de l’ordinateur

Premièrement, le malware va empêcher l’ordinateur de redémarrer en créant une fenêtre et en y ajoutant un message qui s’affichera lorsque l’utilisateur voudra éteindre son ordinateur :

Vérification de données du bootloader

Le malware va ensuite vérifier l’état du bootloader. Est-il en UEFI? Si non, il s’arrête, si oui, il vérifie si le Secure Boot est activé :

Création du driver

La suite est un peu plus floue pour moi. On sait que le malware va installer un driver malveillant pour s’installer dans l’UEFI. Ce driver est bel est bien inclus dans le malware (comme nous avons pu le voir au début de l’analyse) et sera déchiffré en AES-CBC par la suite :

Le malware va ensuite contacter le site officiel de Microsoft pour télécharger d’autres drivers, dans mon cas, il n’a jamais validé ce téléchargement :

Enfin, le driver sera stocké dans l’UEFI :

Désactivation de fonctions de sécurité

L'Hypervisor Enforced Code Integrity & Bitlocker seront enfin désactivés, permettant une plus grande liberté d’action pour le driver malveillant :

Effacement des traces

La dernière étape de ce malware consistera à effacer ses traces. Premièrement, le fichier du malware va s’autodétruire, si bien qu’il sera introuvable par la suite.

La fenêtre créée plus tôt pour empêcher l’extinction sera elle aussi supprimée.

Enfin, le malware redémarre l’ordinateur afin de charger le driver malveillant et passer à la prochaine étape.

Conclusion

Ce malware aura mené sans aucun doute à la meilleure analyse que j’ai pu réaliser depuis que j’ai lancé ce blog. J’ai compris la quasi intégralité des fonctions de ce binaire, ce qui m’a permis de rendre une analyse très détaillée à son sujet.

Cependant, je regrette de ne pas avoir eu plus de temps pour analyser le driver malveillant. Bien que cela ne fasse pas partie du challenge initial, cela aurait été l’occasion de découvrir les outils et procédures pour l’analyse en mode kernel.

En résumé, bien que bas niveau (syscalls, appels UEFI, objets COM) ce malware était plutôt simple à comprendre grâce au manque d’obfuscation, mais n’en reste pas moins très intéressant.

comments powered by Disqus
Généré avec Hugo
Thème Stack conçu par Jimmy