用于反编译为人类可读格式的 IL

逆向工程 拆卸 反编译
2021-06-12 09:01:13

在编写了自己的反汇编程序后,我现在希望使其汇编列表更具可读性,例如来自(人工)示例

push    ebp
mov     ebp, esp
sub     esp, 10h
mov     eax, dword ptr [55431824h]
imul    eax, dword ptr [ebp+8]
add     eax, dword ptr [ebp+0ch]
mov     dword ptr [ebp-4], eax
mov     eax, dword ptr [ebp-4]
leave
ret     10h

通过(>标记一个自动识别的序言/尾声):

>push    ebp
>mov     ebp, esp
>sub     esp, 10h

 mov     eax, dword ptr [_global_55431824]
 imul    eax, dword ptr [arg_0]
 add     eax, dword ptr [arg_4]
 mov     dword ptr [local_4], eax
 mov     eax, dword ptr [local_4]

>leave
>ret     10h

eax = 10
eax *= arg_0
eax += arg_4
local_4 = eax
eax = local_4
return

此时,每条伪指令仍与其原始的反汇编表示相关联。打印时,我可以扫描某些序列,因此我将前两行替换为

eax = 10 * arg_0

然后跳过打印下一行。但是,我能做到的程度是有限的。连接下一个操作,这将导致

eax = 10 * arg_0 + arg_4

需要向前看2个指令,并且将只为这个特定组合的工作movimuladd,同时确保相同的目的地寄存器仍然有针对性的和中间的指令对其他寄存器没有持久的影响。

目标是最终得到这样的结果:

local_4 = 10 * arg_0 + arg_4
eax = local_4
return

其中显然eax在 a 之前专门分配了一个值return,所以最后一行可以是

return eax

最后整个建筑可以折叠成

return 10 * arg_0 + arg_4

(这非常接近我在最初的普通 C 程序中开始的内容)。我正在努力处理复合线的内部表示,例如最后一条。否则非常可靠的反编译器页面backerstreet.com/creating_statements在原始汇编器和类 C 复合语句之间随意切换:

if(!expr1 && !expr2)
  ...

无需解释此中间步骤如何存储在内存中并可以创建输出字符串。

我应该查看哪种类型的中间存储形式,其中 (a) 可以从原始反汇编指令构建,(b) 可以组合和重新排列其元素,以及 (c) 可以转换为人类可读的输出,例如最后一行?

值得一提的是,我的目标并不是创建最终的通用反编译器 :) 我的测试输入是用 Delphi Pascal 的一个古老版本(很可能是 1993 年之前的)编译的,但它的程序集并没有真正优化并且相当可读首先,我仍然想加倍努力,让我的计算机做它最擅长的事情,并使代码更容易理解。


更长的真实世界示例

下面是一些实际的拆解。基本块编号后跟一个支配位集(loops十六进制编号;我生成了它们但尚未使用该数据)。ENTRYEXIT数字将基本块再次和用于生成功能的.DOT图。

; ## BLOCK 0; loops: 01; EXIT to 1, 4
401966A8    (  0)  8B 15 42201590       mov     edx,dword ptr [42201590]
401966AE    (  0)  8B 12                mov     edx,dword ptr [edx]
401966B0    (  0)  80 BA 39 01 00 00 02 cmp     byte ptr [edx+139h],2
401966B7    (  0)  75 11                jnz     label_4

; ## BLOCK 1; loops: 03; ENTER from 0; EXIT to 2, 4
401966B9    (  0)  83 78 24 02          cmp     dword ptr [eax+24h],2
401966BD    (  0)  7D 0B                jge     label_4

; ## BLOCK 2; loops: 07; ENTER from 1; EXIT to 3=RET
401966BF    (  0)  BA 02 00 00 00       mov     edx,2
401966C4    (  0)  2B 50 24             sub     edx,dword ptr [eax+24h]
401966C7    (  0)  8B C2                mov     eax,edx

; ## BLOCK 3 (epilog); loops: 0F; ENTER from 2
401966C9          >C3                   retn    

; ## BLOCK 4; loops: 11; ENTER from 0, 1; EXIT to 5, 7
                        label_4:
