Lumma Stealer 信息窃取程序的反混淆

科技   2024-12-24 12:57   广东  

介绍

Lumma Stealer 是一种存在已有数年的信息窃取程序,并且一直在 MalwareBazaar 等网站的统计中名列前茅,是分布最广的恶意软件家族之一。Lumma Stealer 首次发布时,几乎没有混淆。最终,它融入了控制流平坦化、不透明谓词等内容,最近在 2024 年初开始使用控制流间接。在有关添加控制流间接的消息传出之前,我着手开发一个 Hex-Rays 插件来对第一个链接中的样本进行反混淆。但是,只要先删除控制流间接,此方法仍然适用于较新的版本。在这篇文章中,我将介绍我在这个项目中遇到的不同挑战以及我是如何克服它们的。

混淆的初步分析

在 IDA 中打开该WinMain()函数后,我们可以立即看到已应用控制流平坦化。这是此特定二进制文件中最简单的实例之一:


解决这个函数所需的一切或多或少都是我在以前的Agent Tesla 帖子中所做的,即找到哪些块对应于映射号并修补扁平块以跳转到其正确的目的地。在这里,我们有jz或jnz指令来检查调度程序变量(eax寄存器)值。如果匹配并且操作码为jz,则执行跳转。否则,如果是,则执行 fallthroughjnz分支。这是它在未扁平形式下的样子:
int __stdcall __noreturn WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)
{
  mw_integrity_checks();
  mw_set_cnc();
  mw_main_routine();
  ExitProcess(0);
}

下一个函数看起来要复杂得多。它不仅长得多(400 行),而且有些奇怪的事情正在发生 - 函数中不断重复一种模式。它似乎涉及一堆数学运算,以一个语句结束,if该语句将一个变量(在本例中为v9)乘以一个随机的 16 位常数,然后减去1并检查它是否等于v9。调度程序值会根据这是否为真而变化。

仔细观察后,我们可以确定这些都是不透明的谓词。

8247 * v9 - 1 == v9

为什么?因为上面的代码要求v9是分数才为真,而这些是整数。因此, 的真分支jz永远不会被采用,并导致垃圾代码。这意味着v3总是被设置为0xCA7D9612。此外,还有一些块使用jnz来代替,在这些情况下,情况正好相反,即总是采用真分支。

但是这个功能还没有解决我们面临的问题。还有另一个问题:

大量扁平化块不会直接返回调度程序。它们会进入我所说的“优化块”。这里发生的情况是,由于所有受影响的块都将其变量结果存储在寄存器中eax并需要将其移动到堆栈变量var_1074,因此编译器创建了一个块并将它们全部指向它,而不是为每个受影响的扁平化块都保留同一个“优化”块的副本。

我记下了上述阻止我稍后进行反混淆的问题,并开始思考从哪里开始开发一个可以反混淆二进制文件的插件。

选择起点

在此之前,我从未使用过 Hex-Rays API,但我知道有两个项目利用它来反混淆扁平化代码。第一个是Rolf Rolles用 C++ 编写的原始HexRaysDeob插件,其他所有 Hex-Rays 反扁平化插件都是基于此插件编写的。如果您还没有读过他的文章,我建议您读一读,因为我认为这是必读文章。第二个是Boris Batteux 编写的D810,它是用 Python 编写的,本身支持插件/配置文件。我也强烈建议您阅读这篇文章。但是,我不仅比 Python更习惯使用 C/C++,而且我想直接使用微代码本身,而不必另外学习 D-810 的内部工作原理,所以我选择更新前者。最后,我遇到的各种挑战导致我或多或少完全破坏了原始插件代码。

旅程开始

在开发插件的过程中,我并不一定按照下面描述的顺序进行。我尝试在完全完成反扁平化移除之前移除不透明谓词,结果发现这是一个错误。我还尝试了许多函数,有时在开始另一个函数之前没有完成一个函数,然后又返回。但是,为了让本文的流程更容易理解,我将逐个描述扁平化移除的过程,然后再介绍不透明谓词。

