#逆向工程基本原理08
动态分析技术概览
逆向工程中常见的两类分析方法:
- 静态分析:不运行程序,直接分析二进制文件、反汇编代码、反编译代码、字符串、导入表等信息
- 动态分析:运行程序,在运行过程中观察程序的真实状态,例如变量值、寄存器值、分支选择、API 调用、系统调用、内存访问等
两者是互补关系。
静态分析适合先建立整体认识,例如函数结构、控制流、字符串引用、导入函数和关键常量。动态分析适合验证静态分析得到的假设,尤其适合处理以下情况:
- 静态代码过大,难以直接定位关键逻辑
- 代码存在混淆、自修改、字符串加密等保护
- 需要确认某条分支在真实输入下是否会被执行
- 需要观察函数输入输出,而不关心函数内部全部实现
- 需要修改寄存器、内存或返回值来验证程序行为
动态分析的核心问题是:如何让程序在关键位置停下来,如何观察现场,如何让程序继续以可控方式执行。
断点
断点是专门设置的程序暂停位置。程序执行到断点时,会触发异常,操作系统接管目标程序,并把控制权交给调试器。
断点命中后,分析人员可以:
- 观察线程调用栈
- 观察寄存器、栈、堆、全局数据等状态
- 观察模块加载情况
- 修改变量、寄存器或内存
- 修改代码或跳转目标
断点工作流程
断点的一般工作流程:
- 分析人员通过调试器设置断点
- 程序运行到断点位置或满足断点条件
- CPU 触发异常,操作系统暂停目标程序
- 操作系统将目标程序状态交给调试器
- 分析人员在调试器中观察或修改程序状态
- 调试器令程序继续运行、单步执行或返回上层函数
可以概括为:
1 | 设置断点 -> 运行程序 -> 命中断点 -> 分析现场 -> 跟踪执行 -> 继续运行 |
断点分类
根据触发时机,断点可以分为:
- 指令断点:目标指令被执行时触发
- 数据断点:目标内存被读、写或执行时触发,也称 Watchpoint
- 事件断点:特定事件发生时触发,例如库加载、程序初始化完成、键盘输入等
逆向时最常用的是指令断点和数据断点。
指令断点适合定位代码执行位置,例如在 main、printf、strcmp 或某个反汇编地址处暂停。
数据断点适合定位谁访问了某块内存,例如输入字符串已经读入内存,但不知道后续由哪段代码处理,此时可以对该内存地址设置 watchpoint。
软件断点
软件断点通过修改目标指令实现。调试器把目标地址处的原始指令替换为特殊的中断指令,当 CPU 执行该指令时触发异常。
ARM/AArch64 中常见的中断相关指令包括:
BRKSWIBKPTUND
例如在 0x9014 处设置指令断点,可以把原始指令临时替换为 brk:
1 | 0x9000 adrp x8, 0x20008 |
执行到 brk 后,CPU 在用户态触发异常,再由内核引导调试器接管程序。
软件断点的特点:
- 不依赖专用硬件,使用范围广
- 常用于指令断点和事件断点
- 会修改目标代码内存,因此可能被反调试逻辑检测
硬件断点
硬件断点通过 CPU 的专用调试寄存器实现,不需要直接改写目标代码。
AArch64 中与数据断点相关的寄存器包括:
DBGWVR<n>_EL1:保存被监控的内存地址DBGWCR<n>_EL1:保存断点条件,例如读、写、执行和启用状态
例如监控地址 0x20008 的写访问:
1 | DBGWVR<n>_EL1 = 0x20008 |
当程序执行类似下面的写内存指令时,硬件检测到访问地址满足条件,触发异常:
1 | 0x9000 adrp x8, 0x20008 |
硬件断点的特点:
- 常用于数据断点
- 不需要修改目标指令
- 数量有限,依赖硬件支持
断点的典型用途
定位输入处理代码
如果已知输入数据所在内存地址,但不知道后续由哪段代码处理,可以对输入缓冲区设置数据断点。
1 | (gdb) watch *0xaaaaaaacd200 |
命中后查看当前 PC 附近的指令:
1 | (gdb) x/2i $pc-4 |
此时 0xaaaaaaab769c 附近的代码就是访问输入变量的代码。
观察复杂函数的输入输出
如果某个函数很复杂,可以先不分析内部实现,而是观察调用前后的寄存器和内存状态。
ARM64 调用约定中,整数和指针参数通常从 x0 到 x7 传入,返回值通常在 x0。
1 | (gdb) b *0xaaaaaaab0f08 |
根据输入和输出关系,可以推断该函数可能把数字 ID 转换为字符串。
修改程序状态
动态调试不仅能观察,还能修改程序状态。
例如在函数调用前修改第一个参数:
1 | (gdb) b *0xaaaaaaab0f08 |
这种方法适合快速测试函数在不同输入下的输出,不一定需要从程序外部构造完整输入。
调试器工作原理
调试器本质上是一个能够接管目标程序的工具。它通过操作系统提供的调试接口控制目标程序,在目标程序触发异常、系统调用或断点时暂停程序。
调试器暂停程序后,常见观察对象包括:
- 寄存器:临时变量、函数参数、返回值
PC:当前执行指令位置SP:当前栈顶FP:当前栈帧基址CPSR:条件标志和控制位- 调用链:当前函数从哪些函数调用进入
- 内存映射:代码段、堆、栈、共享库的地址和权限
ptrace
Linux 上常用的调试器依赖 ptrace 系统调用。
1 |
|
ptrace 提供一个程序接管另一个程序的能力:
- 接管程序称为 tracer
- 被接管程序称为 tracee
- tracer 可以读取或修改 tracee 的寄存器、内存和执行状态
- tracer 可以令 tracee 恢复执行、单步执行或继续到下一个事件
需要注意:
ptrace的接管粒度是线程- 一个线程同一时间只能被一个 tracer 接管
- 这个限制也是很多反调试技术的基础
ptrace 追踪关系
追踪关系的一般流程:
- 调试器调用
ptrace,声明要追踪目标程序 - 调试器调用
waitpid,等待目标程序状态变化 - 目标程序运行到断点、异常或系统调用位置
- 内核暂停目标程序,并把状态转发给调试器
- 调试器读取或修改目标程序状态
- 调试器再次调用
ptrace让目标程序恢复执行
1 | 调试器 ptrace attach |
GDB
GDB 是基于 ptrace 的常见调试器。
基本工作流程:
- GDB 通过
ptrace请求监控目标程序 - 目标程序发生断点、异常或中断时,GDB 接管程序
- 分析人员通过 GDB 命令观察、修改程序状态
- 分析人员令程序继续运行、单步执行、步过函数或运行到函数返回
本地调试:
1 | gdb /bin/sh |
远程调试:
1 | # 目标环境 |
远程调试适合目标环境无法方便运行完整 GDB 的情况,例如固件、开发板、模拟器和非完整 Linux 环境。
GDB 常见操作
GDB 命令是分析人员控制目标程序的主要接口。命令中经常包含表达式,例如寄存器、地址、指针和内存解引用。
例如:
1 | x/s *0xdeadbeef |
含义是把 0xdeadbeef 处保存的值作为字符串地址,并打印该字符串。
启动与连接
加载程序:
1 | (gdb) file ./a.out |
运行程序:
1 | (gdb) run |
连接远程目标:
1 | (gdb) target remote localhost:1234 |
附加到已有进程:
1 | gdb attach <pid> |
断点命令
按函数名下断:
1 | (gdb) b printf |
按源码行下断:
1 | (gdb) b 32 |
按地址下断:
1 | (gdb) b *0x400800 |
设置条件断点:
1 | (gdb) b *0x400800 if $x0 == 0 |
查看断点:
1 | (gdb) info breakpoints |
启用断点:
1 | (gdb) enable 1 |
设置数据断点:
1 | (gdb) watch *0xaaaaaaacd200 |
执行控制
继续运行到下一个断点:
1 | (gdb) c |
执行下一条源码语句:
1 | (gdb) n |
执行到当前函数返回:
1 | (gdb) finish |
直接从当前函数返回指定值:
1 | (gdb) return 0 |
return 命令不会继续执行当前函数剩余代码,而是直接让当前函数以指定返回值返回。分析反调试逻辑时可以用它跳过检测函数。
寄存器和内存观察
查看主要寄存器:
1 | (gdb) info registers |
查看所有寄存器:
1 | (gdb) info all-registers |
查看指定寄存器:
1 | (gdb) i r $x0 |
打印内存:
1 | (gdb) x/16gx $sp |
常见格式:
x/i:按指令反汇编x/s:按字符串打印x/gx:按 8 字节十六进制打印x/wx:按 4 字节十六进制打印
修改寄存器:
1 | (gdb) set $x1 = 456 |
修改内存:
1 | (gdb) set {int}0x400000 = 0 |
调用栈和源码辅助
查看调用栈:
1 | (gdb) bt |
选择栈帧:
1 | (gdb) frame 1 |
打印源码:
1 | (gdb) list |
查看局部变量:
1 | (gdb) info locals |
逆向场景中经常没有源码和调试符号,此时更依赖 x/i、disas、寄存器和内存观察。
ARM64 动态调试实践
实验环境中经常需要在 x86 主机上调试 ARM64 程序,可以通过 qemu-aarch64 和 gdb-multiarch 完成。
编译和运行 ARM64 程序
使用交叉编译器生成 AArch64 程序:
1 | aarch64-linux-gnu-gcc -o a.out code.c |
使用 QEMU 运行:
1 | qemu-aarch64 -L /usr/aarch64-linux-gnu ./a.out |
其中 -L /usr/aarch64-linux-gnu 用于指定 ARM64 用户态运行所需的动态库路径。
QEMU 开启远程调试端口
让 QEMU 在程序启动时等待 GDB 连接:
1 | qemu-aarch64 -L /usr/aarch64-linux-gnu -g 1234 ./a.out |
-g 1234 表示打开 1234 端口,程序会暂停等待远程调试器连接。
另开一个终端连接:
1 | gdb-multiarch -q --nh \ |
各参数含义:
set architecture aarch64:指定目标架构file a.out:加载本地符号和 ELF 信息target remote localhost:1234:连接 QEMU 暴露的调试端口layout split:同时显示源码/汇编和命令窗口layout regs:显示寄存器窗口
基本调试流程
一个常见流程:
1 | (gdb) b main |
这个流程体现了动态分析的几个动作:
- 先让程序停在稳定入口,例如
main - 反汇编当前函数,找到感兴趣的调用点
- 对库函数或地址下断
- 命中断点后观察参数寄存器
- 修改参数寄存器
- 继续执行并观察输出变化
GDB 脚本
手动调试适合探索,脚本适合重复执行。GDB 支持两类脚本:
- 原生 GDB 脚本:多条 GDB 命令按行组合
- GDB Python 脚本:通过
gdb模块编写更复杂的自动化逻辑
脚本可以通过两种方式执行:
1 | gdb -x gdb-script |
或在 GDB 中执行:
1 | (gdb) source gdb-script |
原生 GDB 脚本
例如在 printf 处断下,修改第二个参数寄存器 x1,再继续执行:
1 | b printf |
这里有两个 c:
commands 1内部的c:断点命中并修改寄存器后继续运行- 最后一行
c:脚本加载后立刻启动/恢复程序运行
自动打印函数输入输出
假设已知一个解密函数的入口和返回位置,可以用脚本自动打印输入 ID 和返回字符串:
1 | file mirai.aarch64 |
入口断点打印 x0,用于观察传入的字符串 ID。返回断点打印 x0 指向的字符串,用于观察解密结果。
GDB Python 脚本
GDB Python 脚本可以定义断点类,并在断点命中时自动执行逻辑:
1 | import gdb |
return False 表示断点触发后不让程序停住,而是执行脚本逻辑后继续运行。这样可以把人工断点操作变成自动日志。
插桩
插桩是在目标程序或执行环境中插入分析逻辑,用于收集运行时信息或控制程序状态。
在逆向工程中,插桩通常指对二进制程序进行修改或拦截。
调试和插桩的区别:
| 项目 | 调试 | 插桩 |
|---|---|---|
| 目标 | 实时控制和分析执行过程 | 收集执行过程中的数据 |
| 干预方式 | 主动暂停、单步、修改状态 | 通常自动记录或拦截 |
| 使用场景 | 定位特定函数、观察分支、修改变量 | 长期跟踪、日志记录、批量行为分析 |
两者并不冲突。调试适合交互式探索,插桩适合自动化和可重复分析。
插桩的关键概念
- 插桩目标:被分析的软件
- 插桩器:负责修改插桩目标的工具
- 修改目标:代码、数据、进程状态、系统调用等被修改对象
- 插桩逻辑:插入的新逻辑,用于记录信息或改变执行
评价插桩技术时,常看三个方面:
- 粒度:系统调用级、函数级、基本块级、指令级
- 适用范围:是否只适用于动态链接库函数,是否支持静态链接程序
- 副作用:是否影响语义,是否显著降低性能,是否破坏寄存器状态
系统调用级插桩
系统调用是程序访问操作系统资源的入口,例如文件读写、网络通信、进程创建等。
通过系统调用级插桩,可以观察程序是否:
- 打开敏感文件
- 读写特定路径
- 连接网络地址
- 创建进程或线程
- 修改文件权限
基于 ptrace 的系统调用级插桩流程:
1 | 声明要追踪的系统调用 |
系统调用级插桩适合观察系统资源访问,但不能可靠拦截所有库函数行为。例如 malloc 是 libc 函数,不等同于一次固定的系统调用。
GOT 表函数级插桩
动态链接库函数调用通常通过 PLT/GOT 完成。
大致过程:
- 程序调用
malloc@plt - PLT 表项读取 GOT 表项中的真实函数地址
- 程序跳转到 GOT 表项指向的地址
如果把 malloc 对应 GOT 表项改成钩子函数地址,那么后续对 malloc 的调用就会先进入钩子函数。
示例:
1 | 630 <malloc@plt>: |
修改前:
1 | GOT[malloc] = 0x30004 # malloc |
修改后:
1 | GOT[malloc] = 0x40004 # record_a |
这样程序调用 malloc 时会先跳转到 record_a,由 record_a 打印参数寄存器或记录信息,再跳回真实 malloc。
基于 GOT 表插桩的一般步骤:
- 获取程序在内存中的加载基地址
- 根据 ELF 头和程序头表定位
DYNAMIC段 - 通过
DYNAMIC段定位.symtab、.rel.plt、.strtab - 遍历动态链接函数表项,找到目标函数对应的重定位项
- 根据重定位项计算目标函数 GOT 表项地址
- 获取目标函数在 libc 中的真实地址
- 修改 GOT 表项为钩子函数地址
- 修改钩子函数中的函数指针,使其最终跳回真实目标函数
实验中的 Task 1 就是用 GDB 对指定函数进行 GOT 表插桩:
- 学号最后一位为奇数时目标函数为
malloc - 学号最后一位为偶数时目标函数为
free - 钩子函数已在源码中给出,需要自行找出
- 钩子函数负责打印传参寄存器,结束时通过函数指针跳回目标函数本体
需要注意延迟绑定:
- 程序如果使用 lazy binding,第一次调用导入函数时 GOT 表项可能还指向动态解析逻辑
- 如果太早修改 GOT 表,可能被首次解析覆盖
- 实践时要确认 GOT 表项当前是否已经解析为真实 libc 函数地址
指令级插桩
系统调用级和函数级插桩不能覆盖所有需求。例如控制流扁平化反混淆时,常常需要记录基本块跳转边或间接跳转目标。
指令级插桩会修改目标指令,使插桩逻辑能获取每条关键指令执行时的上下文。
例如监控间接跳转目标:
1 | BR X1 // 跳转到 X1 指定的地址 |
可以在 BR X1 前插入逻辑:
1 | MOV X0, X1 |
这样每次执行间接跳转前都会打印目标地址。
基于跳板的指令级插桩
实际插桩时通常不直接在原位置插入多条指令,而是把目标指令改成跳转到插桩逻辑的跳板。
1 | 0007d0 ldr w0, [sp, #12] |
实现步骤:
- 记录目标待插桩指令
- 记录目标指令下一条指令地址
- 分配插桩逻辑内存空间
- 拷贝上下文保存和恢复逻辑
- 生成实际功能逻辑,例如打印寄存器
- 在插桩逻辑末尾执行原始指令
- 跳回原始指令的下一条地址
- 把原始位置指令改成跳板跳转
关键点是保存和恢复上下文。插桩逻辑不能破坏目标程序原本依赖的寄存器、栈和标志状态。
反动态分析
动态分析会改变程序运行环境,因此目标程序可以检测或干扰调试器。这类技术称为反动态分析,常见包括反调试、反插桩、反模拟执行等。
本节主要关注反调试。
检查 TracerPid
Linux 的 /proc/<pid>/status 中包含 TracerPid 字段。
未被调试时:
1 | TracerPid: 0 |
被调试时,该字段可能变为调试器进程的 PID:
1 | TracerPid: 16614 |
程序可以读取自己的状态文件,并根据 TracerPid 判断是否被调试:
1 | int fd = open("/proc/self/status", O_RDONLY); |
对应的关键 API 或 syscall:
openreadstrstrstrcmp
逆向时可以在这些位置下断点,观察程序是否正在读取 TracerPid。
接管异常处理
调试器依赖异常接管程序。目标程序也可以主动注册自己的异常处理逻辑,从而干扰调试器。
例如:
1 | sigset_t sigs; |
含义:
- 屏蔽
SIGINT,阻止外部中断影响程序 - 注册自己的
SIGTRAP处理函数,干预调试断点触发后的控制流
如果关键逻辑被放在异常处理函数里,简单跳过反调试逻辑可能导致程序功能缺失。
自 Trace
Linux 中一个线程同一时间只能被一个 tracer 接管。自 Trace 利用这一点实现反调试。
常见流程:
- 程序
fork出子进程 - 子进程请求父进程
ptrace自己 - 父进程成为子进程的 tracer
- 外部调试器再尝试调试子进程时失败
有些实现还会检查父进程是否真的是自己的 tracer,如果发现 TracerPid 异常,就退出或触发干扰逻辑。
对抗反调试
对抗反调试的一般步骤:
- 根据反调试原理定位检测逻辑
- 分析检测逻辑的上下文
- 修改程序数据或控制流
- 让检测结果变为“没有调试器”
- 继续执行并验证程序行为
定位方式:
- 找反调试相关 API 或 syscall
- 对
ptrace、open、read、strstr、strcmp、signal、sigprocmask、exit下断 - 观察是否访问
/proc/self/status - 观察是否处理
SIGTRAP
绕过方式:
- 关闭反调试代码逻辑
- 修改 API 或 syscall 的返回值
- 修改检测结果的处理逻辑
- 修改
open参数,让程序读取伪造的 status 文件 - 修改
strcmp或检测函数返回值,使其认为TracerPid为 0 - 在即将
exit前断下,跳过退出路径
实践中基础反调试任务的思路:
1 | 目标:让程序继续运行并输出目标字符串 |
例如:
1 | (gdb) b ptrace |
如果程序通过 ptrace 返回值判断调试器是否存在,可以在 ptrace 返回后把返回值改成期望的成功值。
也可以在即将退出的位置跳过:
1 | (gdb) b exit |
具体修改点需要结合当前二进制的反汇编和实际控制流判断,不能只机械套用命令。
动态分析的一般流程
面对一个新的目标程序,可以按以下流程分析:
- 静态浏览程序结构
- 查看字符串、导入函数、符号、函数列表、交叉引用
- 初步定位输入、输出、加密、校验、网络、文件相关逻辑
- 选择动态入口
- 在
main、关键库函数、系统调用或可疑函数上下断点 - 如果已知数据地址,优先考虑数据断点
- 在
- 运行到断点并观察现场
- 查看
PC/SP/FP - 查看参数寄存器
x0-x7 - 查看返回值寄存器
x0 - 查看栈和目标内存
- 查看调用栈
- 查看
- 跟踪执行
- 单步进入关键逻辑
- 步过不关心的库函数
- 使用
finish跑到当前函数返回 - 在返回点观察输出
- 修改状态验证假设
- 修改寄存器参数
- 修改内存数据
- 修改返回值
- 跳过检测或退出路径
- 自动化重复操作
- 把稳定的断点和观察动作写成 GDB 脚本
- 对高频函数考虑插桩
- 对反调试逻辑单独写绕过脚本
动态分析不是替代静态分析,而是把静态分析中的猜测放到真实运行状态中验证。对于逆向任务,常见模式是先静态定位大概位置,再动态确认关键数据流,最后回到静态代码中整理完整逻辑。