$ cat ~ / posts /reverse /lecture04 7.2k Words ~ 28 Mins
cover.png
逆向工程基本原理04

#逆向工程基本原理04

exdoubled Lv5

编译过程

大部分程序使用高级语言编写,逆向工程需要从二进制文件出发。编译过程就是将高级语言源代码转换为二进制文件的过程,理解编译过程有助于理解源代码到二进制文件之间发生的变化

编译流程概述

从源代码到可执行文件的完整编译流程分为四个阶段:

1
源代码(.c)  →  预处理  →  生成汇编代码  →  生成可重定位文件(.o)  →  链接生成可执行文件

对应的 GCC 命令:

1
2
3
4
5
6
gcc main.c -E -o main.i     # (1) 预处理
gcc main.i -S -o main.s # (2) 生成汇编代码
gcc main.S -c -o main.o # (3) 生成可重定位文件
gcc main.o -o main # (4) 链接生成可执行文件

gcc main.c -o main -v # 查看完整编译过程的详细信息

(1) 预处理

预处理将源代码中的一些特定指令展开成实际的代码,包括:

  • #include 展开:将头文件内容插入源代码
  • 宏展开:将宏定义替换为实际代码
  • 条件编译:处理 #if#else#endif 等条件编译指令
  • 注释删除:移除源代码中的注释
  • 编译器指令:处理 #pragma 等指令

(2) 生成汇编代码

将预处理后的源代码编译为汇编代码,主要步骤包括:词法分析、语法分析、运行时存储分配、中间代码生成、编译器优化

运行时存储分配

C/C++ 中的内存使用分为静态内存分配和动态内存分配:

  • 静态内存分配(不需要手动释放)
    • 局部变量 → 栈内存
    • 全局变量 / 静态变量 → 全局数据段
  • 动态内存分配(需要显式释放以避免内存泄漏)
    • 通过 malloc / new 动态分配堆内存
1
2
3
4
5
6
7
8
9
10
11
#include <stdlib.h>

int main() {
int a = 0; // 局部变量,栈上分配
static double b = 3.14; // 静态变量,全局数据段
char *buffer = malloc(0x20); // 动态分配,堆上
// ...
free(buffer); // 显式释放
buffer = NULL;
return 0;
}

中间代码与编译器优化

为了支持多种源语言和目标架构的扩展,编译器先将源代码转换为中间代码表示(IR),在 IR 上进行各种优化,再将优化后的 IR 翻译为目标架构的汇编代码

1
2
3
C    ┐              ┌→ x86
C++ ├→ IR → 优化 → ├→ ARM
Swift┘ └→ MIPS

编译器优化通常分为:

  • 速度优化:使程序运行速度更快
  • 空间优化:使程序体积更小

编译器优化可能会改变代码逻辑与源代码之间表示的关系,例如死代码消除:

1
2
3
4
5
6
int bar(int n) {
int result = 0;
for (int i = 0; i <= n; ++i)
result += i;
return n / 2; // result 未被使用,整个循环被优化掉
}

编译后(开启优化):

1
2
3
4
bar:
add w0, w0, w0, lsr 31
asr w0, w0, 1
ret

循环计算 result 的代码被编译器识别为死代码并完全删除,只保留了 return n / 2 的计算

(3) 生成可重定位文件

汇编阶段将汇编代码转换为机器码,生成可重定位目标文件(.o 文件)

汇编器的主要工作:

  • 指令编码:将特定架构的汇编指令转换为机器码
  • 符号记录:在汇编过程中维护符号表,记录代码和数据的符号引用
  • 重定位信息:记录需要在链接阶段确定地址的符号引用
1
2
3
4
5
6
7
8
9
.global_var_1
0x10 .long 0x12345678

.func
0x20 ldr w0, [global_var_1] ; 引用符号 global_var_1
0x24 ret

.func_2
0x30 call func ; 引用符号 func

汇编器在遇到符号引用时,先在符号表中查找;若符号已定义,直接使用对应偏移;若未定义,记录为待解析的符号引用,留给链接器处理

