Post

Deobfuscation of Lumma Stealer

Introduction

Lumma Stealer is an infostealer that has been around for several years now, and consistently tops statistics on sites like MalwareBazaar as one of the most commonly distributed malware families. When it first released, Lumma Stealer had little to no obfuscation at all. Eventually, it incorporated things like control flow flattening, opaque predicates and more recently around the beginning of 2024 began using control flow indirection. I set about developing a Hex-Rays plugin to deobfuscate the sample from the first link prior to the news about control flow indirection being added. However, this methodology should still be applicable to the newer version as long as the control flow indirection is removed first. In this writeup, I will go through the different challenges I experienced during this project and how I overcame them.

Initial Analysis of the Obfuscation

Upon opening the WinMain() function in IDA, we can immediately see that control flow flattening has been applied. This is one of the simplest instances in this particular binary:

alt text

All that is required to solve this function is more or less what I did in my old Agent Tesla post, which is to find which blocks correspond to the mapping numbers and patch the flattened blocks to jump to their proper destinations. Here, we either have jz or jnz instructions which check the dispatcher variable (eax register) value. If it matches and the opcode is jz, the jump is taken. Otherwise, if it’s jnz the fallthrough branch is taken. This is how it would look in it’s unflattened form:

1
2
3
4
5
6
7
int __stdcall __noreturn WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)
{
  mw_integrity_checks();
  mw_set_cnc();
  mw_main_routine();
  ExitProcess(0);
}

The next function looks much more complex. Not only is it much longer at 400 lines, but something weird is going on - there is a pattern that is repeated constantly in the function. It seems to involve a bunch of math operations, ends with an if statement which multiples a variable (in this case v9) by a random 16-bit constant, then subtracts 1 and checks if it equals v9. The dispatcher value changes depending on if this is true or not.

alt text

alt text

After looking closely, we can determine that these are opaque predicates.

1
8247 * v9 - 1 == v9

Why? Because the above would require v9 to be a fraction to be true, and these are integers. Therefore, the true branch of the jz is NEVER taken, and leads to junk code. That means v3 will always be set to 0xCA7D9612. There are additionally blocks that use jnz instead, and in those cases the opposite is true where the true branch is always taken.

But we aren’t out of the woods yet with this function. There is yet another problem:

alt text

A large amount of the flattened blocks don’t go directly back to the dispatcher. They go to what I call an ‘optimization block’. What is happening here is that since all of the affected blocks have their variable result stored in the eax register and require it to be moved to the stack variable var_1074, the compiler created one block and pointed them all to it instead of having a copy of that same ‘optimization’ block for every affected flattened block.

I noted down the aforementioned issues preventing me from unflattening for later, and began thinking of where to begin development of a plugin that would deobfuscate the binary.

Choosing a Starting Point

I had never worked with the Hex-Rays API at all before this, but I was aware of two projects that utilized it to deobfuscate flattened code. The first one is the original HexRaysDeob plugin written in C++ by Rolf Rolles of which every other Hex-Rays unflattener plugin has been based off of. If you have not read his writeup, I would suggest to do so as I consider it required reading. The second is D810 by Boris Batteux, which is written in Python and supports plugins/profiles in itself. I also highly recommend reading this writeup as well. However, not only am I much more comfortable using C/C++ over Python, but I wanted to work directly with the microcode itself and not have to in addition learn the inner workings of D-810, so I opted to update the former. In the end, various challenges I encountered would result in me more or less completely gutting the original plugin code.

The Journey Begins

During the development of the plugin, I didn’t necessarily go in the exact order as described below. I tried removing the opaque predicates before completely finishing the unflattening removal, which turned out to be a mistake. I also bounced around many functions, sometimes not finishing one before I began another and going back. However, to make the flow of this writeup easier to follow I will describe the process of the flattening removal function-by-function and then move on to the opaque predicates.

The original plugin works by finding the most frequent occurence of a comparison between a certain register or stack variable, which will be referred to as the dispatcher variable. Each of these comparison blocks’ numeric value that is being compared is then saved into a map with its corresponding child block. Next, the code detects mov instructions that move a numeric value into that variable, and patches those at the end to be jumps to the corresponding comparison block’s child block. Unmodified, it only searched for comparisons using the jz opcode. The first change I made to the plugin was about as easy as it gets: I added handling for the jnz cases as well. This change resulted in the unflattening of the WinMain() function.

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
int visit_minsn(void)
{
	// We're looking for jz instructions that compare a number ...
	if ((curins->opcode != m_jz && curins->opcode != m_jnz) || curins->r.t != mop_n)
		return 0;

	// ... against our comparison variable ...
	if (!equal_mops_ignore_size(*m_CompareVar, curins->l))
	{
		// ... or, if it's the dispatch block, possibly the assignment variable ...
		if (blk->serial != m_DispatchBlockNo || !equal_mops_ignore_size(*m_AssignVar, curins->l))
			return 0;
	}

	int blockNo;

	switch (curins->opcode)
	{
	case m_jz:

		// ... and the destination of the jz must be a block
		if (curins->d.t != mop_b)
			return 0;

		blockNo = curins->d.b;
		break;
	case m_jnz:
		blockNo = blk->succ(0);
		break;
	}

  .......
}

