拆开程序看它的骨头:基础逆向工程入门

法律边界声明(请务必先阅读)

本文所有实验对象都是本文自己写的程序、以及 crackmes.one 等平台上明确供练习用的样本。逆向技术本身是中性的,它既可以用来做安全审计、兼容分析和恶意样本研判,也可以用来侵犯他人权益。未经授权对他人软件做逆向、修改和分发,在多数司法辖区都不被允许。 请在合法范围内练习,本文不为此提供任何边界外的指导。


一、逆向工程到底是什么

一句话:在只有成品的前提下,反推出它是怎么设计和实现的。

这件事听起来玄,其实你每天都在做类似的事——看到一个陌生人的行为,猜测他的动机和习惯。软件逆向只是把这件事做得更严谨:你手上没有源码、没有文档、没有开发者,只有一个二进制文件,你要从它机器码的排列里还原出:

  • 它用了什么数据结构
  • 它的分支条件是什么
  • 它跟操作系统、网络、文件系统怎么交互
  • 它有没有藏着没告诉你的行为

1.1 三种典型场景

场景目的典型例子
安全审计评估一个程序会不会干坏事分析来路不明的样本、检查闭源软件的隐私行为
兼容与维护源码丢了但还得改它老工业控制软件、遗留系统的接口逆向
学习理解编译器和优化器的真实行为看自己写的代码被编译成了什么

注意这三件事的共同点:产出是"理解",不是"注册码"。

很多人把逆向和破解混为一谈。技术上它们确实同源——你要破解,必须先逆向——但逆向的终点是"我搞清楚了它是怎么算的",破解的终点是"我绕过了它"。前者是能力,后者是目的,两者不是一回事。

1.2 逆向的思维模型

我把逆向分成三个层次,入门阶段一定要先建立这个框架:

1
2
3
行为层  →  我观察到程序做了什么?(输入什么、输出什么、调了哪些 API)
结构层 → 这些行为在代码里是怎么组织的?(函数划分、调用关系、数据结构)
语义层 → 每一条指令在业务上意味着什么?(这个 cmp 在比什么?这个循环在算什么?)

新手最容易犯的错误是直接从行为层跳到"改哪个字节",跳过结构层。结果就是:改是改对了,但你不知道为什么对,换个程序立刻不会。

能停下来解释"为什么",才算真的会了。


二、你得先知道程序是什么东西

2.1 从一行 C 到机器码

写一行 int add(int a, int b) { return a + b; },中间经过了四步:

1
2
3
4
5
6
7
8
9
源码 (.c)
↓ 预处理 预处理指令展开:#include 展开、#define 替换、条件编译
预处理后 (.i)
↓ 编译 语法/语义分析 → 中间表示 → 优化 → 汇编代码
汇编 (.s)
↓ 汇编 助记符 → 机器码,生成目标文件
目标文件 (.o)
↓ 链接 符号解析、地址重定位、合并段、绑定动态库
可执行文件 (exe / elf)

真正到了 CPU 面前,a + b 大概长这样(x64,System V 调用约定):

1
2
3
4
5
6
7
8
9
add:
push rbp ; 保存调用者的栈基址
mov rbp, rsp ; 建立自己的栈帧
mov DWORD PTR [rbp-4], edi ; 第 1 个参数 a 存到本地的 [rbp-4]
mov DWORD PTR [rbp-8], esi ; 第 2 个参数 b 存到 [rbp-8]
mov eax, DWORD PTR [rbp-4] ; eax = a
add eax, DWORD PTR [rbp-8] ; eax = a + b
pop rbp
ret

就两行 C 代码,膨胀成了 8 条指令。理解"为什么膨胀",是理解逆向的第一步——因为编译器的每一个"多余"动作都是为某种规则服务的(栈帧、调用约定、寄存器保存)。

而且这还只是 -O0。开 -O2 之后编译器会把它压成 lea eax, [rdi+rsi] 一条指令。逆向时你看到的代码,永远是"某个编译器、某个优化级别、某个目标平台"的产物,而不是原始源码。 这句话值得贴在显示器上。

2.2 可执行文件的结构

你的程序在磁盘上不是一坨乱码,它是有严格格式的容器。Windows 用 PE,Linux 用 ELF。

