$ cat ~ / posts /reverse /lecture07 3.4k Words ~ 13 Mins
cover.png
逆向工程基本原理07

#逆向工程基本原理07

exdoubled Lv5

高级语言中非结构化代码的生成

非结构化高级语言代码是一般的顺序执行代码,不包含高级语言的循环/分支结构

生成方式:将 IR 翻译为高级语言代码,并简化

1
2
3
4
5
6
7
8
9
(unique, 0x3580, 4) INT_MULT (register, 0x24, 4), (const, 0x4, 4)
(unique, 0x10000008, 4) INT_ADD (register, 0x20, 4), (unique, 0x3580, 4)
(register, 0x24, 4) CAST (unique, 0x10000008, 4)
↓ 翻译
tmp1 = x * 4
tmp2 = y + tmp1
z = tmp2
↓ 合并表达式
z = y + x * 4

使用惯用语(Idioms)简化代码

一些复杂的语句可以转化为更易读的形式

步骤:

  1. 设计匹配模式
  2. 在代码中匹配原始表达式
  3. 将原始表达式转化为惯用语

示例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
2
3
4
5
6
7
8
    a
/ \
cond !cond
/ \
b |
\ /
\ /
t

对应:if (cond) { b } t

(2) if-else 分支

CFG 特征:

  • 入口节点 a 有 2 个后继节点(且条件相反)
  • 两个后继节点的入边和出边均为 1
  • 两个后继节点的后继节点相同
1
2
3
4
5
6
7
    a
/ \
cond !cond
/ \
b c
\ /
t

对应:if (cond) { b } else { c } t

(3) if-else-if 分支

在 if-else 的结构上嵌套了更多的 if/if-else 结构,CFG 结构更复杂、更难以被识别

1
2
3
4
5
6
7
8
9
10
11
    a
/ \
cond1 !cond1
/ \
b c'
/ \
cond2 !cond2
/ \
c |
\ /
t

对应:if (cond1) { b } else if (cond2) { c } t

编译器会产生新的节点 c’ 用于判断嵌套条件

(4) 复合逻辑条件的 if 分支

条件判断中的逻辑表达式被拆分为新的条件分支

逻辑与(&&)的 CFG 特征:被拆分为嵌套的 if 分支,出口节点 t 有多条入边

1
2
3
4
5
6
7
if (cond1 && cond2) { b }
↓ 编译后等价于
if (cond1) {
if (cond2) {
b
}
}
1
2
3
4
5
6
7
8
9
10
11
      a
/ \
cond1 !cond1
/ \
a' |
/ \ |
cond2 !cond2|
/ \ /
b t
\ /
t

复合逻辑与(cond1 && cond2 && cond3)会产生更多嵌套 if 结构

逻辑或(||)的 CFG 特征:被拆分为 if-else-if 分支,内容代码块 b 有多条入边

1
2
3
4
if (cond1 || cond2) { b }
↓ 编译后等价于
if (cond1) { b }
else if (cond2) { b }
1
2
3
4
5
6
7
8
9
      a
/ \
!cond1 cond1
/ \
a' b
/ \ /
cond2 !cond2
/ \
b t

(5) switch 分支

switch 可能对应两种 CFG 结构:

  1. if-else-if 结构:对应一系列嵌套的条件比较
  2. 多分支结构:入口有多条出边,出口有多条入边,分支跳转对应的汇编指令通常为间接跳转(如 br x0),跳转目标由指针运算得出
1
2
3
4
5
6
多分支结构:
a
/ | \ \
s1 s2 ... sd
\ | / /
t

循环语句的 CFG 特征

死循环

死循环是没有出口的循环,CFG 特征最简单

1
2
3
while (1) {}
do {} while (1);
for (;;) {}

do-while 循环

至少循环一次,结构最简单的通用循环

1
2
3
4
5
6
a:
do { b } while (cond);
t:

CFG:
a → b → b' → {cond: b, !cond: t}

while 循环

在入口处比 do-while 多一个条件判断

1
2
3
4
5
6
a:
while (cond) { b }
t:

CFG:
a → b' → {cond: b → b', !cond: t}

for 循环

初始化(init)节点是条件判断的前继节点,更新(update)节点也是条件判断的前继节点

1
2
3
4
5
6
a:
for (init; cond; update) { b }
t:

CFG:
a → init → b' → {cond: b → update → b', !cond: t}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def tarjan(u):
DFN[u] = LOW[u] = ++Index
Stack.push(u)

for each (u, v) in E:
if v is not visited: # 条件1: 第一次访问v, u→v将成为树边
tarjan(v) # DFS递归访问子节点
LOW[u] = min(LOW[u], LOW[v]) # DFS回溯后更新LOW
elif v in Stack: # 条件2: v在Stack中, u→v不是横向边
LOW[u] = min(LOW[u], DFN[v]) # 判断回边前更新LOW
if DFN[v] <= LOW[u]: # 条件3: v是u的祖先, u→v是回边
mark_back_edge(u, v)

