Decrypting Mirai configuration With radare2 (Part 2)

This is the third part of our three-part series on code emulation for malware analysis:

  • Part 1 explains how to use radare2’s function emulation feature, featuring a password-cracking exercise using radare2’s Python scripting plugin, r2pipe.
  • Part 2 demonstrates how to decode the configuration of the Mirai IoT botnet by writing an automation script using radare2’s Python scripting capabilities.
  • Part 3 (this post) builds on the previous script by adding support for searching encrypted string addresses and generating function signatures to locate the decryption routine dynamically.

In the previous two posts, we explored how to emulate string decryption routines using radare2 macros and Python scripts. We successfully decrypted parts of the configuration, but not all of it. In this post, we will enhance our automation script to solve this. Specifically, we will find the addresses of the encrypted data dynamically and feed them into our emulator.

We will also address another interesting challenge: when testing our script against different Mirai variants, the decryption function offset changes. Even though the underlying assembly remains identical, the hardcoded address breaks. We can elegantly solve this by creating function signatures—another incredibly useful feature in radare2. Let’s get right into it!

#Searching via Data References

If you analyzed the decryption routine closely in the previous post, you might argue that we took a unnecessarily complex path. Instead of manually constructing a fake configuration structure on the stack and modifying register values, why didn’t we just pass the configuration index as an argument and let the function emulation run naturally?

That would indeed be the ideal approach if the array of configuration structures existed statically in memory. However, it doesn’t. Mirai dynamically populates this configuration array at runtime. Let’s inspect the memory at 0x08052800—the base address of the configuration structure array.

As you can see, there are numerous cross-references pointing to this location. We see DATA XREFS originating from the function sub.7_1_700 (located at 0x0804d700). Let’s investigate what that function is doing.

This block contains a recurring pattern: pushing a byte value, pushing a global memory address, and invoking a function. This looks like a dynamic initialization routine copying encrypted strings and metadata into the runtime configuration array. Let’s disassemble the helper function called at 0x0804e0f0 to see what is happening under the hood.

This nested function contains a loop copying bytes from edx + ebx to edx + esi, with edx acting as the loop counter. This is a custom string/memory copy routine.

Why didn’t the malware authors use the standard library memcpy? Because Mirai is designed to be highly portable across stripped and embedded Linux environments that might lack standard C runtime libraries (libc). Writing custom implementations of standard functions ensures the malware runs reliably on almost any target.

Now that we know the initialization function (sub.7_1_700) references our encrypted data, we can programmatically retrieve these memory addresses. In radare2, the agaj command generates a control flow graph with local and global data references in JSON format. The destination addresses are stored in the title field of the nodes. We can parse this JSON in Python and feed the extracted addresses directly into our decryption function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Start address of the configuration initialization function
config_addr_start = '0x0804d700'

# End address of the initialization function
config_addr_end = '0x0804e080'

r.cmd('s ' + config_addr_start)

# Retrieve data references as a JSON object
data_refs = r.cmdj('agaj')

# Extract reference addresses from the node titles
data_refs = map(lambda y: y['title'], data_refs['nodes'])

for str_addr in data_refs:
print(emu_decrypt(str_addr))

