Destructive IoT Malware Emulation – Part 2 of 3 – Hooking Techniques

Aug 1, 2024 •
Tatjana Ljucovic
Tatjana Ljucovic's Bild

Tatjana Ljucovic

Active since: 2019

destructive,iot,malware,qiling,wiper

Welcome back to part 2 of our IoT Malware Emulation series. If you are new here, check out part 1 where we describe the setup to emulate malware.

Just as a reminder, we have a Docker container and are using our script “Qiliot,” which is based on Qiling, to emulate the IoT wiper malware AcidRain. AcidRain searches for some storage devices and primarily targets both character-based and block-based devices. We manually created these devices as files.

In this episode, we will discuss the technical aspects. We fronted some issues, which we will describe along with our solutions. Let’s start with an issue we teased in part 1, but first, a small addition.

Define Analysis Cases

As we mentioned before, AcidRain overwrites two different storage devices, which will be discussed later. We want to mention that we defined four emulation cases. The goal is to run every execution path. Each emulation execution path is defined by which storage device we have and whether AcidRain has root privileges. Root privileges don’t change the functionality in AcidRain; they just alter the sequence of its tasks.

        # Set attributes for AcidRain cases.
        match emucase:
            case 0:
                self.root = True
                self.mtd_type = 4
            case 1: 
                self.root = True
                self.mtd_type = 0
            case 2:
                self.root = False
                self.mtd_type = 4
            case 3:
                self.root = False
                self.mtd_type = 0

To select a defined emulation case in our environment, we will set a parameter when running Qiliot:

parser.add_argument('-c', '--emucase', required=True, type=int, metavar="EMULATION CASE", default="INFO", choices=[0, 1, 2, 3])
Update: 27.08.2024

In the positive case of root privileges, we need to set up everything relevant for AcidRain and the emulation environment:

# Set up everything relevant for root
if self.root:
    ql.os.uid = 0
    ql.os.gid = 0
    ql.os.euid = 0
    ql.os.egid = 0
    ql.os.root = True

Bugs and Issues

Right at the beginning, when we emulated AcidRain, we ran into an error with getdents64 which occurs in the Qiling framework.

open("/", 0x10080, 00) = 3
0x004018f4: fstat(fd = 0x3, buf_ptr = 0x7ff3cb38) = 0x0
0x00403418: fcntl(fd = 0x3, cmd = 0x2, arg = 0x1) = 0x0
brk: increasing program break from 0x488000 to 0x48a000
Write dir entries: bytearray(b’\x00\x00\x00\x00\x00\x06R\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x15\x04.\x00’)

[... Write dir entries logs ..]

Write dir entries: bytearray(b’\x00\x00\x00\x00\x00\x06O<\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1e\x08bram_1.bin\x00’)
getdents64(0, /* 37 entries */, 0x1000) = 1046
0x0040365c: getdents64(fd = 0x3, dirp = 0x7ff3bc00, count = 0x1000) = 0x416
Traceback (most recent call last):

[...Traceback log ...]

qiling.exception.QlErrorCoreHook: _hook_intr_cb : not handled

The problem is, AcidRain is compiled and runs under the MIPS architecture. MIPS is part of the RISC architecture family, where data alignment is crucial. Whenever data access happens, it needs to be naturally aligned (e.g., a 16-bit access must be on a 2-byte boundary). Qiling had not considered this case. That’s why we needed to fix it and create a pull request in the Qiling project (by the way: it is still open).

Note: Until the pull request is merged, we have a workaround that includes patch files rolled out by the Dockerfile.

From here, the fun part starts: Hooking syscalls … - Not yet!

The Daemon Process

After the bug fix, we can see that Qiling emulates AcidRain from beginning to end (without the malicious part because of “not implemented syscalls”, which I will get to shortly). In the terminal, we can see all the syscalls that AcidRain uses. However, in the log file, we only see the beginning, meaning a significant amount of logs are missing.

Long story short: The problem is that AcidRain creates a new child process with the fork syscall and terminates the parent process, hiding itself as a daemon process. It invokes the setsid syscall to initiate a new session. Since a terminal can only host one session, this action classifies the process as a daemon and appoints it as the group leader. As a result, AcidRain tries to function as a daemon, remaining undetected and avoiding termination via the terminal.