Next, I moved on to mw_integrity_checks(). Ignoring the opaque predicates, the major problem here is the optimization blocks. As described before, they do not create a direct path to the dispatcher since there are a massive amount of flattened blocks pointing to the opt block. My initial solution was based on the assumption that all of these optimization blocks only contained the instruction which moved the numeric value from whatever variable it was being stored in into the correct dispatcher variable. I treated each optimization block as its own dispatcher, noted down the variable it was using and iterated its predecessors, pointing them to the correct blocks.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct opt_block_info
{
	int block_num;
	mop_t op; // The operand that is being mov'd into first before the dispatcher variable
};

for (auto opt_block : cfi.opt_blocks)
{
	// For optimization
	for (auto opt_pred : mba->get_mblock(opt_block.blockNum)->predset)
	{
		unflatten(opt_pred, opt_block.op, ...);
	}
	...
}

The function then could be decompiled in unflattened form without error. However, the assumption I made turned out to be incorrect. In less common cases, other functions in the binary had other code besides the single mov in the optimization block. By doing what I did, I was losing vital code for the function. Not knowing this at the time, I continued on.

Optimization Woes

The next couple functions mw_display_crypt_warning() and mw_send_empty_get_req() seemed to unflatten fine with the code I had. That means the next function on the list was the third call in the WinMain(), mw_main_routine(). Upon decompiling the function, I was greeted with over 3000 lines of obfuscated code.

alt text

Additionally, when I tried to decompile the function with my plugin enabled it failed to detect the dispatcher variable being used. I started debugging to find the problem, and the first thing I did was check the assembly view in IDA to see if there was anything peculiar.

alt text

Looking at the first block of the function, we can see that the stack base ebp is being moved into the register esi. Then, a variable at base of stack + 4 is being written to. The difference is, in our previous functions, ebp was used directly. Here it is not. I assumed this wouldn’t be a problem because Hex-Rays optimization should recognize that esi + 4 is a stack variable. This turned out to be incorrect. Rolf’s original project includes a microcode explorer (like the unflattening plugin, the first of its kind) which can be used to view the microcode as a graph. I opened the microcode explorer and browsed the microcode maturity level I was operating at, MMAT_LOCOPT.

That is when the issue started to become a bit more clear:

alt text

The mov instructions that accessed the stack through esi were not being recognized as stack variables by Hex-Rays. Forward propagation had not been performed, so the instructions were either ldx or stx instead of mov. This was a major problem, because my code was looking specifically for mov instructions. I thought of a few ways to fix this problem. The first was to implement a second handling which would look for ldx and stx instructions in the case the optimization was not applied. I did not like this idea at all, as it would bloat the code significantly. Another idea I had was to see if I could operate at a later maturity level like MMAT_CALLS. After looking into this idea, I noticed it introduced another optimization-related problem.

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
; 1WAY-BLOCK 1 INBOUNDS: 0 OUTBOUNDS: 2 [START=42A9CE END=42AA08] MINREFS: STK=4C/ARG=150, MAXBSP: 3C
; USE: sp+1C.4,(GLBLOW,GLBHIGH)
; DEF: eax.4,esi.4,sp+14.4,sp+20.8,(cf.1,zf.1,sf.1,of.1,pf.1,edx.4,ecx.4,fps.2,fl.1,c0.1,c2.1,c3.1,df.1,if.1,GLBLOW,sp+C.4,GLBHIGH)
; DNU: eax.4,esi.4,sp+14.4,sp+20.8
mov    call $GetSystemMetrics<std:"int nIndex" #0.4>.4, %var_2C.4{1} ; 42A9DF u=(GLBLOW,GLBHIGH) d=sp+20.4,(cf.1,zf.1,sf.1,of.1,pf.1,rax.8,ecx.4,fps.2,fl.1,c0.1,c2.1,c3.1,df.1,if.1,GLBLOW,sp+C.4,GLBHIGH)
mov    call $GetSystemMetrics<std:"int nIndex" #1.4>.4, %cy.4{2} ; 42A9E7 u=(GLBLOW,GLBHIGH) d=sp+24.4,(cf.1,zf.1,sf.1,of.1,pf.1,rax.8,ecx.4,fps.2,fl.1,c0.1,c2.1,c3.1,df.1,if.1,GLBLOW,sp+C.4,GLBHIGH)
mov    #0xB503DEBC.4, %var_38.4 ; 42A9EB u=           d=sp+14.4
mov    #0xB503DEBC.4, eax.4    ; 42A9F3 u=           d=eax.4
mov    %var_30.4{3}, esi.4{3}  ; 42A9F8 u=sp+1C.4    d=esi.4