(4) 链接生成可执行文件

链接(Linking)是将多个目标文件合并成同一个可执行文件的过程,包括:

  • 符号解析:为目标文件中未定义的符号在其他目标文件中找到定义
  • 重定位:将多个目标文件合并为一个可执行文件后,确定所有符号的地址
1
2
3
4
5
// main.c                    // bar.c
int main() { #include <stdio.h>
bar(); void bar() {
} printf("PoRE");
}
1
2
3
main.c → main.o ┐
├→ 链接器(ld) → 可执行文件 program
bar.c → bar.o ┘

二进制文件结构

设计原理

二进制文件结构的设计需要考虑:可移植性、架构支持、动态链接支持、文件大小控制、调试信息支持等

二进制文件中的通用元数据包括:

  • 文件头:位于文件头部,包含文件的基本信息
  • 长度信息:各段和数据块的长度
  • 标志位:描述文件状态和属性
  • 时间戳:文件的创建时间、修改时间
  • 校验和:验证文件完整性的数据
  • 结构描述:文件中各结构的信息(如各字段的名称、大小和属性)

不同平台的二进制文件格式

不同操作系统使用的二进制可执行文件结构各不相同:

操作系统文件格式
WindowsPE(Portable Executable)
LinuxELF(Executable and Linkable Format)
macOSMach-O

ELF 文件结构