However, by terminating the parent process with the exit syscall, Qiling halts the process with ql.os.stop, thereby concluding the emulation and triggering the summary output. In contrast, for child processes, Qiling employs os._exit(0), leading to an immediate termination without a proper halt in emulation or a summary output. This scenario is not indicative of a flaw in Qiling but rather highlights a workaround that fails to fulfill the specific requirements of this analysis. Therefore, modifications have been made to ensure child processes are halted in the same manner as the parent processes.

The hook looks like this:

    def hook_fork(self, ql:Qiling, *args, **kwargs):
        '''
        Hook fork to set ql.os.child_processes to False for every child to prevent qiling to do os.exit()
        
        Args:
            ql (Qiling): The Qiling emulator instance
            *args, **kwards: Additional and keyword argumentd
        '''
        # Prevent qiling doing os.exit()        
        ql.os.child_processes = False

And now, before the ql.run() command which starts the emulation, we add a hook to Qiling that is always triggered after fork has been called:

ql.os.set_syscall("fork", self.hook_fork, QL_INTERCEPT.EXIT)

Qiling checks whether the current process is a child by using the attribute child_processes. To achieve comprehensive logging, it is practical to patch this attribute to false after a fork syscall, ensuring that each child process is appropriately stopped. If the analyzed process forks, the analysis also forks to track all paths of execution. It is worth noting that this customization does not affect the functionality of AcidRain’s emulation.

The modifications ensure that it now starts and exits properly.

And now … Hooking the syscalls.

Hooking Overwrite

Qiling emulates a variety of syscalls, including the ioctl syscall. This syscall is used to perform operations on storage devices (or various other devices). These operations are defined as commands within the ioctl syscall. Qiling has not implemented all commands, only a few. So the goal is to hook the ioctl syscall and implement the commands that AcidRain needs.

First of all, AcidRain uses the following ioctl syscalls for overwriting storage devices:

Command Value Info
BLKGETSIZE64 0x40041272 Retrieve the size of the block device in bytes
MEMGETINFO 0x40204d01 Get memory device information
MEMUNLOCK 0x80084d06 Unlock a memory region
MEMERASE 0x80084d02 Erase a memory region
MEMWRITEOOB 0xc00c4d03 Write out-of-band data to a memory region
MEMLOCK 0x80084d05 Lock a memory region
Figure 01: ioctl syscall commands used by AcidRain

While Qiling has implemented the ioctl syscall, it does not support these specific commands. Therefore, we are creating an enum of the necessary ioctl commands containing both the name and the hex value like we can find here:

class IoctlCommands(Enum):
    BLKGETSIZE64 = 0x40041272
    MEMGETINFO = 0x40204d01
    MEMUNLOCK = 0x80084d06
    MEMERASE = 0x80084d02
    MEMWRITEOOB = 0xc00c4d03
    MEMLOCK = 0x80084d05

Custom Hook

We defined different emulation cases. To set the storage device (mtd_type), we need to create a custom hook for the ioctl syscall to expand the parameter list. Our custom call hook is assembled with partial and then attached to the ioctl syscall with the intercept type CALL:

custom_hook = partial(self.hook_ioctl, mtd_type=self.mtd_type)
custom_hook.__name__ = "hook_ioctl"
ql.os.set_syscall('ioctl', custom_hook, QL_INTERCEPT.CALL)

Hook for Block-Based Devices

AcidRain searches for storage devices by generating device names. If it finds a match, it starts its overwriting function. As mentioned before, this function differentiates between block-based and character-based storage devices. We will begin with the block-based devices and later build up the hook for character-based devices.

So, let’s begin. If a name matches, AcidRain uses the ioctl command BLKGETSIZE64 to determine the block size and then overwrites it with decrementing data. The data is overwritten in 0x40000 byte blocks, with synchronization every 1024 loops using fsync.

[ ... ]
0x004019d4: getuid() = 0x0
open("/dev/sda", 0x1, 00) = 3
0x00401b28: open(filename = 0x7ff3cd98, flags = 0x1, mode = 0x0) = 0x3
0x00401a44: ioctl(fd = 0x0, cmd = 0x40041272, arg = 0x7ff3cd58) = -0x1 (EPERM)
lseek(3, 0x0, 0) = 0
0x00401aa4: lseek(fd = 0x0, offset = 0x0, origin = 0x0) = 0x0
0x00401d34: write(fd = 0x3, buf = 0x447004, count = 0x40000) = 0x40000
0x00401d34: write(fd = 0x3, buf = 0x447004, count = 0x40000) = 0x40000
0x00401d34: write(fd = 0x3, buf = 0x447004, count = 0x40000) = 0x40000
0x00401d34: write(fd = 0x3, buf = 0x447004, count = 0x40000) = 0x40000
[ ... ]

