拆开程序看它的骨头:基础逆向工程入门
法律边界声明(请务必先阅读)
本文所有实验对象都是本文自己写的程序、以及 crackmes.one 等平台上明确供练习用的样本。逆向技术本身是中性的,它既可以用来做安全审计、兼容分析和恶意样本研判,也可以用来侵犯他人权益。未经授权对他人软件做逆向、修改和分发,在多数司法辖区都不被允许。 请在合法范围内练习,本文不为此提供任何边界外的指导。
一、逆向工程到底是什么
一句话:在只有成品的前提下,反推出它是怎么设计和实现的。
这件事听起来玄,其实你每天都在做类似的事——看到一个陌生人的行为,猜测他的动机和习惯。软件逆向只是把这件事做得更严谨:你手上没有源码、没有文档、没有开发者,只有一个二进制文件,你要从它机器码的排列里还原出:
- 它用了什么数据结构
- 它的分支条件是什么
- 它跟操作系统、网络、文件系统怎么交互
- 它有没有藏着没告诉你的行为
1.1 三种典型场景
| 场景 | 目的 | 典型例子 |
|---|---|---|
| 安全审计 | 评估一个程序会不会干坏事 | 分析来路不明的样本、检查闭源软件的隐私行为 |
| 兼容与维护 | 源码丢了但还得改它 | 老工业控制软件、遗留系统的接口逆向 |
| 学习 | 理解编译器和优化器的真实行为 | 看自己写的代码被编译成了什么 |
注意这三件事的共同点:产出是"理解",不是"注册码"。
很多人把逆向和破解混为一谈。技术上它们确实同源——你要破解,必须先逆向——但逆向的终点是"我搞清楚了它是怎么算的",破解的终点是"我绕过了它"。前者是能力,后者是目的,两者不是一回事。
1.2 逆向的思维模型
我把逆向分成三个层次,入门阶段一定要先建立这个框架:
1 | 行为层 → 我观察到程序做了什么?(输入什么、输出什么、调了哪些 API) |
新手最容易犯的错误是直接从行为层跳到"改哪个字节",跳过结构层。结果就是:改是改对了,但你不知道为什么对,换个程序立刻不会。
能停下来解释"为什么",才算真的会了。
二、你得先知道程序是什么东西
2.1 从一行 C 到机器码
写一行 int add(int a, int b) { return a + b; },中间经过了四步:
1 | 源码 (.c) |
真正到了 CPU 面前,a + b 大概长这样(x64,System V 调用约定):
1 | add: |
就两行 C 代码,膨胀成了 8 条指令。理解"为什么膨胀",是理解逆向的第一步——因为编译器的每一个"多余"动作都是为某种规则服务的(栈帧、调用约定、寄存器保存)。
而且这还只是 -O0。开 -O2 之后编译器会把它压成 lea eax, [rdi+rsi] 一条指令。逆向时你看到的代码,永远是"某个编译器、某个优化级别、某个目标平台"的产物,而不是原始源码。 这句话值得贴在显示器上。
2.2 可执行文件的结构
你的程序在磁盘上不是一坨乱码,它是有严格格式的容器。Windows 用 PE,Linux 用 ELF。
PE(Portable Executable):
1 | ┌─────────────────────────┐ |
ELF(Executable and Linkable Format):
1 | ┌─────────────────────────┐ |
2.3 三个必须分清的概念
这是新手第一个大坑,务必弄清楚:
| 概念 | 含义 | 用途 |
|---|---|---|
| VA(Virtual Address) | 虚拟地址,进程里看到的完整地址,如 0x00401000 | 调试器里显示的地址 |
| RVA(Relative Virtual Address) | 相对虚拟地址 = VA − 镜像基址 | PE 头里存的、文件偏移换算用 |
| 文件偏移(File Offset) | 这个字节在磁盘文件里的位置 | 十六进制编辑器里用的 |
换算关系:
1 | RVA = VA - ImageBase |
为什么重要? 因为你在调试器里看到一个地址 0x00401234,想去十六进制编辑器里改那个字节,你得先换算成文件偏移。算错一位,改出来的文件就跑不起来了。这也是为什么 PE 编辑工具(CFF Explorer、PE-bear、StudyPE+)存在——它们帮你做这个换算。
ASLR 会把这件事搞复杂:开了地址随机化之后,每次运行 ImageBase 都不同,所以调试器里看到的绝对地址会变。但 RVA 不变,所以逆向时要习惯看 RVA 或"模块名+偏移",而不是绝对地址。
三、汇编:入门只需要认识这些
不要一上来啃《Intel 汇编语言程序设计》八百页。入门阶段你需要的是一张能随时查的表,加上反复在调试器里验证。
3.1 寄存器
x86 的寄存器是 x64 的子集,名字有历史包袱:
| 64 位 | 32 位 | 16 位 | 8 位(低) | 传统用途 |
|---|---|---|---|---|
| rax | eax | ax | al | 累加器,函数返回值 |
| rbx | ebx | bx | bl | 基址,常被用来存指针 |
| rcx | ecx | cx | cl | 计数器,x64 下是第 4 个参数 |
| rdx | edx | dx | dl | 数据,x64 下是第 3 个参数 |
| rsi | esi | si | sil | 源索引 |
| rdi | edi | di | dil | 目的索引,x64 下是第 1 个参数 |
| rbp | ebp | bp | bpl | 栈帧基址(逆向时最常盯的) |
| rsp | esp | sp | spl | 栈顶指针 |
| r8-r15 | r8d-r15d | r8w | r8b | x64 新增,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 == 5ZF=0说明eax != 5
3.3 指令速查
数据传送
1 | mov eax, ebx ; eax = ebx |
mov 和 lea 的区别是新手第一个坎:mov 取值,lea 取地址。而且编译器特别爱用 lea 做算术:
1 | lea eax, [eax+eax*2] ; eax = eax * 3 |
看到 lea 别以为是"加载地址",八成是在算乘法。
算术与逻辑
1 | add eax, 5 ; eax += 5 |
xor eax, eax 是编译器优化出来的"清零"惯用法,比 mov eax, 0 短一个字节。看到它不要想歪。
比较与跳转
1 | cmp eax, ebx ; 比较(做减法,只改标志) |
这里有个高频陷阱:jg/jl 是有符号比较,ja/jb 是无符号比较。同样是 cmp eax, 5,如果 eax = 0xFFFFFFFF(有符号是 -1,无符号是 4294967295):
jl会跳(-1 < 5)jb不会跳(4294967295 > 5)
编译器根据你的 C 代码里变量是 int 还是 unsigned 自动选。看不懂为什么跳了/没跳,八成是符号性搞错了。
调用与返回
1 | call func ; 压入返回地址,跳到 func |
其他常用
1 | nop ; 什么都不做(打补丁时用来"填充") |
四、函数调用:栈帧长什么样
这是必须搞懂的一章。看懂栈帧,你就能看懂 80% 的汇编。
4.1 栈的生长方向
x86/x64 的栈从高地址向低地址生长。push 让 rsp 减小,pop 让 rsp 增大。这一点非常反直觉,但记住就好:
1 | 高地址 |
4.2 标准栈帧
一个典型函数(帧指针 ebp 参与)的完整生命周期:
1 | ; ===== 调用者 ===== |
画成图(32 位,栈从高到低):
1 | 高地址 |
方括号里的偏移是逆向时最值钱的信息:
[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 fastcall | rcx, rdx, r8, r9,其余压栈 | 调用者 | Windows x64 默认 |
x64 下还有个坑:前 4 个参数用寄存器,第 5 个开始才压栈,而且调用者要在栈上预留 32 字节的"影子空间"(shadow space)。所以你会看到 sub rsp, 0x28 这种看起来莫名其妙的数字(0x20 影子空间 + 8 对齐)。
五、工具链
5.1 静态分析器(看代码,不运行)
| 工具 | 平台 | 价格 | 反编译 | 点评 |
|---|---|---|---|---|
| IDA Pro | Win/Mac/Linux | 商业,很贵 | Hex-Rays(业界最强) | 事实标准,插件生态无敌 |
| Ghidra | 全平台 | 免费开源(NSA) | 有,质量不错 | 入门首选,功能足够 |
| Binary Ninja | 全平台 | 有免费版 | 有 | 现代 UI,API 好用 |
| Cutter / Rizin | 全平台 | 免费开源 | 内置 Ghidra 反编译 | 界面友好 |
| Hopper | Mac/Linux | 便宜 | 有 | Mac 上的性价比之选 |
入门建议用 Ghidra 或 Cutter。IDA 免费版(IDA Free)功能受限且只有 x86/x64,但作为过渡也够。
5.2 动态调试器(让程序跑起来,边跑边看)
| 工具 | 平台 | 点评 |
|---|---|---|
| x64dbg | Windows | Windows 入门第一选择,开源、活跃、界面现代 |
| OllyDbg | Windows | 老牌,只支持 32 位,插件多,教程多 |
| WinDbg | Windows | 微软官方,内核/驱动调试必备,学习曲线陡 |
| GDB + pwndbg / peda | Linux | Linux 标准,配插件后体验接近 x64dbg |
| LLDB | Mac/Linux | Clang 生态的标准调试器 |
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 / readelf | Linux 下的静态分析(反汇编、节表) |
| ltrace / strace | Linux 下跟踪库调用/系统调用 |
我的实际工作流通常是:DIE 查壳 → strings 抓字符串 → Ghidra 静态定位 → x64dbg 动态验证 → 打补丁/写结论。
六、静态 vs 动态:两条腿走路
这是方法论层面最重要的区分。
| 静态分析 | 动态分析 | |
|---|---|---|
| 做法 | 反汇编/反编译,读代码 | 运行程序,下断点,看寄存器 |
| 优点 | 能看到全貌、能看到不执行的分支、不担心中反调试 | 能看到真实运行状态、参数实际值、解密后的数据 |
| 缺点 | 看不到运行时数据(解密的字符串、动态生成的代码) | 只见树木不见森林,容易迷路,可能触发反调试 |
| 适合 | 理解整体结构、找关键函数 | 验证假设、破解校验、脱壳 |
正确的姿势是交替使用:
1 | 静态:在 strings 里发现 "Wrong password" |
每转一圈,你对程序的理解就深一层。这本质上是"提出假设 → 实验验证"的科学方法,只不过实验对象是二进制。
七、实战:写一个 CrackMe 然后拆了它
CrackMe 是专门用来练习逆向的小程序,功能就是"输入一个序列号,判断对错"。下面这个是我们自己写的,源码完全公开,这样你能对照着看——入门阶段一定要有源码对照,否则你不知道自己猜得对不对。
7.1 源码
1 | #include <stdio.h> |
编译(关键:关掉优化,保留清晰的栈帧):
1 | gcc crackme.c -o crackme.exe -O0 |
7.2 三条找关键点的路
① 字符串回溯法(最实用,90% 的情况用它)
程序里的提示语是写死在二进制里的。你在 Ghidra 里搜 "Correct!",找到它所在的地址,然后看谁引用了它:
1 | Ghidra → Search → For Strings → "Correct" |
② API 断点法(对付有交互的程序特别有效)
printf/MessageBoxA/GetWindowTextA 这些是"路标"。在调试器里给它们下断点,程序一停,往回翻栈就能找到调用者:
1 | x64dbg → 命令栏输入 bp printf |
③ 单步跟踪法(慢,但最锻炼人)
从程序入口开始一路 F8(单步跳过),遇到 call 就看一眼,遇到 cmp + jcc 就停下来推标志位。适合小样本,或者你完全没头绪的时候。
7.3 用 x64dbg 定位关键跳转
在 x64dbg 里,check 函数编译出来大致是:
1 | check: |
而 main 里调用它的地方:
1 | call check |
test eax, eax + je 这一对,就是整个程序的"生死线"。90% 的 CrackMe 都能在这一对上做文章。
7.4 四种改法(从暴力到优雅)
方法一:NOP 掉跳转
把 je short .wrong 改成两条 nop(90 90)。这样不管 eax 是什么,都会往下走到 Correct!。
1 | 改前: 74 0A (je +0x0A) |
方法二:改成无条件跳转
把 je 改成 jmp(EB),跳转逻辑直接失效。
1 | 改前: 74 0A |
方法三:反转条件
把 je(74)改成 jne(75)。这样输入错误的时候才走 Correct! 分支——是"反向 CrackMe"。
方法四:直接改返回值(最优雅)
不动 main,改 check 函数本身:让它一进来就返回 1。
1 | check: |
对应的字节是 B8 01 00 00 00 C3。这种方法不依赖具体的跳转结构,对任何 CrackMe 都通用。
7.5 打补丁要落盘
在 x64dbg 里改的是内存,关掉调试器就没了。要永久生效:
1 | x64dbg → 选中修改过的指令 → 右键 → "复制到可执行文件" → 保存为新文件 |
或者用十六进制编辑器(HxD)直接改文件对应偏移的字节。这时候前面讲的 RVA → 文件偏移换算就派上用场了。
⚠️ 两个坑:
- 改了代码可能破坏 PE 的校验和,某些程序会拒绝运行
- 有数字签名的程序改完签名就失效了
7.6 反过来:不破解,直接算出序列号
前面做的都是"绕过校验"。但逆向的真正价值在于理解——既然我们已经从汇编里读出了三条约束:
1 | len == 8 |
那就直接解方程:需要 5 个字符,ASCII 和 500,平均每个 100。'd' 的 ASCII 正好是 100,所以:
1 | r e v d d d d d |
合法序列就是 revddddd。
这时候你会发现:不需要改任何一个字节,程序自己就认了。 这才是逆向的成就感——你搞懂了它的规则,然后用它的规则跟它对话。
(这也是为什么"逆向"和"破解"不能划等号:前者让你能算出一个合法输入,后者只是让程序闭嘴。)
八、八个立刻能用的实战技巧
- 字符串永远是第一入口。 大多数程序把提示语、错误信息、URL、注册表路径写死在二进制里。一条
strings命令的价值超过半小时的乱翻。 - API 是路标。
MessageBox、GetWindowText、RegOpenKey、CreateFile、connect—— 断在这些函数上,你就站在了"程序与外界交互"的现场。 cmp+jcc是决策点。 任何业务逻辑,最后都收敛成"比较 + 跳转"。找到那对指令,你就找到了开关。test eax, eax就是cmp eax, 0。 这是编译器判断"返回值是否为零"的惯用法,出现频率极高。xor eax, eax是清零,别当成异或运算。lea经常在做乘法。lea eax, [eax+eax*2]=eax * 3。看到奇怪的lea先想想是不是在算地址步长。switch语句会变成跳转表。 看到jmp [eax*4 + 0x401234]或者jmp qword ptr [rax*8 + ...],说明这里有个 switch 或多态调用,表里每一项是一个 case 的地址。- 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,看看改成别的值之后序列号该怎么算。