原始插件的工作原理是查找某个寄存器或堆栈变量之间最常出现的比较,这些变量将被称为dispatcher variable。然后将每个comparison block被比较的数值保存到与其对应的子块一起的映射中。接下来,代码检测mov将数值移动到该变量的指令,并在末尾修补这些指令以跳转到相应比较块的子块。未经修改,它仅使用jz操作码搜索比较。我对插件所做的第一个更改非常简单:我jnz还添加了对案例的处理。此更改导致函数不平坦化WinMain()。

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;
  }

  .......
}

接下来,我转向了mw_integrity_checks()。忽略不透明谓词,这里的主要问题是optimization blocks。如前所述,它们不会创建到调度程序的直接路径,因为有大量扁平块指向 opt 块。我最初的解决方案基于这样的假设:所有这些优化块仅包含将数值从存储它的变量移动到正确的调度程序变量的指令。我将每个优化块视为自己的调度程序,记下它正在使用的变量并迭代其前任,将它们指向正确的块。

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, ...);
  }
  ...}

然后,该函数可以以非扁平形式进行反编译,而不会出现错误。然而,我做出的假设被证明是错误的。在不太常见的情况下,二进制文件中的其他函数除了mov优化块中的单个代码外,还有其他代码。通过我所做的,我丢失了该函数的重要代码。当时我并不知道这一点,所以我继续了下去。

优化困境

接下来的几个函数mw_display_crypt_warning()和mw_send_empty_get_req()似乎与我手头的代码配合得很好。这意味着列表中的下一个函数是WinMain(),中的第三个调用mw_main_routine()。反编译该函数后,我看到了超过 3000 行混淆代码。

此外,当我尝试在启用插件的情况下反编译该函数时,它无法检测到正在使用的调度程序变量。我开始调试以查找问题,我做的第一件事就是检查 IDA 中的汇编视图,看看是否有任何异常。

查看函数的第一个块,我们可以看到堆栈基址ebp被移入寄存器esi。然后,将变量base of stack + 4写入。不同之处在于,在我们之前的函数中,ebp直接使用。这里不是。我认为这不会是个问题,因为 Hex-Rays 优化应该识别出这esi + 4是一个堆栈变量。结果证明这是不正确的。Rolf 的原始项目包括一个微码资源管理器(如 unflattening 插件,这是同类中的第一个),可用于以图形形式查看微码。我打开了微码资源管理器并浏览了我所操作的微码成熟度级别MMAT_LOCOPT。

从那时起,问题开始变得更加清晰:

通过 访问堆栈的 mov 指令esi未被 Hex-Rays 识别为堆栈变量。没有执行前向传播,因此指令要么是ldx,stx要么是mov。这是一个大问题,因为我的代码专门寻找mov指令。我想到了一些解决这个问题的方法。第一个是实现第二个处理,在未应用优化的情况下寻找ldx和stx指令。我一点也不喜欢这个想法,因为它会使代码变得臃肿。我的另一个想法是看看我是否可以在更高的成熟度级别上操作,比如MMAT_CALLS。研究完这个想法后,我注意到它引入了另一个与优化相关的问题。

; 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

现在块 4 完全为空,块 5 已更改为 goto。这是之前的样子MMAT_LOCOPT

; 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

我决定亲自咨询 Rolf Rolles,以了解到底发生了什么。他解释说,这是名为值范围优化的优化技术的结果,可以在 Hex-Rays 反编译器设置中禁用此技术。这意味着我的选择要么是实现臃肿的代码来处理ldx/stx指令,要么MMAT_CALLS在要求我的插件用户每次想要使用它时开始摆弄反编译器设置的情况下运行。这两个选项听起来都很糟糕,所以我想出了第三个选项:继续在 中运行,但实现修复所有/指令MMAT_LOCOPT的代码,使其具有正确的堆栈变量。ldxstxmov

我实现这一点的方法是,首先通过检查是否使用指令访问调度程序变量来查看是否未执行正向传播ldx/stx。在这种情况下,我们记下堆栈基址被移入的寄存器,并迭代从那里读取或写入的所有指令,将操作码更改为mov,最后创建一个新的stkvar_ref_t。

实现这一点最初令人困惑,因为我注意到从代码中转储的微码与微码资源管理器中的转储不同:

据罗尔夫说,这样做的原因是