In our environment, /dev/sda is not a real block device, so ioctl syscalls cannot be effectively executed on it. In cases where the ioctl command fails, AcidRain sets the value to 2^64 - 1 (0xFFFFFFFFFFFFFFFF), indicating the size of data intended for writing. This would result in AcidRain calling the write system call approximately 70 trillion times. Consequently, the fsync function would be invoked approximately 69 billion times, either as a routine operation or if the write process fails due to reaching the end of the file. The emulation of the ioctl syscall below returns a result value of 0x0, signaling successful execution, and writes the size to the memory pointed to by the ioctl parameter.

def hook_ioctl(self, ql: Qiling , fd:int, request, pointer, mtd_type, *args, **kwargs):
    '''
    Hook ioctl to simulate request commands with MTD_NANDFLASH storage devices.
    Return successful operation and write needed values of structs onto the stack.
    '''

    if request == IoctlCommands.BLKGETSIZE64:
        # Write size for block devices onto stack. Value of 270008320 is 1026 x 0x40000
        # to trigger in AcidRain the fsync after every 1024 x 0x40000 write syscall
        ql.mem.write(pointer , ql.pack64(270008320))
        return 0x0
    return -0x1

Like with the fork hook, we need to add the hook to the emulation. But here, we will use the intercept type CALL as shown below::

ql.os.set_syscall('ioctl', custom_hook, QL_INTERCEPT.CALL)

AcidRain synchronizes the data with the fsync syscall after every 1024 write operations, so in the emulation, the size is set to 1026 times 0x40000, equaling 0x10080000 (270008320). This setting triggers the synchronization process once and executes it. After 1026 iterations of the write system call, a return value of -1 is observed, indicating the completion of the overwrite process. AcidRain then synchronizes the new data and closes the file before searching for the next device.

How we implemented the ioctl hook in Qiliot is described and implemented in our GitHub project Qiliot.

Okay, got all that? Great - that was just the warm-up. Now, let’s extend the hook for character-based devices.

Hook for Character-Based Devices

AcidRain has a special case for those with names starting with /dev/mtd and generates device names from 0 to 99. AcidRain starts as follows:

The fstat function provides the status of the current file in the form of a stat structure, which includes the mode_t field describing how the file is protected. Various flags can be set in this field, as detailed in the Linux Man Page for fstat. For AcidRain, the initial flag in the mode that describes the file type is critical.

AcidRain performs a bitwise AND operation between the mode value and the file mask type value 0xf000, expecting the result 0x2000, which represents a character device.

If the file is indeed a character-based device, AcidRain uses the ioctl syscall with the MEMGETINFO command to retrieve further data. The data will be written into the MtdInfoUser struct, which contains information such as the size of the storage device, the sizes of the areas for writing and erasing, and the type of MTD device which we mentioned in the beginning where we defined the emulation cases.

class MtdInfoUser:
    '''
    Initialize struct for mtd_type. Is utilzed for ioctl syscall with MEMGETINFO.
    Needed for MTD type = 4 (NANDFLASH)
    '''
    def __init__(self, type:int):
        self.type = type # Type 0 or Type 4
        self.flags = 0
        self.size = 100  # 1 MB in local_d4l
        self.erasesize = 50 # 0x1000 in local_d0
        self.writesize = 256
        self.oobsize = 64

To handle the MEMGETINFO write the required information into memory, you can extend the hook_ioctl function as follows:

        if request == IoctlCommands.BLKGETSIZE64:
            # Write size for block devices onto stack. Value of 270008320 is 1026 x 0x40000
            # to trigger in AcidRain the fsync after every 1024 x 0x40000 write syscall
            ql.mem.write(pointer , ql.pack64(270008320))
            return 0x0
        elif request == IoctlCommands.MEMGETINFO:
            # Handle MEMGETINFO and write onto stack the mtd_info_user structure with values.
            mtd_info = MtdInfoUser(type=mtd_type)
            ql.mem.write(pointer, ql.pack8(mtd_info.type))
            ql.mem.write(pointer + 4, ql.pack32(mtd_info.flags))
            ql.mem.write(pointer + 8, ql.pack32(mtd_info.size))
            ql.mem.write(pointer + 12, ql.pack32(mtd_info.erasesize))
            ql.mem.write(pointer + 16, ql.pack32(mtd_info.writesize))
            ql.mem.write(pointer + 20, ql.pack32(mtd_info.oobsize))
            return 0x0