Running this code produces the following output:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
+\xfb\x94l\x12l\x90\x8doxx\xfb\xbct\xf1\xbb\x12l\x10\xc0v}p(\x90\xab
zantari.duckdns.org
OGISyourdady.duckdns.org
\x15\xb3x\xfc\xa1xKuasa Menjejaskan Anda
\xfc\xa1xKuasa Menjejaskan Anda
Kuasa Menjejaskan Anda
/proc/
/exe
/fd
/proc/net/tcp
/maps
/status
.anime
/proc/net/route
/proc/cpuinfo
BOGOMIPS
/etc/rc.d/rc.local
g1abc4dmo35hnp2lie0kjf
assword
/dev/watchdog
/dev/misc/watchdog
/dev/FTWDT101_watchdog
/dev/FTWDT101 watchdog
/dev/netslink/

Success! We have recovered highly critical strings, such as the C2 domain (zantari.duckdns.org), virtual file paths (/proc/net/tcp, /maps), and fallback credentials (g1abc4dmo35hnp2lie0kjf).

However, we are still missing a few configuration strings that our previous heuristic detected. To get a complete extraction, we can try an alternative approach.

#Searching via Assembly Patterns

The initialization routine pushes arguments onto the stack immediately before calling the copy function. We can find every push instruction inside this routine, parse the pushed argument (the pointer to the encrypted string), and run our emulator on it.

We can search for instructions matching a specific pattern using radare2’s opcode search engine. The command /atj push searches for all push instructions and returns the results in JSON.

To avoid scanning the entire binary, we must limit our search window to the initialization function. We can restrict the search boundaries using the search.from and search.to configuration variables. Here is how we implement this in Python:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Define search boundaries
config_addr_start = '0x0804d700'
config_addr_end = '0x0804e080'

# Adjust radare2 search limits
r.cmd('e search.from = ' + config_addr_start)
r.cmd('e search.to = ' + config_addr_end)

# Search for all push instructions in the range
push_list = r.cmdj('/atj push')

for inst in push_list:
# We look for 5-byte instructions (push <32-bit immediate address>)
if inst['size'] == 5:
data_offset = inst['opstr'].replace('push ', '')
print(emu_decrypt(data_offset))

Using this assembly search pattern, we get the following output:

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
49
50
51
52
53
54
zantari.duckdns.org
OGISyourdady.duckdns.org
\x15\xb3x\xfc\xa1xKuasa Menjejaskan Anda
\xfc\xa1xKuasa Menjejaskan Anda
Kuasa Menjejaskan Anda
/proc/
/exe
/fd
/proc/net/tcp
/maps
/status
.anime
/proc/net/route
/proc/cpuinfo
BOGOMIPS
/etc/rc.d/rc.local
g1abc4dmo35hnp2lie0kjf
assword
/dev/watchdog
/dev/misc/watchdog
/dev/FTWDT101_watchdog
/dev/FTWDT101 watchdog
/dev/netslink/
V[Ov
WsGA4@F6F
ACDB
AbAd
iaGv
PRIVMSG
GETLOCALIP
KILLATTK
Eats8
/proc/self/exe
\x15\x09\x0ezu9>4w9=3uZxshell
shell
enable
system
sh
/bin/busybox kkuuaassaa
kkuuaassaa: applet not found
ncorrect
assword
ogin
enter
/bin/busybox ps
/bin/busybox kill -9
TSource Engine Query
/etc/resolv.conf
nameserver
/dev/watchdog
/dev/misc/watchdog
ox0PP2rRkIoK6qyZO166
dvrHelper
http

This is outstanding! This method extracts every single encrypted string, including critical commands (/bin/busybox ps, /bin/busybox kill -9) and protocol-specific patterns (TSource Engine Query).

Now that we can extract the complete configuration, let’s focus on portability. We want to remove all hardcoded offsets so this script works out-of-the-box on new Mirai samples.

#Creating and Searching Function Signatures

To locate functions dynamically in radare2, we can generate a unique signature. The z command family manages signatures. To create a signature for a function, seek to its starting address and run:

  1. zaf sub.7_1_700 config_func: This generates a signature named config_func for our configuration initialization routine.
  2. zaf fcn.decrypt decrypt_func: This generates a signature named decrypt_func for our string decryption function.

You can save these signatures to a file using zos [filename] and load them later using zo [filename]. We will use this signature database inside our Python script to find function boundaries automatically.

#Locating Functions Programmatically

Once signatures are loaded, we can scan the binary using z/. Matching locations are flagged in the sign flag space under the name sign.bytes.[signature_name].

To process these results in Python, we switch to the signature flag space using fs sign and query the flags in JSON format using fj.

Here is our updated Python routine to locate the decryption function dynamically:

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
def find_decryption_func():
func_sig = './mirai_decrypt_func.sdb'

# Load signature database
r.cmd('zo ' + func_sig)

# Perform signature scan
r.cmd('z/')

# Select the signature flag space
r.cmd('fs sign')

# Get flags as a JSON array
func_srch_res = r.cmdj('fj')
if len(func_srch_res) == 0:
print('[-] Encryption function signature not found')
return None
print('[+] Encryption function signature found')

# Extract start address of the first match
func_start_addr = func_srch_res[0]['offset']
r.cmd('s ' + str(func_start_addr))

# Retrieve function metadata
func_info = r.cmdj('afij')[0]

# Calculate function boundary
func_end_addr = func_start_addr + func_info['size']

# Find the address of the 9th instruction, where we want to overwrite ECX
halt_addr = r.cmdj('pdj 9')[-1]['offset']

# Restore the default flag space (crucial for subsequent searches!)
r.cmd('fs *')
return func_start_addr, halt_addr, func_end_addr

We do the same to locate our configuration initialization function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def find_config_func():
r.cmd('fs sign')
func_srch_res = r.cmdj('fj')

# Filter matches for our config_func signature
srch_res = list(filter(lambda x: 'config_func' in x['name'], func_srch_res))
if len(func_srch_res) == 0 or len(srch_res) == 0:
print('[-] Configuration function signature not found')
return None
print('[+] Configuration function signature found')

func_start_addr = srch_res[0]['offset']
r.cmd('s ' + str(func_start_addr))
func_info = r.cmdj('afij')[0]

func_end_addr = func_start_addr + func_info['size']
r.cmd('fs *')
return func_start_addr, func_end_addr

# Resolve offsets dynamically at runtime
config_addr_start, config_addr_end = find_config_func()
func_start_addr, addr_of_halt, func_end_addr = find_decryption_func()

Our script is now completely modular and immune to address offsets!

#Partial Emulation Troubleshooting Tips

During this analysis, I ran into a few edge cases. Here are some key takeaways for debugging radare2’s emulation engine:

  1. Unresolved System Calls: If your target function nested-calls other routines that make syscalls, ESIL will halt or behave unpredictably. Keep your emulation boundaries as narrow as possible.
  2. Uninitialized Globals: If the target routine reads global variables that aren’t mapped or initialized in your VM, it will cause infinite loops or invalid state.
  3. Byte Ordering: Pay close attention to endianness when writing addresses directly to the virtual stack. A single mismatched byte will point the emulator to unmapped memory, resulting in crashes.

#Conclusion

This concludes our three-part exploration of radare2’s partial code emulation capabilities. We reversed Mirai’s decryption logic, mapped its structures, automated cipher text extraction, and made our script robust and portable using function signatures. Emulating code directly saves hours of manual static analysis and avoids the need to port complex obfuscation logic to external scripts.

You can download the full, automated script on GitHub. If you are a malware researcher and want to analyze the samples used in this post, please email me using your work or institutional email address to verify your identity.

Thanks for reading, and happy reversing!

Comments

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×