if DFN[u] == LOW[u]:
Stack.pop_until(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 中的节点合并为具有结构的代码块

代码生成思路:

  1. 基于规则的 CFG 结构化简:所有代码结构都有化简规则与之对应
  2. 不断应用规则,识别相关的 CFG 节点和结构,进行化简
  3. 直到 CFG 只包含一个节点,即该 CFG 的所有代码

默认化简规则

不考虑代码可读性,覆盖所有 CFG 结构以完成化简:

  • 直接跳转 → goto
  • 分支语句 → if-goto-else-goto
1
2
3
4
5
6
7
8
9
10
C1:
if (c1) goto N1; else goto C2;
N1: n1; goto C1;
C2:
if (c2) goto N2; else goto N3;
N3: n3; goto C3;
N2: n2; goto C3;
C3:
if (c3) goto C1; else goto N4;
N4: n4

这种方式产生的代码可读性差(充满 goto),但保证能完成化简

针对性化简规则

针对特定 CFG 结构设计化简规则,提高代码可读性:

顺序结构的化简

入度为 1 的连续块可以合并成一个代码块

1
a → b → c → d    化简为    a → merge(b,c) → d
循环结构的化简

根据回边和循环特点识别和化简不同的循环结构:

  • 识别回边目标为条件判断 → 可能是 while 循环
  • 回边源自循环体末尾 → 可能是 do-while 循环
  • 循环之间可以相互转换(for 可化简为 while)
1
2
3
4
for 循环恢复需要识别 init 和 update,但也可以化简为 while:
a → init → b' → {cond: b → update → b', !cond: t}
化简为
a → init → while(cond) { merge(b, update) } → t

一些情况下 for 循环可读性更强:i=0; while(i < 10) ++i vs for(i=0; i<10; ++i)

分支结构的化简

根据不同分支的特点进行递归化简(简单 if、if-else 等)

循环中的特殊语句

break 和 continue 通常和条件判断连用:

  • if-break:跳转目标为循环出口
  • if-continue:跳转目标为循环入口(条件判断节点或 update 节点)

两者都可以替换为 if-goto,但后者可读性差

困难情况

针对性化简规则通常是不完备的,复杂的控制流结构有时难以被匹配,导致必须使用默认化简规则,从而产生 goto 语句。通常在复杂的嵌套分支/循环结构中容易出现这种情况

高级语言中声明代码的生成

声明语句可以帮助理解和重新编译反编译后的代码

识别变量类型

变量类型有助于理解变量的含义:

  • 基本类型:蕴含变量长度、是否为浮点数、是否为指针等信息
    • IR 中的基本类型与高级语言中的类型通常存在对应关系(如 i32intu32unsigned int
  • 复合类型(数组/结构体):帮助理解数据的组成和访存模式
    • 信息在编译中会丢失,结构体通常表现为内存中连续存放的基本类型变量
    • 例如 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]

生成变量名

编译通常不会保留变量名,需要对反编译生成的变量重新命名

多种方式:

  1. 使用编号进行命名:如 var_1
  2. 根据变量的内存位置命名:如寄存器变量命名为 reg_1,栈上的命名为 stack_var_1
  3. 根据变量类型命名:如 char * 类型的变量可以命名为 str 增强可读性
  4. 根据上下文语义命名:如 for 循环的枚举变量可以命名为 i, j, k, ...
  5. 根据已知 API 的参数声明命名:如 fopen(file)

全局变量声明

反编译时数据段内存有时需要被声明为全局变量,方便代码引用。全局变量具有初始化值

1
2
3
4
5
内存:0x000A0000 byte 00 00 00 00
声明:int glob_000A0000 = 0;

内存:0x000A0000 byte 61 61 62 62 00 → 识别为字符串 "aabb"
声明:char *str_aabb = "aabb";

生成函数声明

函数声明可以帮助理解函数的语义(参数列表、返回值、函数名)

编译可能丢失函数声明,反编译需要恢复声明以提高代码可读性:

1
2
3
FILE *fopen(char *path, char *mode);     // 有声明,语义清晰
// vs
int sub_00044890(int arg1, int arg2); // 无声明,难以理解

恢复函数声明的途径:

  1. 分析函数调用约定:识别参数列表和返回值
  2. 识别为已知函数:根据已知函数恢复完整声明
  3. 其他方法

根据调用约定恢复函数声明

调用约定(Calling Convention)是一种底层代码实现方案,描述被调用函数如何从调用者接受参数、如何返回值。不同平台(Windows、Linux)和不同架构(ARM、X86)可能使用不同的调用约定

设计原则:优先使用寄存器,寄存器不够时才使用内存(栈),从而提高数据传递的效率

函数返回值的识别

每个 C/C++ 函数最多一个返回值:

  • 较小的返回值(如 int)通过寄存器返回(ARM64 上通过 W0/X0)
  • 较大的返回值(如大型 struct)通过隐含的指针参数返回(ARM64 上 X8 指向调用者分配的缓冲区)
1
2
3
4
5
6
7
8
9
10
11
; int ret_int() { return 0; }
ret_int:
mov w0, #0 ; 返回值通过 W0 传递
ret

; struct Temp ret_struct() { ... }
ret_struct:
; X8 = 调用者传入的结构体缓冲区地址
str w1, [x8] ; 将数据写入缓冲区
...
ret
函数参数的识别

根据调用约定,参数通过特定寄存器和栈传递:

  • ARM64:前 8 个整数/指针参数通过 X0-X7 传递,浮点参数通过 D0-D7 传递
  • 超出寄存器数量的参数通过栈传递

通过分析函数入口处对参数寄存器的读取模式,可以推断参数的数量和类型

在 ARM64 调用约定中,X0 既是第一个参数寄存器,也是整数返回值寄存器。反编译器需要通过数据流分析区分参数和返回值的使用场景

$ discussion
# Comments
waline