These hooks ensure that AcidRain can retrieve the necessary information from the character-based MTD devices and proceed with its intended overwrite operations.

AcidRain then unlocks the MTD device to progressively erase it based on the erasure size from the MEMGETINFO struct. AcidRain distinguishes between MTD devices of type MTD_NANDFLASH (0x4) and others. We have hardcoded all this information and written it to the current memory pointer using our ioctl hook, so that AcidRain can function properly.

For all other ioctl commands, we will expand the ioctl hook. The next step is to unlock the storage device. AcidRain uses the MEMUNLOCK command, and it needs the EraseInfoUser struct:

class EraseInfoUser:
    '''
    Initialize struct for erase_info_user. Is utilzed for ioctl syscall with MEMUNLOCK and MEMERASE request.
    Needed for MTD type = 4 (NANDFLASH)
    '''
    def __init__(self, start, length):
        self.start = start
        self.length = length

This defines the block where it starts and the length, which AcidRain can delete. MEMUNLOCK and MEMERASE use this struct. MEMUNLOCK is used to open the area to erase, and MEMERASE contains the information of the next block to be deleted. So we will expand our hook with the following commands:

        elif request == IoctlCommands.MEMUNLOCK:
            # Handle MEMUNLOCK to unlock a device and write onto stack the erase_info_user struct
            # with informations where acidrain starts to erase the flash and the length
            erase_info_user = EraseInfoUser(20, 40)
            ql.mem.write(pointer, ql.pack32(erase_info_user.start))
            ql.mem.write(pointer, ql.pack32(erase_info_user.length))
            return 0x0
        elif request == IoctlCommands.MEMERASE:
            # Handle MEMERASE and write new section onto stack which acidrain trys to deletes next
            erase_info_user = EraseInfoUser(80, 250)
            ql.mem.write(pointer, ql.pack32(erase_info_user.start))
            ql.mem.write(pointer, ql.pack32(erase_info_user.length))
            return 0x0

In the case of other MTD types, AcidRain utilizes the MEMWRITEOOB command to overwrite the storage device. To lock the device, it uses MEMLOCK. Here, we need to expand our ioctl hook again but only with the return code 0x0, and pretend that the operation is successful. Feel free to implement additional functionality, but for the purpose of emulating and executing AcidRain, it is not necessary:

        elif request == IoctlCommands.MEMWRITEOOB:
            return 0x0
        elif request == IoctlCommands.MEMLOCK:
            return 0x0

After these operations, AcidRain uses implemented syscalls to ensure that the data modifications are fully written to the storage device and that the device is in a consistent state post-deletion.

