Z2A Challenge 0x5 | BlackLotus UEFI

BlackLotus bootkit analysis.

BlackLotus is one of the first bootkits of its generation allowing to bypass SecureBoot. It is a malware that lodges itself in the UEFI, therefore starting before the operating system and bypassing any antivirus detection. This malware uses a patched vulnerability to add itself to the list of trusted drivers and therefore launch even in the presence of SecureBoot.

Objectives

Z2A did not give us clear objectives for this challenge (perhaps due to its higher difficulty than usual challenges). We will therefore try to analyze this malware as deeply as possible (without getting lost in the menu details) and see what we can automate/understand about it.

First approach

Opening this binary in IDA Pro, we can see a rather clean and understandable structure of functions:

Start Function

We also realize that this binary contains no imports or exports. On the strings side, there is nothing either.

However, we can see that a large part of the binary contains unexplored data, that is, IDA did not find any instructions in this code. This often indicates the presence of another binary in encrypted form:

In blue, the instructions, in yellow, the encrypted binary

Syscalls

When analyzing the first function, we see a rather unusual instruction: syscall.

To make it brief, a syscall is a low-level call used by the Windows Zw APIs to cross the barrier between user mode and kernel mode. A syscall takes a hexadecimal code in eax as input, which will then be compared to a table to redirect this call to the correct kernel function.

Syscalls are often used to not directly call the classic Windows APIs, and therefore avoid detection by antiviruses.

We now know that the malware uses syscalls to call low-level APIs. As my knowledge on the subject is non-existent, I decide to inform myself thanks to the excellent video from OAlabs on the subject:

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

But a passage in the video strongly resembles the code I am analyzing. In this video, the author uses a project called SyscallsWhisper2 which allows him to use syscall calls in his program.

https://github.com/jthuraisamy/SysWhispers2

After reading the source code and comparing it with the implementation of the malware, I was able to understand how the APIs were resolved via syscalls:

  • First, the malware saves the arguments it wants to pass to the function.

  • Then, a hash is passed to the API resolution function.

  • The resolution function will create a table containing the name of the API (e.g. ZwTerminateProcess, allowing to terminate a program), its address, as well as the hash of its name.

  • The APIs are ordered by increasing address, and the order corresponds to the syscall identifier (for example, if the function is third on the list, its identifier will be 0x3).

  • The function therefore returns the identifier corresponding to the requested hash.

  • The arguments are restored in the x64 registers.

  • The syscall call occurs.

Automation

We now know how the syscall resolution function works. Can we automate it to automatically resolve API names? Yes, but first we need to get the syscall table. This table changes with each new version of Windows (build version), so it’s most reliable to create our own table.

  • First, we need to retrieve the list of all Zw APIs. These are in ntdll.dll and can be extracted using tools like DllExportsViewer.

  • Once we have this list, we will reorder the APIs by address in ascending order.

  • Finally, we will hash the API name with SDBM.

Here is the hashing function that this malware uses to hash the API names:

The first time I encountered this hashing algorithm was in 2020 when analyzing Emotet. The number 65599 generally corresponds to SDBM.

Note that the hashing algorithm is not the same as in SyscallWhispers, which uses XOR mixed with ROR8.

Here is an excerpt from the syscall table for my system:

 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

Once this table is created, we can rename each function by its API name using a simple IDAPython script:

 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

Anti-debugging is a technique used by almost all malware to crash or change behavior if it detects a debugger.

This malware uses about ten techniques that mix anti-debugging and anti-VM to ensure that the program is not analyzed or running in a sandbox.

Here are all the functions that can detect a debugger or a VM. The instruction MEMORY[Ø] = 20108 crashes a debugger. Therefore, if any of these functions returns True, the program crashes.

