恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
BUUCTF CrackMe逆向实战:UPX脱壳与注册机算法还原
首页
资讯中心
/
BUUCTF CrackMe逆向实战:UPX脱壳与注册机算法还原
BUUCTF CrackMe逆向实战:UPX脱壳与注册机算法还原
发布时间:2026/9/16 18:58:16
BUUCTF 的 REVERSE 分类里crackMe 这道题可以说是入门注册机分析必刷的一关。它属于逆向里最经典的“破解程序校验逻辑”题型目的不是让你直接找 flag 字符串而是分析一个 Windows 小程序的注册码算法搞清楚程序凭什么判断“注册成功”。对于刚接触逆向、想从只会用工具过渡到能独立还原算法的朋友来说这个题的梯度设置很舒服既能练到脱壳、静态分析又能练到动态调试和脚本编写基本把 CTF 逆向入门的主线流程串了一遍。我是在刷 BUUCTF 时做的这道题版本是一个 32 位的 Windows 对话框程序界面很简单一个用户名输入框、一个注册码输入框、一个确定按钮。难点主要在两部分一是程序加了 UPX 壳不脱壳的话 IDA 和 x64dbg 都会看得非常难受二是核心校验算法不是简单的字符串比较而是要对用户名做一轮移位、累加、异或运算生成注册码。下面把这个过程的完整思路、踩坑点和最终注册机实现都拆开讲。1. 拿到 crackMe.exe 之后的现场勘查查壳、运行与初步判断1.1 题目背景与环境的确认先说环境。我这边的分析机是 Windows 10 x64装了 IDA Pro 7.732 位和 64 位都装了、x64dbg、DIEDetect It Easy以及 Python 3.9。实际分析 32 位程序时我习惯优先用 32 位版 IDA因为库函数识别和变量类型推断在小尺寸 PE 上更准确尤其是老的 MSVC 6.0 编译产物。题目下载下来是一个名为 crackMe.exe 的文件大小大概几十 KB。这种体量基本排除了资源型题目或者带大量附加数据的可能就是一道标准的算法逆向题。用 DIE 打开后看到的信息很重要编译器是 Microsoft Visual C 6.0加壳信息是 UPX版本 3.96PE 格式32 位。这三个信息直接决定后续工具链的选择MSVC 6.0 意味着大量库函数符号能被 IDA 自动识别UPX 3.96 是标准壳可以用官方工具直接脱32 位意味着 x64dbg 打开时要用 x32dbg 而不是 x64dbg。1.2 运行程序观察行为特征在动任何工具之前我会先把程序原样运行一遍观察它的交互逻辑和提示信息。双击打开弹出一个标准的 Win32 对话框上面有“用户名”和“注册码”两个编辑框还有一个“确定”按钮。随手输入 admin / 123456点确定立刻弹出一个 MessageBox文字是“注册码错误请重试”。这个观察看似基础但信息量不小。第一程序有明确的字符串输出说明弹窗类 API 是可以作为突破口下断点的比如 MessageBoxA。第二错误提示是中文说明文件可能是用 GBK 编码的资源或者字符串存放在 .rdata 段IDA 的字符串窗口里它可能显示为 UTF-8 乱码需要手动设置编码才能看清。第三“确定”按钮触发的校验是在对话框的消息处理函数里完成的后续在 IDA 里可以直接通过对话框 ID 和控件 ID 往下追。运行一遍之后我会顺手把程序关掉再用 Resource Hacker 看看资源结构确认控件 ID。这种做法在很多 crackMe 里能省不少时间。我打开资源发现用户名编辑框 ID 是 1001注册码编辑框 ID 是 1002确定按钮 ID 是 1000。这三个 ID 后面在 IDA 分析 GetDlgItemTextA 调用时会对得上保证定位不出偏差。1.3 查壳结果与 UPX 脱壳实操UPX 3.96 是历史悠久的压缩壳很多 CrackMe 都直接用 UPX 打包因为它的脱壳太成熟了。第一步先试命令行脱壳命令是upx -d crackMe.exe执行后如果没有报错会提示脱壳成功生成一个新的 crackMe.exeUPX -d 默认覆盖原文件所以操作前最好先备份一份或者用 -o 指定输出文件名。我习惯先备份然后执行upx -d crackMe.exe -o crackMe_unpacked.exe脱壳后的文件在 DIE 里再看已经检测不到 UPX显示 Microsoft Visual C 6.0区段也从 UPX0/UPX1 变回了正常的 .text/.rdata/.data。这里有一个很容易踩的坑如果 UPX -d 失败常见原因是原程序的入口点被改动过或者附加了自定义数据段。这时候不能硬磕要转 x32dbg 手动脱壳。手动脱 UPX 的标准方法是 ESP 定律在 x32dbg 中加载程序停在系统断点后按 F8 单步一次触发第一个 pushad记下当前 ESP 值然后在 dump 窗口对该地址下硬件访问断点F9 运行断下后单步到 far jmp 或 near jmp这附近就是原始入口点OEP。找到 OEP 后用 Scylla 抓取进程内存并修复导入表。这个过程虽然比一行命令麻烦但却是逆向的基本功练一次不亏。不过这道题既然能直接upx -d就不必自找麻烦。脱壳后的 crackMe_unpacked.exe 才是后面 IDA 静态分析和 x32dbg 动态调试的主角。2. IDA 静态定位从弹窗字符串到核心校验函数2.1 ShiftF12 字符串窗口与交叉引用把 crackMe_unpacked.exe 拖进 IDA按 Enter 完成自动分析后我习惯性按下 ShiftF12 打开 Strings 窗口。UE 字符串、GetDlgItemTextA 之类的 API 名在导入表里也能看到但真正能把我们引到算法附近的还是那些业务字符串。在 Strings 窗口里直接过滤“错误”“正确”“注册”这些中文关键词。IDA 老版本对 GBK 字符串显示时常带转义符显示成类似\xE6\xB3\xA8\xE5\x86\x8C的形态需要手动在字符串列表里设置编码为 GBK 才能可读。这道题里我能看到注册码错误请重试注册码正确谢谢使用选中“注册码错误请重试”这一条按 X 查看交叉引用IDA 会跳到唯一引用它的代码位置通常是某个函数里的 push offset aString 指令。记下这个函数的地址我这儿是 sub_401120它是整个校验逻辑的宿主函数也就是核心函数。2.2 GetDlgItemTextA 的调用点与控件 ID 分析进入 sub_401120 后按 F5 查看伪代码。IDA 自动还原出来的代码结构通常是int __stdcall sub_401120(HWND hDlg, int a2, ...) { char name[256]; char code[256]; GetDlgItemTextA(hDlg, 1001, name, 256); GetDlgItemTextA(hDlg, 1002, code, 256); // ... }看到 1001 和 1002 这两个十六进制数和我在 Resource Hacker 里看到的控件 ID 完全一致基本可以确定第一个 GetDlgItemTextA 读用户名第二个读注册码。继续往下看会发现两者都被传给了某个计算函数最终结果和注册码字符串之间有一次 strcmp。到这里核心函数的边界已经划定接下来只要分析从“拿到用户名”到“strcmp”之间那几行代码即可。这里想多说一句很多新手在 IDA 里一上来就找 main 函数这在 Windows GUI 程序里是行不通的。正确的路径是先通过字符串或者 API 交叉引用定位关键逻辑函数而不是浪费时间在 WinMain 的消息循环里打转。对于对话框程序GetDlgItemTextA、GetWindowTextA、MessageBoxA 这类 API 都是很好的锚点。2.3 F5 伪代码初读循环里的三个关键指令F5 出来的伪代码乍看有一堆变量但核心就是一段循环。我用 N 键把变量重命名去掉编译器临时变量之后逻辑逐渐清晰int v4 0x12345678; for (int i 0; name[i]; i) { v4 (v4 (name[i] (i % 5))) ^ 0x7F; } sprintf(buffer, %08X, v4); if (strcmp(buffer, code) 0) { // 注册码正确 }这段代码的三个关键操作分别是、、^。是移位是累加^是异或。这种“一个循环 一个累加器 多个运算”的结构在 crackMe 里出现频率极高本质就是把一个任意长度的用户名字符串压缩成一个固定长度的整数摘要。只要把循环体和最终的字符串格式化确认清楚注册码就能直接算出来。这里必须提一个容易忽略的细节sprintf 的格式是%08XX 是大写也就是说最终比较时程序期望注册码是大写十六进制字符串如果用户输入小写字母形式的注册码strcmp 会直接判定失败。这是设计者刻意埋的一种“约定”也是很多新手算出正确数字却填不进程序的原因之一。3. 算法还原全过程移位、取模、异或与十六进制拼接3.1 伪代码逐行解读与汇编对照只停留在“看伪代码”还不够要能把它翻译成自己能复现的数学流程。还是以 C 语言思维一行行拆。第一行int v4 0x12345678;这是累加器初始值。为什么选一个非零初始值其实没有特殊原因很多 CrackMe 就喜欢用一个看起来像魔数的十六进制常量作为种子让结果不那么容易一眼看穿。分析时不要被这个数吓到把它当成一个普通整数参与后续运算就行。第二行for (int i 0; name[i]; i)循环条件是用户名不为空i 从 0 开始递增每次处理一个字节。这里有一个关键陷阱i 的起始值是 0不是 1。如果看汇编时没注意把 i 误认为是 1 开始后面取模移位全部错位。这个细节在后面的“常见坑”里还会展开。第三行v4 (v4 (name[i] (i % 5))) ^ 0x7F;。拆解成三步i % 5得到 0 到 4 之间的数把当前字符按照这个位数左移把移位结果加到累加器里整个和异或 0x7F。注意^ 0x7F的优先级比低所以实际是先做加法再整体异或。这个优先级顺序我在初次快速阅读时看错过一次以为先异或再加算出来的注册码自然不对。从汇编代码看循环体大致长这样loc_401180: movzx eax, byte ptr [espeaxname] mov ecx, [espvar_i] mov edx, ecx and edx, 80000004h jns short loc_4011A0 dec edx or edx, 0FFFFFFFCh inc edx loc_4011A0: shl eax, cl add esi, eax xor esi, 7Fh inc i cmp byte ptr [espeaxname], 0 jnz short loc_401180i % 5在编译优化后不是一条 div 指令而是被编译器展开成按位与加修正。因为 5 不是 2 的幂编译器用了and edx, 80000004h这样的技巧完成带符号取模。所以在汇编里看到这种奇怪的 and、dec、or、inc 序列不要慌它很可能就是某个小常数的取模操作。如果能识别出这一点整个循环体就通了。3.2 手动推导一个小例子纸上谈兵不如手动算一遍。以用户名为admin为例字符的十六进制分别是a0x61d0x64m0x6Di0x69n0x6E。代入算法i0(0x12345678 0x61) ^ 0x7F 0x123456A6i1(0x123456A6 (0x64 1)) ^ 0x7F 0x12345711i2(0x12345711 (0x6D 2)) ^ 0x7F 0x123458BAi3(0x123458BA (0x69 3)) ^ 0x7F 0x12345C7Di4(0x12345C7D (0x6E 4)) ^ 0x7F 0x12346322所以在真实程序中用户名admin对应的注册码就是12346322。这个手动推演的过程不要跳过它能帮你验证对指令优先级、移位方向和取模范围的理解是否正确也能在写注册机之前提前暴露思路上的错误。我当时推完第二步时发现自己的第一版理解先异或后加结果不一致回头重查了一遍运算符优先级才纠正过来。3.3 这类“用户名字符串到注册码”算法的常见变体crackMe 里的注册码算法再复杂本质上都是把一个变长字符串映射为一个固定长度值。常见的组合拳包括对每个字符加下标、减下标、乘下标、异或固定常量、左移/右移、轮转字节顺序、用线性同余生成器迭代、甚至查表S盒。这道题用的是“移位累加异或格式化十六进制”属于最基础的入门款。我在 BUUCTF 别的题里还碰到过类似变体移位位数用i而不是i % 5用户名字长一点就会因为左移超过 32 位而溢出导致结果跟编译器实际行为不一致分析时要在每一步都做 0xFFFFFFFF限位。累加前先异或再移位运算顺序不同结果完全不同。循环从字符串末尾往前遍历i 的语义变成倒数的位置。不用十六进制字符串比较而是把整数转换成十进制字符串这时%08X会变成%d需要特别注意。更狡猾的版本会把正确注册码存在一个全局数组里而不是运行时计算。分析时看到数据段有一个固定的 4 字节数组被拿来比较就要考虑是不是藏了答案。学会识别这些变体远比背住某一道题的答案重要。核心是抓住“输入经过什么运算、中间状态如何变化、最终和谁比较”这三个问题。4. x32dbg 动态验证下断点、观察比较参数与爆破思路4.1 断点策略与调试流程静态分析得出的算法必须经过动态调试验证否则哪怕一个优先级理解错写出的注册机也是废的。我习惯在脱壳后的程序上用 x32dbg32位程序对应的是 x32dbg不是 x64dbg重新走一遍。加载 crackMe_unpacked.exe 后先在模块列表里找到程序主模块然后在命令行或者断点窗口设置以下断点GetDlgItemTextA确认读取用户名和注册码的逻辑以及传入的控件 ID。strcmp或lstrcmpA观察比较发生前两个字符串参数的值。MessageBoxA看成功/失败弹窗的触发路径。设置完断点后按 F9 运行程序弹窗出现。随意输入用户名admin注册码填AAAAAAAA点确定。第一次中断在 GetDlgItemTextA继续运行会再次中断这次是读取注册码。接着运行到 strcmp 断点。在 strcmp 断下时看栈窗口或者寄存器。通常调用约定会把两个字符串指针通过栈传递x86 下就是[esp4]和[esp8]这两个参数。我选中第二个参数的内存地址在 dump 窗口跟随发现里面是12346322正是刚才静态推演出来的admin的正确注册码。再选第一个参数跟随看到的是AAAAAAAA。两个参数一个正确一个错误程序比较后自然走失败分支。4.2 在 strcmp 处对比两个参数把这个对比点彻底看清楚是动态调试里最有价值的一步。因为 strcmp 这个 API 本身是导入函数如果程序内部还有别的地方也用到 strcmp容易多断几次。我的做法是在 IDA 的 Imports 窗口找到 strcmp然后查看交叉引用把地址记下来回到 x32dbg 给该地址下断点。这样每次中断都是在校验注册码不是别的无关字符串比较。观察完之后我直接把注册码从AAAAAAAA改成12346322重新运行程序这一次在 strcmp 断下来时看到两个参数完全一致继续运行后程序弹出“注册码正确谢谢使用”的 MessageBox。到这里静态分析的算法和动态行为完全对上可以放心写注册机。这里值得强调一个调试技巧当程序断在 strcmp 时按一次 F8 执行完 strcmp再查看 EAX 寄存器的值。如果 EAX 为 0说明两字符串相等。如果不为 0可以结合test eax, eax / jnz指令分支判断程序走向。很多 crackMe 在 strcmp 之后紧接着就是test eax, eax看到这个模式基本就能确定比较结果的使用方式。4.3 爆破思路与取巧截获 flag除了老老实实算注册码CTF 题目还允许一种“爆破”思路不解读算法直接修改程序控制流。具体做法是在比较之后的跳转指令上做文章。比较典型的情况是call strcmp test eax, eax jnz short loc_fail ; 如果不相等跳到失败分支那就把jnz改成jz或者干脆 NOP 掉。这样无论注册码输什么程序都走成功分支。爆破的好处是省事在动态验证阶段能快速确认分支逻辑是否找对坏处是如果题目要求提交 flag而 flag 藏在成功弹窗里爆破也能看到实际并不亏。这道题成功弹窗里显示的内容是一串flag{...}。具体内容我不写在文章里留给大家自己在 BUUCTF 上跑一遍获得。取巧的方式是在 x32dbg 里直接修改内存中的字符串或者修改跳转然后拦截 MessageBoxA 的第二个参数来拷贝弹窗文本。不过既然是一道入门题我还是建议不要爆破完了事把算法还原出来后面遇到同样套路的题能直接秒杀。5. 注册机编写与同类题复盘5.1 Python 注册机的完整实现确认算法无误后编写注册机就是水到渠成的事。我用 Python 实现def calc_serial(name: str) - str: v4 0x12345678 data name.encode(utf-8, errorsignore) for i, ch in enumerate(data): v4 (v4 (ch (i % 5))) ^ 0x7F v4 0xFFFFFFFF return f{v4:08X} if __name__ __main__: test_names [admin, BUUCTF, crackMe, flag] for n in test_names: print(f{n:10} - {calc_serial(n)})运行结果里最核心的一行是 admin 的输出必须是12346322。如果输出不一致先检查两件事第一Python 的整数是任意精度而 C 的 int 是 32 位溢出会自动截断所以循环体里每次累加后要手动 0xFFFFFFFF第二%08X输出大写十六进制并保证至少 8 位不足补零这和大写字母要求是一致的。我也尝试过写 C 语言版本注册机比如用uint32_t类型就不需要手动限位因为无符号整数天然 32 位循环溢出。C 实现里循环体几乎可以原封不动照抄伪代码这恰好说明 IDA 的 F5 结果已经非常接近原始源码逻辑。两版注册机分别验证结果一致后我对这道题的算法理解才算完全闭环。5.2 遇到的四个典型坑第一坑大小写。sprintf 用%08X输出的是大写字母。我一开始在手动测试时输入小写12346322里的字母部分如果出现a-f就会失败。这道题碰巧12346322全是数字所以没踩到但换一个用户名比如test算出的注册码就可能带字母。写注册机时统一用大写输出避免这种低级错误。第二坑取模的循环起点。i % 5在 i0 时结果是 0也就是说第一个字符是不移位的。有些类似题会写成(i 1) % 5第一个字符左移一位。分析汇编时要确认 idiv 指令和循环变量的真实关系不能想当然。第三坑编码问题。原程序是 ANSI 程序GetDlgItemTextA 按字节读取。如果用户名包含中文name 的每个字节都会参与运算而不是按“字符”作为最小单位。Python 端如果直接for ch in name拿到的会是一个 Unicode 字符encode 成 GBK 之后占两个字节这和 C 语言按字节遍历一致但如果你用了ord(ch)并且 ch 是汉字数值会远超 255导致结果完全错误。这道题的预期用户大概率不会输中文但作为通用注册机还是处理一下字节编码更严谨。第四坑32 位溢出。C 的 int 在 Windows 上是 32 位任何中间结果超过 2^31-1 时如果按有符号处理行为可能会表现为负数。在 Python 里模拟时要注意一方面用 0xFFFFFFFF做无符号限位另一方面如果按有符号数比较可能还要把高位解释成负数。在大多数 crackMe 中寄存器层面的运算都是无符号循环所以统一按uint32_t理解即可。5.3 从 crackMe 延伸到其他 BUUCTF REVERSE 题的通用打法做完这一道题回头看 BUUCTF 的 REVERSE 分类很多题目都能套上同一套流程下载文件查壳脱壳用 IDA 定位关键函数找字符串或 API 交叉引用F5 还原算法用调试器验证最后写脚本输出 flag 或完成验证。区别在于每个环节的难度都会升级。有的题用自定义壳或花指令来干扰静态分析你就得学习手动脱壳和混淆对抗有的题用 C 的 STL 容器和虚函数把逻辑拆得四散你就得熟悉std::string的结构理解 vtable 指向有的题会把算法封装成正则表达式引擎、虚拟机保护壳或者用自修改代码让反汇编结果失效这些都需要额外学习。但无论外壳怎么变“输入到输出的变换关系”永远可以抽象出来IDA 到不了的角落就交给 x32dbg 单步调试器也到不了就交给符号执行和动态插桩。crackMe 这道题能不能算“刷完了”我的标准不是 flag 天出来而是闭着眼睛能画出这条链路图从 PE 文件头到控件 ID从 GetDlgItemTextA 到循环体从shl eax, cl到 Python 的ch (i % 5)每一步都能用自己的话说清楚。能达到这个程度再刷 reverse、xor 这类进阶题心态会稳很多。最后再分享一个个人习惯分析完每个 crackMe我会把原始文件、脱壳后的文件、IDA 数据库.i64、x32dbg 的调试脚本、注册机源码、以及最终的验证结果截图都存进同一个文件夹文件名带上日期和题目名。积累久了你就会发现自己有了一本完全属于自己的逆向活字典遇到相似算法时完全可以先翻旧题找思路比从头硬啃汇编高效太多。