401966CA    (  0)  8B 15 42201590       mov     edx,dword ptr [42201590]
401966D0    (  0)  8B 12                mov     edx,dword ptr [edx]
401966D2    (  0)  80 BA 39 01 00 00 01 cmp     byte ptr [edx+139h],1
401966D9    (  0)  75 0D                jnz     label_7

; ## BLOCK 5; loops: 31; ENTER from 4; EXIT to 6, 7
401966DB    (  0)  83 78 24 00          cmp     dword ptr [eax+24h],0
401966DF    (  0)  75 07                jnz     label_7

; ## BLOCK 6; loops: 71; ENTER from 5; EXIT to 8=RET
401966E1    (  0)  B8 01 00 00 00       mov     eax,1
401966E6    (  0)  EB 02                jmp     label_8

; ## BLOCK 7; loops: 91; ENTER from 4, 5; EXIT to 8=RET
                        label_7:
401966E8    (  0)  33 C0                xor     eax,eax

; ## BLOCK 8 (epilog); loops: 0111; ENTER from 6, 7
                        label_8:
401966EA          >C3                   retn    

401966EB                align 4

这是 .dot 图像;clear ifs 是黄色的,节点包含它们的支配位集,所以我可以尝试理解它们(一个待办事项)。这解释了流程,但您看不到每个节点代表多少代码

功能流程

反汇编被解析为以下伪代码。一些笔记是manually added.

; #### 已解析
401966A8(0)edx = Main.UnitList@20551314;
401966AE(0)edx =(双字)[edx];
401966B0(0)如果((字节)[edx + 139h]!= 2)转到label_4;

401966B9(0)如果((dword)[eax + 24h] >= 2)转到label_4;

401966BF(0)edx=2;
401966C4(0)edx-=(双字)[eax + 24h];
401966C7(0)返回edx;MOV EAX,.. 后面跟着一个退出块

                        返回 ;  .. 而这一行是由实际的退出块生成的

                    标签_4:
401966CA(0)edx = Main.UnitList@20551314;
401966D0(0)edx =(双字)[edx];
401966D2(0)如果((字节)[edx + 139h]!= 1)转到label_7;

401966DB(0)如果((dword)[eax + 24h]!= 0)转到label_7;

401966E1(0)eax=1;
401966E6(0)返回;  这是一个跳转到退出块,所以错过了

                    标签_7:
401966E8(0)eax=0;这是一个 XOR,而不是一个 MOV,所以错过了

                    标签_8:
                        返回 ;

冗长的损失已经值得付出努力:从 21 行代码到 16 行,即使在RET两次失败之前检查 EAX 是否被正确写入的前瞻也是如此

然而,很明显,两个连续的ifs at401966B0可以用 OR 组合成一个。反转条件,整个块可以是一个单一的if并支撑到return, 删除label_4可以将from开始
的操作连接到单个语句中。 此外,at 条件可以合并为一个条件并放入一个块中。因为在它的末尾有一个所以那里没有必要。手动重建:edx401966BFreturn
if401966D2ifreturnelse

401966A8    (  0)       edx = Main.UnitList@20551314 ;
401966AE    (  0)       edx = (dword)[edx] ;
401966B0    (  0)       if ((byte)[edx + 139h] == 2 &&
401966B9    (  0)           (dword)[eax + 24h] < 2)
                        {
401966BF    (  0)           return 2 - (dword)[eax + 24h] ;
                        }

401966CA    (  0)       edx = Main.UnitList@20551314 ;
401966D0    (  0)       edx = (dword)[edx] ;
401966D2    (  0)       if ((byte)[edx + 139h] == 1 &&
401966DB    (  0)          (dword)[eax + 24h] == 0)
                        {
401966E1    (  0)           return 1 ;
                        }

401966E8    (  0)       return 0 ;

将原来的 21 行减少到仅仅 11 行。此外,寄存器值传播解决了对全局类的引用Main.UnitList我手动为其元素创建了名称;401966A8并向前折叠成

401966B0    (  0)       if (Main.UnitList.flag == 2 &&
401966B9    (  0)           Main.UnitList.count < 2)

这使得代码实际上具有可读性。

eax在写入之前访问在这里不是错误。该函数是一个类函数,并eax指向一个类实例,因此(dword)[eax + 24h]读取结构的数据成员。Main.UnitList是一个指向全局声明类实例的指针,因为它指向 BSS。

2个回答

为了创建“人类”可读的表达式,我会使用算术分析树事实上,涉及的每个寄存器和内存位置都有一个单独的解析树。