当您注册一个块优化器并检查 MMAT_LOCOPT 时,您并不会恰好在 MMAT_LOCOPT 处被调用,而是在之后的某个地方被调用,即在那时和下一个成熟度级别 MMAT_CALLS 之间。并且您可能会被调用多次,因此您可能会在将 stx 和 ldx 转换为 mov 指令的分析过程中被调用。

这意味着,尽管微代码探索器和代码被写成在同一阶段转储,但实际上并没有这样做。微代码探索器转储处于MMAT_LOCOPT我无法操作的稍早阶段,这就是为什么左侧的代码mov在第一个块中向前传播了(但没有在第二个块中)。在这次事件之后,我主要使用微代码探索器进行初步分析,并依靠块优化器回调的转储进行调试。

好消息是这个想法奏效了。在启用插件的情况下再次尝试反编译后,所有ldx/stx指令都已修复,并且找到了调度程序变量。然而,还有另一个问题,这个问题对我来说是最难修复且最耗时的。我将使用存在相同问题的单独较小函数来解释。

复杂分支

我的最终反混淆器代码运行后,函数的样子如下:

上面的代码块调用GetAdaptersInfo(),然后继续检查函数是否返回缓冲区溢出错误或返回空大小,如果任一条件满足,则退出。现在让我们看看生成的汇编的控制流图:

由于block #4eax 可以有两个可能的值(0xC92049E7或0xCDFAE9E1),这意味着没有到调度程序的直接路径!这个例子也比较小。有些情况下,if块中会执行多个检查,这可以转化为更大的块链,例如此函数:

这转化为以下汇编:

我将这些情况称为complex branches,我相信它们在 D-810 的写作中提到过。那么,正如您在反编译代码的屏幕截图中看到的那样,我是如何解决这个问题的呢?由于问题是由于多个传入块导致没有直接路径到调度程序,我决定从簇头到调度程序的路径上的所有块都必须只有一个前任。到这个时候,我还注意到了之前关于 的问题,optimization blocks这也是由于没有直接调度程序路径造成的。我打算一石二鸟。

为了实现确保每个块只有一个前任的目标,我首先迭代调度程序块的所有前任。然后,如果我们正在查看的调度程序块前任有两个以上的前任(我将把它们称为子前任),我会迭代它们直到我们到达number of sub-preds - 2。我之所以停在count - 2是因为如果前任恰好有一个 fallthrough 块,它将始终是位于 的最后一个块count - 1。此外,如果调度程序块前任是有条件的,我们也不想删除位于 的另一个分支count - 2。对于每次迭代,我首先检查子前任是否是指向相同调度程序前任的条件块的子块。如果是,我将条件父块和子前任都指向我插入到图中的调度程序块前任的新副本。否则,我只是将子前任指向块,而不触及父块。您可以在下面我们查看的访问适配器的函数中看到此过程:

前:

; 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=

; 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=

查看转储after,您会发现问题optimization block已解决。块#27现在指向optimization block没有其他传入块的副本,因此goto @2稍后将指令修补到其正确位置是安全的。在确认所有调度程序前任都不超过两个前任后,我们可以处理复杂的分支。为此,我再次迭代调度程序前任。这一次,我为每个前任创建了一个“跟踪”。我实施跟踪的方式是从调度程序前任一直到簇头进行深度优先搜索。这将找到通往簇头的每一条可能路径。我通过检查它是否是我们jz/jnz分析开始时的那些比较块之一来确定我们是否击中了簇头。如果是,则跟踪返回。

完成后,我调用另一个函数,该函数获取跟踪信息并将其转换为我们使用块的序列找到的所有可能路径的基本整数数组。

255 84 82 81
255 84 83 82 81
255 85 84 82 81
255 85 84 83 82 81

作为参考,这是在确保所有调度程序前任不超过两个前任之后的微码控制流图。您可以自己跟着看,从上面的列表中查看每条可能的路径!

现在我有了踪迹,我们需要处理一些情况。在上述情况下,有四条可能的路径。发生这种情况时,我只保留底部两条路径。接下来,我检查路径彼此分歧的位置。对于底部两条路径,分歧发生在索引 3 处。然后,我从最后一条路径分歧下方复制块。这意味着块、 和83被84复制。我将其修补为跳转到新复制的块路径。85255block 83

