Decrypting Mirai configuration With radare2 (Part 1)
This is the second part of a three-part series on code emulation for reverse engineering malware:
- Part 1 covers radare2 function emulation basics and walk through cracking a password-checking function using radare2’s Python scripting plugin,
r2pipe. - Part 2 (this post) details how to leverage emulation to decode the configuration of a Mirai IoT botnet sample by implementing a decryption script in Python.
- Part 3 refactors our script to automatically locate encrypted string references and generate function signatures, eliminating hardcoded function offsets.
In the previous post, we explored how to use partial code emulation to decrypt obfuscated strings inside a binary. In this post, we will look at a real-world sample of the infamous Mirai IoT malware. We chose Mirai because it hides critical configuration details—like command-and-control (C2) servers and ports—behind encryption. Since Mirai is compiled for multiple architectures, we will focus on the x86 variant for this analysis.
Our main goal is to automate the decryption of this configuration array using radare2. Along the way, we will perform static analysis on the binary and reverse-engineer the decryption routine to understand its inner workings.
If you aren’t familiar with Mirai, it is an IoT botnet that targets and infects networked Linux devices, turning them into a distributed denial-of-service (DDoS) platform. Because the malware can be cross-compiled for SPARC, MIPS, ARM, and x86 architectures, it poses a broad threat. Compounding this, the author leaked the original source code, giving rise to numerous script-kiddie variants. This widespread adoption is exactly why we need a reliable, automated way to extract their configurations. If you are curious, you can find the original leaked source code in this GitHub repository.
#Malware Files
We will analyze the following samples for this post. Interestingly, these samples map directly to the leaked source code linked above. However, we will proceed as if we do not have access to the source code to simulate a real-world malware analysis scenario.
| File SHA-1 Hash | File Name |
|---|---|
318998d8ad1e5a9be588ecccf9713c42788c9972 |
mirai-telnet.x86 |
a7c901114b6c767562c32d30c9f6a076a380a767 |
mirai-ssh.x86 |
#Locating the Decryption Routine
Malware authors frequently use simple bitwise operations—like XOR, bitwise shifts (SHR/SHL), or bit rotations (ROR/ROL)—to obfuscate strings. The decryption routine is usually a tight loop combining these instructions. To locate this routine in radare2, we can search for specific opcodes. You can list radare2’s search commands by running `/a?.
For our analysis, we can use **/at xor** to find every address containing an xorinstruction. This command searches by instruction family (other families includeand, or, push, etc.). To view all available instruction families, run /atl.
Running an xor search yields 226 hits. Most of these are false positives where a register is XORed with itself (e.g., xor eax, eax), which is a standard compiler optimization to set a register to zero. We are specifically looking for xor instructions where the source and destination operands are different.
We can perform similar searches for shift (shr/shl) and rotation (ror/rol) instructions. After filtering through these locations, we spot a function starting at 0x0804d680 with an unusually high density of these exact operations. Let’s dive in and reverse-engineer it.
#Reverse Engineering the Decryption Function
Before writing an automated script, we need a high-level understanding of how this function accepts arguments and manipulates data.
Open radare2’s visual graph mode by typing VV. This shows the function’s control flow graph. You can cycle through different layouts by pressing p. We will use this visual graph to map out the decryption logic.
This layout is characteristic of string decryption routines:
- Setup: The function initializes pointers to the encrypted string data.
- Key Loading: It loads the decryption key into a register.
- Decryption Loop: It loops through the cipher text, decrypting it byte-by-byte.
In some cases, malware adds a second layer of defense by decrypting the decryption key itself before using it, but the underlying emulation strategy remains the same. The key may be passed as a function argument, loaded from a local stack variable, or read from a global variable.
Before we dissect the assembly, let’s review how to navigate radare2’s graph view. The graph represents basic blocks as nodes connected by colored edges:
- Blue: Unconditional jumps.
- Green: Conditional jump taken (branch is true).
- Red: Conditional jump not taken (branch is false).
Each node displays a shortened hex address representing its start. In the screen above, you can press the hotkey gc to zoom into the node starting at d6a0. The currently active node is indicated by <@@@@@@>. This view is incredibly helpful because you can spot loops and conditional branches instantly. You can use the arrow keys to pan the graph.
Looking at the overall graph, we see the initialization block on the left, followed by an unconditional jump into the decryption loop. There is also a conditional check at the very beginning: if a certain condition is met, the code jumps directly to the function exit. This is likely a boundary check or validation parameter; if it fails, the function skips decryption and exits immediately. If validation succeeds, it finishes setup and enters the loop.
Let’s look at the initialization block on the left. The function pushes ebp, edi, esi, and ebx onto the stack and subtracts 0x1c from esp. This is standard function prologue behavior: the registers are saved so they can be restored later, and subtracting from esp allocates space for local variables. We can confirm this by jumping to the exit block using ga, where we see the corresponding epilogue:
1 | [0x804d66a] |
The stack space is reclaimed, the saved registers are popped, and the function returns.
Next, an xor instruction clears eax, and the lower 8-bit register al is loaded with a byte value from esp + 0x30. Interestingly, the compiler is addressing parameters directly via the stack pointer (esp) rather than the frame pointer (ebp). According to radare2’s analysis, this single byte is the only parameter passed to the function.
We would normally expect to see two parameters: a pointer to the encrypted string and the decryption key. Instead, we have a single integer argument (ranging from 0 to 31). The compiler then runs lea ecx, [eax*8 + 0x08052800].
The lea (Load Effective Address) instruction is calculating an offset using the pattern Base + (Index * Scale). Here:
- Index:
eax(our 0–31 function argument). - Scale:
8bytes. - Base:
0x08052800.
This single instruction reveals the layout of Mirai’s configuration. The configuration is an array of 8-byte structures located at 0x08052800. Each structure likely consists of two 4-byte fields (or a 4-byte pointer and some metadata).
Next, mov eax, dword [0x080526fc] loads a value from 0x080526fc into eax. We will keep an eye on this register to see how it is used. The routine then performs a comparison: cmp word [ecx + 4], 0. Since ecx holds our calculated structure address, ecx + 4 represents the second member of the structure (a 2-byte word). If this value is zero, the function jumps straight to the exit. Let’s look at the false branch (gc) when the value is non-zero:
1 | 0x804d620 [gc] |
Here, the value loaded from 0x080526fc into eax is duplicated across edi, esi, and ebp. It then performs right-shifts (SHR) of 8, 0x10, and 0x18 on these copies, saving the lowest byte of the original value (al) on the stack. This looks like the extraction of a 4-byte XOR key from a single 32-bit global variable. Let’s jump to node gd to see the loop logic.
This block contains multiple XOR operations and serves as the core decryption loop. The final instruction is a comparison that jumps back to the top of this block as long as the loop condition is met.
Let’s break down the registers. The ecx register is never modified, meaning it still points to our 8-byte configuration structure.
mov ebx, [ecx]: The first 4-byte member of our structure is loaded intoebx. This is a pointer to the encrypted cipher text.mov eax, ebx: The pointer is copied toeax.add eax, edx: The loop index (edx, initialized to 0) is added to the pointer to target the current byte.mov ebx, ebp: The XOR key (copied toebpearlier) is loaded intoebx.xor byte [eax], bl: The byte at the calculated address is XORed with the key bytebl, and written back in place.
This confirms our hypothesis: the value at 0x080526fc is the decryption key, and the loop decrypts the string in-place.
Now let’s find the loop termination condition. At the end of the node, eax is compared with edx. Since edx acts as our loop counter (incremented by 1 each iteration), it is being compared to the string length. The length is pulled from ecx + 4 (the second member of our structure). It is masked with 0xffff to ensure it is treated as a 2-byte integer.
We now have everything we need to emulate this function.
#Emulating the Decryption Routine
Instead of writing a custom Python script to replicate the XOR logic, we can let radare2’s ESIL (Evaluable Strings Instruction Language) engine run the assembly code for us.
To emulate a single string decryption, we can:
- Allocate space in the emulator’s virtual memory and write an encrypted string to it.
- Manually construct an 8-byte configuration structure in memory, pointing its first field to our encrypted string and its second field to the string length.
- Start execution from the beginning of the decryption function and halt it immediately after
ecxis populated. - Overwrite
ecxwith the address of our custom structure. - Resume execution until the function exits, then read the decrypted string from memory.
Normally, we can list string candidates using the ps @@ str.* command. However, because these strings are encrypted, they may contain null bytes that throw off radare2’s static detection. To work around this, we will assume a default string length of 100 bytes during emulation. Once decrypted, we can read it as a standard null-terminated string. Let’s test this strategy on an encrypted block at 0x08050ecd.
Here is the radare2 command sequence:
s 0x0804d680: Seek to the start of the decryption function.y 100 @ 0x08050ecd: Copy 100 bytes starting at0x08050ecdinto radare2’s clipboard.aei; aeim; aeip: Initialize the ESIL VM, allocate a stack (by default at0x100000), and set the instruction pointer (eip) to our current seek.yy @ 0x100000: Paste our 100 encrypted bytes into the VM’s memory at0x100000.wx 0x00100000 @ 0x100064: Write our virtual buffer address (0x100000) in little-endian format to0x100064. This is the start of our fake configuration structure.wv 100 @ 0x100068: Write the length (100) as the second member of our structure at0x100068.aecu 0x0804d694: Emulate instructions up to0x0804d694. This is whereecxis normally calculated from the index.ar ecx=0x100064: Overwriteecxto point to our custom structure at0x100064.aecu 0x0804d6f0: Resume emulation until the end of the function (0x0804d6f0).ps @ 0x100000: Print the decrypted null-terminated string from our buffer.
You can package this sequence into a reusable radare2 macro:
1 | (mirai_decrypt flg, s 0x0804d680, y 100 @ $0, aei, aeim, aeip, yy @ 0x100000, wx 0x00100000 @ 0x100064, wv 100 @ 0x100068, aecu 0x0804d694, ar ecx=0x100064, aecu 0x0804d6f0, ps @ 0x100000) |
You can run this macro using .(mirai_decrypt <string_address>).
While macros are great for testing, our goal is complete automation. We can write a Python script using r2pipe to automate this workflow:
1 | import r2pipe |
Now, we can expand this script to loop through all strings discovered by radare2:
1 | for str_obj in r.cmd('psj 1 @@ str.*').split('\n'): |
Running this script yields the following decrypted values:
1 | I@MVJLLVJMVJIOxrxxxxyxzantari.duckdns.org |
#Conclusion
We have made significant progress: we reversed Mirai’s decryption routine, mapped out its array structure, and successfully emulated it to extract several configuration strings (like C2 domains and shell commands).
However, we aren’t quite finished. Some of the output is still garbled, and radare2’s default string detection missed several encrypted blocks because they look like random binary data in their encrypted state. We need a more reliable way to locate these references.
In the next post, we will tackle this problem by extracting references directly from the configuration initialization functions and refactoring our script to find the decryption routine automatically using function signatures.
You can find the completed script on GitHub. The malware samples used in this analysis are available to security researchers upon request. Please send an email from your institutional address to request access. Thanks for reading!