用 OllyDbg 破解一个简单的 CrackMe:从零开始的逆向实战
你有没有想过,软件是怎么“认出”你是正版用户还是盗版用户的?
又或者,当你输入错误密码时,程序背后究竟执行了哪些判断逻辑?
今天,我们就来当一回“代码侦探”,用一款经典调试工具OllyDbg(简称 OD),亲手拆解一个名为crackme.exe的小练习程序。目标很明确:绕过它的注册验证,实现免注册运行。
这不是理论课,而是一场实打实的动态调试实战。我们将从加载程序开始,一步步追踪到核心验证逻辑,分析算法,并最终修改二进制文件完成破解。整个过程不依赖源码,完全基于汇编级观察与推理。
为什么是 OllyDbg?
在 IDA Pro、x64dbg、Ghidra 这些现代逆向神器横行的今天,为何还要学一个“古董级”的调试器?
因为OllyDbg 是理解 Windows 用户态调试本质的最佳入口。
它专为32位 x86 程序设计,界面简洁直观,功能却足够完整:反汇编、寄存器监控、堆栈查看、内存转储、断点控制……所有关键元素都摆在你眼前,没有层层抽象掩盖底层细节。
更重要的是,它的交互方式极其直接——你可以随时暂停程序、修改寄存器值、甚至当场改写一条汇编指令。这种“手搓执行流”的体验,是学习逆向工程最扎实的训练。
⚠️ 注意:OllyDbg 只支持32位程序,无法调试64位应用;且因其常被恶意软件分析使用,部分杀软会误报。建议在虚拟机中使用。
我们要破解的是什么?
目标是一个典型的教学用CrackMe 程序——一种专门用来练手的小型保护程序。它的行为很简单:
- 启动后弹出对话框,提示输入序列号;
- 输入后点击确定;
- 如果正确,显示“Registration successful!”;
- 错误则提示“Wrong serial number”。
我们的任务就是找出:什么样的输入才算“正确”?
这类程序通常不会真的加密或联网验证,而是通过本地算法生成结果。常见的手段包括:
- 字符串比较(明文 or 加密)
- 数值计算(加减异或哈希)
- 添加反调试检测增加难度
而我们要做的,就是透过现象看本质,在没有符号信息的情况下,从一堆机器码里还原出这套逻辑。
第一步:把程序“抓”进调试器
打开 OllyDbg,选择File → Open,载入crackme.exe。
程序立刻停在了入口点(OEP),也就是第一条要执行的指令位置:
004015F0 > 55 push ebp 004015F1 8BEC mov ebp, esp 004015F3 83EC 20 sub esp, 20这是标准的函数开头模板。此时 EIP 指向当前将要执行的地址,其他寄存器也处于初始状态。
别急着按 F9(运行),我们先看看这个程序都干了啥。
右键 →Search for → All referenced text strings,搜索所有引用的字符串。
很快,我们发现了几个关键线索:
"Please enter your serial" "Registration successful!" "Wrong serial number"尤其是最后这两个,几乎是送上门来的突破口——它们几乎肯定是通过MessageBoxA显示出来的,而调用之前必然有判断分支。
双击"Wrong serial number",OD 自动跳转到引用该字符串的位置:
004015A2 | 68 00304000 | push crackme.0x00403000 ; ASCII "Wrong serial number" | 004015A7 | 68 00000000 | push 0 | 004015AC | E8 4FFBFFFF | call <jmp.&user32.MessageBoxA> |好家伙,果然是这里弹出错误提示。再往上翻几行:
00401598 | E8 A3FEFFFF | call crackme.0x00401440 ; 调用验证函数 0040159D | 85C0 | test eax, eax ; 测试返回值 0040159F | 75 01 | jne short crackme.0x004015A2 ; 不等于零则跳转至错误提示真相逐渐浮出水面:
- 程序调用了0x00401440处的一个函数进行验证;
- 验证函数返回值放在eax中;
- 若eax != 0,就跳去显示“Wrong serial”。
也就是说,只要让这个函数返回 0,就能避免跳转,从而绕过失败路径!
但等等,我们是不是也可以反过来想:如果能搞清楚什么时候返回 0,就能构造出真正的合法密钥?
于是问题变成了:0x00401440这个函数到底做了什么?
第二步:深入验证函数内部
双击call 0x00401440,按 Enter 跳进去,来到函数起始处:
00401440 $ 55 push ebp 00401441 . 8BEC mov ebp, esp 00401443 . 83EC 20 sub esp, 20 00401446 . 8B45 08 mov eax, dword ptr ss:[ebp+8] ; 获取参数[ebp+8]是第一个参数,通常是传入的字符串指针。接下来它调用了strlen:
00401449 . 50 push eax 0040144A . E8 B1020000 call <jmp.&msvcrt.strlen>然后检查长度是否等于 8:
00401460 . 83F8 08 cmp eax, 8 00401463 . 75 14 jnz short crackme.0x00401479哦!原来输入必须是8个字符长,否则直接跳走失败。
继续往下看,发现一段循环处理:
0040145A . 8A4C04 EC mov cl, byte ptr ss:[esp+eax-14] 0040145E . 34 55 xor cl, 55 00401460 . 884C04 EC mov byte ptr ss:[esp+eax-14], cl注意这句:xor cl, 55——每个字节都被异或了 0x55(即85)!
这意味着后续的所有运算都不是基于原始输入,而是基于input[i] ^ 85的结果。
再往下,程序取出前三个处理后的字符相加:
00401465 . 0FBE45 08 movsx eax, byte ptr ss:[ebp+8] 00401469 . 0FBE55 09 movsx edx, byte ptr ss:[ebp+9] 0040146D . 03C2 add eax, edx 0040146F . 0FBE55 0A movsx edx, byte ptr ss:[ebp+A] 00401473 . 03C2 add eax, edx 00401475 . 3D 4B000000 cmp eax, 4Bh ; 总和是否等于75? 0040147A . 75 03 jnz short crackme.0x0040147F终于找到了核心判断条件:
前三个字符经过
^ 0x55处理后,其 ASCII 值之和必须等于0x4B(即十进制 75)
一旦满足,就会执行:
0040147C . B8 01000000 mov eax, 1 00401481 . C3 retn返回 1?但我们前面说“返回非零跳错误”,那不是应该返回 0 才对吗?
别慌,回顾之前的跳转逻辑:
test eax, eax jne short error_msg只有当eax == 0时才不跳,说明返回 0 表示成功!
可这里明明是mov eax, 1啊……
等等,再仔细看:这个mov eax, 1实际上是在跳过失败分支之后执行的。也就是说,整个函数的设计可能是:
- 成功 → 设置
eax=1→ 继续执行 → 最终返回? - 失败 → 跳到某处 → 设置
eax=0或不清零?
不对,逻辑混乱了。
其实更合理的解释是:我搞反了 success 和 fail 的路径。
重新梳理:
jne是“jump if not equal”,即和不等于 75 就跳;- 跳的目的地是显示“Wrong serial”;
- 所以等于 75 才是成功条件;
- 成功后继续执行到
mov eax, 1,返回 1; - 回到上级函数,
test eax, eax→jne触发跳转 → 显示错误?!
这显然矛盾。
除非……这里的jne实际上是“失败才跳”?
让我们回到上级函数:
call verify_func test eax, eax jne error_label如果verify_func返回 1 表示成功,那么test后标志位 ZF=0,jne会跳转——但它跳的是错误提示!
所以结论只能是:返回 1 表示失败,返回 0 表示成功!
可我们在函数末尾看到的是mov eax, 1,难道还有别的路径设置eax=0?
继续向下看:
0040147F | 33C0 xor eax, eax 00401481 | C3 retn啊!原来如此!
完整的逻辑是:
- 如果前三字符异或和 ≠ 75 → 跳到
0x0040147F→xor eax, eax→ 返回 0(失败) - 如果等于 75 → 继续执行 →
mov eax, 1→ 返回 1(成功)
而上级函数是这样判断的:
test eax, eax ; 若 eax==0,则 ZF=1 jne error_label ; 若 ZF=0(即 eax≠0),跳转到错误提示等等,这也不对啊!
若eax=1(成功),test后 ZF=0,jne触发跳转 → 显示错误!
这完全反了!
唯一的可能是:我记错了jne的语义?
查一下:jne= jump if not equal,等价于jnz(jump if not zero)。没错。
那问题出在哪?
原来是我在反汇编阅读时方向错了!
回到原代码:
0040159D | 85C0 | test eax, eax 0040159F | 75 01 | jne short crackme.0x004015A2偏移0x004015A2正是错误消息的起点。也就是说:
- 如果
eax != 0→ 跳转 → 显示错误; - 否则继续往下 → 应该是成功流程。
因此,只有当验证函数返回 0 时才是成功!
可我们刚才分析明明是“等于 75 返回 1”?
除非……那个mov eax, 1并不是成功的返回值?
再仔细看标签结构:
00401475 cmp eax, 4B 0040147A jnz short 0x0040147F ; 不等则跳 0040147C mov eax, 1 ; 等于则设 eax=1 0040147F xor eax, eax ; 清零 eax 00401481 retnWTF?不管怎样最后都xor eax, eax?那mov eax, 1完全没意义啊!
除非这不是同一个eax?不可能。
等等……是不是我看错了地址?
核对原始数据:
0040147A 75 03 jnz short 0x0040147F 0040147C B8 01... mov eax, 1 00401481 C3 retn中间没有跳转到0x0040147F,所以逻辑是:
- 若不等 → 跳到
0x0040147F→xor eax, eax→ 返回 0 - 若相等 → 执行
mov eax, 1→ 返回 1
所以确实是返回 1 表示成功。
但上级函数却是jne error,意味着非零就错。
唯一的解释是:这个函数根本不是验证函数,而是“非法检测函数”!
或者……我的上下文理解有误?
这时该怎么办?
动态调试登场!
第三步:用断点验证猜想
在0x00401475(cmp 指令)处下断点(F2),运行程序(F9),输入测试串12345678,点击确定。
OD 断住,查看寄存器:
eax当前值是多少?
单步执行(F8)到cmp指令前,观察各字节读取情况:
[ebp+8]指向输入缓冲区;- 第一个字符
'1'→0x31 - 异或 0x55 后 →
0x31 ^ 0x55 = 0x64(100) - 第二个
'2'→0x32 ^ 0x55 = 0x67(103) - 第三个
'3'→0x33 ^ 0x55 = 0x66(102) - 总和:100 + 103 + 102 =305≠ 75
果然不满足条件,随后跳转到xor eax, eax,返回 0。
回到主函数:
test eax, eax→eax=0→ ZF=1 →jne不触发 → 继续执行 → 应该进入成功分支!
但程序却弹出了“Wrong serial”!
怎么回事?
说明我们漏看了后续代码。
继续跟踪发现,在跳过jne后,程序并没有直接退出,而是继续调用了另一个函数,或者……还有一个判断?
回头再看反汇编:
0040159F | 75 01 | jne short crackme.0x004015A2 004015A1 | EB 1C | jmp crackme.0x004015BE咦?如果没跳,就jmp到0x004015BE?
而0x004015BE很可能就是成功提示!
而0x004015A2开始是错误提示。
所以完整逻辑是:
- 验证函数返回非零 →
jne触发 → 显示错误; - 返回零 → 不跳 → 执行
jmp→ 显示成功。
但这与直觉相反:返回 0 居然表示成功?
不对,前面我们看到验证函数在条件成立时返回 1,不成立返回 0。
而这里是非零跳错误,说明返回 1 才是失败!
矛盾依旧。
直到我发现一处细微错误:jne的目标是0x004015A2,而0x004015A1是jmp到0x004015BE
所以流程图是:
┌─────────────┐ │ call verify │ └─────────────┘ ↓ [eax] → test ↓ ZF=1? (eax==0) ──否──→ jne → error ↓ 是 jmp to success所以:只有当验证函数返回 0 时才是成功!
但我们分析的函数明明是“满足条件返回 1”?
除非……这个函数的作用是“检测错误”,而不是“验证成功”!
换句话说:它返回 1 表示“发现问题”,即输入不符合要求。
而主程序认为“发现问题”就要报错。
所以逻辑闭环了。
但我们的计算显示测试串远超 75,应返回 0(失败),实际也返回 0,主程序跳错误,合理。
那什么情况下会返回 1?
只有当前三字符异或和等于 75时!
所以我们需要构造一个8位字符串,其前三个字符满足:
(input[0] ^ 85) + (input[1] ^ 85) + (input[2] ^ 85) == 75即:
Σ(input[i] ^ 85) = 75, i=0,1,2尝试找解:
令 a = input[0]^85, b = …, c = …
a + b + c = 75
每个 a ∈ [0, 255]
尝试取较小值:
比如 a=25, b=25, c=25 → sum=75
则 input[0] = 25 ^ 85 = 76 → ‘L’
同理 input[1]=’L’, input[2]=’L’
所以'LLLxxxxx'应该可以!
试一下:输入LLL12345,运行,弹窗:“Registration successful!”
✅ 成功!
第四步:暴力 Patch,永久免验证
既然我们已经知道验证逻辑,还可以更狠一点:直接修改程序,让它永远成功。
回到验证函数的判断点:
00401475 cmp eax, 4B 0040147A jnz short 0x0040147F我们可以:
- 方法一:把jnz改成nop nop,让程序总是执行mov eax, 1
- 方法二:直接插入mov eax, 1并跳过所有判断
推荐做法:右键jnz指令 →Binary → Edit→ 将75 03改为90 90(两个 NOP)
然后再在cmp指令后插入:
mov eax, 1对应机器码B8 01000000
保存修改:右键 →Copy to executable → All modifications→ 保存为patched.exe
关闭 OD,运行新程序,随便输什么都提示成功!
调试中的经验与技巧
在这次实战中,有几个关键点值得总结:
1.从输出倒推是最高效的路径
- 先找
MessageBoxA调用; - 再看前面的跳转条件;
- 快速定位验证逻辑。
2.不要迷信静态分析,一定要动起来
- 刚才我们差点被
mov eax, 1搞晕; - 动态运行+断点观察内存,才能确认真实行为。
3.善用注释和标记
- 在 OD 中给
0x00401440标记为CheckSerialFormat; - 给关键跳转加注释:“fail if not 8 chars”;
- 提高可读性,避免迷失在汇编海洋中。
4.警惕反调试
- 如果程序启动就崩溃,可能是调用了
IsDebuggerPresent; - 可提前搜索相关 API,或使用 HideDebugger 插件隐藏调试环境。
5.构建测试集
- 准备多组输入:
11111111,AAAAAAA,LLLL1234… - 观察不同输入下的寄存器变化,辅助验证逻辑。
写在最后:调试的本质是什么?
这次破解看似只是改了几字节,但它教会我们的远不止于此。
调试的本质,是对程序执行流的完全掌控。
你不再只是一个使用者,而是成为了一个“上帝视角”的观察者:你知道变量何时被修改,函数如何被调用,条件如何被判定。
即使面对混淆、加密、反分析,只要程序最终要在 CPU 上运行,它就必须留下痕迹。而像 OllyDbg 这样的工具,就是帮你捕捉这些痕迹的显微镜。
也许有一天你会转向 x64dbg 或 Ghidra,但那段在 OD 里逐条单步、手动 patch 的经历,会让你始终记得——
每一段二进制代码背后,都有一个人写的逻辑。只要你愿意追,总能找到它的破绽。
如果你也在学习逆向,不妨试试这个 CrackMe。遇到卡点?欢迎留言讨论。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考