PE(Portable Executable):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌─────────────────────────┐
│ DOS 头 (MZ...) │ ← 开头两个字节 'MZ',兼容 DOS
│ DOS Stub │ ← "This program cannot be run in DOS mode"
├─────────────────────────┤
│ PE 签名 "PE\0\0" │
│ COFF 文件头 │ ← 机器类型、节数量、时间戳
│ 可选头 (Optional Header)│ ← 入口点 RVA、镜像基址、节对齐
├─────────────────────────┤
│ 节表 (Section Table) │ ← 每个节的名称/大小/RVA/属性
├─────────────────────────┤
│ .text 代码 │ 可执行、可读
│ .rdata 只读数据 │ 字符串常量、导入表
│ .data 已初始化全局变量 │ 可读可写
│ .bss 未初始化变量 │ 运行时才分配
│ .rsrc 资源 │ 图标、对话框、版本信息
│ .reloc 重定位表 │ ASLR 用的
└─────────────────────────┘

ELF(Executable and Linkable Format):

1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────────────────────────┐
│ ELF 头 (7F 45 4C 46) │ ← '\x7fELF'
│ 程序头表 (Program Header)│ ← 运行时加载用:哪些段要映射到哪
├─────────────────────────┤
│ .text 代码 │
│ .rodata 只读数据 │
│ .data 已初始化数据 │
│ .bss 未初始化数据 │
│ .plt 外部函数跳转桩 │ ← 动态链接的关键
│ .got 全局偏移表 │ ← 运行时填真实地址
├─────────────────────────┤
│ 节头表 (Section Header) │ ← 链接/调试用
└─────────────────────────┘

2.3 三个必须分清的概念

这是新手第一个大坑,务必弄清楚:

概念含义用途
VA(Virtual Address)虚拟地址,进程里看到的完整地址,如 0x00401000调试器里显示的地址
RVA(Relative Virtual Address)相对虚拟地址 = VA − 镜像基址PE 头里存的、文件偏移换算用
文件偏移(File Offset)这个字节在磁盘文件里的位置十六进制编辑器里用的

换算关系:

1
2
RVA = VA - ImageBase
文件偏移 = RVA - 节.VirtualAddress + 节.PointerToRawData

为什么重要? 因为你在调试器里看到一个地址 0x00401234,想去十六进制编辑器里改那个字节,你得先换算成文件偏移。算错一位,改出来的文件就跑不起来了。这也是为什么 PE 编辑工具(CFF Explorer、PE-bear、StudyPE+)存在——它们帮你做这个换算。

ASLR 会把这件事搞复杂:开了地址随机化之后,每次运行 ImageBase 都不同,所以调试器里看到的绝对地址会变。但 RVA 不变,所以逆向时要习惯看 RVA 或"模块名+偏移",而不是绝对地址。


三、汇编:入门只需要认识这些

不要一上来啃《Intel 汇编语言程序设计》八百页。入门阶段你需要的是一张能随时查的表,加上反复在调试器里验证。

3.1 寄存器

x86 的寄存器是 x64 的子集,名字有历史包袱:

64 位32 位16 位8 位(低)传统用途
raxeaxaxal累加器,函数返回值
rbxebxbxbl基址,常被用来存指针
rcxecxcxcl计数器,x64 下是第 4 个参数
rdxedxdxdl数据,x64 下是第 3 个参数
rsiesisisil源索引
rdiedididil目的索引,x64 下是第 1 个参数
rbpebpbpbpl栈帧基址(逆向时最常盯的)
rspespspspl栈顶指针
r8-r15r8d-r15dr8wr8bx64 新增,r8/r9 是第 5/6 个参数

外加:

  • rip / eip:指令指针,指向下一条要执行的指令
  • rflags / eflags:标志寄存器

3.2 标志位:分支的真相

所有 if 语句最终都变成"比较 + 看标志位 + 跳转"。四个关键标志:

标志名称什么时候置 1
ZF零标志结果为 0 时
SF符号标志结果为负时(最高位为 1)
CF进位标志无符号运算溢出/借位时
OF溢出标志有符号运算溢出时