In order, a summary of the different techniques:

  • NtSetInformationThread with the parameter 0x11 allows hiding a debugger’s thread, making it possible for breakpoints to not trigger and code to execute without being analyzed.

  • IsBeingDebugged uses the PEB (Process Environment Block) to see if the process is being debugged.

  • CheckURSSLocale: This function is particular, it checks the language used on the computer and compares it to the languages of the East. If the malware is executed on a computer in these countries, it will stop.

  • NtGlobalFlags is a PEB value that is set to 0x70 when the malware is debugged.

  • NtQueryInformationProcess and NtQuerySystemInformation can also determine if the malware is being debugged.

  • RtlAddVectoredExceptionHandler allows executing code when a breakpoint is executed (I did not identify any particular behavior in this sample).

  • The instruction Int 2D acts as a breakpoint and detects a debugger.

  • The malware will then check if any of the attached DLLs correspond to a known antivirus editor’s DLL.

  • Check the program name and compare it with certain names.

  • Compare the running programs to a list, detecting analysis tools and tools like vmtools.

  • Then, check registry keys to detect a 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"
  • Verification of registry keys related to the BIOS (to detect a VM)

  • Checking for known emulation frameworks

  • Checking for the presence of a driver (which one?)

  • Finally, using the “rtdsc” instruction to measure the time elapsed between each instruction and detect a debugger.

This program uses a lot of techniques, which can make analysis much more complex. However, there is a simple patch to bypass these detections that consists of changing the conditional instruction “test eax, eax” to “xor eax, eax” in order to completely ignore the return of the functions.

API resolution

When trying to evade detection, it is generally not a good idea to put all your eggs in one basket. Here, APIs were called through syscalls. Despite this, there were still many unresolved APIs, which suggests that the sample uses another method of resolution.

This function is very simple, it uses the same method as when resolving syscalls.

First, it loads a DLL via its SDBM hash, saves its address, and then searches through each of its exports and finds the APIs via their hashes.

These APIs can be resolved using a Python script.

 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')

Here is how it works:

  • Retrieves exports from selected DLLs

  • Creates a list of “DLL Name; Hash” pairs

  • For each call to “resolve_function_by_hash”, retrieves the hash argument and compares it with the list

  • Renames the qword that will contain the address by the API name

Main function

The malware will then take 2 paths, depending on whether it is launched as an administrator or not. Here, I only analyzed the behavior if launched as admin.

Preventing computer shutdown

First, the malware will prevent the computer from restarting by creating a window and adding a message that will be displayed when the user wants to shut down their computer:

Bootloader data verification

The malware will then check the status of the bootloader. Is it in UEFI? If not, it stops, if yes, it checks if Secure Boot is enabled:

Creating the driver

The rest is a bit more unclear to me. We know that the malware will install a malicious driver to install itself in the UEFI. This driver is indeed included in the malware (as we saw at the beginning of the analysis) and will be decrypted in AES-CBC later:

The malware will then contact Microsoft’s official website to download other drivers, in my case, it never validated this download:

Finally, the driver will be stored in the UEFI:

Disabling security functions

Hypervisor Enforced Code Integrity & Bitlocker will finally be disabled, allowing greater freedom of action for the malicious driver:

Erasing traces

The last step of this malware will be to erase its traces. First, the malware file will self-destruct, so it will be untraceable afterwards.

The window created earlier to prevent shutdown will also be deleted.

Finally, the malware will restart the computer in order to load the malicious driver and move on to the next step.

Conclusion

This malware has undoubtedly led to the best analysis I have been able to perform since I started this blog. I understood almost all of the functions of this binary, which allowed me to provide a very detailed analysis about it.

However, I regret not having had more time to analyze the malicious driver. Although it was not part of the initial challenge, it would have been an opportunity to discover the tools and procedures for kernel mode analysis.

In summary, although low-level (syscalls, UEFI calls, COM objects), this malware was relatively easy to understand due to the lack of obfuscation, but it remains very interesting.

comments powered by Disqus
Built with Hugo
Theme Stack designed by Jimmy