#逆向工程基本原理09
高级对抗技术概述
逆向工程本质上是一个对抗过程。分析者希望借助反汇编器、反编译器、调试器和脱壳工具尽快理解程序逻辑;软件保护者或恶意代码作者则希望提高分析门槛,拖慢分析速度,甚至让工具给出错误结果。
本节课关注的高级对抗技术主要包括:
- 对抗逆向工具:反反汇编、反反编译
- 加壳与脱壳:压缩壳、加密壳、动态脱壳、反脱壳
- 虚拟机保护:将原始指令翻译成自定义虚拟指令
- 实践处理:DIE 查壳、UPX 脱壳、Ghidra 中 Patch Instruction、NOP 和 return 滥用修复
需要注意的是,逆向工具不是绝对可靠的。工具本身也是程序,会受到算法假设、实现细节和启发式规则的限制。高级对抗技术往往不是让程序不能运行,而是让逆向工具误判。
对抗逆向工具
前面课程中主要使用了静态逆向工具来分析二进制程序,其中最核心的两个能力是反汇编和反编译:
- 反汇编:将机器码翻译成汇编指令
- 反编译:在反汇编和程序分析基础上恢复出类似高级语言的伪代码
对抗逆向工具也可以从这两个层面入手:
- 反反汇编:让反汇编器在错误位置解码,或把数据当作指令
- 反反编译:让反编译器产生不完整、错误或误导性的伪代码
反反汇编
反反汇编是指使用经过特殊设计的代码或数据,使反汇编器产生不正确的结果。
常见效果:
- 隐藏真正执行的指令
- 将数据误识别为指令
- 将指令误识别为数据
- 破坏函数边界和控制流图
- 进一步导致反编译失败
反反汇编通常直接在二进制层面实施,所以比较容易自动化,也容易影响多个逆向工具。
回顾反汇编算法
反汇编的核心难点是区分代码和数据。二进制文件中的 .text 段大部分是代码,但也可能混入常量、跳转表、内联数据;数据段中也可能出现可执行代码或运行时生成的代码。
经典反汇编算法有两类:
- 线性扫描
- 递归下降
线性扫描从给定起点开始逐条解码,默认后续字节都是指令:
1 | 起始地址 -> 指令1 -> 指令2 -> 指令3 -> 指令4 -> ... |
优点是实现简单,覆盖率高。缺点是无法识别代码流中的数据,一旦数据字节也能被解释成合法指令,结果就会错位。
递归下降会根据控制流递归识别指令:
1 | 入口地址 |
递归下降比线性扫描更聪明,但依赖控制流起点和跳转目标识别。遇到间接跳转、虚假控制流、运行时计算地址时,仍然可能漏掉代码或错误扩展。
非连续指令流
非连续指令流主要对抗线性扫描反汇编。基本思想是在不会执行的位置插入可以被反汇编成合法指令的数据。
例如:
1 | MOV R0, #0 ; 清零 R0 |
执行到 BX LR 后,控制流已经返回调用者,后面的 DCD 0x47745E9B 不会被执行。但是如果反汇编器继续线性扫描,它可能把这 4 个字节解释成指令:
1 | LDRSH R3, [R3, R2] |
这类伪指令会污染反汇编结果。如果后续反编译器基于错误的反汇编继续分析,伪代码也会被误导。
虚假控制流
虚假控制流主要对抗递归下降反汇编。递归下降会把潜在跳转目标当成代码起点,因此可以构造“看起来可能跳转,实际上永远不会执行”的路径。
第一种方式是使用相同目标、条件相反的跳转指令:
1 | CMP R0, #0 |
BEQ 和 BNE 覆盖了比较结果的两种情况,所以程序一定跳转到 loc_real。但是递归下降反汇编器不一定能识别这种组合语义,可能继续把中间的 DCD 当成 fall-through 代码。
第二种方式是使用恒定判断值,也就是类似不透明谓词的写法:
1 | EOR R0, R0, R0 ; R0 = 0 |
静态看起来是条件跳转,但语义上等价于无条件跳转。工具如果只看到分支形态,不理解前面 EOR R0, R0, R0 让 R0 恒为 0,就会把不可达分支也纳入分析。
反反汇编处理思路
处理反反汇编时不能完全依赖反编译结果,需要回到汇编和控制流本身:
- 找到反编译失败或出现
undefined、halt_baddata()、异常函数边界的位置 - 检查对应汇编是否存在无条件跳转后面的数据、相同目标条件跳转、恒定条件跳转
- 判断哪些指令实际不可达,哪些才是真正执行路径
- 在工具中把不可达垃圾指令改为
NOP,或直接改为等价跳转 - 重新分析函数,观察反编译结果是否恢复正常
其中第 3 步最重要。不是看到奇怪指令就直接删除,而是要先证明这些指令不会影响程序原本功能。
反反编译
反反编译是指修改汇编代码或二进制代码,使反编译器产生错误伪代码。
反编译器需要做大量程序分析:
- 函数边界识别
- 栈变量恢复
- 参数个数推断
- 控制流结构化
- 类型恢复
- 异常处理识别
- 间接跳转和间接调用分析
这些分析都依赖规则和假设,因此可以被特殊构造的代码干扰。
return 指令滥用
很多反编译器会把 ret 或 ARM 中的 BX LR 视为函数结束标志。但在真实执行中,返回地址寄存器可以被提前修改,ret 不一定返回调用者,也可以被滥用为间接跳转。
ARM64 示例:
1 | mov x13, x30 ; 保存原始 LR |
这段代码的效果接近:
1 | b nojump |
但是反编译器看到 ret 后,可能直接认为当前函数结束,于是把 nojump 后面的真实逻辑排除在函数之外,导致反编译结果不完整。
实践中处理这类代码时,需要检查所有可疑的 ret:
ret前是否修改了x30ret后面是否还有明显可执行逻辑ret的目标是否是当前函数内部地址- 反编译结果是否在
ret处异常截断
如果确认只是用于跳转,可以在 Ghidra 中将其 patch 成直接跳转:
1 | b nojump |
或者把保存和恢复 x30 的冗余指令替换为 NOP,保留真实控制流。
错误处理隐藏代码
有些反编译器对异常处理结构支持不完整,尤其是复杂的 C++ try/catch、SEH 或编译器生成的异常处理辅助结构。
保护者可以把敏感逻辑放入错误处理路径中:
1 | try 块: |
如果反编译器没有正确恢复异常处理控制流,伪代码中可能只显示普通路径,而隐藏了 catch 中真正关键的代码。
分析时要注意:
- 函数中是否存在异常处理表或异常相关符号
- 反汇编中是否有跳到错误处理块的边
- 反编译伪代码是否明显缺少某些基本块
- 是否有“看似错误处理,实际参与主逻辑”的代码
基于 ROP 技术隐藏代码
ROP 是 Return-oriented Programming。它本来常见于漏洞利用,但也可以用于隐藏程序逻辑。
核心概念是 gadget:
- gadget 是以
ret结尾的短指令序列 - 每个 gadget 完成很小的操作
- 通过控制栈上的返回地址,把多个 gadget 串成执行链
一个简单的 ROP 链可以写成:
1 | stack: |
对应执行过程:
1 | gadget1: |
程序并没有一段连续的“真实逻辑代码”。真实语义被拆散在多个已有代码片段中,由栈上的地址串起来执行。反编译器通常以函数为单位恢复结构化代码,遇到这种跨函数、跨片段、依靠 ret 串联的执行方式时,很难给出可读伪代码。
对抗 ROP 隐藏时,重点不是只看单个 gadget,而是恢复整个栈布局:
- 找到控制栈内容的位置
- 识别每个 gadget 的地址
- 按
ret顺序模拟执行链 - 把 gadget 的微操作合并成完整语义
加壳与脱壳
加壳是在原程序外部包裹一段保护代码,用于隐藏原程序代码和数据。
常见目的:
- 保护商业软件核心算法
- 防止补丁修改和破解
- 隐藏恶意代码特征
- 绕过静态查杀和自动分析
- 增加 CTF 逆向题难度
EP 与 OEP
EP 是 Entry Point,表示程序入口点。OEP 是 Original Entry Point,表示加壳前原程序的入口点。
加壳后,程序入口通常会被改到壳代码:
1 | 加壳前: |
加壳过程大致为:
- 加载源文件并映射到内存
- 验证文件格式和有效性
- 增加或修改区段,用来存储壳代码和压缩数据
- 压缩或加密原程序区段
- 修改入口点 EP,使程序先执行壳
- 保存新的可执行文件
壳运行时通常会做这些事情:
- 获取自身需要的库函数地址
- 解压或解密原程序区段
- 修复导入表、重定位等运行环境
- 跳转到 OEP,把控制权交还原程序
壳的分类
按照功能可以粗略分为三类:
- 压缩壳:压缩原程序代码和数据,运行时解压
- 加密壳:加密原程序代码和数据,运行时解密
- 虚拟壳:把原始指令翻译成自定义虚拟机指令,由虚拟机解释执行
UPX 是最常见的压缩壳之一。它会把可执行文件压缩到更小体积,同时保持程序运行效果不变。
查壳方法
逆向一个程序时,通常应该先判断是否加壳。
常见查壳方法:
- 看入口函数是否是复杂的壳 stub
- 查看区段名和程序头中是否有加壳器标识,例如
UPX0、UPX1 - 使用 DIE、Exeinfo 等工具自动识别
- 计算熵值,压缩或加密后的区段通常熵值更高
DIE 的基本原理是为不同壳编写启发式检测规则。规则通常会匹配入口点附近的字节特征、区段名、版本信息和已知签名。
实践中可以这样理解 DIE 的结果:
1 | 有壳: |
UPX 脱壳
如果程序是标准 UPX 壳,最直接的方法是使用 UPX 自带脱壳:
1 | upx -d Lab8_demo |
脱壳后再导入 Ghidra,入口函数和主逻辑会明显更清晰。
如果不脱壳直接分析,通常会看到:
- 入口函数是复杂的解压 stub
- 导入表很少
- 原始函数和字符串不可见或不完整
- 反编译结果大部分是壳逻辑,不是业务逻辑
脱壳之后,才能更有效分析原程序中的反反汇编、反反编译和真实功能。
脱壳流程
脱壳是加壳的逆过程,目标是还原被保护的原始程序。
一般步骤包括:
- 解密或解压缩被保护区段
- 按区段头描述重新加载代码和数据
- 修复导入表,例如 PE 的 IAT、ELF 的 PLT/GOT
- 处理重定位
- 调整入口点到 OEP
- 导出脱壳后的可执行文件
脱壳方法常分为两类:
- 硬脱壳:分析壳算法,写出对应逆算法
- 动态脱壳:让壳自己在运行时还原程序,然后 dump 内存镜像
现实中很多壳会变形,硬脱壳维护成本很高,所以实战里常用动态脱壳。
ESP 定律
ESP 定律是堆栈平衡原理在脱壳中的应用。壳代码通常会先保存原程序现场,完成解压解密后恢复现场,再跳到 OEP。
示意:
1 | pushad ; 保存现场,栈指针改变 |
基本操作步骤:
- 程序执行完第一条压栈指令后,对当前栈顶地址下数据断点
- 程序继续运行,后续
popad或恢复现场时会访问该地址并触发断点 - 断点触发后单步执行,通常第一个
ret附近就是跳转 OEP 的位置 - 在原程序已经解压到内存后 dump 并修复文件
局限性:
- 依赖明显的“保存现场到恢复现场”结构
- 如果壳故意破坏栈平衡或多层跳转,定位会变困难
- 不适用于所有壳,只是一种经验方法
内存镜像法
内存镜像法不关心壳算法细节,只关心程序在某个运行时刻内存中已经出现明文代码和数据。
基本观察:
- 自解压或自解密时,壳会逐步还原各个区段
- 当开始修改
.data时,.text很可能已经解压完毕 - 当开始执行
.text时,代码和数据通常已经接近原始状态
实践思路:
- 对
.data段下内存断点,观察壳何时开始写数据段 - 对
.text段下执行断点,捕获跳回原程序的时刻 - 在 OEP 附近暂停并 dump 内存
- 修复导入表、重定位和入口点
反脱壳
加壳程序也会使用反脱壳技术加强保护。
常见手段:
- 防篡改:对代码和数据做完整性校验,发现 patch 后退出
- 模拟器检测:检测 QEMU、模拟器文件、DMI 信息、特定驱动
- 反调试:使用
ptrace、调试状态检测、多线程互相附加 - 元数据修改:修改 UPX 魔数等元数据,让标准脱壳工具失效
元数据修改通常不能真正隐藏程序信息,只是让自动工具失败。分析者发现问题后,可以手动修复文件头或更新检测规则。
虚拟机保护
虚拟机保护 VMP 是更高强度的代码保护技术。它不是简单压缩或加密原始代码,而是把原始指令翻译成自定义虚拟机指令,由保护器生成的虚拟机解释执行。
VMP 和普通压缩壳、加密壳的关键区别在于,普通壳运行到某个阶段通常会把原始代码还原出来并跳到 OEP;VMP 往往不会把原始机器码完整恢复回来,而是让解释器持续执行自定义字节码。因此分析重点不再是单纯找 OEP,而是识别虚拟机入口、字节码格式、dispatch 和 handler 语义。
基本结构:
1 | 虚拟字节码 |
其中:
- Fetch:取出下一条虚拟指令
- Decode:按照虚拟机指令格式解析 opcode 和 operand
- Dispatch:根据 opcode 跳到对应 handler
- Handler:执行虚拟指令语义
- Terminator:结束虚拟机执行或进入下一轮
虚拟指令示例
假设固定 4 字节是一条虚拟指令:
1 | 第 1 字节:操作码 |
操作码可以设计为:
1 | 01: MOV A B -> A = B |
寄存器和参数也由虚拟机自己定义:
1 | 参数:P1 P2 P3 P4 -> 01 02 03 04 |
这时逆向人员面对的不再是 ARM 或 x86 原始指令,而是保护器自定义的 ISA。要理解真实逻辑,需要先恢复虚拟指令格式、handler 语义和字节码位置。
VMProtect
VMProtect 是典型的虚拟机保护工具。公开资料中通常认为它是基于栈的虚拟机,会把原始指令编译成虚拟指令,再由解释引擎执行。
VMProtect 常见混淆技术:
- 虚拟指令集
- 寄存器轮转
- 字节码加密
- 随机验证
虚拟指令集与 nor
VMProtect 的虚拟指令集会包含算术运算、栈操作、内存操作、系统相关操作和逻辑运算。
课件中提到一种极端设计:逻辑运算只有一条 nor。因为 nor 可以组合出其他逻辑运算。
令:
\[P(a,b)=\lnot a \land \lnot b\]
则:
\[not(a)=P(a,a)\]
\[and(a,b)=P(P(a,a),P(b,b))\]
\[or(a,b)=P(P(a,b),P(a,b))\]
\[xor(a,b)=P(P(P(a,a),P(b,b)),P(a,b))\]
这说明虚拟机指令集可以非常小,但仍然表达复杂逻辑。对逆向人员来说,单条虚拟指令含义越抽象,恢复高级语义越困难。
寄存器轮转
寄存器轮转是指虚拟寄存器和实际存储槽之间的映射会不断变化。
例如共有 16 个寄存器槽,其中 2 个槽始终空闲。vEax 原本在第 1 个槽中,某条虚拟指令更新 vEax 后,VMProtect 可能随机选择一个空闲槽保存新值,原槽进入空闲池。
结果是:
- 同一个虚拟寄存器在不同位置可能对应不同槽
- 同一个槽在不同时间可能表示不同虚拟寄存器
- 静态分析难以直接追踪寄存器数据流
字节码加密与随机验证
字节码加密会让虚拟指令只在执行时被解密。不同 handler 可能使用不同的数据解密逻辑,每次解密后 seed 还会变化。
随机验证用于防篡改。保护器在编译过程中随机选择代码片段计算哈希,并插入类似 calchash 的虚拟指令。如果运行时发现代码被修改,就拒绝继续执行。
因此对 VMP 的分析通常要面对三层问题:
- 找到虚拟机和字节码
- 还原字节码解密和 handler 语义
- 绕过完整性校验和反调试
Code Virtualizer 与 Virbox Protector
Code Virtualizer 会将原始代码转换成内部虚拟机才能理解的虚拟操作码。它的特点是每个受保护程序可以拥有不同的虚拟机和虚拟操作码,导致一次逆向经验难以直接复用。
Virbox Protector 是商用多语言、多平台保护工具,支持 x86、ARM、.NET IL、JVM、Dalvik 等场景,提供防逆向、防篡改、运行时自保护、脚本和资源加密等功能。
这类工具说明高级代码保护不是单一技术,而是组合了虚拟机、加密、反调试、完整性校验和混淆的系统工程。
Movfuscator
Movfuscator 是一个很有意思的对抗工具,它可以把程序编译成只包含 mov 指令的程序。
它利用的是 x86 mov 指令的图灵完备性:理论上只用 mov 也能模拟任意计算。
例如用 mov 实现:
1 | if X == Y: |
可以先把条件选择转化为数组下标选择:
1 | SELECT_X[] = { &Z, &X } |
再用内存读写模拟 X == Y:
1 | mov [x], 0 |
这类程序在反汇编中没有传统控制流,反编译器也很难恢复出正常的 if/while 结构。缺点是执行效率和内存消耗都很差。
实践引导
本次实践目标是逆向一个包含高级对抗技术的程序,识别它使用的保护方式,脱壳后修复反反编译干扰,最终得到完整反编译逻辑并判断程序功能。
实践分为两个部分:
- 壳对抗技术实践
- 反对抗逆向工具技术实践
使用 DIE 查壳
DIE 的使用方式比较直接:
- 打开 DIE
- 导入待分析程序
- 查看基本信息和检测到的壳信息
- 记录壳类型、版本、压缩算法和是否被修补
如果是 UPX 加壳,DIE 可能显示类似信息:
1 | Packer: UPX |
报告中需要说明:
- 该程序采用了什么加壳技术
- 分析依据是什么
- 是通过特征、区段名、入口 stub、DIE 结果还是熵值判断
使用 UPX 脱壳
如果 DIE 判断为标准 UPX,可以直接脱壳:
1 | upx -d Lab8_demo |
脱壳前后对比:
- 未脱壳:Ghidra 中入口是壳代码,逻辑复杂,原始函数不清楚
- 脱壳后:入口和函数边界更正常,可以继续分析原程序
实验报告中需要写清楚使用的脱壳命令和脱壳后的分析效果。
Ghidra 中处理反反编译
脱壳后还可能遇到反反编译。实践引导中主要处理两类:
- 永远不会执行的冗余语句
- return 指令滥用
处理流程:
- 在反编译窗口发现异常,例如
halt_baddata()、undefined1或函数提前结束 - 回到 Listing 窗口查看对应汇编
- 判断哪些代码是真正执行路径,哪些是冗余干扰
- 右键选择
Patch Instruction - 将无用语句改为
nop,或将间接跳转改为直接跳转 - 重新反编译并检查函数逻辑是否完整
NOP 处理不可达垃圾指令
示例结构:
1 | b.eq rxrHR5Ie |
这里真正有用的是跳转到 aabbcc 后的逻辑。中间 undefined1 是干扰反编译的垃圾数据或不可达指令,可以改为 NOP:
1 | b.eq rxrHR5Ie |
NOP 只占位,不改变程序状态。它适合替换确认不可达或确认无副作用的冗余指令。
处理冗余参数推断
反编译器会根据调用前寄存器赋值推断函数参数。在 ARM64 调用约定中,x0 到 x7 常用于传参。如果调用前故意给很多寄存器赋值,反编译器可能误以为函数有很多参数。
实践中可能看到:
1 | getUserInput(a, b, c, d, e, f, g, h) |
但实际函数只使用 x0、x1,也就是两个参数。其余寄存器赋值只是干扰。
处理方法:
- 进入被调用函数确认真实使用的参数寄存器
- 回到调用点检查哪些赋值没有被使用
- 对冗余赋值使用
Patch Instruction改为nop - 重新反编译,确认函数调用恢复成两个参数
处理 return 指令滥用
实践中要仔细检查所有可疑 ret。典型结构如下:
1 | mov x13, x30 |
这段代码表面上是 ret,实际是跳转到 nojump。因为 ret 前已经把 x30 改成 nojump 地址。
处理方式有两种。
第一种:删除冗余语义,把无用语句改为 nop:
1 | nop |
第二种:更直接地把核心目的改成直接跳转:
1 | b nojump |
关键判断是:原片段只是为了让执行流到达 nojump,中间保存和恢复 x30 不贡献真实业务逻辑。patch 后反编译器能继续把后续代码纳入同一个函数,伪代码会更完整。
实验报告要点
报告需要覆盖两个任务。
任务一:壳对抗技术实践
- 程序采用了什么壳
- 判断依据是什么
- 如何脱壳
- 使用的脱壳命令是什么
任务二:反对抗逆向工具技术实践
- 程序使用了哪些对抗逆向工具技术
- 如何修改程序获得完整逻辑
- 哪些指令被改为
NOP - 哪些
ret被识别为跳转滥用 - 程序完整功能是什么
写报告时不要只贴最终伪代码。更重要的是说明判断过程:为什么某段汇编不可达,为什么 patch 不改变原程序语义,为什么反编译结果变完整。
小结
高级对抗技术的共同特点是利用工具假设和程序分析边界。
反反汇编利用的是“代码和数据难以完全区分”这一根本问题。反反编译利用的是函数边界、控制流结构化、参数推断、异常处理和返回语义中的工程限制。加壳隐藏原程序,使分析者必须先恢复 OEP 和原始区段。虚拟机保护进一步把原始指令集替换成自定义虚拟指令集,把逆向问题变成恢复虚拟机语义。
实践中比较稳妥的顺序是:
1 | 查壳 -> 脱壳 -> 重新导入分析工具 -> 定位反编译异常 -> 回到汇编验证控制流 -> patch 冗余指令 -> 重新反编译 -> 总结程序功能 |
逆向工具能显著提高效率,但不能替代对底层执行语义的判断。遇到高级对抗时,应优先相信真实控制流和指令语义,而不是直接相信反编译窗口。