记住一句话:cmp a, b 本质是 a - b,但只改标志位不存结果。 所以 cmp eax, 5 之后:

  • ZF=1 说明 eax == 5
  • ZF=0 说明 eax != 5

3.3 指令速查

数据传送

1
2
3
4
5
6
mov  eax, ebx          ; eax = ebx
mov eax, [ebp-4] ; eax = 内存[ebp-4] 的值(注意方括号 = 取地址里的内容)
lea eax, [ebp-4] ; eax = ebp-4 这个地址本身(不取值!)
push eax ; 压栈:rsp -= 4/8, [rsp] = eax
pop eax ; 出栈:eax = [rsp], rsp += 4/8
xchg eax, ebx ; 交换

mov 和 lea 的区别是新手第一个坎:mov 取值,lea 取地址。而且编译器特别爱用 lea 做算术:

1
2
3
lea  eax, [eax+eax*2]      ; eax = eax * 3
lea eax, [eax+eax*4] ; eax = eax * 5
lea eax, [ebx+ecx*8+100] ; eax = ebx + ecx*8 + 100

看到 lea 别以为是"加载地址",八成是在算乘法。

算术与逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
add  eax, 5        ; eax += 5
sub eax, 5 ; eax -= 5
inc eax ; eax++
dec eax ; eax--
imul eax, ebx ; 有符号乘法
idiv ebx ; 有符号除法(商在 eax,余数在 edx)
and eax, 0x0F ; 掩码,取低 4 位
or eax, 0x80 ; 置位
xor eax, eax ; ★ 清零!不是"异或运算"
not eax ; 按位取反
shl eax, 2 ; 左移 2 位 = 乘 4
shr eax, 2 ; 右移 2 位 = 无符号除 4
sar eax, 2 ; 算术右移 = 有符号除 4

xor eax, eax 是编译器优化出来的"清零"惯用法,比 mov eax, 0 短一个字节。看到它不要想歪。

比较与跳转

1
2
3
4
5
6
7
8
9
10
11
12
13
cmp  eax, ebx      ; 比较(做减法,只改标志)
test eax, eax ; 按位与(只改标志),常用于判断"是否为零"

jmp label ; 无条件跳
je label ; ZF=1 跳(相等)
jne label ; ZF=0 跳(不等)
jz / jnz ; 和 je / jne 完全一样,只是写法
jg label ; 有符号 大于
jge label ; 有符号 大于等于
jl label ; 有符号 小于
jle label ; 有符号 小于等于
ja label ; 无符号 大于
jb label ; 无符号 小于

这里有个高频陷阱:jg/jl 是有符号比较,ja/jb 是无符号比较。同样是 cmp eax, 5,如果 eax = 0xFFFFFFFF(有符号是 -1,无符号是 4294967295):

  • jl 会跳(-1 < 5)
  • jb 不会跳(4294967295 > 5)

编译器根据你的 C 代码里变量是 int 还是 unsigned 自动选。看不懂为什么跳了/没跳,八成是符号性搞错了。

调用与返回

1
2
3
call func          ; 压入返回地址,跳到 func
ret ; 弹出返回地址,跳回去
leave ; = mov rsp, rbp; pop rbp (函数尾声的标准动作)

其他常用

1
2
3
nop                ; 什么都不做(打补丁时用来"填充")
rep movsb ; 循环复制:ecx 次,从 esi 到 edi
rep stosb ; 循环填充:把 al 填到 edi 指向的内存

四、函数调用:栈帧长什么样

这是必须搞懂的一章。看懂栈帧,你就能看懂 80% 的汇编。

4.1 栈的生长方向

x86/x64 的栈从高地址向低地址生长。push 让 rsp 减小,pop 让 rsp 增大。这一点非常反直觉,但记住就好:

1
2
3
4
5
6
高地址
┌──────────────┐
│ 调用者的栈 │
├──────────────┤
│ ... │
├──────────────┤ ← 函数被调用前的 rsp

4.2 标准栈帧

一个典型函数(帧指针 ebp 参与)的完整生命周期:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
; ===== 调用者 =====
push arg2 ; 压参数(从右往左压,cdecl)
push arg1
call func ; 压入返回地址,跳转