修改之后的控制流图如下。

如您所见,发散问题已得到解决。现在,只需再进行两次传递即可分别处理块274和的两个前身255。以下是没有路径问题的最终产品,可以将其展开!:

对于块内部有几个不同检查的复杂分支if,需要后续循环才能完成,这就是为什么循环直到所有块都有到调度程序的直接路径时才会退出的原因。

不过,我们还需要处理其他一些情况。首先,有时我们会得到三条可能的路径,例如这里:

421 419 418 258
421 420 199 198 197 196
421 420 419 418 258

在这种情况下我们该怎么办?好吧,通过查看上面的路径,很明显一条路径看起来与其他路径非常不同。那就是中间路径,第一个直接指标是结束块不同(196vs 258)。当这种情况发生时,我复制了奇怪路径所采用的整个块范围(421,420,199,198,197),并将第一个块(196)指向该块。这确保我们可以安全地处理剩下的两条路径。

其次,如果我们最终得到两条以不同区块开始的路径会怎么样?以下是我在 中处理的一些示例mw_main_routine:

1276 359 358 357
1276 359 358 457

或者

442 441
442 743

或者

628 627 457
628 627 626

发生这种情况时,我会确保修改的路径不是条件块的 fallthrough 分支。这些分别是1276 359 358 357、442 441或628 627 626上方。由于我们无法从发散处向下复制路径并将条件块指向那里,因此我们只需采用路径列表中的另一条可用路径并修改该路径即可。

有条件启动

现在,经过了所有这些,我们终于可以开始实际的反平坦部分了吗?不!我们还需要处理另一个问题。

看一下这个函数:

变量v3是控制控制流的变量。但是,如果参数为空,v2则会在开始时设置一个变量。直到稍后获取其值时才会使用。我从未见过任何其他控制流展开写作遇到这样的问题,我想出了自己的解决方案。这是我的想法:arg_api_hashv2v3

1)确定两种可能的条件以及常量被移入的寄存器(或在某些情况下为堆栈变量)(在本例中为 EDI):

2)用 1 或 0(代表真/假)替换每个常量

3)条件启动的实际最终修复发生在实际的反平坦阶段,此时我们正在迭代每个调度程序前身并看到以下内容:

当我们发现有一个条件启动ebx( )时,我们从一开始保存的变量中找到对(调度程序使用的变量)的赋值edi,我们将受影响的块更改为分支,方法是将其变为jz。这jz将检查 edi 是否为 0(还记得我们在开始时切换了 mov 指令以移动 1 或 0 而不是块赋值号吗?)

然后,它将跳转到绑定到 0 或 1 的任何情况。如下所示:

这个计划似乎是一个不错的主意。问题是我似乎遇到了 Hex-Rays 的一个错误,特别是一个错误的优化,导致反编译不正确。我联系了他们,他们承认了这个问题,但我还没有收到回复。这不是我第一次在这个项目上破坏 Hex-Rays,因为在此之前,我发现了另一个错误的优化,我联系了他们,他们确实在 IDA 9.0 中修复了这个问题。

终于……恢复平坦!

现在我们已经解决了所有可能阻碍我们进行扁平化的问题,是时候完成了。我也需要提到,我完全重做了DeferredGraphModifier原始插件中的类。现在,它使用队列并支持可以对图形执行的许多不同操作:

enum graph_modification_type
{
  block_target_change,
  block_fallthrough_change,
  block_nop_insns,
  new_block_insertion,
  block_convert_to_branch
};
我强烈建议您这样做,并将所有图形修改排队。我犯了一个错误,试图在循环迭代期间进行大量图形更改,这导致了很多问题并浪费了时间。
我进行反扁平化的方法是跟踪对调度程序变量的写入,直到得到一个数字常量。然后,我将在稍后删除整个写入指令链。我做的一件重要的事情是推迟删除指令。我的意思是,在我们修补每个扁平化块末尾的 goto 以转到正确的目的地后,我们想要删除将该数值移动到调度程序控制变量中的指令,对吗?好吧,如果我们有一个设置控制流变量的分支怎么办?这意味着如果条件块a分支到块b或c我们当前正在反扁平化b并跟踪写入a并删除指令,那么当我们到达块时,c我们不知道它应该使用什么值。现在需要删除的所有指令都在反扁平化过程完成后被删除。
最终结果将是一个完全不平坦的图形,对吗?我重新访问了主函数,兴奋地看到了最终结果。