ELF(Executable and Linkable Format)用于定义 Linux 下的二进制文件,包括:

  • 可重定位目标文件(.o
  • 可执行文件
  • 共享目标文件(.so

ELF 的设计目标是为不同操作系统提供通用的二进制文件信息接口,并使用动态链接机制增强可执行文件的可扩展性

ELF 文件结构在底层为 ELF 文件提供了 Executable(可执行)Linkable(可链接) 两种视图

ELF 文件头

ELF 文件头位于二进制文件的头部,包含文件的基本信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typedef struct {
unsigned char e_ident[EI_NIDENT]; // 魔数、字节序等
Elf64_Half e_type; // 文件类型(.o / exec / .so)
Elf64_Half e_machine; // 处理器架构
Elf64_Word e_version; // ELF 版本
Elf64_Addr e_entry; // 程序执行入口地址
Elf64_Off e_phoff; // 程序头表地址(Program Header Table)
Elf64_Off e_shoff; // 节头表地址(Section Header Table)
Elf64_Word e_flags; // 处理器特定标志
Elf64_Half e_ehsize; // ELF 文件头大小
Elf64_Half e_phentsize; // 程序头表条目大小
Elf64_Half e_phnum; // 程序头表条目数量
Elf64_Half e_shentsize; // 节头表条目大小
Elf64_Half e_shnum; // 节头表条目数量
Elf64_Half e_shtrndx; // 节名字符串表的索引
} Elf64_Ehdr;

可使用 readelf -h <file> 查看 ELF 文件头信息

程序头表(Program Header Table)

程序头表描述了可执行文件中所有的段(Segment),包括段的类型、大小、虚拟地址及对齐方式。运行时段被加载到对应的虚拟地址空间

1
2
3
4
5
6
7
8
9
10
struct Elf64_Phdr {
Elf64_Word p_type; // 段类型
Elf64_Word p_flags; // 段标志(读/写/执行权限)
Elf64_Off p_offset; // 段在文件中的偏移
Elf64_Addr p_vaddr; // 段的虚拟地址
Elf64_Addr p_paddr; // 段的物理地址
Elf64_Xword p_filesz; // 段在文件中的大小
Elf64_Xword p_memsz; // 段在内存中的大小
Elf64_Xword p_align; // 段对齐约束
};

可使用 readelf -l <file> 查看程序头表

节头表(Section Header Table)

节头表描述了可执行文件中所有的节(Section),包括每个节的名称、位置和大小等信息,这些信息用于链接和调试

1
2
3
4
5
6
7
8
9
10
11
12
struct Elf64_Shdr {
Elf64_Word sh_name; // 节名称(字符串表中的索引)
Elf64_Word sh_type; // 节类型
Elf64_Xword sh_flags; // 节标志
Elf64_Addr sh_addr; // 节的虚拟地址
Elf64_Off sh_offset; // 节在文件中的偏移
Elf64_Xword sh_size; // 节的大小
Elf64_Word sh_link; // 关联的节索引
Elf64_Word sh_info; // 附加信息
Elf64_Xword sh_addralign; // 对齐约束
Elf64_Xword sh_entsize; // 条目大小(若节包含固定大小条目)
};

可使用 readelf -S <file> 查看节头表

节类型

类型说明
SHT_NULL对应 ELF 文件中的第一个 NULL section
SHT_PROGBITS程序数据,包含程序定义的信息
SHT_SYMTAB符号表
SHT_STRTAB字符串表
SHT_RELA重定位偏移信息(带加数)
SHT_HASH符号对应的哈希表,用于快速查找符号
SHT_DYNAMIC动态链接相关信息
SHT_NOTE用于标记文件的信息
SHT_NOBITS该节在 ELF 文件中不占用空间(如 .bss)
SHT_REL重定位信息(不带加数)
SHT_DYNSYM动态链接符号表

常见节

节名类型说明
.textPROGBITS程序的可执行代码
.rodataPROGBITS只读数据段(如 C 语言中的字符串等常量)
.pltPROGBITS过程链接表(PLT 表)
.dataPROGBITS已初始化的全局变量或静态变量
.bssNOBITS未初始化的全局变量,内容默认为 0
.got.pltPROGBITS全局偏移表(GOT 表)存储
.symtabSYMTAB包含程序内部的符号信息
.rel.*REL存储需要重定位的符号信息
.dynsymDYNSYM包含可执行文件所需的动态链接库中符号的信息
.hashHASH符号名称的哈希表,用于快速查找符号
.debugPROGBITS包含程序中的调试方法信息

段(Segment)与节(Section)

段(Segment)比节(Section)粒度更粗。一个可加载的只读段可以同时包含只读数据节(.rodata)和可执行代码节(.text

两者的设计初衷不同:

  • :主要用于编译器和链接器处理代码
  • :将具有相同属性的节合并到同一个区域中,供操作系统将文件映射到内存

符号表与重定位表

符号表(.symtab)

符号表存储了各文件中定义的符号信息和各文件引用的符号信息,包括函数符号和数据符号。这些符号在合并多个目标文件时需要进行链接和重定位

符号绑定定义了符号的行为和可见性:

绑定类型说明
STB_LOCAL只在该目标文件内可见,其他目标文件不能引用(对应 C 中的 static 变量)
STB_GLOBAL全局符号,其他目标文件可以引用该符号
STB_WEAK弱全局符号,可以被同名的强符号覆盖
STB_LOPROC … STB_HIPROC为 CPU 预留的范围,不同 CPU 含义可能不同

注意:C 语言中的局部变量不是符号,它们在栈上分配,不进入符号表

符号类型定义了符号所代表的实体:

类型说明
STT_NOTYPE未指定类型
STT_OBJECT该符号代表数据(如数据段中的全局变量)
STT_FUNC该符号代表一个函数或其他可执行代码片段
STT_SECTION该符号代表一个 section,主要在重定位过程中使用

重定位表(.rel)

重定位表记录了程序中所有符号引用的位置(哪些地方使用了符号),以及引用该符号的指令类型、在文件中的偏移,用于在链接阶段对符号地址进行重定位

1
2
3
4
$ readelf -r main.o
Relocation section '.rela.text' at offset 0x228 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
000000000008 000d0000011b R_AARCH64_CALL26 0000000000000000 swap + 0

上述信息表示:在偏移 0x8 的位置使用了 swap 符号,重定位类型为 R_AARCH64_CALL26(即 ARM64 的函数调用指令)

链接与加载

链接

链接可以分为静态链接动态链接

  • 静态链接:将多个可重定位目标文件和静态库文件合并为一个可执行文件,在编译时完成
  • 动态链接:可执行文件中记录所需的函数,在运行时动态加载到内存中进行链接

库文件

链接过程中经常使用的库文件提供了许多通用功能的实现(如 C 标准库函数 printfscanf 等),可分为:

  • 静态链接库.a):以静态链接的方式(通过 ar 指令)将多个 .o 文件打包
  • 动态链接库.so):以动态链接的方式打包多个 .o 文件

GNU C Library(glibc)包含 libc、libm、libpthread 等库,其中 libc 包含了最常用的 C 标准库函数和系统调用,几乎所有 C 语言程序都会链接 libc

静态链接

静态链接将所有外部函数的目标文件片段直接复制到可执行文件中

优点:可移植性好、运行速度快、不依赖动态链接库的运行时环境

缺点:比动态链接文件体积更大;当静态库文件中存在 bug 时,需要重新编译所有使用该库的可执行文件

静态链接的两个步骤

Step 1: 符号解析

使用顺序扫描算法,维护未定义引用符号集合 U 和已定义符号集合 D:

  1. 按顺序扫描所有目标文件中的所有符号
  2. 将目标文件中定义的符号加入集合 D
  3. 将目标文件中使用但未定义的符号加入集合 U
  4. 若目标文件中定义了之前未定义的引用符号,从集合 U 中删除
  5. 所有目标文件扫描完成后,集合 D 为所有已定义的符号,集合 U 应当为空(否则报链接错误)

Step 2: 重定位

  1. 合并同类型的节:将所有目标文件中同类型的节合并(如所有 .text 节合并,所有 .data 节合并)
  2. 对定义符号进行重定位:确定每个符号在合并后的可执行文件中的新地址
  3. 对引用符号进行重定位:修改 .text 节和 .data 节中对每个符号的引用,更新为新地址
1
2
3
4
5
6
7
8
9
10
11
12
可重定位目标文件                       可执行文件
┌──────────┐ ┌──────────┐ ┌──────────┐
│ .text │ │ .text │ │ Headers │
│ main() │ │ swap() │ │ .text │
├──────────┤ ├──────────┤ │ main() │
│ .data │ │ .data │ ──→ │ swap() │
│ buf[2] │ │ *bufp0 │ │ .data │
└──────────┘ ├──────────┤ │ buf[2] │
│ .bss │ │ *bufp0 │
│ *bufp1 │ │ .bss │
└──────────┘ │ *bufp1 │
└──────────┘

重定位示例——对定义符号重定位:

1
2
3
4
5
6
7
# 重定位前(swap.o 中 swap 地址为 0)
$ readelf -s swap.o
16: 0000000000000000 112 FUNC GLOBAL DEFAULT 1 swap

# 重定位后(可执行文件中 swap 地址为 0x76c)
$ readelf -s main
95: 000000000000076c 112 FUNC GLOBAL DEFAULT 13 swap

重定位示例——对引用符号重定位:

1
2
3
4
5
6
7
; main.o 中对 swap 的调用(地址未解析,目标为 0)
0000000000000000 <main>:
0: a9bf7bfd stp x29, x30, [sp, #-16]!
4: 910003fd mov x29, sp
8: 94000000 bl 0 <swap> ; 待重定位

; 链接后,bl 指令的目标地址被修正为 swap 的实际地址

动态链接

动态链接在可执行文件运行过程中,将动态链接库中的函数加载到内存中并进行链接

优点:占用内存少(动态链接库文件只在内存中加载一份,映射到不同进程的不同地址);易于维护(更新动态链接库即可,无需重新编译可执行文件)

缺点:可移植性不佳(需要所有依赖的动态链接库都完成迁移);有一定性能损耗

动态链接的步骤

  1. 从 DYNAMIC 段获取所需的动态链接库信息
  2. 将可执行文件使用的动态链接库加载到内存中(动态链接库本身可能也引用其他动态链接库,需递归加载)
  3. 解析动态链接库中所有符号目标的地址,修改跳转表(PLT/GOT 表)使程序能正确跳转到目标动态链接库中的代码或数据

PLT 与 GOT

动态链接使用 PLT(Procedure Linkage Table,过程链接表)GOT(Global Offset Table,全局偏移表) 实现延迟绑定

  • PLT 表:包含若干指令片段,每一个 PLT 条目有一个对应的 GOT 条目。PLT 中的代码读取 GOT 中存放的地址并进行跳转
  • GOT 表:存储目标函数地址。首次调用前,GOT 条目的值为 PLT0(调度器)的地址;首次调用后,被更新为目标函数的实际地址

PLT 表中的第一个条目(PLT0)是特殊的调度入口,负责调用动态链接器(_dl_runtime_resolve)来解析函数地址

延迟绑定

延迟绑定将函数地址的解析推迟到该函数第一次被调用时:

  • 没有延迟绑定:加载时动态链接器需要解析所有的函数引用,导致加载速度慢
  • 有了延迟绑定:程序只在需要时才解析函数引用,加快了动态链接程序的加载速度

首次调用流程

1
2
3
4
main → bl puts@plt → PLT[puts] → 读取 GOT[puts](值为 PLT0)
→ 跳转到 PLT0 → 调用 _dl_runtime_resolve
→ 解析 puts 的实际地址并写入 GOT[puts]
→ 跳转到 puts 实际地址

再次调用流程

1
2
main → bl puts@plt → PLT[puts] → 读取 GOT[puts](值为 puts 实际地址)
→ 直接跳转到 puts 实际地址

PLT/GOT 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
; main 中调用 puts
0000000000000754 <main>:
764: 97ffffb3 bl 630 <puts@plt> ; 跳转到 puts 的 PLT 条目

; puts 的 PLT 条目
0000000000000630 <puts@plt>:
630: adrp x16, 20000 ; 计算 GOT 条目地址
634: ldr x17, [x16, #32] ; 读取 GOT 中存放的地址
638: add x16, x16, #0x20
63c: br x17 ; 跳转(首次→PLT0,之后→puts实际地址)

; PLT0:调用动态链接器
00000000000005d0 <.plt>:
5d0: stp x16, x30, [sp, #-16]!
5d4: adrp x16, 1f000
5d8: ldr x17, [x16, #4088] ; x17 = _dl_runtime_resolve
5dc: add x16, x16, #0xff8
5e0: br x17 ; 跳转到动态链接器

动态链接器通过 PLT 条目传入的信息(重定位索引和链接映射)来确定需要解析哪个函数

加载

加载(Loading)是将可执行文件加载到内存中的过程:

  1. 读取文件头信息,判断并加载到对应的操作系统内存
  2. 分配对应内存,建立虚拟内存到地址空间的地址映射
  3. 将可执行文件中可加载的段读入到对应的地址空间
  4. .bss 对应内存中的数据清零
  5. 为可执行程序创建栈空间
  6. 设置运行时信息(如命令行参数和环境变量)
  7. 运行程序

反汇编

反汇编的目标是从二进制文件中识别并恢复程序代码

帮助分析人员得到相对易读的代码

反汇编的挑战为:区分代码和数据

在二进制文件中,代码和数据是混在一起的,对于包含调试信息的二进制文件,由于调试信息中包含了符号、类型信息等内容,可以对反汇编过程提供帮助,而无调试信息的反汇编才是主要场景

嵌入式开发中,开发者有时会把常量编入代码段中,使得数据加载到 ROM 而不是 RAM 中,以提高访问速度

而恶意软件会把后门代码隐藏在数据段中,或者把数据嵌入代码段中,以躲避反汇编工具的分析

经典反汇编算法

线性扫描算法

线性扫描(Linear Sweep)是一种按字节顺序进行指令解析的方法

算法步骤:

  1. 确定代码段的起始地址,设置当前指针 cur 指向起始地址
  2. 读取 cur 处的字节,解码操作码(opcode)和操作数(operand)
  3. 计算下一条指令的地址,ARM 下固定为 cur + 4(定长 4 字节指令)
  4. 移动 cur 到下一条指令地址
  5. 重复步骤 (2)-(4),直到 cur 超过代码段的结束地址

线性扫描示例

以一个简单的 main 函数为例,假设 .text 段起始于 0x714:

1
2
3
4
5
6
7
8
9
10
11
12
13
地址      机器码             指令
0x714: FF 43 00 D1 sub sp, sp, #0x10 ← cur = main(起始)
0x718: 20 00 80 52 mov w0, #0x1
0x71c: E0 0F 00 B9 str w0, [sp, #12]
0x720: 40 00 80 52 mov w0, #0x2
0x724: E0 0B 00 B9 str w0, [sp, #8]
0x728: E1 0F 40 B9 ldr w1, [sp, #12]
0x72c: E0 0B 40 B9 ldr w0, [sp, #8]
0x730: 20 00 00 0B add w0, w1, w0
0x734: E0 07 00 B9 str w0, [sp, #4]
0x738: 00 00 80 52 mov w0, #0x0
0x73c: FF 43 00 91 add sp, sp, #0x10
0x740: C0 03 5F D6 ret ← cur > end,结束

cur 从 0x714 开始,每次解码一条指令后移动 4 字节,线性推进直到超过 .text 段结束地址

优点:算法简单,速度快

缺点:无法正确处理跳转指令和数据嵌入的情况,可能导致误解码和遗漏指令

线性扫描的失败案例

当代码段中嵌入了数据时,线性扫描会把数据误当成指令解码

1
2
3
4
5
6
7
8
9
10
11
12
13
int main() {
unsigned int a = 1, b = 2;
asm (
"b 0x8;" // 跳过下面的数据
"my_data: .long 0x52800040;" // 嵌入的数据,不是指令
"ldr w1, my_data;"
"ADD %w[result], %w[input_a], w1;"
: [result] "=r" (b)
: [input_a] "r" (a)
);
printf("b=%x\n", b);
return 0;
}

线性扫描遇到这段代码时:

1
2
3
4
5
地址      机器码             线性扫描解码
0x76c: E0 1F 40 B9 ldr w0, [sp, #28] ✓ 正确
0x770: 02 00 00 14 b 0x778 ✓ 正确(跳过数据)
0x774: 40 00 80 52 mov w0, #0x2 ✗ 错误!这是数据 0x52800040
0x778: E1 FF FF 18 ldr w1, 0x774 ✓ 正确

0x774 处实际上是 .long 0x52800040(嵌入的数据),但线性扫描不关心控制流,直接将其当成指令解码。虽然这个例子中恰好对应一条合法指令 mov w0, #0x2,但在一般情况下数据会被解码成无意义的指令,导致后续整体偏移,出现”指令污染”的连锁错误

objdump 使用线性扫描算法进行反汇编,用 objdump -S 可以观察到这种误解码现象

会产生这种错误的主要原因是线性扫描不考虑程序的真实控制流,仅考虑紧随其后的“下一条指令”

递归遍历算法

又称递归下降算法(Recursive Descent / Recursive Traversal)

与线性扫描不同,递归遍历算法会根据控制流来决定下一条需要解码的指令地址,而不是简单地按照字节顺序进行解码

基本思路:

  1. 从入口地址开始,解码当前指令
  2. 判断指令类型
  3. 如果是顺序指令,下一条指令地址为当前地址 + 指令长度;如果是分支/跳转指令,将所有可能的目标地址(顺序后继 + 跳转目标)加入待处理队列
  4. 重复步骤 (2) 直到队列为空
  5. 输出所有已解码的指令

伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def recursive_descent(b, addr_start):
O = {} # 输出:地址 → 指令映射
q = deque()
q.push(addr_start)

while not q.empty():
addr = q.pop_front()

if addr in O: # 已经解码过,跳过
continue

ins = get_instruction_at(b, addr)
O[addr] = ins

if is_terminate_instruction(ins): # 终止指令(如 ret)
continue

for next in next_instruction_addresses(ins): # 所有后继地址
q.push(next)

return O

递归遍历在遇到分支指令时,会将分支的两个目标地址都加入队列进行解码,因此可以正确处理跳转指令,避免线性扫描中把数据误当成指令的问题

举个例子,给定如下二进制代码:

1
2
3
4
5
6
7
8
9
10
11
0x714: FF 83 00 D1    sub sp, sp, #0x20
0x718: E0 0F 00 B9 str w0, [sp, #12]
0x71c: FF 1F 00 B9 str wzr, [sp, #28]
0x720: 11 00 00 14 b 0x764 ← 无条件跳转,下一条直接去 0x764
...
0x764: E0 0F 40 B9 ldr w0, [sp, #12]
0x768: 1F 00 00 71 cmp w0, #0x0
0x76c: CC FD FF 54 b.gt 0x724 ← 条件跳转,两个后继:0x770 和 0x724
0x770: E0 1F 40 B9 ldr w0, [sp, #28]
0x774: FF 83 00 91 add sp, sp, #0x20
0x778: C0 03 5F D6 ret

递归遍历会从 0x714 开始,顺序解码到 0x720 遇到无条件跳转 b 0x764,跳过中间可能嵌入的数据,直接去 0x764 继续解码。在 0x76c 遇到条件跳转 b.gt 0x724,会将 0x770(顺序后继)和 0x724(跳转目标)都加入队列

对于函数调用指令(如 bl),递归遍历会将调用目标地址也加入队列,作为新的起始点进行解码

1
2
3
0x78c: E0 1F 00 B9    str w0, [sp, #28]
0x790: E1 FF FF 97 bl 0x714 ← 函数调用,目标 0x714 加入队列
0x794: E0 1F 00 B9 str w0, [sp, #28] ← 返回地址,也加入队列

递归遍历的局限

递归遍历无法处理间接跳转和间接调用,因为目标地址在运行时才能确定

1
2
switch (id)           → 跳转表,目标地址运行时计算
int res = p(a, b); → 函数指针调用,目标地址运行时确定

例如间接调用:

1
2
3
0x86c: LDR w1, [sp, #28]
0x870: LDR x2, [sp, #40] ; x2 是函数指针,值运行时确定
0x874: BLR x2 ; 间接调用,无法静态确定目标

BLR x2 的目标地址取决于 x2 寄存器的值,而该值在编译时是未知的,递归遍历算法在此处会停止对该分支的探索

IDA Pro 等工具使用递归遍历算法进行反汇编

启发式算法

启发式算法用于辅助反汇编过程中的决策,主要解决以下问题:

  • 确定函数入口点
  • 区分代码和数据
  • 识别间接跳转的目标

确定 main 函数

对于 stripped 的二进制文件(无符号信息),可以通过启发式方法找到 main 函数

一些工具(如 Ghidra、angr、BAP)会自动识别 main 函数

常见方法:

  • 通过入口点信息定位(objdump -f 可查看入口点)
  • 通过符号表定位(nm 可查看符号表)

对于 gcc 编译的程序,_start 会调用 __libc_start_main,而 __libc_start_main 的第一个参数(ARM 上为 x0)即为 main 函数地址

具体步骤:

  1. 找到 __libc_start_main 的调用位置,ARM 上 x0 寄存器存放第一个参数
  2. 回溯 x0 的赋值,找到 main 函数地址
  3. 从该地址开始反汇编即可得到 main 函数

识别函数边界

通过函数的序言(prologue)和尾声(epilogue)模式来识别函数边界

1
2
3
4
5
6
7
; 函数序言
stp x29, x30, [sp, #-32]! ; 保存 x29(FP) 和 x30(LR) 到栈上
mov x29, sp ; 设置帧指针

; 函数尾声
ldp x29, x30, [sp], #32 ; 恢复 x29 和 x30
ret ; 返回

识别函数调用目标

通过分析调用点前后的指令模式,可以推断间接调用的可能目标

例如,对于函数指针调用:

1
2
3
4
5
6
; angr 等工具可以通过值分析追踪 x2 的来源
0x714: ... ; x0 = 0 + 0x714 = 0x714
; 4. x0 被赋值
; 3. x24 赋值给 x0
; 2. x2 赋值给 x24
; 1. x2 从内存加载

通过数据流分析,可以追踪寄存器值的来源,从而推断间接跳转/调用的目标地址

可重汇编的反汇编

可重汇编(Reassembleable)反汇编的目标是生成可以重新汇编(assemble)回二进制文件的汇编代码

这比普通反汇编要求更高,需要:

  • 正确处理所有符号引用
  • 正确处理控制流完整性(CFI)信息
  • 正确处理重定位信息

反汇编的应用

可以借助反汇编工具(如 Hex-Rays/IDA)对二进制文件进行修改和增强

例如,可以在反汇编的基础上插入边界检查代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
; 原始代码
8bc: bl 770 <scanf>
8c0: ldr w0, [sp, #20]
8c4: sxtw x0, w0
8c8: bl 720 <malloc>
8cc: mov w0, #0
8d0: ret

; 插入边界检查后
8bc: bl 770 <scanf>
8c0: ldr w0, [sp, #20]
8c4: cmp w0, #0
8c8: b.gt 8d4
8cc: mov w0, #-1
8d0: b 8e4
8d4: ldr w0, [sp, #20]
8d8: sxtw x0, w0
8dc: bl 720 <malloc>
8e0: mov w0, #0
8e4: ret

符号化处理

为了生成可重汇编的代码,需要将硬编码的地址替换为符号引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
; 地址形式
bl 770 <scanf>
ldr w0, [sp, #20]
cmp w0, #0
b.gt 8d4
...

; 符号化后
bl scanf
ldr w0, [sp, #20]
cmp w0, #0
b.gt .B1
...
.B1
ldr w0, [sp, #20]
sxtw x0, w0
bl malloc
mov w0, #0
.B2
ret

引用类型

反汇编中的引用可以分为四类:

  • 代码到代码(c2c):代码段中的跳转/调用指向代码段中的另一个位置,例如 b 0x400610bl func
  • 代码到数据(c2d):代码段中的指令访问数据段中的数据,例如 ldr w0, [x0, #0x80] 访问 .bss 段的变量
  • 数据到代码(d2c):数据段中存储的函数指针指向代码段,例如跳转表、虚函数表中存储的函数地址
  • 数据到数据(d2d):数据段中的指针指向另一个数据段的地址

c2c 引用

代码中的跳转和调用指令指向代码段中的其他位置

1
2
3
4
5
6
7
8
400604: sub sp, sp, #0x10
400608: str wzr, [sp, #12]
40060c: b 40065c ; c2c:跳转到 .text 段内的 0x40065c
400610: adrp x0, 420000
...
40065c: ldr w0, [sp, #12]
400660: cmp w0, #0x13
400664: b.le 400610 ; c2c:条件跳转回 0x400610

c2d 引用

代码中的指令引用数据段中的地址

1
2
3
4
5
400614: adrp x0, 420000
400618: add x0, x0, #0x30 ; 420030,指向 .bss 段
...
400634: adrp x0, 420000
400638: add x0, x0, #0x80 ; 420080,指向 .bss 段

识别 c2d 引用时,需要判断目标地址是否落在数据段范围内,通常通过比较地址与各段(.text、.data、.bss)的起止地址来判断,一般取 4 字节(32 位)或 8 字节(64 位)对齐的值作为候选地址

d2c 引用

数据段中存储的值指向代码段,典型场景包括跳转表和虚函数表

1
2
3
4
; .data 段中的函数指针表
0x420038: c4 06 40 00 00 00 00 00 → 0x4006c4(.text 段内的函数)
0x420040: e4 06 40 00 00 00 00 00 → 0x4006e4(.text 段内的函数)
0x420048: 04 07 40 00 00 00 00 00 → 0x400704(.text 段内的函数)

识别步骤:

  1. 以 8 字节(64 位)对齐扫描数据段
  2. 判断 0x420038、0x420040、0x420048 中的值是否落在 .text 段范围内,确认为函数指针
  3. 在代码段中找到间接调用 BLR x2,追踪 x2 的来源确认其从该表中加载

d2d 引用

数据段中的指针指向另一个数据段的地址,通过类似的地址范围判断来识别

$ discussion
# Comments
waline