x32dbg 调试与 VM 区域跳过分析文档
适用范围:
- 使用中文界面的
x32dbg
- 面向基础较弱的分析者
本文针对当前样本 CrackMe.zp.exe 编写。
样本特征:
- 32 位 MFC 程序
- 外层有 ZProtect 壳/VM 保护
- 程序在同一进程里第二次点“注册”容易崩溃
1. 先讲结论
这个样本的调试思路不是“硬啃整个 VM 里每一条指令”,而是:
- 先找到按钮回调函数
- 再找到真正的判断函数
- 识别判断函数中间进了 VM
- 跳过 VM,去 VM 出口断下
- 在 VM 出口处直接读出“正确注册码”
- 多试几个用户名,反推出算法
最终还原出的算法是:
注册码 = 用户名所有字符 ASCII 值之和,再转成十六进制字符串(大写)
例如:
a -> 61
ab -> C3
abc -> 126
admin -> 209
OpenAI -> 21C
2. 关键地址总览
本文会用到 3 个关键地址:
00402630:注册按钮回调函数
00402490:真正的判断函数
00402587:VM 结束后,恢复到正常 x86 代码的位置
说明:
- 这些地址不是预设答案,而是通过消息映射、控制流和 VM 结束标记逐步定位得到
- 下面先给出结果,再说明各自的来源和验证方法
2.1 每个关键地址到底是怎么分析出来的
这一节专门回答一个问题:
本节说明这些地址的分析来源、定位方法和验证方式。
下面按发现顺序来讲。
2.1.1 第一个关键数据地址:0059A570
这个地址不是代码,而是 MFC 消息映射表项。
为什么先找它?
因为我们最开始只知道:
- 主界面有个按钮叫
注册(&R)
- 这个按钮的控件 ID 是
1000
而在 MFC 里,按钮点击通常不是直接写死在窗口过程里,而是通过一张消息映射表转到真正的处理函数。
这条表项是怎么长的
MFC 的 AFX_MSGMAP_ENTRY 结构可以粗略理解成:
struct AFX_MSGMAP_ENTRY {
UINT nMessage;
UINT nCode;
UINT nID;
UINT nLastID;
UINT nSig;
void* pfn;
};
对于“按钮被点击”这件事,关键值是:
WM_COMMAND = 0x111
BN_CLICKED = 0
ID = 1000 = 0x3E8
也就是说,这条表项的前 4 个 DWORD 应该长成:
00000111
00000000
000003E8
000003E8
换成 32 位小端序的字节就是:
11 01 00 00 00 00 00 00 E8 03 00 00 E8 03 00 00
在 x32dbg 里怎么看
可行的定位方式包括:
- 先知道按钮 ID =
1000
- 知道
1000 = 0x3E8
- 在转储窗口里找到满足
111 / 0 / 3E8 / 3E8 的表项
本题中,真实表项位于:
0059A570
在 内存 1 中可见如下数据:
0059A570 11 01 00 00
0059A574 00 00 00 00
0059A578 E8 03 00 00
0059A57C E8 03 00 00
0059A580 3A 00 00 00
0059A584 30 26 40 00
把它按 4 字节解释成 DWORD,就是:
0059A570 00000111
0059A574 00000000
0059A578 000003E8
0059A57C 000003E8
0059A580 0000003A
0059A584 00402630
其中最后一个 DWORD:
30 26 40 00
因为是小端序,所以要还原成:
00402630
这就是为什么说:
0059A570 是“注册按钮”的消息映射表项地址
00402630 是按钮点击后的真正回调函数地址
2.1.2 第一个关键代码地址:00402630
这个地址是从 0059A570 这条消息映射表项里读出来的,不是猜出来的。
为什么它可以确定是按钮回调
因为那条表项等价于:
ON_BN_CLICKED(1000, 某个成员函数)
而最后的函数指针就是:
00402630
怎么验证它真是按钮回调
不是“读到了地址就算完”,还要做行为验证。
验证方法:
- 在
00402630 下“硬件执行断点”
- 回程序点一次
注册(&R)
- 如果立刻断回
x32dbg,且当前地址是 00402630
那就说明:
所以:
0059A570 给出“按钮 -> 函数”的静态映射
- 在
00402630 断下来则给出“按钮点击 -> 真会执行这里”的动态验证
2.1.3 第二个关键代码地址:00402490
00402490 是怎么推出来的?
不是靠猜,而是从 00402630 往下看“控制流程”推出来的。
在 00402630 里要找什么模式
你不需要看懂每条汇编,只要找这种结构:
call 某地址
test eax,eax
je / jne 某处分支
本题里能看到:
00402757 call 00402490
0040275C test eax,eax
00402761 je 0040276F
这段的直观含义是:
- 先调用
00402490
- 然后看返回值
EAX
- 如果
EAX == 0,跳到失败分支
为什么这说明 00402490 是判断函数
因为:
call 后立刻 test eax,eax
- 再立刻
je/jne
- 后面分支继续通向“成功/失败提示”
这是一种非常典型的“判断函数返回真假”的结构。
第二次验证:补丁验证
本题里还可以做一个更强的验证:
把 00402490 直接改成:
mov eax,1
ret 8
机器码:
B8 01 00 00 00 C2 08 00
改完后程序会直接弹出:
恭喜!注册码正确!
这进一步证明:
00402490 的返回值直接决定成功/失败
- 它确实就是注册判断的核心函数
2.1.4 远地址:00760B4E
这个地址不是预设的关键业务函数,而是在 00402490 中少量 F8 后自然观察到的“远跳现象点”。
现象
在 00402490 附近少量按几次 F8,会看到程序突然跳到:
00760B4E
这说明什么
说明:
00402490 中间不是普通代码
- 它会跳进一个很远的“黑盒区域”
对本题而言,这个黑盒就是 VM 区域。
为什么这个地址本身不值得硬啃
原因如下:
- 这里的代码看起来不像正常业务逻辑
- 大量
F7/F8 很容易迷路
所以我们不是把 00760B4E 当“答案点”,而是把它当:
“已经确认进黑盒”的现象点
一旦看到这里,就应该转为寻找:
2.1.5 黑盒结束标记附近:00402570 / VM_VMEND
当已经知道 00402490 会进黑盒之后,下一步不是继续死啃 00760B4E,而是去找:
黑盒从哪里结束,程序从哪里回到正常代码
怎么找结束标记
回到 00402490 附近,把这段区域“在转储中跟随”,然后看转储窗口右边 ASCII。
你会在 00402570 这一带看到可读标记,例如:
ST_START
VM_START
VM_VMEND
其中最关键的是:
VM_VMEND
这表示:
注意:
00402570 只是我们手动查看结束标记附近的方便地址,不是说“程序入口就在这里”。
2.1.6 第三个关键代码地址:00402587
这个地址的来源是:
结束标记 VM_VMEND 后的第一条正常 x86 指令
为什么会是 00402587
VM_VMEND 一共 8 个字符。
如果它从:
0040257F
开始,那么它会占用:
0040257F
00402580
00402581
00402582
00402583
00402584
00402585
00402586
那它后面的下一个地址自然就是:
00402587
为什么 00402587 值得下断点
因为你跳到 00402587 后,看到的不是乱七八糟的 VM 指令,而是重新恢复成正常逻辑代码:
00402587 mov esi,[ebp-14]
0040258A test esi,esi
00402598 push esi
00402599 push [ebp+C]
0040259C call 005434E1
这段一看就像正常业务逻辑:
- 先取一个局部变量
- 再判断它
- 再拿去和用户输入做比较
它为什么是最好的“答案点”
因为:
- VM 已经算完结果
- 正确注册码已经放到了
[ebp-14]
00402587 执行后,ESI 会指向这个“正确注册码”
- 后面马上就会用它去和用户输入比较
所以:
00402490 是“判断入口”
00760B4E 是“进黑盒现象”
VM_VMEND 是“结束标记”
00402587 是“黑盒出口后的答案点”
2.1.7 比较函数地址:005434E1
这个地址不是必须先知道,但它也能帮助你理解 ESI 为什么就是正确注册码。
在 00402587 后面能看到:
00402598 push esi
00402599 push [ebp+C]
0040259C call 005434E1
这说明:
ESI 里的字符串
- 和
[ebp+C] 里的用户输入注册码
- 会一起送进
005434E1 做比较
通过对 005434E1 的行为观察,可以确认它是一个字符串比较函数,并且大小写不敏感。
所以:
程序算出来的那边 = ESI
不是猜,是由“后续比较行为”反向证明的。
2.1.8 分支判断地址:00402761
这个地址也不是最先要记的,但它能帮助你完整理解整个链条。
在 00402630 往下的流程里,可以看到:
00402757 call 00402490
0040275C test eax,eax
00402761 je 0040276F
所以:
00402761 是“根据判断函数返回值决定成功/失败”的分支点
这说明:
00402490 的返回值决定注册真假
00402761 是这个真假结果真正落到分支逻辑的地方
它不是我们读答案的最佳位置,但它是“逻辑闭环”的关键位置。
2.1.9 总结:这些地址是怎样一步步串起来的
把整个发现顺序串起来,就是:
- 已知按钮 ID =
1000
- 在消息映射表里找到表项地址
0059A570
- 从表项最后一个 DWORD 读出回调函数
00402630
- 在
00402630 里看到 call 00402490 -> test eax,eax -> je
- 推出
00402490 是真正判断函数
- 在
00402490 里按几次 F8,看到跳到 00760B4E
- 确认中间进了 VM/黑盒
- 回到
00402490 附近的原始字节,找到 VM_VMEND
- 取
VM_VMEND 后第一条正常指令,得到 00402587
- 在
00402587 处看到 mov esi,[ebp-14]
- 在
00402598 往后看到 push esi / push [ebp+C] / call 005434E1
- 确认
ESI 里就是程序算出来的正确注册码
这才是完整的“地址来源链条”。
3. 一定要先知道的 3 个快捷键
在 x32dbg 里,先记住这 3 个键:
对这个题来说:
- 尽量少用
F7
- 观察结构时少量用
F8
- 大多数时候优先用
F9
原因:
- 样本有 VM
- 中途会跳到很远的地址
- 在 VM 内部大量
F7/F8 会非常容易迷路
4. 中文 x32dbg 界面关键窗口
需要重点关注以下 4 个区域:
4.1 左上:CPU / 反汇编窗口
这里显示当前代码执行到哪一行。
主要信息包括:
- 地址,例如
00402630
- 指令,例如
call / mov / test
4.2 右上:寄存器窗口
这里显示:
EAX
EBX
ECX
EDX
EBP
ESP
ESI
EDI
EIP
后面我们会重点看:
EAX:函数返回值
ESI:VM 出口时会放正确注册码地址
EBP:配合局部变量和参数使用
4.3 右下:栈窗口
这里会显示:
4.4 最下方左侧:转储窗口
标签一般叫:
这里会显示:
- 左边:地址
- 中间:十六进制字节
- 右边:ASCII / 可见字符
这个窗口后面会用来:
- 看消息映射表
- 看
VM_VMEND 标记
- 跟随某个寄存器指向的字符串
5. 第一次调试时的总流程
首次分析建议按以下固定流程执行:
- 正常运行程序到主界面
- 用
x32dbg 附加进程
- 在
00402630 下硬件执行断点
- 点击“注册”
- 命中后,再在
00402490 和 00402587 下硬件执行断点
- 用
F9 跑到 00402490
- 用少量
F8 观察它“跳进很远地址”的现象
- 停止继续单步
- 在
00402587 下断点后,用 F9 等它从 VM 里出来
- 在
00402587 执行 1 次 F8
- 从
ESI 里读出正确注册码
6. 具体操作:从附加程序开始
6.1 先正常启动程序
不要一开始就“从入口开始调试”。
原因:
- 这个样本疑似有反调试检测
- 从入口跟更容易被早期壳层干扰
正确做法:
- 先双击运行
CrackMe.zp.exe
- 等主界面出来
主界面上应该能看到:
6.2 用 x32dbg 附加
- 打开
x32dbg
- 菜单:
- 选择
CrackMe.zp.exe
附加成功后,程序会暂停。
7. 先从按钮回调函数入手:00402630
7.1 跳到 00402630
- 在 CPU 窗口按
Ctrl+G
- 输入:
00402630
- 回车
7.2 下硬件执行断点
- 在
00402630 这一行右键
- 选择:
7.3 回到程序,点一次“注册”
测试时可先输入:
然后点击一次:
如果一切正常,程序会断回 x32dbg。
如果此时停在:
00402630
说明你已经确认:
8. 为什么一开始从 00402630 入手
原因:
- “我点击了一个按钮”
- “点按钮后程序应该进入某个函数”
00402630 就是这个“按钮行为落到代码里的入口”。
它不是最终答案点,但它是最容易接受的开始点。
9. 从 00402630 自己推出 00402490 是判断函数
这一段用于说明判断函数的定位过程。
9.1 在 CPU 窗口里往下看
你不用看懂每条指令,只需要找这种结构:
call 某地址
test eax,eax
je / jne 某处分支
9.2 你应该能看到类似
00402757 call 00402490
0040275C test eax,eax
00402761 je 0040276F
这个结构的含义是:
- 先调用
00402490
- 然后看
EAX
- 如果
EAX == 0,跳到失败分支
9.3 为什么这说明 00402490 是判断函数
因为:
call 后面立刻 test eax,eax
- 后面紧跟成功/失败分支
这在逆向里几乎就是“真假判断函数”的典型模式。
所以到这里你就可以自己推断出:
10. 现在去 00402490 下断点
10.1 跳到 00402490
Ctrl+G
- 输入:
00402490
- 回车
10.2 下硬件执行断点
- 右键这一行
- 选择:
10.3 运行过去
回到你当前断下的位置,按:
F9
程序会继续跑,直到停在 00402490。
11. 到了 00402490 后,不要硬啃
11.1 只做少量观察
在 00402490 附近,你可以少量按几次:
F8
不要按太多,2 到 4 次足够。
11.2 典型现象
你大概率会看到:
- 地址突然跳到了一个很远的位置
- 不再是连续的
0040xxxx
- 看起来不像正常业务代码
例如跳到:
00760B4E
11.3 这说明什么
这说明:
00402490 中间不是普通代码
- 它进了一个“黑盒区域”
- 对这个样本来说,就是 VM 区域
11.4 现在正确做法是什么
看到这种“跳到很远地址”的现象后:
- 立刻停止继续在那个远地址里
F7/F8
- 不要试图看懂里面每条指令
此处不适合继续深度单步。
12. 怎么从“跳进黑盒”推出“要找出口”
到这里你已经知道:
此时最自然的问题不是:
而是:
这就是为什么我们要去找:
13. 用转储窗口找到 VM 结束标记
13.1 回到判断函数附近
- 在 CPU 窗口按
Ctrl+G
- 输入:
004024C0
- 回车
这里只是把视图跳回判断函数附近,不是执行跳转。
13.2 用“在转储中跟随”
在 CPU 窗口里,选中 004024C0 到 00402520 这附近任意一行。
然后:
- 右键
- 找:
这样就会把下面的某个内存窗口,拉到 00402490 这段代码附近。
13.3 在转储窗口里看 ASCII 区
现在不要信 CPU 窗口的“乱反汇编”,而去看下面转储窗口右边的 ASCII。
你会在这片区域看到可读标记,例如:
ST_START
VM_START
VM_VMEND
最关键的是:
VM_VMEND
这说明:
14. 为什么后面会落到 00402587
看到:
VM_VMEND
后,说明这 8 个字符就是结束标记。
如果它从 0040257F 开始,那么它占用:
0040257F
00402580
00402581
00402582
00402583
00402584
00402585
00402586
那它后面的下一条地址就是:
00402587
这就是 VM 结束后恢复到正常代码的位置。
你不用死记这个地址,更重要的是学会这个方法:
15. 去 00402587 下断点
15.1 跳到 00402587
Ctrl+G
- 输入:
00402587
- 回车
15.2 下硬件执行断点
- 右键这一行
- 选择:
15.3 运行到它
然后按:
F9
让程序自己从 VM 里跑出来,撞到这个断点。
16. 断到 00402587 后怎么做
16.1 先确认当前地址
CPU 窗口高亮行应该是:
00402587
16.2 只按一次 F8
这一行通常是:
mov esi,[ebp-14]
按一次:
F8
执行完以后:
16.3 先不用命令,直接看寄存器
右边寄存器窗口里看:
可见类似:
ESI = 0014F508 L"31"
这说明:
ESI 指向一个 Unicode 字符串
- 字符串内容是
31
这里的 L 只是“宽字符串”的标记,不是内容的一部分。
也就是说:
L"31" 真实内容 = 31
17. 不用命令,怎么确认 ESI 就是正确注册码
你在 00402587 后面继续往下看,会看到类似:
00402598 push esi
00402599 push dword ptr ss:[ebp+C]
0040259C call 005434E1
翻译成人话就是:
- 把
ESI 压栈
- 把你输入的注册码压栈
- 调一个字符串比较函数去比
这说明:
ESI 里装的,就是程序算出来并准备用来比较的“正确注册码”。
所以寄存器窗口里显示的:
L"31"
就是当前这次对应的正确注册码。
18. 如果你想用命令栏读取
有些版本 du 输出不直观,但你也可以试:
du esi
du poi(ebp+8)
du poi(ebp+C)
它们分别是:
du esi:正确注册码
du poi(ebp+8):用户名
du poi(ebp+C):你输入的注册码
如果命令栏没有明显输出:
直接查看寄存器窗口中的字符串注释通常更直观。
19. 当前样本的一个重要坑
样本表现如下:
因此应采用以下约束:
- 每次调试里只点一次注册
- 拿到结果后,重新开程序再验证
不要在同一个进程里反复试很多组数据。
20. 拿到一个结果后,怎么还原算法
现在你已经能拿到一组:
接下来就是多取几组。
20.1 测试样本
每次都重新开程序,分别取这些用户名的结果:
a
ab
abc
abcd
admin
OpenAI
20.2 记录表
记录表可采用如下格式:
| 用户名 |
正确注册码 |
| a |
61 |
| ab |
C3 |
| abc |
126 |
| admin |
209 |
| OpenAI |
21C |
20.3 怎么推规律
看 abc -> 126 时,先把字符转成 ASCII:
a = 0x61 = 97
b = 0x62 = 98
c = 0x63 = 99
相加:
97 + 98 + 99 = 294
294 转十六进制:
126
正好对应注册码。
再看:
a
ab
admin
a d m i n
97 + 100 + 109 + 105 + 110 = 521
- 十六进制:
209
所以算法就确定了:
注册码 = 用户名所有字符 ASCII 和的十六进制字符串
21. 最终算法
21.1 文本版
注册码 = 把用户名每个字符的 ASCII 值累加,再转成十六进制
21.2 Python 版
def calc_serial(name: str) -> str:
return format(sum(ord(ch) for ch in name), "X")
21.3 验证例子
abc -> 126
admin -> 209
OpenAI -> 21C
22. 如果你只想直接过题
那你根本不需要啃 VM。
只要:
- 输入用户名
- 断到
00402587
- 看
ESI 注释里的字符串
- 重新启动程序
- 用同一个用户名 + 那个注册码注册
即可通过。
23. 如果你想“补丁强制成功”
这个样本里也可以直接把判断函数改成永远成功。
地址:
00402490
改成:
mov eax,1
ret 8
机器码:
B8 01 00 00 00 C2 08 00
这样任意用户名/注册码都会成功。
25. 本题调试最短闭环
最短闭环流程如下:
- 附加程序
- 在
00402630 下硬件执行断点
- 点一次“注册”
- 在
00402490 下硬件执行断点并 F9
- 看到跳到远地址后停止继续单步
- 在
00402587 下硬件执行断点并 F9
- 在
00402587 按一次 F8
- 看右侧
ESI 注释中的字符串
这个字符串,就是当前用户名对应的正确注册码。
26. 最后一遍总结
26.1 调试思路
00402630:按钮回调
00402490:判断函数
- 中间:VM 黑盒
00402587:VM 出口
ESI:正确注册码字符串地址
26.2 算法
注册码 = 用户名字符 ASCII 和的十六进制
26.3 重点摘要
- 从按钮回调跟到判断函数
- 识别“跳到很远地址”代表进入黑盒/VM
- 不在 VM 内部硬跟,直接在出口取值