手上拿到一个叫 crackme -002 的样本文件体积小得离谱双击跑起来就是个控制台窗口先问用户名再问序列号输错了窗口一闪就关掉连句像样的提示都看不清。这种体验大概每个刚摸逆向的人都经历过——第一次面对一个看得见却看不懂的二进制最大的困惑不是没有工具而是不知道该从哪儿下手。crackme -002 这类练习样本的真正价值不在于破掉它而在于把一段原本只能看见字节和操作码的东西还原成一份能复现、能讨论、能写成脚本的算法说明书最后再顺手写一个注册机把自己算出来的结果验证一遍。这篇内容适合三类人看完全没碰过二进制分析、想知道第一步该干嘛的新手做过一两题、卡在静态看得懂但算不对的过渡期选手以及想把分析流程固化成习惯的老手。我会用一个典型的序列号校验型样本作为对象把工具选型、定位校验函数的思路、动态调试记录数据的方法、算法还原到写注册机的完整链路走一遍中间穿插大量我在实际操作里踩过的坑包括那个让我多花了两个小时的低级失误。1. crackme -002 的题型定位与路线选型1.1 从题号推断难度阶梯为什么它比第一题难一档系列题集通常有难度递进的设计逻辑。第一题往往是明文比较型——你在字符串里直接搜到一长串可疑字符输进去就过了考察的是最基本的字符串检索和文件类型识别。到了 crackme -002 这个位置出题人通常已经收起了这层糖衣正确的序列号不再以明文形式躺在二进制里而是由你输入的用户名经过一段运算生成程序只保存运算规则不保存结果。这个变化带来的核心难点转移非常明确。第一题你只需要找东西第二题你必须读懂过程。找东西靠工具读过程靠脑子加工具。所以 crackme -002 事实上是检验你是否具备把汇编翻译成高级语言能力的第一道门槛也是整个逆向学习曲线里最陡的那一段。从样本行为上也能看出来它接受两个输入、有一个明显的长度下限检查、输错时很可能只返回一个非零值而不给任何提示。这种静默失败的设计逼着你去调试器里看返回值而不是靠界面反馈猜。我一般拿到这类样本第一件事不是打开反汇编器而是先用最小输入跑几遍把它的输入输出行为摸清楚——这一步花五分钟能省后面半小时。1.2 静态分析、动态调试与混合路线该怎么选三条路线各有明确的适用边界选错的代价是时间翻倍。路线核心手段适合场景主要代价纯静态反汇编器逐函数阅读、反编译视图算法结构清晰、无壳无混淆、代码量小寄存器与中间变量容易看串浮点与字符串处理容易误判纯动态调试器单步、断点、内存观察有反调试、有代码自解密、算法藏在回调里容易跟丢循环缺少全局视角改一个分支可能触发别的校验混合推荐静态定位置动态验数据绝大多数练习样本需要在两个工具间来回切换考验笔记习惯crackme -002 这种体量的样本混合路线是性价比最高的。具体分工是静态分析负责回答校验函数在哪、大致调用了什么动态调试负责回答这一轮循环里 sum 到底等于几、某个分支到底走没走。因为逆向最怕的不是看不懂指令而是看懂了指令却对不上实际值的来源——静态视图里的一个[ebp-8]你以为是循环计数器实际上可能是被复用过的临时缓冲区只有跑起来看才确定。我个人的固定习惯是静态分析器只用来读结构任何涉及具体数值的地方一律切到调试器验证。这条规矩让我少犯了很多照着反编译伪代码手算结果和程序行为对不上的错误。反编译器的伪代码是推测产物它对类型和变量边界的判断经常偏保守或者干脆错把它当参考而不是当答案。1.3 一个必须先立的规矩练习样本的边界这里要把话说在前面。crackme 这类样本是专门为学习分析技术而构造的作者主动把它放出来让你拆。所有操作都应当严格限定在这类自建或公开的练习样本上这是技术学习也是行业里对分析能力的基本训练方式。真实的商业程序、他人的软件产品没有明确授权就不要动这条线不清楚的人技术再好也走不远。我见过太多新手一上来就找网上的实战目标结果把学习路径搞歪了基础反而没打牢。2. 样本初勘与工具环境搭好2.1 工具清单与各自的分工工欲善其事先把工具摆齐。下面这套是 Windows 平台上分析 crackme 的常规配置每一样都有明确的职责不需要装一堆用不上的。工具职责选择理由文件类型识别工具判断 PE/ELF、位数、是否加壳三十秒解决这是什么的问题避免用错调试器静态反汇编器带反编译视图函数列表、交叉引用、伪代码交叉引用是定位校验函数最快的入口动态调试器断点、单步、寄存器与内存观察唯一能告诉你实际值是多少的手段十六进制编辑器查看节区、字符串、修改验证改一个字节验证猜想比读十行汇编都快脚本环境写注册机、批量测试算法一变就能改两行重跑位数判断是第一个岔路口。32 位和 64 位的调用约定完全不同32 位下函数参数走栈你在入口能看到push一串参数64 位下前四个参数走寄存器rcx/rdx/r8/r9一眼看过去容易漏掉。用错位数的调试器打开会直接报错但用错位数的思维去读代码错误是隐性的可能读半天才发现自己一直在找一个不存在的栈参数。2.2 文件识别与加壳判断三十秒决定后续路线我把这一步浓缩成三个动作。第一看文件属性里的位数和大小几 KB 到几十 KB 是正常的练习样本体量几百 KB 以上就要怀疑是不是静态链接了运行库。第二用识别工具看节区名和熵值标准节区名代码段、数据段、资源段说明大概率没加壳节区名乱码、可执行节区异常大、熵值接近满值那就是压缩或加密过的得先脱。第三跑一遍看行为和输出字符。这里插一个我踩过的坑。有一次我拿到一个样本识别工具提示可能加壳我花了一个多小时研究脱壳最后发现只是编译器把节区名改了代码根本没压缩。判断加壳不能只看节区名要看可执行段的熵和入口点的指令形态——入口点如果是一段明显无意义的跳转链那才是真壳入口点直接是正常的函数序言保存寄存器、调整栈那基本就是编译器的小动作。这个教训让我后来每次都先看入口点再决定要不要脱壳。字符串初筛也值得认真做但要理解它的局限。crackme -002 里的字符串通常只剩提示语和格式串比如%08X这种格式化输出它的存在本身就是一条强线索——说明程序会把某个整数格式化成八位十六进制字符串那这个整数极可能就是正确序列号的数值本体。反过来如果你搜到了完整序列号明文那说明这题的难度还没到第二档先怀疑自己是不是看错了样本。2.3 建立分析笔记这一步偷懒后面全是债我必须单独强调笔记。逆向分析的信息量是爆炸式的一个函数里十几个局部变量、四五层嵌套调用你不可能全靠脑子记。我的笔记模板很土但极其有效就三列地址、我当时认为它是什么、验证过没有。第三列是关键任何没有经过动态验证的我认为都要标记为待验证。具体到 crackme -002我会在笔记里固定记录这几项校验函数的入口地址、用户名缓冲区的地址、序列号缓冲区的地址、循环计数器的位置、累加变量的位置、最终比较指令的地址。有了这六项动态调试时下断点就不用临时找。还有一个习惯值得养成每完成一个阶段性结论就把当前的分析文件另存一个副本文件名带上步骤号。逆向最痛苦的情况不是卡住而是改错了一个字节导致程序行为变了你想退回去却发现自己把原始状态覆盖了。这种备份成本几乎为零收益却是救命的。3. 静态分析把校验函数从代码海里捞出来3.1 定位入口之后的第一跳找主函数反汇编器打开后默认停在入口点但那不是main。编译器的启动代码会先做运行库初始化然后才调用真正的用户代码。找main的常规手段有两个一是看启动代码末尾的那个call它的目标通常就是主逻辑二是从字符串交叉引用反推因为主函数里必然会引用提示语。我更倾向于第二条路因为它同时给了你两个信息主函数的位置以及提示语在校验流程里被使用的上下文。以%08X这个格式串为例在反汇编器里查它的交叉引用会看到它被压栈传给格式化函数而这次调用的返回值随后又被传给了字符串比较函数。这条链条一旦串起来校验逻辑的骨架就浮出来了先算出整数再格式化再比较。3.2 从比较指令反推校验函数的识别特征序列号校验型样本有一个几乎不变的指纹字符串比较调用。你只要找到比较函数往回追两个参数一个是程序自己算出来的字符串另一个是你输入的字符串那么算出前者的那段代码就是算法本体。我在 crackme -002 上用的就是这个思路定位到的结构大致如下这是基于这类样本常见形态的合理还原不是逐字节的原始转储; 参数准备阶段 lea eax, [ebp-0x18] ; 程序算出的字符串缓冲区 push eax lea ecx, [ebp-0x28] ; 用户输入的序列号缓冲区 push ecx call strcmp_like add esp, 8 test eax, eax jnz short fail_path ; 不相等就跳走看到test eax, eax后面跟jnz跳向失败分支说明这是个相等才算通过的比较。这里有个容易看反的地方很多人下意识以为jnz跳走是成功路径实际上要看它跳去的目的地址干了什么——如果目标地址是一段直接返回 0 的短代码那就是失败。判断依据永远是跳转目标的实际行为不是指令本身。再往回一层就能找到生成字符串的那段循环。这是全题的核心静态阅读时要盯住三件事循环的边界条件用户名长度、循环体内的运算累加、移位、异或、乘法、结果的存储位置。3.3 识别编译器优化留下的痕迹移位加加法等于乘 33crackme -002 的算法核心里有一个非常经典的编译器优化特征我把它单独拎出来讲因为这类痕迹在逆向里出现频率极高mov eax, [ebp-0x08] ; 取出累加变量 shl eax, 5 ; 左移 5 位等于乘 32 add eax, [ebp-0x08] ; 再加一次自身总共乘 33 movzx ecx, byte ptr [ebpecx-0x30] ; 取出用户名的第 i 个字节 add eax, ecx ; 累加上去 mov [ebp-0x08], eax ; 存回新手看到shl加add会以为是两个独立操作其实合起来就是sum sum * 33 name[i]。识别方法很简单shl eax, n等价于乘2^n如果紧接着又add了同一个源操作数那就是乘2^n 1。常见的组合有乘 3shl 1加自身、乘 5shl 2加自身、乘 9、乘 17、乘 33、乘 65。编译器这么做是因为乘法指令在某些微架构上延迟更高移位加加法更快。这个知识点值钱的地方在于如果你按字面理解成先左移再相加两个独立步骤手算结果一定对不上一旦识别出它是乘常数手算就顺了。我在教别人的时候常说读汇编要读出作者想干什么而不是这条指令干了什么——编译器写出来的代码是给 CPU 看的不是给人看的你要做的是反向翻译回人的意图。再看边界条件。循环通常用用户名长度做上界而长度检查往往在更早的地方做表现为一个与常数的比较比如要求长度不小于 4。这个下限检查的存在意味着即使你猜到了算法用户名太短也过不去所以注册机必须做长度校验。这个细节我在第一次做的时候完全忽略了用两个字符的用户名算了个序列号程序死活不认我还以为是算法推错了回头查了半天才发现是长度门槛。3.4 数据类型与溢出的静态判断还有一个隐性但致命的点累加变量的位宽。32 位无符号整数的乘法累加会溢出溢出后的值是截断的而正确序列号是基于截断后的值算出来的。如果你在 Python 里用无限精度整数手算得到的是一个比 2^32 大得多的数格式化成十六进制会超过八位和程序算出来的八位对不上。静态阶段就要确认这个位宽看累加变量在栈上的偏移看操作它的指令是 32 位还是 64 位寄存器eax还是rax、movzx还是mov。crackme -002 这类小样本基本都是 32 位运算所以写脚本时必须处处 0xFFFFFFFF。这条经验我吃过两次亏才记住第二次是因为换了个样本忘了改白白怀疑了一小时算法。4. 动态调试把流过的每一个值都记下来4.1 断点策略三种断点各管一段静态给了你地图动态负责填数字。断点不要乱下按信息量排序我的顺序是字符串比较调用处确认比较的两个字符串实际长什么样这是验证整体思路最快的一刀。循环入口处配合条件断点只在指定轮次停下来避免全程单步。累加结果写回处每一轮把累加变量记下来形成完整数值序列。字符串比较那一刀特别值得先说。我在比较函数入口停住看两个参数指向的内存得到的是程序算出的缓冲区和用户输入缓冲区。这一眼就确认了我的猜想——程序确实是把用户输入和一个由用户名生成的字符串做比较而不是直接比某个常数。如果这一步看到的是两个固定字符串在比那说明这题的难度是第一档路线要立刻调整。循环入口的条件断点是我最常用的技巧。如果你不设条件一个二十字符的用户名意味着一百多次单步人会看花。设条件断点让它只在第三轮、第五轮停下来你就能用少量观察点推出整条数值序列的规律而且不会因为漏看某一轮而前功尽弃。4.2 寄存器与内存记录的实操表格记录这件事我用一张固定表格逐轮填。以用户名Test为例操作过程是这样的轮次 iname[i]字符码上一轮 sum十六进制计算式本轮 sum十六进制0T0x540x000000000×33 0x540x000000541e0x650x000000540x54×33 0x650x00000FA12s0x730x00000FA10xFA1×33 0x730x0002030A3t0x740x0002030A0x2030A×33 0x740x00429F86这里每一格都有来历。第一轮 sum 初值为 0任何数乘 33 还是 0所以只要加首字符即可这是校验你是否找对初始化的一个好办法——如果程序里 sum 的初值不是 0 而是某个常数那第一轮结果就会不同这种种子值是很多第二档题目的加料点。第三轮开始数值就跳得很快因为乘 33 的增长率很猛。到第 4 轮已经是七位数再往后就逼近 32 位上限。这就是为什么四字符的用户名恰好能得到八位十六进制结果也是为什么题目常把最小长度设在 4 左右——太短的话数值区分度低容易被猜出来。填这张表的价值不在于数字本身而在于它会暴露你对算法的理解是否有漏洞。如果我从第三轮开始算出去的值和调试器里看到的不一致那就说明循环体里还有我没发现的运算比如某处偷偷加了个常数、或者字符码被减去了某个偏移。逐轮对账是唯一能快速定位这类遗漏的方法全局对比你只会知道结果不对不会知道从第几轮开始不对。4.3 循环变量的复用陷阱有个坑我印象很深。程序里循环计数器和我以为的另一个变量的栈偏移挨得很近反编译视图把它们显示成了两个不同名字的变量但实际运行时发现它们在循环过程中互相覆盖——编译器复用了同一个栈槽位。这种情况在处理用户名字符串处理时尤其常见函数前半段用[ebp-0x10]存长度后半段同一个地址被拿来存临时字符。静态阅读时你看到的是两个不相干的变量实际行为是一个位置先后承担两个角色。识别方法是单步时留意每次写入该地址的时机如果它在不同阶段被写入不同的东西就是复用。这种时候笔记里那个验证过没有的列就派上用场了——任何我认为是某个含义的偏移都必须在动态里看到它被写入和读取的完整生命周期才能确认。4.4 常见保护的应对反调试、花指令、字符串藏匿crackme -002 这个层级的样本通常不会上重度保护但三种轻度干扰很常见得提前有准备。第一种是反调试检测典型表现是程序在调试器下启动就退出或者行为异常。识别特征是调试器断在某个查询调试标志的调用上返回值被立刻用来做分支判断。应对的思路是观察这个返回值的去向在分支处改掉判断条件或者直接在返回处下手让它永远返回没有被调试的状态。练习样本里做这件事完全合规因为它本来就是给你练的。第二种是花指令也就是插入一些永不被执行或者执行了也不影响结果的垃圾指令目的是干扰反汇编器的线性扫描。特征是一段代码里出现跳转到下一条指令中间的跳转、或者明显无意义的push/pop配对。应对方法是在反汇编器里把无效字节定义成数据让反汇编从正确的位置重新开始。这个技巧说起来简单实操要靠耐心一段一段对齐。第三种是字符串藏匿把该出现的明文字符串在运行时才拼出来。crackme -002 里如果%08X这种格式串在静态视图里找不到那就说明它被逐字节存起来运行时还原这时候在字符串处理的调用处下断点等它自己把内容拼好比在静态里一个字节一个字节找快得多。这三种干扰的共同应对哲学是不要跟混淆较劲去找程序必须做的事在那里等它。5. 算法还原与注册机实现5.1 从指令序列到高级语言伪代码把动态记录和静态结构合起来crackme -002 的算法可以还原成这么一段伪代码这是基于此类样本通行结构的合理复现check(name, serial): if len(name) 4: return 0 sum 0 for c in name: sum (sum * 33 ord(c)) mod 2^32 expect hex_upper_8(sum) return serial expect对照前面那张逐轮表每一步都能对上初值 0、乘 33、加字符码、32 位截断、格式化八位大写十六进制、字符串相等比较。这段伪代码就是整道题的全部答案剩下的只是把它写成能跑的代码。注意hex_upper_8这个细节。程序里用的是大写格式符还是小写宽度是 8 还是不补零这些看似琐碎的差异会直接导致正确的数值被判定为错误。识别方法是看格式化调用的第二参数或者在调试器里直接看格式化后的缓冲区内容——长度、大小写一眼就清楚。我在这里翻过车算出数值完全正确但因为程序输出的是小写我按大写去比较怎么都对不上最后在调试器里看到内存才反应过来。5.2 注册机代码与参数说明def gen_serial(name: str, factor: int 0x21, width: int 8) - str: 根据用户名生成序列号 factor: 循环乘法因子, 0x21 即 33 width: 输出宽度, 不足补零 if len(name) 4: raise ValueError(用户名长度不足程序会在入口处直接拒绝) total 0 for ch in name: total (total * factor ord(ch)) 0xFFFFFFFF return f{total:0{width}X} if __name__ __main__: for u in [Test, Admin, crackme002, abcdefg]: print(u, -, gen_serial(u))把factor和width抽成参数是为了后面万一要试别的样本或者验证因子是否被我读错比如把乘 33 看成乘 17改一个参数就能重跑。 0xFFFFFFFF是硬性要求对应前面说的位宽截断缺了它结果会多出好几位。ValueError那条分支也是必须的它对应程序里那个长度检查少了它你会给出一堆程序根本不接受的有效序列号。这段代码跑出来的结果直接拿去喂给程序验证。第一次验证通过的那一刻是有仪式感的但别急着结束——真正的功夫在于把它做扎实。5.3 验证不止一次边界与鲁棒性测试我验证的时候坚持跑四组缺一组都不放心测试组用户名特征验证目的边界最小恰好 4 个字符下限检查是否与算法一致数值区分度是否够常规长度6 到 10 个字符主流程正确性极端长度30 个字符以上是否触发缓冲区限制或额外分支特殊字符含数字、符号、空格字符码取值是否被程序处理过如转小写、过滤第四组最容易出问题。有些样本会在计算前对用户名做规范化比如全部转成小写、或者跳过非字母字符。如果你的注册机没做同样的规范化用含大写字母的用户名就会算错。检测方法很朴素用Test和test分别算一遍看程序是不是都接受。如果只有一种被接受就说明有规范化逻辑得回去把那段分支补上。长度上限那组也值得试因为很多程序用一个固定大小的缓冲区接收用户名超长时可能截断。截断意味着程序实际计算的只是前 N 个字符而你以为它算了全部。这种情况下你按完整字符串算的序列号是错的得按截断后的算。这个坑不试出来你会一直以为自己算法推错了。5.4 为什么我不推荐一上来就改跳转练习阶段最没价值的操作就是找到比较指令把jnz改成jmp然后看见注册成功。这种做法在这个样本上只需要两分钟但你什么都没学到你不知道序列号长什么样不知道算法能不能复现换个稍微变形的样本立刻原形毕露。我的判断标准很简单如果我不能在不修改程序的前提下用一个自己算出来的合法输入通过校验那这题就没做完。改跳转可以做但只能放在最后一步当验证手段——用来确认我找的这个分支确实决定成败而不是当终点。这个原则在我后来分析更复杂的样本时救过很多次因为真正有意思的逻辑从来不在那个跳转上而在跳转之前算出比较对象的整条链路里。6. 卡点排查与实操避坑速查6.1 症状对照表从现象直接定位问题层我把这些年在这类样本上遇到的现象整理成一张对照表出问题时按症状查比漫无目的重来高效得多。症状大概率原因排查动作静态找不到比较调用比较被内联展开成了逐字节循环找循环里的逐字节比较与提前返回而非函数调用找到了算法但算出的值总差一点位宽没截断或乘数识别错动态看每轮写回的实际值逐轮对账数值对但程序不认格式化大小写、补零宽度、前后空格断在比较处直接看两个缓冲区的字节内容用户名短就失败存在最小长度检查静态找与常数比较后跳失败的分支用户名含大写就失败存在大小写规范化用同字母不同大小写对比测试超长用户名算出错输入被截断到固定缓冲长度检查接收缓冲区的实际长度限制与拷贝逻辑调试器下程序直接退出有反调试检测在调试标志查询的返回处观察分支条件单步跟丢循环循环内调用了函数导致栈切换在循环变量写回处设断点代替全程单步这张表里我最想强调第一行。很多人卡住是因为执着于找比较函数调用但编译器在比较短字符串时经常直接内联成几行逐字节对比加提前返回完全没有函数调用。这时候找函数是永远找不到的得换思路找不相等就跳走的模式。6.2 三个我亲身踩过的坑第一个是关于栈偏移的误判。我一开始把某个偏移地址认定是用户名缓冲区单步下去发现内容和我的输入对不上一度怀疑输入没进去。后来才明白那个偏移是格式化后的输出缓冲区用户名的真实位置在另一个偏移上。教训是不要靠偏移的相邻关系猜含义要在程序刚开始接收输入的那一刻就把所有相关地址记下来一劳永逸。第二个是忘了动态验证就下结论。有次我静态读完觉得算法是乘 17写完脚本一跑全错。回头逐轮对账发现从第二轮开始就不对实际是乘 33。原因是那两条指令中间隔了一行我没注意的add我以为它是在处理字符实际上它是把结果又加了一次自身。这让我立了个规矩任何乘数、加数、初值必须在动态里至少看到一次实际运算才能确认。第三个是关于验证环境的。我有一次算出的序列号在调试器里手工验证通过但直接跑程序却失败折腾了半天发现是复制粘贴时带了尾部空格。这个错误低级到不好意思说但它提醒我一件事输入里的不可见字符是最常见的算法明明对但就是不通过的原因。现在我一律在比较断点处直接读内存里的实际字节不看自己屏幕上打出来的东西。6.3 把流程固化成模板的收益做完这题之后我把整套流程写成了一份检查清单下次拿到新的样本直接照着走识别位数与保护、跑一遍看输入输出行为、字符串交叉引用定位主逻辑、找比较点反推算法、动态逐轮对账、写脚本、四组验证、最后才考虑改跳转做交叉确认。清单看起来死板但它的作用是把我该想什么变成我该做什么省掉了每次开局那段最耗神的迷茫期。我做第三个类似样本的时候从打开文件到注册机跑通只用了不到四十分钟而第一个花了一整晚差别主要就在有没有这套流程。最后分享一个我自己一直在用的判断标准如果你能把这道题的算法用三五句话说清楚并且当场手算出一个合法序列号那这题才算真的过了。能改跳转、能看见成功提示都不算。这个标准听起来严但它保证你每做一题都是净增长而不是在同一个层面上重复劳动。