; 2WAY-BLOCK 2 INBOUNDS: 1 6 9 13 16 18 24 25 27 OUTBOUNDS: 3 10 [START=42AA08 END=42AA0F] MINREFS: STK=2C/ARG=150, MAXBSP: 10
; USE: eax.4
; VALRANGES: eax.4:(==46BC6480|==7B6E7A9B|==97CD0040|==99ED4E33|==B503DEBC), %0x14.4:==B503DEBC
jg     eax.4, #0xC4B3E928.4, @10 ; 42AA0D u=eax.4

; 2WAY-BLOCK 3 INBOUNDS: 2 OUTBOUNDS: 4 14 [START=42AA0F END=42AA16] MINREFS: STK=2C/ARG=150, MAXBSP: 10
; USE: eax.4
; VALRANGES: eax.4:(==97CD0040|==99ED4E33|==B503DEBC), %0x14.4:==B503DEBC
jle    eax.4, #0xAAEC8E2C.4, @14 ; 42AA14 u=eax.4

; 1WAY-BLOCK 4 INBOUNDS: 3 OUTBOUNDS: 5 [START=42AA16 END=42AA21] MINREFS: STK=2C/ARG=150, MAXBSP: 10
; VALRANGES: eax.4:==B503DEBC, %0x14.4:==B503DEBC

; 1WAY-BLOCK 5 INBOUNDS: 4 OUTBOUNDS: 27 [START=42AA21 END=42AA2C] MINREFS: STK=2C/ARG=150, MAXBSP: 10
; VALRANGES: eax.4:==B503DEBC, %0x14.4:==B503DEBC
goto   @27                     ; 42AA26 u=

; 2WAY-BLOCK 6 OUTBOUNDS: 7 2 [START=42AA2C END=42AA33] MINREFS: STK=2C/ARG=150, MAXBSP: 10
; USE: eax.4
jnz    eax.4, #0xC0031B52.4, @2 ; 42AA31 u=eax.4

