#逆向工程基本原理04
编译过程
大部分程序使用高级语言编写,逆向工程需要从二进制文件出发。编译过程就是将高级语言源代码转换为二进制文件的过程,理解编译过程有助于理解源代码到二进制文件之间发生的变化
编译流程概述
从源代码到可执行文件的完整编译流程分为四个阶段:
1 | 源代码(.c) → 预处理 → 生成汇编代码 → 生成可重定位文件(.o) → 链接生成可执行文件 |
对应的 GCC 命令:
1 | gcc main.c -E -o main.i # (1) 预处理 |
(1) 预处理
预处理将源代码中的一些特定指令展开成实际的代码,包括:
#include展开:将头文件内容插入源代码- 宏展开:将宏定义替换为实际代码
- 条件编译:处理
#if、#else、#endif等条件编译指令 - 注释删除:移除源代码中的注释
- 编译器指令:处理
#pragma等指令
(2) 生成汇编代码
将预处理后的源代码编译为汇编代码,主要步骤包括:词法分析、语法分析、运行时存储分配、中间代码生成、编译器优化
运行时存储分配
C/C++ 中的内存使用分为静态内存分配和动态内存分配:
- 静态内存分配(不需要手动释放)
- 局部变量 → 栈内存
- 全局变量 / 静态变量 → 全局数据段
- 动态内存分配(需要显式释放以避免内存泄漏)
- 通过
malloc/new动态分配堆内存
- 通过
1 |
|
中间代码与编译器优化
为了支持多种源语言和目标架构的扩展,编译器先将源代码转换为中间代码表示(IR),在 IR 上进行各种优化,再将优化后的 IR 翻译为目标架构的汇编代码
1 | C ┐ ┌→ x86 |
编译器优化通常分为:
- 速度优化:使程序运行速度更快
- 空间优化:使程序体积更小
编译器优化可能会改变代码逻辑与源代码之间表示的关系,例如死代码消除:
1 | int bar(int n) { |
编译后(开启优化):
1 | bar: |
循环计算 result 的代码被编译器识别为死代码并完全删除,只保留了
return n / 2的计算
(3) 生成可重定位文件
汇编阶段将汇编代码转换为机器码,生成可重定位目标文件(.o 文件)
汇编器的主要工作:
- 指令编码:将特定架构的汇编指令转换为机器码
- 符号记录:在汇编过程中维护符号表,记录代码和数据的符号引用
- 重定位信息:记录需要在链接阶段确定地址的符号引用
1 | .global_var_1 |
汇编器在遇到符号引用时,先在符号表中查找;若符号已定义,直接使用对应偏移;若未定义,记录为待解析的符号引用,留给链接器处理
(4) 链接生成可执行文件
链接(Linking)是将多个目标文件合并成同一个可执行文件的过程,包括:
- 符号解析:为目标文件中未定义的符号在其他目标文件中找到定义
- 重定位:将多个目标文件合并为一个可执行文件后,确定所有符号的地址
1 | // main.c // bar.c |
1 | main.c → main.o ┐ |
二进制文件结构
设计原理
二进制文件结构的设计需要考虑:可移植性、架构支持、动态链接支持、文件大小控制、调试信息支持等
二进制文件中的通用元数据包括:
- 文件头:位于文件头部,包含文件的基本信息
- 长度信息:各段和数据块的长度
- 标志位:描述文件状态和属性
- 时间戳:文件的创建时间、修改时间
- 校验和:验证文件完整性的数据
- 结构描述:文件中各结构的信息(如各字段的名称、大小和属性)
不同平台的二进制文件格式
不同操作系统使用的二进制可执行文件结构各不相同:
| 操作系统 | 文件格式 |
|---|---|
| Windows | PE(Portable Executable) |
| Linux | ELF(Executable and Linkable Format) |
| macOS | Mach-O |
ELF 文件结构
ELF(Executable and Linkable Format)用于定义 Linux 下的二进制文件,包括:
- 可重定位目标文件(
.o) - 可执行文件
- 共享目标文件(
.so)
ELF 的设计目标是为不同操作系统提供通用的二进制文件信息接口,并使用动态链接机制增强可执行文件的可扩展性
ELF 文件结构在底层为 ELF 文件提供了 Executable(可执行) 和 Linkable(可链接) 两种视图
ELF 文件头
ELF 文件头位于二进制文件的头部,包含文件的基本信息:
1 | typedef struct { |
可使用
readelf -h <file>查看 ELF 文件头信息
程序头表(Program Header Table)
程序头表描述了可执行文件中所有的段(Segment),包括段的类型、大小、虚拟地址及对齐方式。运行时段被加载到对应的虚拟地址空间
1 | struct Elf64_Phdr { |
可使用
readelf -l <file>查看程序头表
节头表(Section Header Table)
节头表描述了可执行文件中所有的节(Section),包括每个节的名称、位置和大小等信息,这些信息用于链接和调试
1 | struct Elf64_Shdr { |
可使用
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 | 动态链接符号表 |
常见节
| 节名 | 类型 | 说明 |
|---|---|---|
| .text | PROGBITS | 程序的可执行代码 |
| .rodata | PROGBITS | 只读数据段(如 C 语言中的字符串等常量) |
| .plt | PROGBITS | 过程链接表(PLT 表) |
| .data | PROGBITS | 已初始化的全局变量或静态变量 |
| .bss | NOBITS | 未初始化的全局变量,内容默认为 0 |
| .got.plt | PROGBITS | 全局偏移表(GOT 表)存储 |
| .symtab | SYMTAB | 包含程序内部的符号信息 |
| .rel.* | REL | 存储需要重定位的符号信息 |
| .dynsym | DYNSYM | 包含可执行文件所需的动态链接库中符号的信息 |
| .hash | HASH | 符号名称的哈希表,用于快速查找符号 |
| .debug | PROGBITS | 包含程序中的调试方法信息 |
段(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 | $ readelf -r main.o |
上述信息表示:在偏移 0x8 的位置使用了 swap 符号,重定位类型为 R_AARCH64_CALL26(即 ARM64 的函数调用指令)
链接与加载
链接
链接可以分为静态链接和动态链接:
- 静态链接:将多个可重定位目标文件和静态库文件合并为一个可执行文件,在编译时完成
- 动态链接:可执行文件中记录所需的函数,在运行时动态加载到内存中进行链接
库文件
链接过程中经常使用的库文件提供了许多通用功能的实现(如 C 标准库函数 printf、scanf 等),可分为:
- 静态链接库(
.a):以静态链接的方式(通过ar指令)将多个.o文件打包 - 动态链接库(
.so):以动态链接的方式打包多个.o文件
GNU C Library(glibc)包含 libc、libm、libpthread 等库,其中 libc 包含了最常用的 C 标准库函数和系统调用,几乎所有 C 语言程序都会链接 libc
静态链接
静态链接将所有外部函数的目标文件片段直接复制到可执行文件中
优点:可移植性好、运行速度快、不依赖动态链接库的运行时环境
缺点:比动态链接文件体积更大;当静态库文件中存在 bug 时,需要重新编译所有使用该库的可执行文件
静态链接的两个步骤
Step 1: 符号解析
使用顺序扫描算法,维护未定义引用符号集合 U 和已定义符号集合 D:
- 按顺序扫描所有目标文件中的所有符号
- 将目标文件中定义的符号加入集合 D
- 将目标文件中使用但未定义的符号加入集合 U
- 若目标文件中定义了之前未定义的引用符号,从集合 U 中删除
- 所有目标文件扫描完成后,集合 D 为所有已定义的符号,集合 U 应当为空(否则报链接错误)
Step 2: 重定位
- 合并同类型的节:将所有目标文件中同类型的节合并(如所有
.text节合并,所有.data节合并) - 对定义符号进行重定位:确定每个符号在合并后的可执行文件中的新地址
- 对引用符号进行重定位:修改
.text节和.data节中对每个符号的引用,更新为新地址
1 | 可重定位目标文件 可执行文件 |
重定位示例——对定义符号重定位:
1 | # 重定位前(swap.o 中 swap 地址为 0) |
重定位示例——对引用符号重定位:
1 | ; main.o 中对 swap 的调用(地址未解析,目标为 0) |
动态链接
动态链接在可执行文件运行过程中,将动态链接库中的函数加载到内存中并进行链接
优点:占用内存少(动态链接库文件只在内存中加载一份,映射到不同进程的不同地址);易于维护(更新动态链接库即可,无需重新编译可执行文件)
缺点:可移植性不佳(需要所有依赖的动态链接库都完成迁移);有一定性能损耗
动态链接的步骤
- 从 DYNAMIC 段获取所需的动态链接库信息
- 将可执行文件使用的动态链接库加载到内存中(动态链接库本身可能也引用其他动态链接库,需递归加载)
- 解析动态链接库中所有符号目标的地址,修改跳转表(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 | main → bl puts@plt → PLT[puts] → 读取 GOT[puts](值为 PLT0) |
再次调用流程:
1 | main → bl puts@plt → PLT[puts] → 读取 GOT[puts](值为 puts 实际地址) |
PLT/GOT 示例:
1 | ; main 中调用 puts |
动态链接器通过 PLT 条目传入的信息(重定位索引和链接映射)来确定需要解析哪个函数
加载
加载(Loading)是将可执行文件加载到内存中的过程:
- 读取文件头信息,判断并加载到对应的操作系统内存
- 分配对应内存,建立虚拟内存到地址空间的地址映射
- 将可执行文件中可加载的段读入到对应的地址空间
- 将
.bss对应内存中的数据清零 - 为可执行程序创建栈空间
- 设置运行时信息(如命令行参数和环境变量)
- 运行程序
反汇编
反汇编的目标是从二进制文件中识别并恢复程序代码
帮助分析人员得到相对易读的代码
反汇编的挑战为:区分代码和数据
在二进制文件中,代码和数据是混在一起的,对于包含调试信息的二进制文件,由于调试信息中包含了符号、类型信息等内容,可以对反汇编过程提供帮助,而无调试信息的反汇编才是主要场景
嵌入式开发中,开发者有时会把常量编入代码段中,使得数据加载到 ROM 而不是 RAM 中,以提高访问速度
而恶意软件会把后门代码隐藏在数据段中,或者把数据嵌入代码段中,以躲避反汇编工具的分析
经典反汇编算法
线性扫描算法
线性扫描(Linear Sweep)是一种按字节顺序进行指令解析的方法
算法步骤:
- 确定代码段的起始地址,设置当前指针
cur指向起始地址 - 读取
cur处的字节,解码操作码(opcode)和操作数(operand) - 计算下一条指令的地址,ARM 下固定为
cur + 4(定长 4 字节指令) - 移动
cur到下一条指令地址 - 重复步骤 (2)-(4),直到
cur超过代码段的结束地址
线性扫描示例
以一个简单的 main 函数为例,假设 .text 段起始于 0x714:
1 | 地址 机器码 指令 |
cur 从 0x714 开始,每次解码一条指令后移动 4 字节,线性推进直到超过 .text 段结束地址
优点:算法简单,速度快
缺点:无法正确处理跳转指令和数据嵌入的情况,可能导致误解码和遗漏指令
线性扫描的失败案例
当代码段中嵌入了数据时,线性扫描会把数据误当成指令解码
1 | int main() { |
线性扫描遇到这段代码时:
1 | 地址 机器码 线性扫描解码 |
0x774 处实际上是 .long 0x52800040(嵌入的数据),但线性扫描不关心控制流,直接将其当成指令解码。虽然这个例子中恰好对应一条合法指令 mov w0, #0x2,但在一般情况下数据会被解码成无意义的指令,导致后续整体偏移,出现”指令污染”的连锁错误
objdump 使用线性扫描算法进行反汇编,用 objdump -S 可以观察到这种误解码现象
会产生这种错误的主要原因是线性扫描不考虑程序的真实控制流,仅考虑紧随其后的“下一条指令”
递归遍历算法
又称递归下降算法(Recursive Descent / Recursive Traversal)
与线性扫描不同,递归遍历算法会根据控制流来决定下一条需要解码的指令地址,而不是简单地按照字节顺序进行解码
基本思路:
- 从入口地址开始,解码当前指令
- 判断指令类型
- 如果是顺序指令,下一条指令地址为当前地址 + 指令长度;如果是分支/跳转指令,将所有可能的目标地址(顺序后继 + 跳转目标)加入待处理队列
- 重复步骤 (2) 直到队列为空
- 输出所有已解码的指令
伪代码如下:
1 | def recursive_descent(b, addr_start): |
递归遍历在遇到分支指令时,会将分支的两个目标地址都加入队列进行解码,因此可以正确处理跳转指令,避免线性扫描中把数据误当成指令的问题
举个例子,给定如下二进制代码:
1 | 0x714: FF 83 00 D1 sub sp, sp, #0x20 |
递归遍历会从 0x714 开始,顺序解码到 0x720 遇到无条件跳转 b 0x764,跳过中间可能嵌入的数据,直接去 0x764 继续解码。在 0x76c 遇到条件跳转 b.gt 0x724,会将 0x770(顺序后继)和 0x724(跳转目标)都加入队列
对于函数调用指令(如 bl),递归遍历会将调用目标地址也加入队列,作为新的起始点进行解码
1 | 0x78c: E0 1F 00 B9 str w0, [sp, #28] |
递归遍历的局限
递归遍历无法处理间接跳转和间接调用,因为目标地址在运行时才能确定
1 | switch (id) → 跳转表,目标地址运行时计算 |
例如间接调用:
1 | 0x86c: LDR w1, [sp, #28] |
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 函数地址
具体步骤:
- 找到
__libc_start_main的调用位置,ARM 上 x0 寄存器存放第一个参数 - 回溯 x0 的赋值,找到 main 函数地址
- 从该地址开始反汇编即可得到 main 函数
识别函数边界
通过函数的序言(prologue)和尾声(epilogue)模式来识别函数边界
1 | ; 函数序言 |
识别函数调用目标
通过分析调用点前后的指令模式,可以推断间接调用的可能目标
例如,对于函数指针调用:
1 | ; angr 等工具可以通过值分析追踪 x2 的来源 |
通过数据流分析,可以追踪寄存器值的来源,从而推断间接跳转/调用的目标地址
可重汇编的反汇编
可重汇编(Reassembleable)反汇编的目标是生成可以重新汇编(assemble)回二进制文件的汇编代码
这比普通反汇编要求更高,需要:
- 正确处理所有符号引用
- 正确处理控制流完整性(CFI)信息
- 正确处理重定位信息
- …
反汇编的应用
可以借助反汇编工具(如 Hex-Rays/IDA)对二进制文件进行修改和增强
例如,可以在反汇编的基础上插入边界检查代码:
1 | ; 原始代码 |
符号化处理
为了生成可重汇编的代码,需要将硬编码的地址替换为符号引用
1 | ; 地址形式 |
引用类型
反汇编中的引用可以分为四类:
- 代码到代码(c2c):代码段中的跳转/调用指向代码段中的另一个位置,例如
b 0x400610、bl func - 代码到数据(c2d):代码段中的指令访问数据段中的数据,例如
ldr w0, [x0, #0x80]访问 .bss 段的变量 - 数据到代码(d2c):数据段中存储的函数指针指向代码段,例如跳转表、虚函数表中存储的函数地址
- 数据到数据(d2d):数据段中的指针指向另一个数据段的地址
c2c 引用
代码中的跳转和调用指令指向代码段中的其他位置
1 | 400604: sub sp, sp, #0x10 |
c2d 引用
代码中的指令引用数据段中的地址
1 | 400614: adrp x0, 420000 |
识别 c2d 引用时,需要判断目标地址是否落在数据段范围内,通常通过比较地址与各段(.text、.data、.bss)的起止地址来判断,一般取 4 字节(32 位)或 8 字节(64 位)对齐的值作为候选地址
d2c 引用
数据段中存储的值指向代码段,典型场景包括跳转表和虚函数表
1 | ; .data 段中的函数指针表 |
识别步骤:
- 以 8 字节(64 位)对齐扫描数据段
- 判断 0x420038、0x420040、0x420048 中的值是否落在 .text 段范围内,确认为函数指针
- 在代码段中找到间接调用
BLR x2,追踪 x2 的来源确认其从该表中加载
d2d 引用
数据段中的指针指向另一个数据段的地址,通过类似的地址范围判断来识别