#逆向工程基本原理07
高级语言中非结构化代码的生成
非结构化高级语言代码是一般的顺序执行代码,不包含高级语言的循环/分支结构
生成方式:将 IR 翻译为高级语言代码,并简化
1 | (unique, 0x3580, 4) INT_MULT (register, 0x24, 4), (const, 0x4, 4) |
使用惯用语(Idioms)简化代码
一些复杂的语句可以转化为更易读的形式
步骤:
- 设计匹配模式
- 在代码中匹配原始表达式
- 将原始表达式转化为惯用语
示例:x ^ -1(x 为 32 位整数,-1 的机器码为 0xFFFFFFFF,相当于把 x 每一位取反)可以替换为 -x - 1,因为 x 取反加 1 相当于负 x,更符合人类阅读习惯
高级语言中结构化代码的生成
结构化代码包含分支、循环结构。编译时结构化代码被转换为跳转指令,丧失了结构特征,降低了代码可读性。结构化代码生成就是将跳转语句恢复为高级语言中的分支、循环结构
通过 CFG 分析代码结构:
- 分支语句(if/else, switch):在 CFG 中可以观察到存在多条出边的节点
- 循环语句(do-while, while, for):在 CFG 中可以观察到环路
分支语句的 CFG 特征
(1) 简单 if 分支
CFG 特征:
- 入口节点 a 有 2 个后继节点,且跳转条件相反
- a 的后继节点之一 b 仅有 1 条入边和 1 条出边
- b 和 a 有相同的后继节点 t
1 | a |
对应:if (cond) { b } t
(2) if-else 分支
CFG 特征:
- 入口节点 a 有 2 个后继节点(且条件相反)
- 两个后继节点的入边和出边均为 1
- 两个后继节点的后继节点相同
1 | a |
对应:if (cond) { b } else { c } t
(3) if-else-if 分支
在 if-else 的结构上嵌套了更多的 if/if-else 结构,CFG 结构更复杂、更难以被识别
1 | a |
对应:if (cond1) { b } else if (cond2) { c } t
编译器会产生新的节点 c’ 用于判断嵌套条件
(4) 复合逻辑条件的 if 分支
条件判断中的逻辑表达式被拆分为新的条件分支
逻辑与(&&)的 CFG 特征:被拆分为嵌套的 if 分支,出口节点 t 有多条入边
1 | if (cond1 && cond2) { b } |
1 | a |
复合逻辑与(cond1 && cond2 && cond3)会产生更多嵌套 if 结构
逻辑或(||)的 CFG 特征:被拆分为 if-else-if 分支,内容代码块 b 有多条入边
1 | if (cond1 || cond2) { b } |
1 | a |
(5) switch 分支
switch 可能对应两种 CFG 结构:
- if-else-if 结构:对应一系列嵌套的条件比较
- 多分支结构:入口有多条出边,出口有多条入边,分支跳转对应的汇编指令通常为间接跳转(如
br x0),跳转目标由指针运算得出
1 | 多分支结构: |
循环语句的 CFG 特征
死循环
死循环是没有出口的循环,CFG 特征最简单
1 | while (1) {} |
do-while 循环
至少循环一次,结构最简单的通用循环
1 | a: |
while 循环
在入口处比 do-while 多一个条件判断
1 | a: |
for 循环
初始化(init)节点是条件判断的前继节点,更新(update)节点也是条件判断的前继节点
1 | a: |
continue/break/goto
- continue:在 do-while 和 while 中跳转到条件判断节点;在 for 循环中跳转到 update 节点
- break:跳转到循环的后继节点(循环出口)
- goto:所有跳转都可以用 goto 语句替代
识别 CFG 中的回边
与分支不同,循环的 CFG 结构中包含回边(Back Edge)。识别循环的关键是发现回边——每一个循环对应一条回边,回边的目标节点是循环的入口节点
回边的定义
笼统来说,在遍历 CFG 过程中访问到之前访问过的节点即存在回边
严格来说,在遍历 CFG 过程中访问到之前访问过的节点,且该节点存在一条到当前节点的路径,即存在回边
基于 DFS 树定义回边
通过回溯的方式在 CFG 上进行 DFS(深度优先搜索),每个点/每条边最多访问一次,按遍历顺序连接节点生成对应的 DFS 树
DFS 树的节点关系:
- 祖孙关系:若树上存在 u 到 v 的路径,则 u 为 v 的祖先,v 为 u 的子孙
- 非祖孙关系:若树上不存在 u 到 v 或 v 到 u 的路径
CFG 上的四种边:
| 边类型 | 说明 |
|---|---|
| 树边 | 在 DFS 树上的边 |
| 前向边 | 不在 DFS 树上,由祖先节点指向子孙节点 |
| 后向边(回边) | 不在 DFS 树上,由子孙节点指向祖先节点 |
| 横叉边 | 不在 DFS 树上,边两端的节点无祖孙关系 |
基于 Tarjan 算法识别回边
核心想法:记录 DFS 过程中节点在路径中被访问的序号(时间),如果有一条边从访问序号晚的节点指向访问序号早的节点,那么就是回边
核心数据结构:
- STACK:栈,记录当前路径上的节点
- DFN[u]:节点 u 在 DFS 中首次出现的序号(Deep First Number),一旦赋予不会改变
- LOW[u]:从节点 u 出发可以到达的所有节点中最小的 DFN 序号,在 DFS 过程中不断更新
1 | def tarjan(u): |
算法关键:
- 条件 1:若第一次访问 v,则
u → v将成为树边,递归后更新 LOW - 条件 2:若 v 在 Stack 中(说明 v 在当前 DFS 路径上),则
u → v不是横叉边 - 条件 3:若
DFN[v] ≤ LOW[u](v 是 u 的祖先),则u → v是回边
横叉边
e → i的判断:i 已被访问但不在 Stack 中(已经从 Stack 中弹出),说明 i 不在当前 DFS 路径上
结构化代码的生成
核心思路是化简/归约:将 CFG 中的节点合并为具有结构的代码块
代码生成思路:
- 基于规则的 CFG 结构化简:所有代码结构都有化简规则与之对应
- 不断应用规则,识别相关的 CFG 节点和结构,进行化简
- 直到 CFG 只包含一个节点,即该 CFG 的所有代码
默认化简规则
不考虑代码可读性,覆盖所有 CFG 结构以完成化简:
- 直接跳转 →
goto - 分支语句 →
if-goto-else-goto
1 | C1: |
这种方式产生的代码可读性差(充满 goto),但保证能完成化简
针对性化简规则
针对特定 CFG 结构设计化简规则,提高代码可读性:
顺序结构的化简
入度为 1 的连续块可以合并成一个代码块
1 | a → b → c → d 化简为 a → merge(b,c) → d |
循环结构的化简
根据回边和循环特点识别和化简不同的循环结构:
- 识别回边目标为条件判断 → 可能是 while 循环
- 回边源自循环体末尾 → 可能是 do-while 循环
- 循环之间可以相互转换(for 可化简为 while)
1 | for 循环恢复需要识别 init 和 update,但也可以化简为 while: |
一些情况下 for 循环可读性更强:
i=0; while(i < 10) ++ivsfor(i=0; i<10; ++i)
分支结构的化简
根据不同分支的特点进行递归化简(简单 if、if-else 等)
循环中的特殊语句
break 和 continue 通常和条件判断连用:
- if-break:跳转目标为循环出口
- if-continue:跳转目标为循环入口(条件判断节点或 update 节点)
两者都可以替换为 if-goto,但后者可读性差
困难情况
针对性化简规则通常是不完备的,复杂的控制流结构有时难以被匹配,导致必须使用默认化简规则,从而产生 goto 语句。通常在复杂的嵌套分支/循环结构中容易出现这种情况
高级语言中声明代码的生成
声明语句可以帮助理解和重新编译反编译后的代码
识别变量类型
变量类型有助于理解变量的含义:
- 基本类型:蕴含变量长度、是否为浮点数、是否为指针等信息
- IR 中的基本类型与高级语言中的类型通常存在对应关系(如
i32→int,u32→unsigned int)
- IR 中的基本类型与高级语言中的类型通常存在对应关系(如
- 复合类型(数组/结构体):帮助理解数据的组成和访存模式
- 信息在编译中会丢失,结构体通常表现为内存中连续存放的基本类型变量
- 例如
string.buffer[string.length - 1]在缺乏类型定义时可能表现为*(&string + *(&string + offset_length) - 1)
识别复合类型
根据已知 API 调用识别:若存在函数调用 fclose(x),则可以判断 x 的类型为 FILE *
根据内存访问模式识别:通过编写规则识别特定复合类型的内存访问模式
例如,若有代码 for (i=0; i < X; ++i) *(buff + (i<<2)) = 0;,可以推断 buff 的类型为数组 int buff[X]
生成变量名
编译通常不会保留变量名,需要对反编译生成的变量重新命名
多种方式:
- 使用编号进行命名:如
var_1 - 根据变量的内存位置命名:如寄存器变量命名为
reg_1,栈上的命名为stack_var_1 - 根据变量类型命名:如
char *类型的变量可以命名为str增强可读性 - 根据上下文语义命名:如 for 循环的枚举变量可以命名为
i, j, k, ... - 根据已知 API 的参数声明命名:如
fopen(file)
全局变量声明
反编译时数据段内存有时需要被声明为全局变量,方便代码引用。全局变量具有初始化值
1 | 内存:0x000A0000 byte 00 00 00 00 |
生成函数声明
函数声明可以帮助理解函数的语义(参数列表、返回值、函数名)
编译可能丢失函数声明,反编译需要恢复声明以提高代码可读性:
1 | FILE *fopen(char *path, char *mode); // 有声明,语义清晰 |
恢复函数声明的途径:
- 分析函数调用约定:识别参数列表和返回值
- 识别为已知函数:根据已知函数恢复完整声明
- 其他方法
根据调用约定恢复函数声明
调用约定(Calling Convention)是一种底层代码实现方案,描述被调用函数如何从调用者接受参数、如何返回值。不同平台(Windows、Linux)和不同架构(ARM、X86)可能使用不同的调用约定
设计原则:优先使用寄存器,寄存器不够时才使用内存(栈),从而提高数据传递的效率
函数返回值的识别
每个 C/C++ 函数最多一个返回值:
- 较小的返回值(如 int)通过寄存器返回(ARM64 上通过 W0/X0)
- 较大的返回值(如大型 struct)通过隐含的指针参数返回(ARM64 上 X8 指向调用者分配的缓冲区)
1 | ; int ret_int() { return 0; } |
函数参数的识别
根据调用约定,参数通过特定寄存器和栈传递:
- ARM64:前 8 个整数/指针参数通过 X0-X7 传递,浮点参数通过 D0-D7 传递
- 超出寄存器数量的参数通过栈传递
通过分析函数入口处对参数寄存器的读取模式,可以推断参数的数量和类型
在 ARM64 调用约定中,X0 既是第一个参数寄存器,也是整数返回值寄存器。反编译器需要通过数据流分析区分参数和返回值的使用场景