; ===== 被调用者:序言 =====
push ebp ; 保存调用者的 ebp
mov ebp, esp ; 建立自己的栈帧基准
sub esp, 0x20 ; 为局部变量开辟空间

; ===== 函数体 =====
mov eax, [ebp+8] ; 第 1 个参数
mov ebx, [ebp-4] ; 第 1 个局部变量

; ===== 尾声 =====
mov esp, ebp ; 或直接用 leave
pop ebp ; 恢复调用者的 ebp
ret ; 返回

画成图(32 位,栈从高到低):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
        高地址
┌──────────────┐
│ arg2 │ [ebp+12]
├──────────────┤
│ arg1 │ [ebp+8] ← 第 1 个参数
├──────────────┤
│ 返回地址 │ [ebp+4]
├──────────────┤
ebp→│ 旧 ebp │ [ebp] ← ebp 指向这里
├──────────────┤
│ 局部变量 1 │ [ebp-4]
├──────────────┤
│ 局部变量 2 │ [ebp-8]
├──────────────┤
esp→│ ... │ ← esp 指向栈顶
└──────────────┘
低地址

方括号里的偏移是逆向时最值钱的信息:

  • [ebp+8]、[ebp+12]、[ebp+16] → 逐个往后数就是参数
  • [ebp-4]、[ebp-8] → 局部变量
  • [ebp-4] 出现得特别频繁,因为它是第一个局部变量

但现代编译器经常省略帧指针(-fomit-frame-pointer,x64 下这是默认)。这时候没有 ebp 当锚点,只能靠 rsp 相对偏移,读起来难一些。看到函数序言是 sub rsp, 0x28 而没有 push rbp; mov rbp, rsp,就是这种情况。

4.3 调用约定对照

约定参数传递谁清理栈典型场景
cdecl从右往左压栈调用者C 语言默认(32 位)
stdcall从右往左压栈被调用者(ret 8)Win32 API
fastcall前 2 个放 ecx/edx,其余压栈被调用者部分优化代码
x64 fastcallrcx, rdx, r8, r9,其余压栈调用者Windows x64 默认

x64 下还有个坑:前 4 个参数用寄存器,第 5 个开始才压栈,而且调用者要在栈上预留 32 字节的"影子空间"(shadow space)。所以你会看到 sub rsp, 0x28 这种看起来莫名其妙的数字(0x20 影子空间 + 8 对齐)。


五、工具链

5.1 静态分析器(看代码,不运行)

工具平台价格反编译点评
IDA ProWin/Mac/Linux商业,很贵Hex-Rays(业界最强)事实标准,插件生态无敌
Ghidra全平台免费开源(NSA)有,质量不错入门首选,功能足够
Binary Ninja全平台有免费版有现代 UI,API 好用
Cutter / Rizin全平台免费开源内置 Ghidra 反编译界面友好
HopperMac/Linux便宜有Mac 上的性价比之选

入门建议用 Ghidra 或 Cutter。IDA 免费版(IDA Free)功能受限且只有 x86/x64,但作为过渡也够。

5.2 动态调试器(让程序跑起来,边跑边看)

工具平台点评
x64dbgWindowsWindows 入门第一选择,开源、活跃、界面现代
OllyDbgWindows老牌,只支持 32 位,插件多,教程多
WinDbgWindows微软官方,内核/驱动调试必备,学习曲线陡
GDB + pwndbg / pedaLinuxLinux 标准,配插件后体验接近 x64dbg
LLDBMac/LinuxClang 生态的标准调试器

Windows 上的学习路径:x64dbg 起步 → 需要时学 WinDbg。 Linux 上的:GDB + pwndbg。

5.3 辅助工具(很多时候比主武器还重要)

工具用途
Detect It Easy (DIE)查壳、查编译器、查语言、查打包器 —— 拿到样本第一件事
PE-bear / CFF Explorer看 PE 结构、导入表、节表
Process Monitor看文件/注册表/网络访问(行为分析神器)
Process Hacker / System Informer看进程、线程、句柄、内存
API Monitor记录程序调了哪些 API、参数是什么
HxD / 010 Editor十六进制编辑,打补丁
Frida动态插桩,运行时改函数行为(进阶)
strings一条命令抓出所有可打印字符串 —— 性价比最高的工具
objdump / readelfLinux 下的静态分析(反汇编、节表)
ltrace / straceLinux 下跟踪库调用/系统调用

