2026/11/3 11:38:55

从静态反汇编到动态调试:crackme-002逆向分析实战

从静态反汇编到动态调试:crackme-002逆向分析实战 1. 先搞清楚 crackme-002 到底在考什么1.1 从玩具程序到思维训练场crackme 这类东西外行看着像破解练习圈内人更愿意把它叫逆向思维的训练器材。它是一个专门写出来给人练手的小程序功能单一得很弹个框让你输入一串东西判断对错对了就给你一句Congratulations。它不承载业务不保护资产唯一的作用就是把逆向分析里最核心的那条链路——观察、假设、验证、收敛——压缩到几十KB的体量里让你在半小时内跑完一遍完整流程。所以看到 crackme -002 这种编号我的第一反应不是这是个难题而是这是一个被设计过的题面出题人一定在某个地方埋了明确的考察点。002 这个序号通常意味着它是系列里的第二题难度比 001 高一点但还没到上反调试、上虚拟机壳的程度大概率停留在字符串处理、简单算术变换、循环校验这个层级。我之所以拿它出来单独写一篇是因为这类小样本特别适合建立分析习惯。真实项目里的二进制动辄几十MB、几千个函数你不可能一上来就全看懂必须靠一套稳定的切入方法把范围一步步缩小。crackme 的好处是它足够小你可以在两小时内把整套方法跑三遍把每个动作练成肌肉记忆。等你面对真正复杂的样本时手上的动作就不需要现想直接按流程走。这篇内容适合三类人看完全没有逆向基础但想入门的、做过一两次但每次都是瞎撞撞对的、以及想把自己分析流程规范化的人。如果你已经能闭着眼写反汇编器那这篇对你参考价值不大。1.2 拿到样本后的三个前置问题很多人打开文件就直奔反汇编窗口翻两页看不懂就放弃了。我在动手之前会先强迫自己回答三个问题这三个问题的答案决定了后面所有动作的方向。第一个问题它要什么。是要求输入用户名加注册码两段还是只要一段密码还是要拖一个文件进去形态不同校验逻辑的组织方式完全不同。单段密码最简单两段的话第二段大概率是由第一段推导出来的那就意味着你必须先搞定第一段的处理逻辑才能推算第二段。第二个问题它怎么判断。是跟你输入的明文直接比还是把你输入的东西做一遍变换再比还是根本不需要你输对——它用一个循环把你的输入累加成一个数只看那个数等不等于某个常量。这三种情况的分析成本差着一个数量级。第三个问题它把答案藏在哪。有的题答案就明晃晃摆在字符串里strings一跑就出来了有的藏在比较指令的立即数里有的干脆不藏答案只藏了一个等式你得自己解方程。这三个问题在没有打开反汇编器之前就能回答一部分——跑一遍程序、看看界面、故意输错几次看提示变化这些信息成本极低但价值极高。我见过太多人跳过这一步结果在反汇编里翻了两个小时才发现哦原来它还有第二段输入。1.3 工具清单与环境准备工具这块我不建议一上来就装满一硬盘。逆向分析的工具链是有主次的先把核心三件套用熟剩下的按需补。下面这张表是我常用的组合覆盖了绝大多数 crackme 的分析场景。类别工具用途上手成本静态反汇编免费版反汇编器IDA Free / Ghidra 等看反汇编、看伪代码、交叉引用中动态调试用户态调试器x64dbg 等下断点、看寄存器、跟执行流中快速侦察strings / PE 信息查看器拉字符串、看导入表、看编译器特征低十六进制任意十六进制编辑器看常量、改字节做验证实验低辅助记录纯文本笔记记录地址、寄存器快照、假设列表零注意所有分析都应当在你自己搭建的隔离环境里进行样本来源必须是你自己编写或公开教学用途的练习程序。分析商业软件并绕过其授权机制属于违规行为这条线不能碰。环境上我强调两点。第一一定要用快照。调试到一半程序崩了、或者你手滑改错了字节有快照能省你半小时。第二务必建一个笔记文件。人脑记不住二十个地址你在反汇编里来回跳三次就会开始混淆哪个函数是哪个。笔记不用讲究格式我自己就是地址 - 一句话说明 - 当前假设三列写满一页回头看思路比任何工具都清晰。1.4 002 这类题目的典型结构预判基于这类练习程序的常见设计002 大概率长这样一个窗口一个输入框一个按钮点按钮触发校验函数。校验函数拿到输入字符串后先做一个长度检查不满足就直接失败长度过了之后进一个循环逐字符做某种运算把结果累加或者拼到一个缓冲区最后拿这个结果跟一个硬编码的常量做比较相等就弹成功提示。我之所以这么预判是因为这套结构几乎是教学类 crackme 的标准模板能同时考察字符串读取、循环分析、算术逆推三个基础能力而出题成本很低。当然也有变体比如把比较换成哈希、把循环换成查表、把常量藏在资源段里。预判不是结论它的作用是让你在反汇编里有个找东西的方向找到对得上就加速对不上就修正假设。这个先假设后验证的循环才是逆向分析真正的内核。2. 静态分析让二进制先自己开口说话2.1 第一步不该是打开调试器新手最常见的动作是双击样本、调试器自动挂上去、然后按 F8 一路单步。这条路能走通但效率极低因为你在用线性方式探索一个非线性结构。程序真正的校验逻辑可能埋在三层调用之下你从入口点一步步走过去中间要穿过运行时初始化、窗口创建、消息循环全是与题目无关的噪音。正确的顺序是让我先把文件读一遍。看文件大小——几十KB说明没加壳逻辑全在里面几百KB以上可能带运行库静态链接得先剥掉看区段——正常代码段加数据段如果有个区段名字奇怪、大小异常、熵值很高那可能被压缩或加密过。看导入表——这是信息密度最高的地方。一个只用了几十个 API 的程序导入表就是它的能力清单。有字符串比较相关的函数说明可能是明文比对有格式化输出函数说明可能在拼接字符串有大量数学相关函数说明可能在做运算。这些信息不需要任何专业知识就能拉出来花三分钟看完你对这个程序的轮廓就有了基本判断。我见过有人跳过这一步直接进调试器跟了四十分钟才发现程序根本没使用任何输入函数——它的密码是硬编码在资源里的。三分钟的侦察能省掉四十分钟的盲目摸索这笔账怎么算都划算。2.2 字符串与导入表里藏着线路图拉字符串是成本最低、回报最高的一个动作。跑一遍把输出按长度排序从长到短看。为什么从长看因为短字符串大量是编译器和运行库留下的噪音长的更可能是人写的。你会看到什么提示语、窗口标题、按钮文字这些是界面层还有一类是看起来不该出现在界面上的东西——一串十六进制字符、一段看似无意义但长度固定的字母数字组合、一个格式化的模板字符串。这些就是重点怀疑对象。举个典型例子你可能会看到类似%s加一个 32 位长度的可疑字符串。%s说明这里做了字符串格式化那么紧邻它的那个字符串就极可能是格式模板而格式化操作的参数来自哪里顺着交叉引用就能摸到校验函数附近。这就是从数据反推代码的思路数据是死的代码是活的但从死的常量出发找活的逻辑往往比从入口点顺流而下更容易命中。导入表要分两类看。一类是输入获取一类是结果判断。输入获取类告诉你数据从哪个 API 进来在那里下断点必然能截到你的输入结果判断类通常出现在比较的地方是定位校验核心的最快路标。如果导入表里比较类函数很少甚至没有那说明作者自己实现了比较逻辑比如逐字节对比的循环这种情况下你就得靠循环结构和条件跳转来找难度会高一些。2.3 反汇编视图里的三个路标反汇编窗口打开之后满屏指令容易让人发懵。我不会逐条读只找三个东西。第一个是条件跳转密集区。一段代码里连续出现多个条件跳转且跳转目标都在附近这几乎可以肯定是一个判断链——长度检查、字符集检查、格式检查之类的连续校验。这类区域的典型特征是检查失败就跳到一段简短的失败处理成功就继续往下。你只要找到那个继续往下的落点就穿过了第一道门。第二个是循环结构。循环在反汇编里很好认一个向后的跳转跳转目标在前面不远。找到循环之后看循环体里访问了什么——如果它在按索引读你输入的缓冲区那这个循环就是校验的核心。循环是这类题目的心脏几乎所有关键逻辑都在循环里。第三个是对常量的引用。反汇编里那些以立即数形式出现的十六进制值尤其是看起来不像地址也不像常见掩码的值比如0x1F3A这种多半就是比较用的目标常量。找到它被引用的位置你就站在了答案门口。这三个路标之间有天然的关联常量在循环里被比较循环外面包着条件跳转。把这三者的位置在笔记里标出来画出它们之间的调用关系你就得到了一张简略的逻辑地图。这张图不需要精确能指方向就行。2.4 伪代码阅读先看主干再看枝叶有条件的话一定要用带反编译功能的工具。伪代码的价值不在于更准确而在于降低认知负荷。汇编里一个mov / cmp / jne三连在伪代码里就是一句if (a ! b)省掉的是你大脑的翻译工作。但伪代码有个陷阱它会把你输入的每个细节都详细展开看起来信息量很大实际上大部分是无关的。我的读法是先扫签名和循环后看细节。快速滚动只看函数签名、循环、条件判断把整体骨架在脑子里搭出来。搭完之后再回头逐个看关键函数内部。伪代码里我最关注的是变量命名和类型。反编译器做类型推断时如果它把某个变量推成char并且在一个循环里被反复索引那基本就是输入缓冲区如果推成int且被不断累加那就是校验和类的累加器。这两类变量同时出现在一个函数里这个函数就是你要找的东西。还有一个小技巧看函数的参数个数。校验函数通常接收一到两个参数一个是输入缓冲区指针一个是长度。如果一个函数接收三个以上参数它更可能是通用工具函数而不是校验逻辑本身。用参数个数做快速过滤能帮你在一堆函数里迅速筛掉不相干的。2.5 静态分析的边界在哪里静态分析不是万能的它有两个明显的天花板。第一是间接调用如果校验函数是通过函数指针调用的静态视图里你只能看到一个寄存器间接跳转找不到明确的调用目标。第二是自修改代码或运行时解密代码在文件里是加密的跑起来才解开静态看就是一堆垃圾字节。碰到这两种情况就别在静态视图里死磕了切到动态调试。这也是我为什么强调静态先行、动态跟进——静态负责建立整体认知和缩小范围动态负责突破静态看不清的地方。两者不是替代关系而是接力关系。反过来也一样如果你在动态调试里跟丢了一次回到静态视图重新看整体结构往往能重新找回方向。来回切几次是很正常的别觉得这是走了弯路。3. 动态调试把猜变成看3.1 断点策略的选择与优先级动态调试的核心是断点但断点不能乱下。下多了程序处处停顿你反而看不出流程下少了又抓不住关键瞬间。我的策略是分级下断逐层收窄。第一级断点下在输入获取 API 上。这是最稳的起点因为无论程序后面怎么处理输入总要从这里进来。在这个断点上停下来你能看到输入缓冲区的地址、长度、原始内容。把地址记下来这是后面所有分析的锚点——后面你在任何地方看到这个地址被引用那就是在读写你的输入。第二级断点下在字符串比较类 API 上。如果程序用了系统提供的比较函数在这里下断能直接截到比较双方分别是什么。这一刀往往能省掉大量分析工作因为比较的一方是你输入的变换结果另一方就是目标。看到目标之后逆向工程就从分析变成了构造——你只要反推出什么样的输入能产生那个目标值。第三级断点是内存访问断点。当前两级都没收获时用这个对你输入的缓冲区下访问断点程序每次读这块内存都会停下来。这个断点信息量极大但噪音也大因为程序可能因为各种原因读这块内存。用的时候要配合条件比如只在读取位置超过某个偏移时才停。提示内存访问断点在多线程程序里可能会被大量无关线程触发切到单线程模式或者用条件过滤能大幅降低噪音。三级断点用完还没定位到说明这个题目的保护强度超出预期或者你的假设方向错了。这时候不要硬撑退回去重新做静态侦察检查是不是漏掉了某个入口。3.2 单步的节奏控制单步是调试器最基础的功能也是最容易用错的功能。F8步过和 F7步入的差别本质上是你想不想进入这个调用。判断标准很简单如果这个调用是运行库或者系统 API你大概不想进去除非你在追它内部的行为如果这个调用是自己写的函数且在静态视图里看着可疑那就进去。真正的技巧在于什么时候不该单步。循环是最典型的例子。一个循环跑二十次你单步跟二十遍毫无意义因为每一遍的逻辑是一样的。正确做法是在循环里找一个判断点在那里下条件断点让程序自己跑到你关心的那一轮。比如你想看第 10 次的中间值就设条件为计数器等于 10。另一个不该单步的场景是长串的初始化代码。程序启动时会有大量内存清零、结构体填充之类的操作这些跟校验逻辑毫无关系。识别方法是看这段代码调用了什么——如果全是内存操作类 API 且没有涉及你的输入缓冲区直接跳到后面。我自己的节奏是粗跟 → 找到可疑函数 → 在函数入口下断 → 重跑 → 在函数内精跟。粗跟阶段用 F8 快速掠过只在遇到自己写的函数调用时稍作停留精跟阶段才逐条看寄存器变化。这样能把单步量控制在几十步以内效率高很多。3.3 现场记录寄存器与栈的快照法调试过程中最痛苦的事是刚才那个值我忘了。所以记录必须做而且要养成条件反射式的习惯。我在每次关键停顿处会记四样东西当前地址、关键寄存器的值、栈顶附近的内容、以及一句话说明这段代码在干什么。为什么要记栈因为很多校验函数的中间变量、局部缓冲区、循环计数器都放在栈上。你在循环里看栈的变化能直接看出这一轮处理的是第几个字符。而且栈上的局部缓冲区往往就是变换后的结果存放处你在这里看到的字节序列很可能就是最终拿去比较的东西。记录方式上我推荐对比法而不是单点记录。同一个循环记录第 1 轮和第 2 轮的寄存器、栈内容两者一比就能看出哪个寄存器是索引、哪个是累加器、缓冲区是怎么增长的。这比单看一轮信息量大得多也更容易发现规律。3.4 从输入到比较到输入变换到比较的思维切换这是我在分析这类题目时感受最深的一个转变点。新手往往期待这样的流程我输入abc程序直接拿abc跟xyz比较。这种情况下xyz就是答案看到就赢了。但 002 这个难度的题目几乎肯定会加一层变换。流程变成我输入abc程序把abc的每个字符做点运算得到一个中间结果再拿中间结果跟目标比。这种情况下你看到的比较目标不是答案是答案经过变换后的样子。你必须把变换逆过来才能得到真正的输入。这个认知切换非常重要。很多人在调试器里看到比较双方一个是0x1F3A一个是某个值就直接把那个值拿去当输入试结果不对然后困惑明明比较成功了为什么输入不对。原因就是他把中间值当成了最终答案。养成习惯看到比较先问一句这个值是从哪来的顺着数据流往回追追到输入端为止中间的每一层变换都要记下来。3.5 让程序自己告诉你答案的工具化思路有一类情况特别值得提当你已经大致看出变换逻辑但手工逆推太麻烦或者容易算错时可以让程序替你算。具体做法是写一小段脚本或者用调试器的表达式求值功能把变换函数原位复用——找到变换函数在内存里的地址从你自己的代码里调用它喂不同的输入观察输出。这样你就不需要手工推导公式只需要在输入空间里搜索。如果输入空间很小比如只有几位数字直接穷举如果输入空间很大就利用变换的可逆性减少搜索量——固定一部分输入只对一个字节做穷举通常是 0 到 255 共 256 次几秒钟就能跑完。这个技巧在对付循环累加型的校验时特别好用后面第 4 章会细讲。4. 常见校验模式与逆推方法4.1 明文比较型最简单也最容易看漏明文比较型是校验逻辑的最基础形态——程序把你的输入原封不动地跟一个硬编码字符串比较。这种题目的难度通常不在逻辑而在隐藏。目标字符串可能被拆成几段分别存放运行时拼接可能每个字节都加了个固定偏移在比较前才减回来可能被编码成看起来像资源 ID 的十六进制数组。对付这类最有效的手段是在比较点上直接读两个操作数。断在比较指令上看寄存器或者栈上的两个值其中一个是你的输入另一个就是目标。如果是被偏移过的你会在比较前的几条指令里看到减法或者异或操作把偏移量记下来自己运算即可。有一个细节要注意如果比较的是宽字符你在调试器里看内存会看到每个字符后面跟一个零字节。这时候别急着下结论说目标字符串里有空字符那只是编码方式。切换调试器的显示格式到宽字符或者手动跳过零字节去读就能看到真正的文本。这个坑我踩过不止一次第一次遇到时盯着T\0e\0s\0t\0看了半天没反应过来。4.2 长度与字符集约束先摸清合法输入的边界在进入真正的校验之前程序往往先做一层合法性过滤长度必须等于 N或者必须是由数字组成或者必须包含至少一个特殊字符。这层过滤不是考你的重点但它决定了你后面搜索的空间大小所以必须先摸清楚。摸的方法很直接——故意输不同长度的输入看程序在哪一步失败。如果长度为 5 时提示长度错误长度为 8 时提示内容错误那说明长度要求是 8。如果各种长度都报同一个错那说明没有长度检查或者检查藏在更深处。字符集约束同理尝试输入非数字、非字母的字符看是否被拦截。这类信息在你后面做穷举搜索时非常关键——如果确定输入只能是数字搜索空间就从 256 的 N 次方直接降到 10 的 N 次方天壤之别。注意这一步要用动态方式做不要靠静态猜。静态视图里找到的长度检查可能是个通用函数被多处调用你未必能确定它是不是作用在校验路径上。跑一遍看实际行为最可靠。4.3 算术变换型把算式倒过来写这是这类题目里最常见的考察点。程序对你的每个字符做一个算术运算然后比较结果。比如unsigned char transform(unsigned char c) { return (unsigned char)((c * 3 7) 0xFF); }如果目标输出是0x40你需要解(c * 3 7) 0xFF 0x40。直接减 7 得0x39然后除以 3——但整除在这里是陷阱因为模 256 域上的除法要用乘法逆元。3 在模 256 下的乘法逆元是 171因为3 * 171 513 2 * 256 1 ≡ 1 (mod 256)。所以unsigned char invert(unsigned char t) { return (unsigned char)(((t - 7) 0xFF) * 171 0xFF); }代入t 0x40(0x40 - 7) 0x39 5757 * 171 97479747 0xFF 9747 - 38 * 256 9747 - 9728 19。所以 c 19也就是一个控制字符。这个结果看着奇怪但数学上是对的——如果程序接受的输入范围是全字节那答案就可能是不可打印字符如果程序只接受可打印字符那说明这个变换参数我推错了得回调试器重新确认。这个例子说明两件事。第一模运算下的除法要用逆元直接除会丢精度。第二算出来的答案要拿回程序验证如果答案是控制字符而程序明显只接受可见字符那说明变换公式记错了。这个交叉验证的习惯能帮你及早发现推导错误。常用变换的逆运算我整理成表遇到时直接查正向变换逆运算备注c kt - k注意模 256 回绕c - kt k同上c ^ kt ^ k异或自逆最省事c * kk 为奇数t * inv(k)需先求 k 在模 256 下的逆元~c~t按位取反自逆rol(c, n)ror(t, n)循环移位(c n) | (c (8-n))反向循环移位注意 n 为 0 的边界奇数在模 256 下一定有逆元偶数没有——这也是为什么很多题目喜欢用乘以奇数既增加了推导难度又保证了变换是双射每个目标值都对应唯一解。4.4 循环累加与异或型利用可分离性降维比单字符变换更进一步的是把整个输入揉成一个值再比较。典型结构是这样int check(const char *s, int len) { unsigned int sum 0; for (int i 0; i len; i) { sum (unsigned char)s[i] * (i 1); } return sum 0x1F3A; }这种情况下你没法逐字符独立求解因为每个字符的贡献被加在了一起。但这不代表没法解——利用可分离性把 N 维搜索降成 2 维。思路是固定前len-1个字符为任意值比如全填A只让最后一个字符变化。由于前len-1个字符的贡献是固定的最后一个字符的贡献是c * len整个式子就变成了一元一次方程c可以直接解出来或者在 0 到 255 范围里穷举 256 次。如果解出来不在可打印字符范围内说明你选的任意值不合适换一组再试。多试几组不同的填充值就能找到一组让所有字符都可打印的解。这个方法对付累加型、异或型都有效本质上是把求一个满足条件的向量变成求一个满足条件的标量。异或型还有一个额外好处异或满足交换律和结合律你可以任意调换字符顺序来调整结果。如果解出来的字符组合里有不可打印的把可打印字符的位置换个排列往往就能凑出合法答案。这个技巧在字符集受限的题目里特别管用。4.5 初级反调试的处理思路002 这个难度大概率不带反调试但如果带了处理思路值得提前说一下。初级反调试通常就几招检查调试器进程、检查调试寄存器、用时间差检测单步、检查代码段是否被下断点。这些手段的共同特点是它们最终都要产生一个分支——检测到调试器就走到另一条路径上。处理的核心思路不是把检测代码全删掉而是找到那个分支改掉它的判断结果。具体做法是在检测逻辑附近下断点看它比较的是什么值然后把这个值改成未检测到调试器对应的值。这样程序的其余逻辑一字不动你只是把它的判断结果掰回来了。更省事的做法是找检测触发的后果在哪里。有些程序检测到调试器会弹一个提示然后退出你只要把那个提示所在的代码路径跳过就行。还有的程序检测到调试器会改变校验逻辑比如把目标常量改掉这种情况下你必须找到常量被改写的位置在改写之后、使用之前把它改回来。提示不管用什么方法绕过处理完之后一定要重新完整跑一遍确认后续逻辑没有因为你的修改而走偏。我吃过这个亏——改完直接跳到了结论结果后面还有个二次校验没跑导致我以为解出来了实际是假成功。5. 实操复盘002 的完整通关记录5.1 第一阶段信息收集与假设建立下面这一节我按实际操作的顺序还原一遍。开始之前先说清楚这里描述的样本是我自制的练习程序结构上模仿了常见的教学型 crackme不涉及任何真实软件。先跑程序。窗口很简单一个输入框一个按钮。随便输test点按钮弹出一个输入不正确的提示框。再试空输入提示变成请输入内容。这就拿到了第一条信息存在空输入检查说明校验入口处会先判长度。拉字符串。按长度排序看到几条界面文本还有一条值得注意的——一个十六进制的数组看起来像0x1A, 0x2B, 0x3C, ...这样的字节序列长度大概十几个。这个序列长度跟我想象中的密码长度接近先标记为高度怀疑对象。看导入表。有输入框读取相关的 API有几个字符串处理函数比较类函数不多。这个组合说明程序读取输入做了某种处理然后比较——但比较可能不是调库而是自己实现的循环。这跟我在第 1.4 节的预判吻合。能量化的信息先量化下来疑似密码长度大概 12 到 16 之间从字节数组长度推测存在空输入检查存在字符串处理。基于这些我建立第一个假设程序会把输入逐字符做变换变换结果跟那个字节数组比较。5.2 第二阶段定位校验核心打开反汇编器跳到入口点但不在入口点停留直接去找导入的输入获取 API 的调用位置。有三处调用都在同一个函数里看上下文像是窗口创建和初始化的逻辑跳过。第四处在另一个函数里紧跟着调用之后有一段循环。这就是目标了。在循环入口下断点重跑程序输入AAAAAAAAAAAA12 个 A点按钮。断点命中。看寄存器有一个寄存器指向我的输入缓冲区内容是41 41 41 ...确认是输入。然后往下单步走完第一轮循环看变化累加器寄存器从 0 变成了0x41 * 1 0x41。第二轮走完累加器变成0x41 0x41 * 2 0x41 * 3。规律出来了累加器每次增加c * i其中 i 从 1 开始。再往后走循环结束后程序把累加器和某个值比较。看这个值——0x1F3A之类的一个常量。同时我看到循环里还有一个分支如果某个条件不满足就直接跳出并失败。仔细看这个条件是(s[i] 0x01) 0之类的东西说明输入字符有奇偶性约束。这条信息很关键它把字符集缩小了一半。5.3 第三阶段逆推与验证到这一步逻辑已经清楚了sum(c[i] * (i1)) TARGET且每个字符要满足奇偶约束。用第 4.4 节的降维法固定前 11 个字符只解最后一个。12 个字符最后一个的系数是 12。设前 11 个字符贡献为 S则S c[11] * 12 TARGET即c[11] (TARGET - S) / 12。这里 12 是偶数不保证整除所以实际做法是把前 11 个字符都填成满足奇偶约束的值比如全填A的 ASCII 是 0x41是奇数符合约束算出差值然后在一个范围内穷举c[11]看哪个能让等式成立。我实测下来全填A的时候差值是 12 的倍数直接算出一个可打印字符。拿到 12 个字符回程序里输进去弹出Congratulations。到这里就算通关了。但我不建议到这里就停。再做一轮边界验证把解出来的字符顺序打乱试试——如果程序只做累加那只要系数对应好、字符值不变实际上顺序换了结果会变因为系数跟着位置走。所以正确的做法是验证改一个字符就失败确认这个解是唯一的。改一下第一个字符果然失败说明约束是严的解是唯一的。5.4 复盘的复盘这次分析里做对了什么回头看这次分析几个动作明显提升了效率。第一是没在入口点单步直接从导入表定位到校验函数省掉了穿过运行时初始化的时间。第二是先读界面行为再看反汇编空输入提示这条信息直接告诉我有长度检查让我在反汇编里一眼认出了那个分支。第三是循环内没有逐轮单步只跟了两轮看出规律就跳出来了。也有做得不够好的地方。我一开始把那个字节数组当成了目标实际上它是另一种用途用在了别的分支里。这个误判让我多花了十几分钟在错误的方向上。后来是靠比较指令旁边的常量这个更可靠的线索找对了目标。这个教训就是静态看到的可疑数据要跟动态看到的实际比较行为对上号才算数别急着下结论。6. 常见问题与排查速查6.1 卡点速查表分析过程中卡住是常态关键是卡住时知道往哪找。下面这张表是我整理的高频卡点覆盖了大部分我明明断在这里了但看不到想看的的情况。现象可能原因排查动作断点不命中校验走了另一条路径或断点位置被跳过改用内存访问断点对输入缓冲区下断看到输入但内容为空断在输入读取之前或读到的是另一块缓冲区确认断点在读取 API 返回之后检查指针指向比较双方都是乱码数据是加密或编码的中间态往回追数据来源找解码/解密位置单步走进系统库出不来误用了步入而不是步过在系统库入口按步出或直接设返回断点程序在断点处崩溃断点位置在关键数据使用前打乱了时序改用硬件断点或换一个更安全的断点位置算出答案但程序不认存在未发现的二次校验或变换公式记错完整跑一遍看是否有二次比较重新核对每步变换输入长度改了就不对长度参与运算或长度本身是校验条件之一记录每一轮循环的索引上限确认长度约束6.2 几条反复踩过的坑坑一把调试器显示格式当成数据本身。调试器默认可能按四字节、按 ASCII、按十六进制显示内存同一个字节序列在不同格式下看起来完全不一样。我第一次遇到宽字符的时候看到T\0e\0s\0t\0还以为字符串里有空字符分隔。养成习惯看内存之前先确认当前显示格式必要时切换。这个动作两秒钟能省掉十分钟的困惑。坑二在 32 位和 64 位之间搞混调用约定。32 位下参数通常在栈上传递64 位下前几个参数走寄存器。你在断点处找参数找错了地方就什么都看不到。看程序是几位的、用的是什么调用约定这是下断点前必须确认的事。坑三改完字节忘了同步文件。有些调试器里改的是内存重启程序就没了。如果你的分析需要反复重跑要么每次重跑前重新改一遍要么把改动写回文件做备份。我在这个上面浪费的时间加起来大概有几个小时。坑四只验证成功没验证失败。解出一个能通过的输入之后很多人就不管了。但有些程序设计成多个输入都能通过或者你的解碰巧命中了某个特殊分支。改一个字符看是否失败这个反向验证只要十秒钟能确认你找到的是真正的解而不是巧合。还有一条心得值得单独说卡住超过二十分钟就换方法。人的思维会陷入局部最优你在一条路上越想越细越细越看不出全局。这时候放下调试器回到静态视图重新看整体或者干脆去泡杯茶回来重想往往一下就通了。这不是玄学是因为你换了个视角重新组织信息。6.3 把每次分析沉淀成模板最后说个长期收益最高的事把分析过程写成可复用的模板。我自己有一个纯文本的检查清单每次分析新样本就复制一份按顺序过一遍——看文件特征、拉字符串、看导入表、列可疑常量、定位输入点、下断点、记录循环规律、推导变换、求解、反向验证。清单本身没什么技术含量但它在状态不好的时候能帮你回到正轨不至于因为一时想不起下一步而卡在那里。模板还要记录这次学到了什么。比如这次我记下了模 256 下的除法要用乘法逆元下次遇到乘法变换就不会再犯除法丢精度的错。这类小知识点攒上几十条你面对新样本的反应速度会明显变快——因为大部分情况你都见过类似的只需要确认细节而不是从零推理。我个人在这类小样本上最大的体会是它考的从来不是你知道多少技巧而是你能不能稳定地执行一套流程。技巧可以查流程得练。002 这种体量的题目认真做完一遍把每个步骤的理由都想清楚收获比囫囵吞枣做十个要大得多。做完之后不妨回过头把整个分析过程倒着走一遍——从最后的答案往回推到最初的输入点看看每一层变换能不能说清楚为什么说不清的地方就是你还没真正搞懂的地方。这个反向检查我做了很多次每次都能发现一两个理解上的漏洞。