open("/dev/mtd0", 0x2, 00) = 3
0x00401b28: open(filename = 0x7ff3cd98, flags = 0x2, mode = 0x0) = 0x3
Received interrupt: 0x11
0x004018f4: fstat(fd = 0x3, buf_ptr = 0x7ff3cbd8) = 0x0
0x00401a44: ioctl(fd = 0x3, request = MEMGETINFO, pointer = 0x7ff3cca4) = 0x0
0x00401a44: ioctl(fd = 0x3, request = MEMUNLOCK, pointer = 0x7ff3cc90) = 0x0
0x00401a44: ioctl(fd = 0x3, request = MEMERASE, pointer = 0x7ff3cc90) = 0x0
0x00401a44: ioctl(fd = 0x3, request = MEMUNLOCK, pointer = 0x7ff3cc90) = 0x0
0x00401a44: ioctl(fd = 0x3, request = MEMERASE, pointer = 0x7ff3cc90) = 0x0
0x00401a44: ioctl(fd = 0x3, request = MEMUNLOCK, pointer = 0x7ff3cc90) = 0x0
0x00401a44: ioctl(fd = 0x3, request = MEMERASE, pointer = 0x7ff3cc90) = 0x0
0x00401a44: ioctl(fd = 0x3, request = MEMUNLOCK, pointer = 0x7ff3cc90) = 0x0
0x00401a44: ioctl(fd = 0x3, request = MEMERASE, pointer = 0x7ff3cc90) = 0x0
0x00401a44: ioctl(fd = 0x3, request = MEMWRITEOOB pointer = 0x7ff3cc98) = 0x0
0x00401a44: ioctl(fd = 0x3, request = MEMUNLOCK, pointer = 0x7ff3cc90) = 0x0
0x00401a44: ioctl(fd = 0x3, request = MEMERASE, pointer = 0x7ff3cc90) = 0x0
0x00401a44: ioctl(fd = 0x3, request = MEMWRITEOOB, pointer = 0x7ff3cc98) = 0x0
0x00401a44: ioctl(fd = 0x3, request = MEMUNLOCK, pointer = 0x7ff3cc90) = 0x0
0x00401a44: ioctl(fd = 0x3, request = MEMERASE, pointer = 0x7ff3cc90) = 0x0
0x00401a44: ioctl(fd = 0x3, request = MEMWRITEOOB, pointer = 0x7ff3cc98) = 0x0
0x00401a44: ioctl(fd = 0x3, request = MEMUNLOCK, pointer = 0x7ff3cc90) = 0x0
0x00401a44: ioctl(fd = 0x3, request = MEMERASE, pointer = 0x7ff3cc90) = 0x0
0x00401a44: ioctl(fd = 0x3, request = MEMWRITEOOB, pointer = 0x7ff3cc98) = 0x0
fsync(3) = -1
0x00401974: fsync(fd = 0x3) = -0x1 (EPERM)
0x00401aa4: lseek(fd = 0x3, offset = 0x0, origin = 0x0) = 0x0
0x00401a44: ioctl(fd = 0x3, request = MEMLOCK, pointer = 0x7ff3cc90) = 0x0
0x00401a44: ioctl(fd = 0x3, request = MEMLOCK, pointer = 0x7ff3cc90) = 0x0
0x00401a44: ioctl(fd = 0x3, request = MEMLOCK, pointer = 0x7ff3cc90) = 0x0
0x00401a44: ioctl(fd = 0x3, request = MEMLOCK, pointer = 0x7ff3cc90) = 0x0
fsync(3) = -1
0x00401974: fsync(fd = 0x3) = -0x1 (EPERM)
close(3) = 0

Hooking Reboot

After the overwrite functionality, AcidRain attempts to reboot the device in a redundant and excessive manner.

First, it uses the reboot syscall with different magic numbers specifying how the system should reboot. This syscall is called four times. Secondly, it utilizes the fork syscall also four times to continue executing Linux reboot binaries and to exit the child processes.

For both the reboot and execve syscalls, we hook them and simulate successful execution. We do not actually want to reboot the device nor execute the reboot process. Generally, we aim to emulate a new process which the malware is invoking, but in this case, the reboot binary would typically be emulated by the Qiling framework. We choose to avoid this as it would include all related information in the logs, which is not desired in this scenario. The implemented hooks look like this:

    def hook_reboot(self, ql:Qiling, magic:int, magic2: int , cmd:int, *arg):
        return 0x0

    def hook_execve(self, ql: Qiling, filename, argv, envp, **kwargs):
        ql.log.info(f"Simulates execve: {ql.os.utils.read_cstring(filename)}")
        return 0x0

These hooks are integrated as usual with the following commands:

        ql.os.set_syscall('execve', self.hook_execve, QL_INTERCEPT.CALL)
        ql.os.set_syscall('reboot', self.hook_reboot, QL_INTERCEPT.CALL)

With the implementation of the hooks and fixes, AcidRain was successfully emulated. According to the log messages, it appears that AcidRain executed its primary functionalities.

Note: The logging mechanism was patched to yield direct and readable output. For instance, in Debug mode, the written data is displayed, which can clutter the log; hence, only the length of the written data is shown. You can find the patches in our GitHub project of Qiliot.

Now we want to see, I mean really see, if this works, and we love nothing more than numbers. For this, Qiling offers a function to create coverage files. How we utilize this functionality, what the results look like, and whether it’s as simple as it sounds will be covered in part 3.

Spoiler alert: No, it didn’t work out of the box. :D