我的实际工作流通常是:DIE 查壳 → strings 抓字符串 → Ghidra 静态定位 → x64dbg 动态验证 → 打补丁/写结论。


六、静态 vs 动态:两条腿走路

这是方法论层面最重要的区分。

静态分析动态分析
做法反汇编/反编译,读代码运行程序,下断点,看寄存器
优点能看到全貌、能看到不执行的分支、不担心中反调试能看到真实运行状态、参数实际值、解密后的数据
缺点看不到运行时数据(解密的字符串、动态生成的代码)只见树木不见森林,容易迷路,可能触发反调试
适合理解整体结构、找关键函数验证假设、破解校验、脱壳

正确的姿势是交替使用:

1
2
3
4
5
6
7
8
9
静态:在 strings 里发现 "Wrong password"
↓
动态:在 "Wrong password" 的引用处下断点
↓
动态:运行,断下来,往回看寄存器 —— 发现 eax 是输入长度
↓
静态:跳到那个函数,看它怎么用这个长度
↓
……

每转一圈,你对程序的理解就深一层。这本质上是"提出假设 → 实验验证"的科学方法,只不过实验对象是二进制。


七、实战:写一个 CrackMe 然后拆了它

CrackMe 是专门用来练习逆向的小程序,功能就是"输入一个序列号,判断对错"。下面这个是我们自己写的,源码完全公开,这样你能对照着看——入门阶段一定要有源码对照,否则你不知道自己猜得对不对。

7.1 源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>
#include <string.h>

int check(char *in) {
if (strlen(in) != 8) return 0; /* 约束 1:长度必须是 8 */
if (in[0] != 'r' || in[1] != 'e' || in[2] != 'v')
return 0; /* 约束 2:前三位是 rev */
int s = 0;
for (int i = 3; i < 8; i++) s += in[i]; /* 约束 3:后五位 ASCII 和 */
if (s != 0x1F4) return 0; /* 必须等于 500 */
return 1;
}

int main(void) {
char buf[64];
printf("Serial: ");
scanf("%63s", buf);
if (check(buf)) printf("Correct!\n");
else printf("Wrong.\n");
return 0;
}

编译(关键:关掉优化,保留清晰的栈帧):

1
gcc crackme.c -o crackme.exe -O0

7.2 三条找关键点的路

① 字符串回溯法(最实用,90% 的情况用它)

程序里的提示语是写死在二进制里的。你在 Ghidra 里搜 "Correct!",找到它所在的地址,然后看谁引用了它:

1
2
3
4
Ghidra → Search → For Strings → "Correct"
→ 定位到 .rdata 段某地址
→ 右键 → References → Show References to
→ 跳到一个函数,那个函数就是判定逻辑所在

② API 断点法(对付有交互的程序特别有效)

printf/MessageBoxA/GetWindowTextA 这些是"路标"。在调试器里给它们下断点,程序一停,往回翻栈就能找到调用者:

1
2
3
x64dbg → 命令栏输入 bp printf
→ 运行,输入序列号
→ 断下来 → 看调用栈 (Call Stack) → 找到 check 函数

③ 单步跟踪法(慢,但最锻炼人)

从程序入口开始一路 F8(单步跳过),遇到 call 就看一眼,遇到 cmp + jcc 就停下来推标志位。适合小样本,或者你完全没头绪的时候。

7.3 用 x64dbg 定位关键跳转

在 x64dbg 里,check 函数编译出来大致是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
check:
push rbp
mov rbp, rsp
sub rsp, 0x20
mov [rbp-8], rcx ; 参数 in 存到局部变量
mov rax, [rbp-8]
mov rdi, rax
call strlen
cmp rax, 8 ; ★ 约束 1
je short .len_ok
mov eax, 0
jmp .epilogue
.len_ok:
mov rax, [rbp-8]
movzx eax, byte ptr [rax]
cmp al, 'r' ; ★ 约束 2
jne short .fail
...
; 循环累加后
cmp DWORD PTR [rbp-0x10], 0x1F4 ; ★ 约束 3
je short .success
.fail:
mov eax, 0
jmp .epilogue
.success:
mov eax, 1
.epilogue:
leave
ret