; 2WAY-BLOCK 7 INBOUNDS: 6 OUTBOUNDS: 8 9 [START=42AA33 END=42AA4B] MINREFS: STK=2C/ARG=150, MAXBSP: 10
; USE: esi.4,(GLBLOW,sp+2C..,GLBHIGH)
; DEF: eax.4,esi.4,(cf.1,zf.1,sf.1,of.1,pf.1,edx.4,ecx.4,fps.2,fl.1,c0.1,c2.1,c3.1,df.1,if.1,GLBLOW,sp+2C..,GLBHIGH)
; DNU: eax.4
sub    (esi.4 >>l #0xF.1), #0x72.4, esi.4{4} ; 42AA36 u=esi.4      d=esi.4
call   $sub_42A9CE <cdecl:>.0  ; 42AA39 u=(GLBLOW,sp+2C..,GLBHIGH) d=(cf.1,zf.1,sf.1,of.1,pf.1,rax.8,ecx.4,fps.2,fl.1,c0.1,c2.1,c3.1,df.1,if.1,GLBLOW,sp+2C..,GLBHIGH)
mov    #0xAAEC8E2D.4, eax.4    ; 42AA3E u=           d=eax.4
jae    esi.4{4}, #0x3800.4, @9 ; 42AA49 u=esi.4

Block 4 is now completely empty and block 5 has been changed to a goto. This is how it looked before in MMAT_LOCOPT

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
; 2WAY-BLOCK 6 INBOUNDS: 5 OUTBOUNDS: 7 23 [START=42AA16 END=42AA21] MINREFS: STK=0/ARG=50, MAXBSP: 10
; USE: eax.4
; DEF: cf.1,zf.1,sf.1,of.1,pf.1
; DNU: cf.1,zf.1,sf.1,of.1,pf.1
setb   eax.4, #0xAAEC8E2D.4, cf.1 ; 42AA16 u=eax.4      d=cf.1
seto   eax.4, #0xAAEC8E2D.4, of.1 ; 42AA16 u=eax.4      d=of.1
setz   (eax.4+#0x551371D3.4), #0.4, zf.1 ; 42AA16 u=eax.4      d=zf.1
setp   (eax.4+#0x551371D3.4), #0.4, pf.1 ; 42AA16 u=eax.4      d=pf.1
sets   (eax.4+#0x551371D3.4), sf.1 ; 42AA16 u=eax.4      d=sf.1
jz     eax.4, #0xAAEC8E2D.4, @23 ; 42AA1B u=eax.4

; 2WAY-BLOCK 7 INBOUNDS: 6 OUTBOUNDS: 8 34 [START=42AA21 END=42AA2C] MINREFS: STK=0/ARG=50, MAXBSP: 10
; USE: eax.4
; DEF: cf.1,zf.1,sf.1,of.1,pf.1
; DNU: cf.1,zf.1,sf.1,of.1,pf.1
setb   eax.4, #0xB503DEBC.4, cf.1 ; 42AA21 u=eax.4      d=cf.1
seto   eax.4, #0xB503DEBC.4, of.1 ; 42AA21 u=eax.4      d=of.1
setz   (eax.4+#0x4AFC2144.4), #0.4, zf.1 ; 42AA21 u=eax.4      d=zf.1
setp   (eax.4+#0x4AFC2144.4), #0.4, pf.1 ; 42AA21 u=eax.4      d=pf.1
sets   (eax.4+#0x4AFC2144.4), sf.1 ; 42AA21 u=eax.4      d=sf.1
jz     eax.4, #0xB503DEBC.4, @34 ; 42AA26 u=eax.4

; 2WAY-BLOCK 8 INBOUNDS: 7 OUTBOUNDS: 9 4 [START=42AA2C END=42AA33] MINREFS: STK=0/ARG=50, MAXBSP: 10
; USE: eax.4
; DEF: cf.1,zf.1,sf.1,of.1,pf.1
; DNU: cf.1,zf.1,sf.1,of.1,pf.1
setb   eax.4, #0xC0031B52.4, cf.1 ; 42AA2C u=eax.4      d=cf.1
seto   eax.4, #0xC0031B52.4, of.1 ; 42AA2C u=eax.4      d=of.1
setz   (eax.4+#0x3FFCE4AE.4), #0.4, zf.1 ; 42AA2C u=eax.4      d=zf.1
setp   (eax.4+#0x3FFCE4AE.4), #0.4, pf.1 ; 42AA2C u=eax.4      d=pf.1
sets   (eax.4+#0x3FFCE4AE.4), sf.1 ; 42AA2C u=eax.4      d=sf.1
jnz    eax.4, #0xC0031B52.4, @4 ; 42AA31 u=eax.4

I decided to consult Rolf Rolles himself to find out what was going on. He explained that this is the result of an optimization technique called value range optimization, and it is possible to disable this technique in the Hex-Rays decompiler settings. This meant that my options were either to implement bloated code to handle ldx/stx instructions, or operate in MMAT_CALLS with the requirement that the user of my plugin start screwing around with the decompiler settings every time they want to use it. Both of those options sounded pretty awful, so a third option was devised: Continue to operate in MMAT_LOCOPT, but implement code which fixes all the ldx/stx instruction to be mov with the proper stack variable.

The way I implemented this was to first see if forward propagation was not performed by checking if ldx/stx instructions are being used to access the dispatcher variable. In that case, we note down the register that the stack base was moved into and iterate all instructions which read or write from there, change the opcode to mov and finally create a new stkvar_ref_t.

Implementing this was initially confusing, because I noticed that my microcode dump from the code was different than the dump in the microcode explorer:

alt text

According to Rolf, the reason for this was

When you register a block optimizer and check for MMAT_LOCOPT, you’re not getting called exactly at MMAT_LOCOPT, you’re getting called somewhere afterwards, between then and the next maturity level MMAT_CALLS. and you might get called more than once, so you might get called in the middle of this analysis that is transforming the stx and ldx into mov instructions.

This means that the microcode explorer and the code, despite being written to dump at the same stage, did not actually do so. The microcode explorer dump was at a slightly earlier stage of MMAT_LOCOPT that I was unable to operate at, hence why the code on the left has forwarded-propagated the mov in the first block (but not the second). After this incident, I mostly used microcode explorer strictly for initial analysis and relied on the dumps from the block optimizer callback for debugging.

The good news was that the idea worked. After trying again to decompile with the plugin enabled, all of the ldx/stx instructions were fixed and the dispatcher variable was found. There was another issue, however, and this one turned out to the most difficult and time-consuming for me to fix. I will use separate smaller function which has the same problem to explain.

Complex Branches

This is how the function looks after my final deobfuscator code has been ran:

alt text

The above block of code calls GetAdaptersInfo() and then proceeds to check if the function returned a buffer overflow error or returned an empty size and exits if either condition is satisified. Now let’s look at the control flow graph of the generated assembly:

alt text

Since block #4 can have two possible values for eax (0xC92049E7 or 0xCDFAE9E1), that means there is no direct path to the dispatcher! This example is also a small one. There are cases where there are multiple checks being performed in the if block which can translate to even bigger chains of blocks such as this function:

alt text

That translates to the following assembly:

alt text

I call these cases complex branches, and I believe they’re mentioned in the D-810 writeup. So, how did I fix this problem as you can see in the screenshots of the decompiled code? Since the problem is due to multiple incoming blocks causing there to be no direct path to the dispatcher, I decided that all blocks on the path from cluster head to the dispatcher must only have a single predecessor. By this time, I had also noticed the problem from earlier regarding the optimization blocks which was also due to not having a direct dispatcher path. I was about to kill two birds with one stone.

To accomplish my goal of ensuring each block only has a single predecessor, I first iterate all of the dispatcher block’s predecessors. Then, if the dispatcher block predecessor we are looking at has more than two of it’s own predecessors (I’m going to refer to these as sub-preds), I iterate those until we reach number of sub-preds - 2. The reason I stop at count - 2 is because if the predecessor happens to have a fallthrough block, it will always be the last one located at count - 1. Additionally, if the dispatcher block predecessor is conditional, we also don’t want to remove the other branch which would be located at count - 2. For each iteration, I first check if the sub-pred is the child of a conditional block which is also pointing to the same dispatcher predecessor. If so, I point both the conditional parent block and the sub-pred to a new copy of the dispatcher block predecessor that I insert into the graph. Otherwise, I just point the sub-pred to the block and don’t touch the parent block. You can see this process illustrated below in the function we looked at that accesses the adapters:

Before:

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
; 1WAY-BLOCK 27 INBOUNDS: 26 OUTBOUNDS: 231 [START=420324 END=420329] MINREFS: STK=0/ARG=F8, MAXBSP: 84
goto   @231                    ; 420324 u=

....

; 2WAY-BLOCK 229 INBOUNDS: 150 OUTBOUNDS: 230 231 [START=420EEB END=420F03] MINREFS: STK=0/ARG=F8, MAXBSP: 84
; USE: sp+DC.4
; DEF: cf.1,zf.1,sf.1,of.1,pf.1,eax.4,ecx.4,sp+DC.4
; DNU: cf.1,zf.1,sf.1,of.1,pf.1,eax.4
mul    #0x1920000.4, (%var_14.4 >>l #7.1), ecx.4 ; 420EF1 split4 u=sp+DC.4    d=ecx.4
mov    ecx.4, %var_14.4        ; 420EF7 u=ecx.4      d=sp+DC.4
mov    #0x559A1BB7.4, eax.4    ; 420EFA u=           d=eax.4
mov    #0.1, cf.1              ; 420EFF u=           d=cf.1
mov    #0.1, of.1              ; 420EFF u=           d=of.1
setz   ecx.4, #0.4, zf.1       ; 420EFF u=ecx.4      d=zf.1
setp   ecx.4, #0.4, pf.1       ; 420EFF u=ecx.4      d=pf.1
sets   ecx.4, sf.1             ; 420EFF u=ecx.4      d=sf.1
jz     ecx.4, #0.4, @231       ; 420F01 u=ecx.4

; 1WAY-BLOCK 230 INBOUNDS: 229 OUTBOUNDS: 231 [START=420F03 END=420F08] MINREFS: STK=0/ARG=F8, MAXBSP: 84
; DEF: eax.4
; DNU: eax.4
mov    #0x937A5D68.4, eax.4    ; 420F03 u=           d=eax.4

; 1WAY-BLOCK 231 INBOUNDS: 27 ........ 229 230 OUTBOUNDS: 2 [START=420F08 END=420F10] MINREFS: STK=0/ARG=F8, MAXBSP: 84
; USE: eax.4
; DEF: sp+E0.4
mov    eax.4, %var_10.4        ; 420F08 u=eax.4      d=sp+E0.4
goto   @2                      ; 420F0B u=

After

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
; 1WAY-BLOCK 27 INBOUNDS: 26 OUTBOUNDS: 232 [START=420324 END=420329] MINREFS: STK=0/ARG=F8, MAXBSP: 84
goto   @232                    ; 420324 u=

....

; 2WAY-BLOCK 229 INBOUNDS: 150 OUTBOUNDS: 230 231 [START=420EEB END=420F03] MINREFS: STK=0/ARG=F8, MAXBSP: 84
; USE: sp+DC.4
; DEF: cf.1,zf.1,sf.1,of.1,pf.1,eax.4,ecx.4,sp+DC.4
; DNU: cf.1,zf.1,sf.1,of.1,pf.1,eax.4
mul    #0x1920000.4, (%var_14.4 >>l #7.1), ecx.4 ; 420EF1 split4 u=sp+DC.4    d=ecx.4
mov    ecx.4, %var_14.4        ; 420EF7 u=ecx.4      d=sp+DC.4
mov    #0x559A1BB7.4, eax.4    ; 420EFA u=           d=eax.4
mov    #0.1, cf.1              ; 420EFF u=           d=cf.1
mov    #0.1, of.1              ; 420EFF u=           d=of.1
setz   ecx.4, #0.4, zf.1       ; 420EFF u=ecx.4      d=zf.1
setp   ecx.4, #0.4, pf.1       ; 420EFF u=ecx.4      d=pf.1
sets   ecx.4, sf.1             ; 420EFF u=ecx.4      d=sf.1
jz     ecx.4, #0.4, @231       ; 420F01 u=ecx.4

; 1WAY-BLOCK 230 INBOUNDS: 229 OUTBOUNDS: 231 [START=420F03 END=420F08] MINREFS: STK=0/ARG=F8, MAXBSP: 84
; DEF: eax.4
; DNU: eax.4
mov    #0x937A5D68.4, eax.4    ; 420F03 u=           d=eax.4

; 1WAY-BLOCK 231 INBOUNDS: 229 230 OUTBOUNDS: 2 [START=420F08 END=420F10] MINREFS: STK=0/ARG=F8, MAXBSP: 84
; USE: eax.4
; DEF: sp+E0.4
mov    eax.4, %var_10.4        ; 420F08 u=eax.4      d=sp+E0.4
goto   @2                      ; 420F0B u=

; 1WAY-BLOCK 232 INBOUNDS: 27 OUTBOUNDS: 2 [START=420F08 END=420F10] MINREFS: STK=0/ARG=F8, MAXBSP: 84
; USE: eax.4
; DEF: sp+E0.4
mov    eax.4, %var_10.4        ; 420F08 u=eax.4      d=sp+E0.4
goto   @2                      ; 420F0B u=

Take a look at the after dump and you can see that the optimization block issue has been solved. Block #27 now points to a copy of the optimization block that has no other incoming blocks, so it would be safe to patch the goto @2 instruction to its correct location later on. After all dispatcher predecessors have been confirmed to have no more than two predecessors, we can handle the complex branches. To do so, I again iterate the dispatcher predecessors. This time, I create a ‘trace’ for each predecessor. The way I implemented tracing was to do a depth-first search from the dispatcher predecessor all the way up to the clusterhead. This will find every possible path to the clusterhead. I determine if we’ve hit the clusterhead by checking if its one of those jz/jnz comparison blocks from the beginning of our analysis. If so, the trace returns.

Once this is done, I call another function which takes the trace information and turns it into a basic integer array of all the possible paths we found using the blocks’ serials.

1
2
3
4
255 84 82 81
255 84 83 82 81
255 85 84 82 81
255 85 84 83 82 81

For reference, here is a control flow graph of the microcode after all dispatcher predecessors have been ensured to have no more than two predecessors. You can follow along yourself and see each possible path from the list above!

alt text

Now that I have the trace, there are a few cases we need to handle. In the case above, there are four possible paths. When this happens, I keep only the bottom two paths. Next, I check where the paths diverge from eachother. In the case of the bottom two paths, the divergence occurs at index 3. I then copy the blocks from below the divergence of the last path. This would mean that blocks 83, 84, 85 and 255 are copied. I patch block 83 to be a jump to the newly copied block path.

Here is how the control flow graph looks after the modifications.

alt text

As you can see, the divergence issue has been fixed. Now, just two more passes are required to handle the two predecessors of blocks 274 and 255 respectively. Below is the final product with no path issues, which is ready to be unflattened!:

alt text

For complex branches that have a few different checks inside of the if block, it will take subsequent passes of the loop to complete, which is why the loop does not exit until all blocks have a direct path to the dispatcher.

There are a few other cases we need to handle though. Firstly, sometimes we’ll get three possible paths such as here:

1
2
3
421 419 418 258
421 420 199 198 197 196
421 420 419 418 258

What do we do in that case? Well, by taking a look at the paths above it is apparent that one path looks very different from the others. That would be the middle path, and the first immediate indicator is that the end block is different (196 vs 258). When this happens, I copy the entire block range that the oddball path takes (421, 420, 199, 198, 197) and point the first block (196) to that instead. That ensures we can safely work on the remaining two paths.

Secondly, what if we end up with two paths that start with different blocks? Here are some examples I dealt with in mw_main_routine:

1
2
1276 359 358 357
1276 359 358 457

or

1
2
442 441
442 743

or

1
2
628 627 457
628 627 626

When this happens, I ensure that the path I modify is not the fallthrough branch of a conditional block. These would be 1276 359 358 357, 442 441, or 628 627 626 above respectively. Since we can’t copy the path down from the divergence and point the conditional block there, we simply take the other available path in the path list and modify that one instead.

Conditional Starts

Now, after all this are we finally ready to get to the actual unflattening part? NOPE! There is yet another problem we need to deal with.

Take a look at this function:

alt text

The variable v3 is the variable which controls the control flow. However, a variable v2 is being set at the beginning depending if the argument arg_api_hash is empty. v2 is not being used until later on, when v3 takes its value. I had never seen any other control flow unflattening writeup that ran into such an issue, and I came up with my own solution. Here is my idea:

1) Identify the two possible conditions and the register (or stack variable in some cases) that the constants are moved into (in this case EDI):

alt text

2) Replace each constant with a 1 or 0 (representing true/false)

alt text

3) Where the actual final fix for the conditional start happens is during the actual unflattening stage when we are iterating each dispatcher predecessor and see this:

alt text

When we find an assignment to ebx (the variable the dispatcher uses) from the variable that was saved at the beginning when we noticed there was a conditional start (edi), we change the affected block to a branch by making it a jz. This jz will check if edi is 0 or not (remember we switched the mov instructions at the beginning to move 1 or 0 instead of the block assignment number?)

Then, it will jump to whichever case is bound to 0 or 1. Here is how it looks after:

alt text

This plan seemed like a solid idea. The problem is that I seemed to possibly be encountering a Hex-Rays bug, particularly an incorrect optimization that prevented the decompilation from being correct. I contacted them about this issue and they acknowledged it, but I have not heard back. This would not be the first time I ended up breaking Hex-Rays while working on this project, as I identified another incorrect optimzation prior to this which I contacted them about and that they actually did fix for IDA 9.0.

Unflattening…finally!

Now that we have addressed all possible issues preventing us from unflattening, it is time to get it done. I do need to mention as well that I completely redid the DeferredGraphModifier class from the original plugin. Now, it uses a queue and supports many different operations which can be done to the graph:

1
2
3
4
5
6
7
8
enum graph_modification_type
{
	block_target_change,
	block_fallthrough_change,
	block_nop_insns,
	new_block_insertion,
	block_convert_to_branch
};

I strongly suggest doing this and queueing up all your graph modifications. I made the mistake of trying to do a lot of graph changes during the loop iteration which caused lots of problems and wasted time.

The way I did unflattening was to trace the writes to the dispatcher variable until we got to a numeric constant. Then, I would remove the entire chain of write instructions later on. An important thing I did was to defer my instruction removals. What I mean by this, is after we patch the goto’s at the end of each flattened block to go to the proper destination, we want to remove the instruction which moves that numeric value into the dispatcher control variable, right? Well, what if we have a branch that sets the control flow variable? This means if conditional block a branches to either block b or c and we are currently unflattening b and trace the writes into a and remove the instruction, then by the time we get to block c we have no idea what value it should be using. All instructions that need to be removed now get removed AFTER the unflattening process is done.

The end result will be a completely unflattened graph, right? I revisited the main function excited to see the end result.

alt text

…and was greeted with disappointment. I spent so much time on the unflattening, I had forgotten all about the opaque predicates from the beginning! They were still there, and causing all sorts of problems. Determined to get a clean decompiler output once and for all, I set my sights on removing them.

Opaque Predicates / Junk Code

I ran into several issues trying to remove the opaque predicates. Firstly, legitimate instructions get interwoven with the opaque predicates / junk code. Based off of the few functions I had looked at when I originally tried to remove them, it sufficed to simply erase the entire affected block by filling it with nops. I later found out the consequences of doing this, so I had to change my strategy.

1
2
3
4
44. 0 xdu    [ds.2:(%var_B4.4{85}+#3.4)].1, %var_4C.4{68} ; 4332CA u=ds.2,sp+18.4,(GLBLOW,GLBHIGH) d=sp+80.4  <-------------------legit instruction thats getting removed!
44. 1 mul    (%var_BC.4{71}-#0xFA.4){70}, (%var_BC.4{71}-#0xFA.4){70}, eax.4{72} ; 433336 split4 u=sp+10.4    d=eax.4
44. 2 sub    (((#0x8A.4*%var_BC.4{71})-#0xC210.4) >>l #0xD.1), #0x20.4, %var_BC.4{73} ; 433352 u=sp+10.4    d=sp+10.4
44. 3 jnz    eax.4{72}, ((#0x139F.4*eax.4{72})-#1.4), @67 ; 43335D inverted_jx u=eax.4

The above is a screenshot from MMAT_CALLS showing how legitimate instruction can appear in blocks that have junk instructions. You may be wondering why I’m suddenly at MMAT_CALLS after spending the entirety of this project working at MMAT_LOCOPT. Well, I was originally trying to remove the opaques/junk at MMAT_LOCOPT and was running some issues. See how each instruction, despite containing a ton of operations only is technically one line? During MMAT_LOCOPT, Hex-Rays had not performed any optimizations on the instructions and I was getting all sorts of different instruction patterns which broke my signature.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
226. 0 ; 2WAY-BLOCK 226 INBOUNDS: 131 OUTBOUNDS: 227 228 [START=43332C END=43335F] MINREFS: STK=0/ARG=D0, MAXBSP: 8
226. 0 ; USE: sp+10.4
226. 0 ; DEF: cf.1,zf.1,sf.1,of.1,pf.1,rax.8,ecx.4,sp+10.4
226. 0 ; DNU: cf.1,zf.1,sf.1,of.1,pf.1,ecx.4
226. 0 mul    (%var_BC.4-#0xFA.4), (%var_BC.4-#0xFA.4), eax.4 ; 433336 split4 u=sp+10.4    d=eax.4
226. 1 sub    (#0x139F.4*eax.4), #1.4, edx.4 ; 43334B u=eax.4      d=edx.4
226. 2 sub    (((#0x8A.4*%var_BC.4)-#0xC210.4) >>l #0xD.1), #0x20.4, %var_BC.4 ; 433352 u=sp+10.4    d=sp+10.4
226. 3 mov    #0xB2BA095E.4, ecx.4    ; 433356 u=           d=ecx.4
226. 4 setb   eax.4, edx.4, cf.1      ; 43335B u=rax.8      d=cf.1
226. 5 seto   eax.4, edx.4, of.1      ; 43335B u=rax.8      d=of.1
226. 6 setz   eax.4, edx.4, zf.1      ; 43335B u=rax.8      d=zf.1
226. 7 setp   eax.4, edx.4, pf.1      ; 43335B u=rax.8      d=pf.1
226. 8 sets   (eax.4-edx.4), sf.1     ; 43335B u=rax.8      d=sf.1
226. 9 jz     eax.4, edx.4, @228      ; 43335D u=rax.8
226. 9

I decided to keep everything related to the unflattening in MMAT_LOCOPT, and wait until MMAT_CALLS to begin the opaque predicate removal. Thus, my plugin operates at two separate microcode maturities. While at the MMAT_CALLS stage, I was able to create a working signature to detect and find opaque predicate blocks.

The way this worked was to look for conditional blocks which multipled by a 16 bit constant and then subtracted the constant 1. I store all stack variables that were accessed by these blocks and kept track of their count every time another block was detected. I patch each opaque predicate to remove its fake branch depending on if it was jz or jnz. Then, I used the most common stack variable stored from earlier to find other leftover junk instructions that got interwoven with other blocks and remove them.

And what is the end result?

alt text

Fully deobfuscated code! Our largest function in the binary went from over 3000 lines of code to 429.

The great thing about the deobfuscator is that since it works on subsequent versions (until they added control flow indirection), we can easily track the evolution of the malware. For example, the same large function is actually even larger in a different Lumma binary I found at over 5000 lines of code. In this version, the developers added string encryption as well as implemented the option to choose between two possible execution methods: LoadLibraryW and rundll32 for DLLs. This feature is missing in the previous version as seen below:

alt text

Journey’s End

In the end, I ended up deobfuscating probably around 50 opaque’d and flattened functions. This project was one of the hardest yet most rewarding I’ve ever done. There were many times I felt like I was trying to do something that couldn’t be accomplished, but I was relentless and would not give up. I have to give a huge thanks to Rolf Rolles for not only creating the original plugin project that this was based off of, but also being kind enough to answer my questions about Hex-Rays internals. Without his incredible knowledge of the microcode API, I don’t know if I’d ever have been able to finish this project.

The next addition to this project would probably be a microcode emulator. Lumma Stealer didn’t require one, but there are other malware families which likely would. Another feature I’d be interested in implementing is a profile system, maybe similar to what D-810 has although I have not looked at it in detail. All in all, there are endless possibilities for this project and it will surely come in use for analyzing obfuscated samples in the future. I hope you enjoyed reading this writeup as much as I did writing it, and I can’t wait to publish more in the future!

Lumma Stealer Sample SHA256: 00F1A9C6185B346F8FDF03E7928FACFC44FC63E6A847EB21FA0ECD7FB94BB7E3

Lumma Stealer Sample #2 SHA256: ECABBEAE6218B373F2D3A473D9F6ADD4BA5482EA3B97851C931197FB8993F8EF

This post is licensed under CC BY 4.0 by the author.