……结果却令人失望。我花了太多时间进行反扁平化,以至于我忘记了一开始的不透明谓词!它们仍然存在,并引发了各种问题。我决心一劳永逸地获得干净的反编译器输出,因此我着眼于删除它们。

不透明谓词/垃圾代码

我在尝试移除不透明谓词时遇到了几个问题。首先,合法指令与不透明谓词/垃圾代码交织在一起。根据我最初尝试移除它们时查看的几个函数,只需用 nops 填充整个受影响的块即可。后来我发现了这样做的后果,所以我不得不改变策略。

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

上面的屏幕截图MMAT_CALLS显示了合法指令如何出现在包含垃圾指令的块中。您可能想知道为什么我MMAT_CALLS在花费了整个项目的时间之后突然变成了这样MMAT_LOCOPT。好吧,我最初试图删除不透明/垃圾,MMAT_LOCOPT但遇到了一些问题。看看每条指令,尽管包含大量操作,但从技术上讲只有一行?在期间MMAT_LOCOPT,Hex-Rays 没有对指令进行任何优化,我得到了各种不同的指令模式,这破坏了我的签名。

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

我决定将与反扁平化相关的所有内容保留在 中MMAT_LOCOPT,并等到 才MMAT_CALLS开始删除不透明谓词。因此,我的插件在两个单独的微码到期时运行。在此MMAT_CALLS阶段,我能够创建一个工作签名来检测和查找不透明谓词块。

其工作原理是查找乘以一个16位常数然后减去常数的条件块1。我存储了这些块访问的所有堆栈变量,并在每次检测到另一个块时跟踪它们的计数。我修补每个不透明谓词以删除其假分支,具体取决于它是jz或jnz。然后,我使用之前存储的最常见的堆栈变量来查找与其他块交织在一起的其他剩余垃圾指令并将其删除。

最终结果如何?

完全反混淆代码!二进制文件中最大的函数从3000 多行代码减少到 429 行。

反混淆器的优点在于,由于它可以在后续版本上运行(直到他们添加控制流间接性),我们可以轻松跟踪恶意软件的演变。例如,我发现另一个 Lumma 二进制文件中的相同大函数实际上更大,超过5000 行代码。在此版本中,开发人员添加了字符串加密,并实现了在两种可能的执行方法之间进行选择的选项:LoadLibraryW和rundll32DLL。以前的版本中缺少此功能,如下所示:

旅程的终点

最后,我最终对大约 50 个不透明和扁平化的函数进行了反混淆。这个项目是我做过的最困难但最有回报的项目之一。有很多次我觉得自己在尝试做一些无法完成的事情,但我坚持不懈,不会放弃。我必须非常感谢 Rolf Rolles,他不仅创建了这个项目所基于的原始插件项目,还非常友好地回答了我关于 Hex-Rays 内部的问题。如果没有他对微码 API 的丰富知识,我不知道我是否能够完成这个项目。

该项目的下一个补充可能是微代码模拟器。Lumma Stealer 不需要微代码模拟器,但其他恶意软件家族可能需要。我感兴趣的另一个功能是配置文件系统,可能类似于 D-810 所具有的配置文件系统,尽管我还没有详细研究过它。总而言之,这个项目的可能性无穷无尽,将来肯定会用于分析混淆的样本。我希望您喜欢阅读这篇文章,就像我写这篇文章一样,我迫不及待地想在未来发表更多文章!

  • Lumma Stealer 样本 SHA256:

00F1A9C6185B346F8FDF03E7928FACFC44FC63E6A847EB21FA0ECD7FB94BB7E3

  • Lumma 窃取者样本 #2 SHA256:

ECABBEAE6218B373F2D3A473D9F6ADD4BA5482EA3B97851C931197FB8993F8EF


感谢您抽出

.

.

来阅读本文

点它,分享点赞在看都在这里

Ots安全
持续发展共享方向:威胁情报、漏洞情报、恶意分析、渗透技术(工具)等,不会回复任何私信,感谢关注。
 最新文章