而 main 里调用它的地方:

1
2
3
4
5
6
7
8
9
10
    call    check
test eax, eax ; ★★★ 这就是"关键跳转"的源头
je short .wrong ; eax == 0 就跳去打印 "Wrong."
lea rcx, "Correct!"
call printf
jmp .end
.wrong:
lea rcx, "Wrong."
call printf
.end:

test eax, eax + je 这一对,就是整个程序的"生死线"。90% 的 CrackMe 都能在这一对上做文章。

7.4 四种改法(从暴力到优雅)

方法一:NOP 掉跳转

把 je short .wrong 改成两条 nop(90 90)。这样不管 eax 是什么,都会往下走到 Correct!。

1
2
改前: 74 0A          (je +0x0A)
改后: 90 90 (nop nop)

方法二:改成无条件跳转

把 je 改成 jmp(EB),跳转逻辑直接失效。

1
2
改前: 74 0A
改后: EB 0A

方法三:反转条件

把 je(74)改成 jne(75)。这样输入错误的时候才走 Correct! 分支——是"反向 CrackMe"。

方法四:直接改返回值(最优雅)

不动 main,改 check 函数本身:让它一进来就返回 1。

1
2
3
check:
mov eax, 1
ret

对应的字节是 B8 01 00 00 00 C3。这种方法不依赖具体的跳转结构,对任何 CrackMe 都通用。

7.5 打补丁要落盘

在 x64dbg 里改的是内存,关掉调试器就没了。要永久生效:

1
x64dbg → 选中修改过的指令 → 右键 → "复制到可执行文件" → 保存为新文件

或者用十六进制编辑器(HxD)直接改文件对应偏移的字节。这时候前面讲的 RVA → 文件偏移换算就派上用场了。

⚠️ 两个坑:

  1. 改了代码可能破坏 PE 的校验和,某些程序会拒绝运行
  2. 有数字签名的程序改完签名就失效了

7.6 反过来:不破解,直接算出序列号

前面做的都是"绕过校验"。但逆向的真正价值在于理解——既然我们已经从汇编里读出了三条约束:

1
2
3
len == 8
in[0..2] == "rev"
in[3..7] 的 ASCII 码之和 == 500

那就直接解方程:需要 5 个字符,ASCII 和 500,平均每个 100。'd' 的 ASCII 正好是 100,所以:

1
2
r e v d d d d d
↑ 5 个 'd',100 × 5 = 500 ✓

合法序列就是 revddddd。

这时候你会发现:不需要改任何一个字节,程序自己就认了。 这才是逆向的成就感——你搞懂了它的规则,然后用它的规则跟它对话。

(这也是为什么"逆向"和"破解"不能划等号:前者让你能算出一个合法输入,后者只是让程序闭嘴。)


八、八个立刻能用的实战技巧

  1. 字符串永远是第一入口。 大多数程序把提示语、错误信息、URL、注册表路径写死在二进制里。一条 strings 命令的价值超过半小时的乱翻。
  2. API 是路标。 MessageBox、GetWindowText、RegOpenKey、CreateFile、connect —— 断在这些函数上,你就站在了"程序与外界交互"的现场。
  3. cmp + jcc 是决策点。 任何业务逻辑,最后都收敛成"比较 + 跳转"。找到那对指令,你就找到了开关。
  4. test eax, eax 就是 cmp eax, 0。 这是编译器判断"返回值是否为零"的惯用法,出现频率极高。
  5. xor eax, eax 是清零,别当成异或运算。
  6. lea 经常在做乘法。 lea eax, [eax+eax*2] = eax * 3。看到奇怪的 lea 先想想是不是在算地址步长。
  7. switch 语句会变成跳转表。 看到 jmp [eax*4 + 0x401234] 或者 jmp qword ptr [rax*8 + ...],说明这里有个 switch 或多态调用,表里每一项是一个 case 的地址。
  8. C++ 的虚函数调用长这样:call [rax + 偏移]。 那个偏移是虚表里的槽位。看到 mov rax, [rcx] 然后 call [rax+8],基本可以确定是虚函数。

