Unpacking malware

In this post I’ll show you a common trick used to unpack malware quickly that works with most packers. I used it a lot when I was reversing malware samples every day, so I’ve decided to share it.

Finding a sample

I went to MalwareBazaar’s statistics page to find out what’s most popular nowadays:

MalwareBazaar statistics

Mirai is #1 - but it’s a botnet that mostly targets IoT / Linux and I’m looking for commodity Windows malware, so I’m picking #2, Vidar, which according to my LLM is:

Vidar is an information-stealing malware that infects Windows systems and collects sensitive data such as saved passwords, browser cookies, cryptocurrency wallet information, and system details.

Looking at its samples page, there’s plenty - great! I chose a random hash tagged exe1 so we can go straight to unpacking: 958f2b36bdddb666f1a635744fac41d16007a667995154a3f0dbd88a3ac95026.

Note that I’ve never looked at this malware family, so I have no previous knowledge of it and will be going through it for the first time with you along the post.

First look

Dropping the sample into IDA we see the entry point:

IDA first view

Immediately this strikes me as Go. Googling the entry point’s name shows:

Google search oep

…confirming that the sample is written in Go. Following through the library code and arriving at runtime.main() shows a call to main_main(), presumably the actual main function of the Go code:

main_main call

However, decompilation for that function doesn’t work:

main can’t be decompiled: stack frame too big

Normally, the “stack frame too big” error means that IDA incorrectly parsed the stack pointer-manipulating instructions either because of an analysis mistake, or because the sample is intentionally written to be confusing, so we’d have to go into assembly / graph mode, trace the stack pointer, find where it gets borked, and fix it. But here, I couldn’t even go into graph view:

Graph is too big, over 1000 nodes

I tried increasing the maximum graph size to 2000, 3000, and even 5000 nodes, but it still errored out saying it’s too big! I only got the graph to show up by setting the maximum number of nodes at 10k. That’s a lot:

Go graph

Big functions exist, and Go can inline a lot of code - but malware is almost always packed, and packers often inflate function size with either meaningless or entirely invalid instructions to make analysis harder. Here, the shape and patterns of the graph look semi-credible in that it’s not a completely messed up graph with dangling nodes, but it also doesn’t look a lot like regular code. Furthermore, browsing online, there are multiple blog posts claiming Vidar to be written in C/C++, not Go, so let’s attempt to skip all the fluff and try to unpack directly.

Unpacking technique

To unpack itself, malware generally needs to either:

  1. Allocate a new writable page, write to it, then make it executable, or
  2. Allocate a new writable + executable page and write to it

…and then jump to the newly unpacked code and run it.

There’s no universal way of telling IDA “put a breakpoint wherever you see a jmp malware_oep to catch the malware right before execution”, but we can do the next best thing: put a breakpoint on the allocation / protection functions to catch the page where the unpacked malware will be written. Notice that we’re looking for executable pages, as the code has to run after being unpacked. We can do this with conditional breakpoints:

  1. BP on NtAllocateVirtualMemory, breaking only when the requested page protection type is executable (parameter ULONG Protect), and
  2. BP on NtProtectVirtualMemory, breaking only when the new protection type is executable (parameter ULONG NewAccessProtection)2

For the condition expressions, I used:

# NtAllocateVirtualMemory(a1, a2, a3, a4, a5, Protect)
# 6th parameter in __fastcall convention is in rsp+0x30
bool(get_wide_dword(get_reg_value("RSP") + 0x30) & 0xF0)

# NtProtectVirtualMemory(a1, a2, a3, NewAccessProtection, a5)
# 4th parameter in __fastcall convention is in r9
bool(idc.get_reg_value("r9") & 0xF0)

# Executable page flags:
# 
# 0x10 -> PAGE_EXECUTE
# 0x20 -> PAGE_EXECUTE_READ
# 0x40 -> PAGE_EXECUTE_READWRITE
# 0x80 -> PAGE_EXECUTE_WRITECOPY
#
# 0xF0 == (PAGE_EXECUTE | PAGE_EXECUTE_READ |
#          PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY)

I put a breakpoint on the entry point and ran the sample. Once there, I put the conditional breakpoints on the syscalls (named ntdll_Nt* in IDA), and let it run.

The first hit was attempting to make some advapi32 code executable, so I ignored it and continued. The next one landed on NtAllocateVirtualMemory:

NtAllocateVirtualMemory call

The stack trace shows that the call is coming from user code (internal_runtime_syscall_windows.asmstdcall.abi0 is a Go library function), so it’s possible this is the call we’re looking for. I stepped through the code and noted the address of the newly allocated page, 0x0000000110000000, and looked at it in the Segments (Ctrl + S) view:

Newly allocated executable page

The page is still empty, but as you can see it’s RWX (readable, writable, executable), so presumably it’ll be written to and executed.

Note that to write the final unpacked code, you need some sort of loop, e.g.:

// Pseudocode

// Write unpacked code to target memory page
code = get_unpacked_code();
for (int i = 0; i < code->len; i++) {
    new_page[i] = code->buf[i];
}

// Jump to it
jump_to(new_page);

To catch the loop right before the jump to the unpacked code, we can set a hardware breakpoint on write somewhere on the page:

Hardware breakpoint on write

Press F9 to run, and we see this immediately:

Hardware breakpoint triggered

In this case, we end up at runtime_memclrNoHeapPointers, which is a Go library function that zeroes out a bunch of memory:

memclrNoHeapPointers call

This is just zeroing out the newly allocated page - still no evidence of unpacked malware. But it might come soon, so let’s run again: the breakpoint triggers again, and this time, we see:

Call to memmove

A call to runtime_memmove - could this be it? Let’s check the allocated page:

Unpacked binary

Here we see the MZ header, which is in fact the unpacked (or next stage) binary. Here we can just dump it out with any PE dump tool, and analyze it statically:

Before and after unpacking

On the left is the original ~6.3MB binary, on the right the dumped ~1.3MB binary.

Over 1MB could still be indicative of further obfuscation or packing, but that’s for another post - here, we’re done unpacking this layer.

Conclusion

Most packers are lazy loops that decode the payload, allocate a new page, write to it, then execute it - so why spend time reversing and trying to understand them? We can just cut directly to the payload.

That’s it for this post - hope you found the technique useful.


  1. There are other sample types that are non executable - those are usually documents or other file types used for the delivery of the actual malware, containing some sort of technical or social engineering-style trick to get the victim’s machine to execute malicious code. ↩︎

  2. Note that there’s also NtAllocateVirtualMemoryEx, section mapping, and other ways to place the unpacked malware in executable memory, but the idea is the same: catch the target memory region at the syscall level. ↩︎