每个赋值都将目标树的根复制到目标树。

每个算术运算都会创建一个包含该运算的新根,操作数作为左右孩子。结果树被分配给算术运算的目标。

你的例子是这样构建的:

示例解析树

从那里,发出结果公式只是一个 ltr 树遍历。您可能希望首先简化您的表达式;您可以用操作结果替换具有两个常量子节点的运算符,并且您可能希望通过尽可能向下移动常量来转换树,如下所示:

              eax
               |
              add
               |
     +---------+--------+
     |                  |
    add                 5
     |                  
+----+-------+          
|            |
2           ebx


              eax
               |
              add
               |
     +---------+--------+
     |                  |
    add                ebx
     |                  
+----+-------+         
|            |
2            5

              eax
               |
              add
               |
     +---------+--------+
     |                  |
     7                 ebx

谷歌搜索“表达式树”和“常量折叠”以获取更多相关资源。

请注意,树可能是“自引用”的,尤其是在循环中;循环控制变量将在顶部节点以及树下的某处看到。这实际上是识别循环变量的好方法。

我可能会首先尝试识别基本块(所有不包含 jmp 指令或目标的东西),然后为这些基本块构建和转储表达式树。如果你让它工作得很好,你可以继续识别跨越几个块但在其中一些块中保持不变的东西,比如循环,以找出在整个函数上构建的内容。

编辑:另一个例子,它显示了如何处理寄存器移动

原始寄存器移动只是将树从源寄存器复制到目标寄存器。这是来自一个旧的 watcom C 程序,编译的时候imul很慢,一系列的 shift/add 指令更快;编译器在这里计算 var*45:

mov eax, [var]
mov edx, eax      <-- tree copy here (not much of a tree, however)
shl edx, 2
add eax, edx
mov edx, eax      <-- tree copy again
shl eax, 3
add eax, edx

算术示例 var*45

您在这里描述的内容本质上与静态二进制翻译器相同,后者使用高级语言(如 C)作为其输出目标,而不是特定的体系结构。它的机制不如反编译那么先进,但在街机视频游戏等受限领域中已被证明非常有效。(例如http://vide.malban.de/vectrex-programs/vecfever)已经写了一些;例如,我有针对 6809、6502、z80 和 Cinematronic CPU 执行此操作的正在进行的代码(尽管可能无法立即使用 - 它是四个独立的背景项目的结合,没有太大的紧迫性)。无论如何,请随意浏览一下,看看这里是否有任何对您有帮助的内容:http : //gtoal.com/SBTPROJECT/ (如果您只是浏览而不实际运行它,那么 6809 可能是最干净的代码)

http://gtoal.com/sbt/上有一些关于整体方法的文章

输出看起来像这样:

            //        LDA   $FFFF,X                 ; 0431: A6 1F         
      A = memory[(UINT16)(X + 0xffff)];
  //  N = A;
  //  Z = A;
  //  V = 0;

            //        LEAU  ,X                      ; 0433: 33 84         
      U = X;

            //        LDX   #$0EC8                  ; 0435: 8E 0E C8      
      X = 0x0ec8;
  //  Z = X;
  //  N = (X) >> 8;
  //  V = 0;

(注释掉的行是由于优化了冗余商店)

您可以通过输出详细描述操作正在执行的操作的注释来满足 OP 的请求,而不是输出用于执行的功能代码。

David Welch、Norbert Keher、Thomas Sontowski 和其他人编写了其他实用的翻译器。还有 Project Orion,这是一个多人多目标翻译器,由 Neil Bradley(受人尊敬的模拟器作者)和 Orion 邮件列表的其他几个成员领导 - 该翻译器有 6502 和 z80 的前端工作,68000 和 6809也在开发中,并构建了一个内部 AST(抽象语法树,很像本线程早期的一个答案中描述的那样),以便向包括 C 在内的各种后端输出更清晰、更高效的代码。(还有Christina Cifuentes 等人制作的一些学术著作。)

格雷厄姆

PS 当输出是通用 IR(内部表示)(例如 C 或 LLVM 的内部 IR,而不是直接生成二进制文件或通过为特定架构输出 asm 间接生成)时,该技术被正确称为“编译指令集模拟” - 但大多数人只是称之为静态二进制翻译。