前阵子趁着吾爱破解的解题领红包活动,我把 Windows 初级题完整走了一遍。这类题说是“初级”,其实正好卡在“刚接触逆向”和“能独立解题”的分界线上:它不考高深的壳和混淆,但要求你懂基本的 PE 结构、会看汇编、能操作调试器。我觉得它是最适合新手拿来练手的一档题,所以这篇文章把我从拿到压缩包、查壳、静态分析到动态调试、还原算法、算出 key 的全过程完整复盘一遍。工具可以换,但思路是通用的,照着走一遍基本就能拿下同类型的题目。
先交代一下这类题的典型面貌。Windows 初级题通常是一个很小的 Win32 程序或控制台程序,运行后让你输入一串 key 或答案,程序内部做一次校验,正确就弹窗提示成功,错误则提示失败。你要做的不是爆破,而是从程序里逆出正确的 key 字符串去提交。相比直接 patch 跳转,还原出 key 才是这类题的核心目的,这也是它和单纯改写逻辑的 crackme 不一样的地方。
1. 先拆解题思路:初级题的考点其实很固定
1.1 这类题的标准套路与考点
既然叫初级题,那它的考点就不会是 VMP、Themida 这种重型壳,也不会是离谱的虚拟机保护。我自己复盘了近几年的题目,发现出题人基本围绕几个固定方向出招:
- PE 文件基本结构:能识别程序是 32 位还是 64 位,知道入口点、节区、导入表这些概念。
- 加壳识别与脱壳:最常见的是 UPX 壳,出题人通常会稍微改动一下入口或标志,让你不能一条命令脱干净。
- 字符串与导入表定位:通过“错误提示”“成功提示”这类字符串,反向交叉引用找到校验函数。
- 关键 API 断点:GetDlgItemTextA、GetWindowTextA、lstrcmpA、MessageBoxA 这些是重点。
- 简单算法还原:字符串比较、逐字节 XOR、加减变换、查表替换,属于入门基本功。
这些点单独拿出来都不难,但组合到一起,对刚接触逆向的人来说就是一个完整的小项目。解题的大致流程也很固定,我一般压缩成五步:黑盒观察、查壳识别、静态定位校验函数、动态跟踪到比较点、还原算法拿 key。后面所有章节都是按这个顺序展开的。
1.2 准备一套顺手的工具链
选工具的原则是不追求最新最强,只要稳定、教程多、能覆盖 32 位和 64 位就行。我这次用的是下面这套组合,也是社区里最常见的:
| 用途 | 工具 | 说明 |
|---|---|---|
| 运行环境 | VMware 里的 Win7 x86 / Win10 x64 | 题目多数是 32 位,Win7 兼容性最好;Win10 x64 也能跑,注意杀毒误报 |
| 动态调试 | x64dbg | 32 位用 x32dbg、64 位用 x64dbg,还在活跃维护,插件生态好 |
| 静态分析 | IDA Free / Ghidra | 看反汇编和伪代码,IDA 的 F5 对新手很友好 |
| 查壳 | Detect It Easy(DIE) | 识别壳和编译器信息比 PEiD 更准 |
| 十六进制 | HxD / 010 Editor | 查看数据区、手动 patch 时用 |
| 行为观察 | Process Monitor | 备用,看程序是否读写文件、是否联网 |
不需要一上来就全装,但虚拟机和 x64dbg 是必须的。为什么强调虚拟机?因为在调试过程中要执行来自未知作者的二进制程序,而且脱壳、patch 后的文件大概率会被杀毒软件标记,在虚拟机里折腾不心疼。我给虚拟机开了快照,每次开始调试前恢复一次干净状态,这样即使把系统搞坏,五分钟就能回来。
1.3 开工前的环境准备与题目接收
活动题目一般以压缩包形式发布,通常带一层解压密码,密码写在活动说明里。别嫌麻烦,这个密码本身就是规则,目的是过滤不看说明的人。我拿到压缩包后的第一件事不是解压运行,而是先记下文件的 SHA-256,方便后面和别人交流时确认拿的是同一份文件。Windows 下用 certutil 直接算:
certutil -hashfile 题目.zip SHA256解压之后先看一眼目录里有什么,一般是一个 exe,可能附带一个说明 txt。把 exe 单独复制到工作目录,关闭杀毒软件实时防护或者加入排除项,然后双击运行。到这里才算正式开始解题。还有一个细节:运行过程全程保持在虚拟机里,不要放在常用电脑上跑,原因前面说过,安全第一。
2. 黑盒运行与静态侦察:先把入口找出来
2.1 先让它跑起来,观察反馈
双击运行后,我的做法是先当普通用户操作一遍。这次遇到的程序是个 Win32 对话框程序,界面上有一个输入框和一个确定按钮。我随便输入了 abcdef,点确定,弹出一个 MessageBox,标题是“提示”,内容是“答案错误,再接再厉”。这个反馈信息非常关键:它说明程序一定有完整的“读取输入、比较、弹出结果”链路。读取输入用的多半是 GetDlgItemTextA 或 GetWindowTextA,弹窗用的是 MessageBoxA,这两个 API 就是后面动态调试的天然断点位置。
如果程序是控制台程序,对应的就是 scanf / gets 和 printf / puts,思路完全一样。另外我建议顺手观察一下程序会不会联网、会不会释放文件。初级题基本不会做这些事,但看一眼 Procmon 日志会更安心,也免得后面调试时被意外行为带偏。
2.2 查壳与识别位数
用 DIE 打开 exe,先看两个信息:打包器和位数。这次的文件 DIE 识别出 UPX,右侧显示 32 位。看到 UPX 其实有点开心,因为这是最适合新手练手的壳,出题人也喜欢用它来默认筛掉完全不懂脱壳的人。
处理 UPX 有两条路。第一条是直接命令行脱壳,如果程序只是标准 UPX 加壳且没有被修改,一条命令就能搞定:
upx -d 题目.exe但题目往往会做点小改动,比如改掉 UPX 标志头,这时候 upx -d 会直接报错,就要走第二条路,手动脱壳。UPX 的入口特征非常明显,开头是一条 pushad。用 x64dbg 打开程序,停到入口后看到 pushad,记下当前 ESP 指向的栈地址,在这一地址上下一个硬件访问断点,然后 F9 运行。因为 pushad 和 popad 之间的代码不会真正访问这个栈顶位置,程序会在 popad 附近停下来,这时候再单步几步,看到一条大跳转就能跳到真正的入口点 OEP。把这套流程跑顺,再用 dump 插件把进程 dump 下来,配合 Scylla 修复导入表,就能得到一个干净的脱壳文件。
新手可能会问:不脱壳能不能直接做?可以,动态调试完全能在带壳状态下进行,只是静态分析会很难受。我的建议是:UPX 这种壳尽量手动脱一遍,这是基本功;实在脱不动,再退回去选择动态调试。
2.3 字符串与导入表里藏着第一手线索
脱壳之后,把程序丢进 IDA Free 或者 x64dbg 的字符串窗口。我习惯先在 IDA 里按 Shift+F12 打开 Strings 窗口,一眼扫过去就能看到刚才运行时的两个字符串:“答案错误,再接再厉”和“恭喜,答案正确”。在“答案错误”上按 X 查看交叉引用,会直接跳到引用它的代码位置,顺着这里往上翻函数头,就能定位到校验逻辑所在的函数。
这里要说一下为什么字符串是初级题最快的突破口。Windows 程序要显示文字,最终一定会调用文本输出相关的 API,而大部分编译器会把字符串直接放在 .rdata 节,明文可搜。除非出题人做了字符串加密或者运行时拼接,否则你总能通过报错信息找到校验函数。如果搜不到,就先检查是不是还有壳没脱干净,或者字符串被拆成好几段存储。
导入表窗口也值得单独看一眼。如果看到 GetDlgItemTextA 和 lstrcmpA 同时出现,基本可以断言校验逻辑是“取出输入,和某个硬编码字符串比较”。如果看到 lstrcmpA 这类比较函数,直接在其上下断点是最省事的方式。
2.4 用 IDA 定位校验函数的思路
在 IDA 里跟着字符串交叉引用进入校验函数后,按 F5 看伪代码。这一题的校验函数大概长这样:
int __stdcall sub_401080(HWND hDlg) { char buffer[32]; GetDlgItemTextA(hDlg, 1001, buffer, 32); if (check_key(buffer)) MessageBoxA(hDlg, "恭喜,答案正确", "提示", 0); else MessageBoxA(hDlg, "答案错误,再接再厉", "提示", 0); return 1; }check_key 是内部函数,IDA 显示为 sub_401150 之类的名字。双击跟进去,能看到它接收一个指针参数、循环读取每个字节、做一次 XOR、再和一段全局数据比较。看到这样的结构,这题就完成 80% 了,剩下的事就是把这个 XOR 比较逆向出来:把密文字节取出来,异或回去得到明文 key。具体怎么逆向,放在下一章一起讲。
3. 动态调试与算法还原:一次完整实战演示
3.1 在关键 API 上下断点
静态分析已经把校验函数定位到了,但纸上谈兵容易错,最好还是在调试器里亲手走一遍。我建议从原始文件或脱壳后的文件重新用 x64dbg 打开。如果打开的是脱壳后的文件,先确认程序能正常跑起来;如果跑不起来,就回退用原文件调试。
在 x64dbg 的命令行输入:
bp GetDlgItemTextA bp MessageBoxA按 F9 运行程序,在输入框里输入假 key,比如 12345678,点确定。程序会先断在 GetDlgItemTextA 上。这时候不要急着乱按,先按 Ctrl+F9 执行到返回,再按 F8 单步两步,回到程序领空。看堆栈窗口和寄存器,能找到输入字符串所在的缓冲区地址,在数据窗口跟随这个地址,就能看到你输入的 12345678。
接下来继续 F8 单步,程序会调用 check_key。等 MessageBoxA 断点命中时,说明比较已经结束,马上要弹结果了。所以关键就在于 GetDlgItemTextA 返回之后、MessageBoxA 断下之前这一段代码。如果 F8 太慢,可以在 check_key 函数入口直接下断,运行过去,再单步看内部逻辑。
3.2 顺着返回处单步,找到关键比较汇编
深入 check_key 后,代码会变得很有辨识度。我这次看到的汇编片段和下面这个结构类似:
00401150 mov esi, [ebp+8] ; esi = 输入缓冲区指针 00401153 xor ecx, ecx ; ecx 清零,当作循环计数 00401155 mov al, byte ptr [esi+ecx] ; 取输入的第 ecx 个字节 00401158 cmp al, 0 ; 判断是否结束 0040115A je short fail ; 提前结束说明长度不够 00401160 xor al, 5Ah ; 与 0x5A 异或 00401163 cmp al, byte_402000[ecx] ; 与固定密文字节比较 00401169 jnz short fail ; 不相等就失败 0040116B inc ecx 0040116C cmp ecx, 8 0040116F jl short 00401155 ; 循环 8 次 00401171 mov eax, 1 ; 全部相等则返回 1看到这段代码,解题思路就清晰了。判断条件是:(输入每个字符 XOR 0x5A) == 固定密文的每个字节。这就是典型的逐字节 XOR 比较。循环次数 8 说明 key 长度是 8 个字符。此时已经把动态和静态的信息对上了:静态分析看到的 check_key,动态看到的就是这套循环,两边互为印证。
3.3 一个典型的 XOR 校验:从汇编还原到 Python
先解释一下为什么出题人喜欢用 XOR:因为异或运算自带“对称性”,加密和解密是同一操作。程序里做的是 input XOR 0x5A 再和密文比较,反过来就是:密文 XOR 0x5A 就是 input。这也是很多类似题目的通用解法——把比较条件倒过来,就是答案。
现在去数据窗口取固定密文字节。在 IDA 里双击 byte_402000 把数据提取出来,或者直接在 x64dbg 数据窗口选中 8 个字节复制。假设提取出来是这样的(具体值每份题目不一样,这里只演示用法):
1B 18 19 1E 6B 68 69 6E注意直接看到的是十六进制,要把它还原成字符。手工先算第一个:0x1B XOR 0x5A = 0x41,也就是字符 A。再算第二个:0x18 XOR 0x5A = 0x42,是 B。全手算容易错,写个三行脚本更稳:
enc = [0x1B, 0x18, 0x19, 0x1E, 0x6B, 0x68, 0x69, 0x6E] key = "".join(chr(b ^ 0x5A) for b in enc) print(key)跑出来就是 ABCD1234。我这里用作演示的密文组合刚好能解出这个可读字符串,实际题目里的密文可能对应任意字符,但方法就是这一条:把密文数组取出来,逐字节 XOR 回去,得到的就是正确 key。
3.4 手工验证与提交:让结果闭环
算出 key 后别急着提交,先回程序里验证一遍。运行程序,输入 ABCD1234,点确定,这次弹出的是“恭喜,答案正确”。到这里解题闭环就完成了。如果你用的是脱壳后的文件验证,建议再用原始文件验证一次,防止因为脱壳修改数据导致 key 失效,虽然这种情况很少见,但确认一下总没错。
提交的地方是活动页面,要求填的正是这个 key 字符串。有些年份的规则是把 key 用于解开下一题,有些年份是直接按 key 发放红包,不管哪种,key 都是核心交付物。顺手说一下,这题如果想爆破过关,只需要把 check_key 结束时的 jnz 改成 jz,或者直接 nop 掉失败分支,程序就会对所有输入弹正确。但解题领红包的规则通常不接受 patch 结果,必须提交正确的 key。所以在练习时,我建议把“还原出 key”作为目标,而不是只会 patch。
4. 常见卡壳点与排错实录:新手最容易踩的坑
4.1 断点打不中、程序闪退怎么办
这一节全是实际操作里最常见的坑。第一个坑:断点下在 GetDlgItemTextA 上,怎么输入都不命中。先确认用的调试器位数对不对——32 位程序要用 x32dbg,64 位程序要用 x64dbg,这个错了断点必然有问题。第二个坑:程序一开起来就闪退,根本没机会下断。这种情况通常是程序在启动阶段做了反调试检测,或者因为 ASLR 每次加载地址都变。x64dbg 默认会处理 ASLR,停在系统断点时用 Ctrl+G 跳到模块入口,先在那里下断再运行。
如果程序检测调试器,初级题一般就是检测 IsDebuggerPresent,你可以在该函数头部把返回值改成 0,或者直接把调用它的地方跳过去。实在不行还有一个保底思路:在 ExitProcess 上下断,程序退出前会停一下,这时候再往回找是谁调用了退出,往往能摸到反调试逻辑的源头。
4.2 脱壳后跑不起来的两个基本操作
脱壳后文件双击没反应,是新手最容易卡住的地方。原因九成是导入表坏了,还有一成是重定位表问题。UPX 壳的导入表是压缩的,dump 出来之后必须重建。操作流程:用 x64dbg 打开带壳原始程序,按前面讲的路径跑到 OEP,停下来后不要关闭调试器,直接用 Scylla 插件附加当前进程,点 IAT AutoSearch,再点 Get Imports,正常情况下会列出所有 API,最后点 Fix Dump,选择刚才 dump 出来的文件,Scylla 会生成一个修复后的新文件。跑这个新文件,大概率就正常了。
如果这步还不行,还有一个不脱壳的替代方案:直接用原始带壳文件做动态调试。虽然静态分析看不痛快,但只要断点能命中,照样能拿到 key。刚开始学的时候,不必死磕脱壳修复,先把题解出来,建立信心更重要。
4.3 遇到花指令和反调试的保底打法
初级题不排除加一点花指令,像下面这种模式很常见:
00401170 push eax 00401171 pop eax 00401172 jmp 00401173 00401173 db 0xE8 ; 干扰数据花指令的目的就是让反汇编器出错,静态看起来混乱。我的处理方法是:动态调试时不要在这种代码块里单步太久,直接找代码块末尾的真实跳转,或者干脆跳过整段无意义指令。如果静态分析被干扰得没法看,就完全以动态调试为准,反正真正校验的地方就那么几行,找到比较指令就赢了。反调试也一样,初级题用的基本就是 IsDebuggerPresent、PEB 的 BeingDebugged 标志这类老手段。知道了机制就不慌:要么 patch 掉检测,要么动态调试时留意寄存器里可疑的返回值,手动改掉。
4.4 新手误区速查表
把新手最容易犯的错整理成一张表,解题前扫一眼比踩坑后再看性价比高得多:
| 误区 | 后果 | 正确做法 |
|---|---|---|
| 拿到文件直接开调试器,没先运行观察 | 不知道程序预期行为,断点乱下 | 先跑一遍,记录提示信息 |
| 字符串搜不到就怀疑题目太难 | 可能漏了壳或字符串加密 | 先确认脱壳是否完整,再找交叉引用 |
| 只盯寄存器,不看堆栈和数据窗口 | 找不到输入缓冲区和密文数据 | 寄存器、堆栈、数据窗口三连看 |
| 在入口点反复单步浪费时间 | 效率低,容易绕晕 | 直接对关键 API 下断,一步到位 |
| 在实机跑未知样本 | 有安全风险 | 全程在虚拟机里操作 |
| 算出 key 不验证就提交 | 可能因大小写或空格错误被拒 | 先在程序里验证,再提交 |
这张表我会在每次解题前扫一遍,习惯之后基本能避开整个新手期的绝大多数弯路。
5. 复盘与延伸:解完这一题,下一步学什么
5.1 把解题过程当成一次“体检”
解完题后,我通常会花一点时间做复盘,而不是急着庆祝。复盘的方式很简单:把解题过程按时间线过一遍,标出每一步花了多少时间。如果查壳和找字符串只花了五分钟,说明流程熟悉;如果卡在某一步超过半小时,那一步就是你的短板。比如我自己早期经常卡在“从 GetDlgItemTextA 返回到 check_key 之间的那十几条指令”,后来发现是因为不熟悉常见 Win32 API 的调用约定,于是专门补了 stdcall 和 fastcall 的知识。
复盘之后,再针对性复习:汇编指令不熟就背常用指令表,PE 结构不熟就把节区、导入表、重定位表过一遍,API 不熟就查微软文档。这种“做题、复盘、补课”的循环,比单纯刷题有效得多。
5.2 初级题的常见变种,提前练手
这一题的套路是 XOR 校验,但初级题的变种也值得提前了解,免得下次换个形式就懵:
- 变种一:换壳。把 UPX 换成 ASPack、NSPack,或者手工改过的壳。应对思路一样,先识别壳,再决定命令行脱壳还是手动脱壳。
- 变种二:换算法。把 XOR 换成加减、乘除、查表替换,或者先用某个固定字符串做几次迭代运算。应对方法是从比较指令往前推,把每一步操作逆回去。
- 变种三:内存注册码。程序不直接存储密文,而是在运行时动态生成正确答案,然后用 lstrcmpA 去比较。这时直接给 lstrcmpA 下断点,断下来后去内存里找它第二个参数指向的字符串,那就是答案。
- 变种四:多处校验。程序在校验通过后还会做二次校验,patch 第一处不够。建议还是顺着逻辑把所有校验函数都还原出来,一次拿全 key。
提前把这些变种在脑子里过一遍,下次遇到类似题目,你已经知道大概往哪个方向走。
5.3 最后的提醒:边界感与长期主义
最后说一点题外话。会逆向之后,很多朋友会手痒,想拿商业软件练手。我的态度很明确:这类社区活动题和 CTF 题是用来学习的,但破解商业软件、绕过授权验证是另一回事,不仅有法律风险,也不符合大部分逆向学习者的初衷。学习逆向的价值,在于理解程序运行的底层逻辑,能读懂二进制,能在合法授权下做安全研究、漏洞分析、恶意代码分析。把这份能力用在正道上,路会越走越宽。
我建议把每年的解题领红包初级题当作年度体检:代码分析能力退步没有、工具链熟不熟、汇编有没有生疏,一题就测出来了。保持这个习惯,比临时抱佛脚有效得多。
最后分享一个我自己的心得:做这类 Windows 初级题,真正花时间的从来不是“调不明白”,而是“不知道方向对不对”。一旦你确认程序读取了输入、找到了比较指令、能把比较条件倒过来,剩下就是体力活。新手阶段最容易提高的,其实就是这个“找方向”的能力——多解几题,形成自己的解题 SOP,后面会越来越快。我现在回头再看这题,从打开压缩包到算出 ABCD1234,总耗时不到二十分钟,但第一次做的时候,光在入口点发呆就花了半小时。所以别急,照着这个顺序多走几遍,你的第二题、第三题会快得多。