九、加壳、混淆、虚拟化:知道有这三级台阶

学到这里你会遇到"打不开的程序"。这是正常的,逆向本来就分层次:

层级手段特征入门阶段怎么办
无保护就是编译出来的原样节区正常,字符串清晰可见直接干
压缩壳UPX、ASPack节区名 UPX0/UPX1,熵很高,字符串很少upx -d 直接脱,或按 ESP 定律找 OEP
加密壳Themida、VMProtect 的部分功能代码加密,运行时才解密需要找 OEP + dump + 修复 IAT
虚拟化VMProtect、Themida 虚拟化代码变成自定义字节码 + 解释器属于高级课题,入门阶段绕开

入门阶段的原则:遇到壳先绕开,不要硬刚。 先用 DIE 查一下,是 UPX 就直接 upx -d 脱了再看;是 VMProtect 就先换个样本练。把有限的时间花在"理解逻辑"上,而不是"跟壳搏斗"上。

脱壳本身是个独立的大话题(ESP 定律、单步到 OEP、OllyDump、Scylla 修复导入表),值得单独写一篇。


十、一条可执行的学习路径

我把它拆成四周,每周有明确的产出:

第 1 周:汇编 + 调试器肌肉记忆

  • 读《汇编语言:基于 x86 处理器》的前 6 章,或者看一遍王爽《汇编语言》
  • 用 x64dbg 打开一个自己写的 hello world,从入口点单步走到 printf 返回,把每一步的寄存器和栈都记下来
  • 产出:能说清楚"函数调用时栈上发生了什么"

第 2-3 周:刷 CrackMe

  • 去 crackmes.one 下 20 个 difficulty 1-2 的样本(这个站点每个样本都标了难度和平台)
  • 每做完一个,写三行笔记:关键跳转在哪、改了什么、有没有算出合法序列
  • 产出:20 个样本的解题笔记,形成自己的"特征库"

第 4 周:上反编译

  • 用 Ghidra 把前两周做过的样本重新看一遍,对照反编译出来的 C 代码验证你的判断
  • 挑 2-3 个自己写的 C 程序,用 -O0 和 -O2 各编译一次,对比反编译结果的差异,理解优化器做了什么
  • 产出:能看着反编译代码说出"这里对应源码的哪一行"

之后(选一个方向深入)

  • Windows 方向:PE 结构精读、脱壳、驱动逆向
  • Linux 方向:ELF/PLT/GOT、ptrace、恶意样本分析
  • 移动端方向:APK 反编译(jadx)、smali、Frida 动态插桩
  • 漏洞方向:从逆向到漏洞挖掘(fuzzing + 崩溃分析)

资源清单

类型推荐
书《加密与解密(第 4 版)》段钢 —— 中文逆向的"圣经"
书《Practical Reverse Engineering》 —— 系统性最好
书《Reverse Engineering for Beginners》Dennis Yurichev —— 免费在线,例子极多
练习crackmes.one —— 按难度分级,有解法和讨论
练习pwn.college / picoCTF 的 RE 题目
社区看雪论坛(kanxue)、52pojie
视频OALabs、stacksmashing 的 YouTube 频道

十一、最后的几句话

写完回头看,这篇里真正重要的其实不是工具清单,也不是那 30 条指令。最重要的是那个思维模型:

从行为提出假设 → 用静态分析定位 → 用动态分析验证 → 修正假设 → 重复

工具会换(IDA 会过期,x64dbg 会停更),平台会变(从 x86 到 ARM 到 RISC-V),但这个方法不变。

还有一个心态上的提醒:逆向是"读"的艺术,不是"猜"的艺术。 新手容易陷入"我试试改这个字节行不行"的暴力循环——运气好改对了,但什么都没学到,下一个程序还是要重新试。而真正会逆向的人,是在读懂了逻辑之后,才动手的。

那时候你会发现,很多程序根本不需要改——你只是需要知道它的规则。


本文的 CrackMe 源码可以自己编译运行,合法序列 revddddd 已经在文中推导出来了。如果你想加深理解,可以试着改一下 check 函数里的 0x1F4,看看改成别的值之后序列号该怎么算。