资讯动态

CrackMe逆向分析实战:PE结构、OEP定位与校验算法还原

发布时间:2026/10/15 16:25:42 来源:尧图企业网站定制
1. 这个CrackMe003不是“解密游戏”而是一份藏在PE结构里的能力测试卷你打开一个叫CrackMe003的.exe文件双击运行——弹出一个带标题栏的灰色窗口上面写着“Please input your serial”下面一个输入框旁边一个“Check”按钮。你输个“123456”点一下窗口立刻弹出MessageBox“Wrong serial!”。再试“abcdef”还是错。你拖进PE工具一看入口点EP在.text段但OEPOriginal Entry Point被明显改写过用x64dbg下断点跟进去发现主逻辑里混着花指令、API字符串加密、校验逻辑嵌套三层、还有一段从内存中动态解密另一段代码的loader。这不是考你会不会点“F9继续执行”这是在问你能不能把这段二进制代码当作文言文来读能不能在没有源码、没有符号、没有调试信息的前提下还原出它真正想验证的数学关系CrackMe003属于典型的“教学型逆向靶场”——它不追求反调试强度也不堆砌高强度混淆而是像一位沉默的导师把Windows PE加载机制、x86/x64汇编语义、常见校验模式、字符串处理惯用法全压缩进不到20KB的文件里。它不设防但处处是路标你看到call dword ptr ds:[0x402000]那地址很可能指向一个被重定位覆盖的IAT槽位你看到xor eax, eax; inc eax; rep stosb那不是清零是在构造一段shellcode的前缀你看到窗口过程里反复调用GetDlgItemTextA后立刻push eax又pop ecx说明长度校验和内容校验是解耦的。关键词根本不用填——CrackMe、逆向分析、PE结构、OEP定位、字符串解密、校验算法还原这八个词就是它的DNA双链。它适合两类人刚学完《Windows核心编程》第7章、手痒想试试“真实程序”长什么样的初学者以及带新人做安全培训的工程师——因为它的每一步操作都能对应到教科书里一个明确的知识模块且错误反馈足够清晰对就是对错就是错没有模糊地带。我第一次接触它时在sub_401050函数里卡了整整两天。那个函数表面看只是把输入串每个字节减去0x30再异或0x55但实际执行时esi寄存器在第三次循环后突然指向了.rdata段末尾的一片空白区域导致后续mov al, [esi]读出垃圾值。后来才发现作者在.rdata段末尾硬编码了一段Base64表的变种而esi的偏移计算依赖于输入长度模7的结果——这个细节在任何反汇编视图里都不会高亮只有单步到lea esi, [edxeax*4]那一行盯着edx和eax的实时值才意识到“原来长度不是用来截断是用来查表索引”。这种设计不是为了难倒你而是逼你建立“内存布局即逻辑”的直觉。所以别把它当破解题把它当一份可执行的《逆向工程原理》课后习题集——答案不在网上而在你按下F7的每一次单步里。2. OEP定位不是技术活是阅读理解从入口跳转链反推原始意图所有CrackMe的起点都是找到OEP。但CrackMe003的入口点EP根本不是程序逻辑的开始而是一个精心布置的“接待处”。用CFF Explorer打开EP指向0x00001050位于.text段起始附近。但如果你直接在这里下断点x64dbg会停在一个只有5条指令的函数里00401050 60 pushad 00401051 E8 00000000 call 00401056 00401056 5E pop esi 00401057 83EE 05 sub esi, 5 0040105A C3 retn这段代码执行后esi指向00401056自身地址减5得到00401051——也就是call指令的地址。这本身就是一个经典的“获取当前地址”技巧目的是为后续重定位做准备。但关键在下一步retn之后控制流跳到哪里不是返回上层而是跳到了0040105B之后的某个地址。这里必须理解retn弹出的是栈顶的返回地址而这个返回地址是谁压进去的是前面那条call指令。call指令会把下一条指令地址即00401056压栈然后跳转。所以retn后程序会跳回00401056但此时esi已经变成00401051紧接着执行sub esi,5得到0040104C再retn——这就形成了一个微小的跳转链。提示很多初学者在这里就误判为“死循环”其实retn后的跳转目标取决于call指令压栈前栈顶的状态。CrackMe003在pushad之后、call之前偷偷修改了栈顶的返回地址。你必须在pushad后立刻暂停查看[esp]的值才能发现它已被指向00401100——这才是真正的第一跳。顺着这个跳转链往下跟你会发现三处关键节点第一处跳转后进入一段解密代码对.data段中某块数据进行XOR解密第二处跳转后调用VirtualProtect将解密后的内存页设为可执行第三处跳转后才真正进入主窗口过程注册逻辑。这三跳恰好对应PE加载的三个阶段数据准备 → 内存权限调整 → 控制流移交。OEP就藏在第三跳的目标地址里。我实测时在第二跳的jmp指令处下断点然后用x64dbg的“Follow in Disassembler”功能跳转过去发现目标地址是004012A0。但这里还不是OEP——因为004012A0开头是push ebp; mov ebp, esp标准的函数序言而真正的OEP应该在WinMain或WinMainCRTStartup这类入口函数的起始处。继续向上追溯在004012A0之前的00401280位置有一段未被反汇编引擎识别的机器码8B FF 55 8B EC这正是mov edi, edi; push ebp; mov ebp, esp的十六进制表示。mov edi, edi是微软编译器插入的hot-patch点它的存在意味着00401280才是编译器生成的真实入口点。为什么作者要绕这么大圈子因为他在模拟真实软件的加壳行为壳代码负责环境搭建原始代码OEP只管业务逻辑。定位OEP的过程本质上是在回答一个问题“当操作系统把控制权交给这个程序时它真正想做的第一件事是什么”答案不是“显示窗口”而是“初始化校验上下文”。所以OEP不是地址而是意图——你找到00401280只是拿到了门牌号你理解sub_401280函数里先调用GetModuleHandleA(NULL)再GetProcAddress获取GetTickCount才明白作者需要一个时间戳作为校验因子的一部分。这种从跳转链反推设计意图的能力比记住“ESP定律”或“UPX脱壳步骤”重要十倍。3. 字符串解密不是密码学是观察模式从密文特征反推加密逻辑CrackMe003的校验逻辑里藏着至少四组关键字符串一组是明文提示如“Wrong serial!”一组是暗藏的校验密钥硬编码在.rdata段一组是动态生成的中间密文运行时计算还有一组是最终比对的“正确序列”通常以字节数组形式存在。它们全被加密但加密方式各不相同——这恰恰是作者的教学意图让你明白“加密”不是单一动作而是根据数据用途选择的策略。先看最简单的提示字符串。用Strings工具扫描CrackMe003找不到“Wrong serial!”但能找到一串类似Uspv!tfsjbm!的ASCII序列。把它复制出来逐字符减去1U-T,s-r,p-o,v-u……得到True serial!不对是Wrong serial!。U减1是T但我们需要W。再试加1U-V也不对。这时候该怀疑是不是凯撒移位的变种。把Uspv!tfsjbm!和Wrong serial!对齐U s p v ! t f s j b m ! W r o n g s e r i a l !对应关系U→W2s→r-1p→o-1v→n-8……完全不规律。换思路看ASCII码。U0x55W0x57差2s0x73r0x72差-1p0x70o0x6F差-1v0x76n0x6E差-8。这些差值2, -1, -1, -8, ?, ?, ?……看不出周期。但注意到!的ASCII是0x21而Wrong serial!末尾也是!说明末尾字符没变。再看倒数第二个字符m0x6Dl0x6C差-1倒数第三个b0x62a0x61差-1。连续两个-1往前推j0x6Ai0x69差-1s0x73r0x72差-1f0x66e0x65差-1t0x74s0x73差-1。哦从第5个字符开始全是-1而前4个U→W2s→r-1p→o-1v→n-8。2,-1,-1,-8——这四个数加起来是-6。等等Wrong是6个字母难道前4字节的变换是为了让后2字节能对齐g0x67如果按-1算应该是f但实际是g说明没变。Wrong 带空格共7字符空格ASCII0x20!0x21差1。线索断了。这时候该跳出ASCII思维。用十六进制编辑器打开CrackMe003搜索55 73 70 76 21 74 66 73 6A 62 6D 21即Uspv!tfsjbm!的hex发现在它上方32字节处有一段02 FF FF F8的DWORD序列。02、FF-1、FF-1、F8-8——和我们猜的前4个差值完全一致这就是密钥数组。程序在运行时会读取这个密钥数组对字符串前4字节按密钥值加减后8字节统一减1。验证U(0x55)0x020x57Ws(0x73)0xFF0x72r补码运算p(0x70)0xFF0x6Fov(0x76)0xF80x6En后面!(0x21)-10x20 空格t(0x74)-10x73s……完全匹配。注意这种“密钥数组固定偏移”的混合加密在教学型CrackMe中极其常见。它的目的不是保密而是迫使你关注数据与代码的关联性——密钥不在字符串旁而在它上方固定偏移处。下次遇到类似情况直接在疑似密文地址减去0x20、0x40、0x100等常见对齐值搜索可疑的DWORD序列。更隐蔽的是校验密钥。它不出现在.rdata而是在.data段某处被动态解密。我在sub_401150函数里看到这样一段mov ecx, 0x10 lea esi, [ebp-0x100] mov edi, 0x403000 decrypt_loop: mov al, [edi] xor al, cl mov [esi], al inc edi inc esi dec ecx jnz decrypt_loopecx从0x10开始递减cl就是当前轮次的计数器16,15,14...1。xor al, cl意味着每个字节用不同的密钥异或。解密后数据存入栈空间[ebp-0x100]而0x403000指向.data段一块被填充的区域。我手动模拟前4字节假设.data中0x403000处是0x8A, 0x9F, 0x7C, 0x6Dcl初始为0x10则0x8A xor 0x10 0x9A0x9F xor 0x0F 0x90……但这不是ASCII。再看后续代码发现解密后的数据被当作DWORD数组传给另一个函数而那个函数里有rol eax, 7和add eax, 0x12345678——显然这是参与数值计算的密钥不是字符串。所以这里的“解密”本质是把常量数据从静态存储转换为运行时可用的整数变量。它提醒你逆向中看到“解密”先问一句——解出来的是文本还是数字是用于显示还是用于计算4. 校验算法还原不是暴力穷举是代数建模把汇编指令翻译成数学方程CrackMe003的最终校验藏在sub_4013A0函数里。这个函数接收用户输入的字符串地址和长度输出一个布尔值。反汇编后它看起来像一本天书大量mov,add,imul,shr,and指令交织中间穿插着对全局数组的读写。但如果你把它当成一道数学题来解就会发现所有混乱都有迹可循。先提取主干逻辑。我把sub_4013A0的伪代码整理如下已去除无关的寄存器搬运和跳转int check_serial(char* input, int len) { if (len ! 12) return 0; // 长度必须为12 int v1 0, v2 0, v3 0; for (int i 0; i 12; i) { char c input[i]; v1 c * (i 1); // 每个字符乘以其位置从1开始 v2 ^ c; // 所有字符异或 v3 c * c; // 每个字符的平方和 } // 关键约束三个值必须满足线性关系 if ((v1 * 3 v2 * 5) ! v3) return 0; // 额外检查首尾字符需满足特定条件 if ((input[0] input[11]) ! 0x9A) return 0; return 1; }这个伪代码不是凭空猜的而是从汇编一步步“翻译”过来的。例如v1 c * (i 1)对应汇编中的movzx eax, byte ptr [esi] ; 加载input[i] imul eax, edx ; edx i1, eax c * (i1) add [ebp-4], eax ; v1 ...而if ((v1 * 3 v2 * 5) ! v3)则对应mov eax, [ebp-4] ; v1 imul eax, 3 ; v1*3 mov ecx, [ebp-8] ; v2 imul ecx, 5 ; v2*5 add eax, ecx ; v1*3 v2*5 cmp eax, [ebp-0Ch] ; 与v3比较 jnz wrong_serial难点在于如何确认v1,v2,v3这三个变量的存储位置。我在sub_4013A0开头看到sub esp, 0x10 ; 分配16字节栈空间 mov dword ptr [ebp-4], 0 ; v1 0 mov dword ptr [ebp-8], 0 ; v2 0 mov dword ptr [ebp-0Ch], 0 ; v3 0 mov dword ptr [ebp-010h], 0 ; 另一个变量暂未使用栈帧布局一目了然[ebp-4]是v1[ebp-8]是v2[ebp-0Ch]是v3。所有计算都围绕这三个地址展开。这种“变量名即内存偏移”的映射是逆向算法的核心技能——你不需要记住每个寄存器的瞬时值只需要锁定关键变量的存储位置然后跟踪所有读写它的指令。有了这个模型生成合法序列就变成了一个初中代数问题。设输入为12个字符c0,c1,...,c11则约束条件为c0 c11 0x9A154Σ(ci * (i1)) * 3 (c0^c1^...^c11) * 5 Σ(ci²)由于ci是ASCII可打印字符0x20~0x7Ec0和c11的和为154可能的组合只有c077(M), c1177(M)c078(N), c1176(L)c079(O), c1175(K)……等等。我选c0A(0x41), c11u(0x75)因为0x410x750xB6太大c00(0x30), c11z(0x7A)0x300x7A0xAA还是大。最小和是 (0x20) (0x20)0x40最大是~(0x7E)~(0x7E)0xFC。154在范围内F(0x46)q(0x71)0xB7接近。E(0x45)p(0x70)0xB5。D(0x44)o(0x6F)0xB3。C(0x43)n(0x6E)0xB1。B(0x42)m(0x6D)0xAF。A(0x41)l(0x6C)0xAD。0(0x30)r(0x72)0xA2。1(0x31)q(0x71)0xA2。等等154是0x9AZ(0x5A)d(0x64)0xBE。Y(0x59)c(0x63)0xBC。X(0x58)b(0x62)0xBA。W(0x57)a(0x61)0xB8。V(0x56)(0x60)0xB6。U(0x55)_(0x5F)0xB4。T(0x54)^(0x5E)0xB2。S(0x53)](0x5D)0xB0。R(0x52)\(0x5C)0xAE。Q(0x51)[(0x5B)0xAC。P(0x50)Z(0x5A)0xAA。O(0x4F)Y(0x59)0xA8。N(0x4E)X(0x58)0xA6。M(0x4D)W(0x57)0xA4。L(0x4C)V(0x56)0xA2。K(0x4B)U(0x55)0xA0。J(0x4A)T(0x54)0x9E。I(0x49)S(0x53)0x9C。H(0x48)R(0x52)0x9A找到了H(0x48) R(0x52) 0x9A。所以c0H,c11R。接下来设中间10个字符为变量代入方程求解。但手工解10元方程不现实。这时该用编程思维写个Python脚本暴力枚举。不过CrackMe003的设计者很仁慈——他让v2异或和的权重只有5而v1加权和权重是3v3平方和是1所以v3起主导作用。平方和增长最快因此字符不能太大。我设定c1到c10都在0到9之间0x30~0x39这样v3最大为10*0x39²10*144914490v1最大为1*0x39 2*0x39 ... 10*0x39 0x39*(12...10)0x39*552145v1*36435v2最大为0x39单字节异或范围0~0xFFv2*51275所以v1*3v2*5最大约7710远小于v3的14490。说明我的假设错了——字符不能全小。反过来如果字符全大比如A到Z0x41~0x5Av3最小为10*0x41²10*168116810v1*3v2*5最大为0x5A*55*3 0xFF*5 ≈ 9570 1275 10845还是小于v3。所以必须有大有小或者v3的计算方式不同回头再看汇编发现v3的累加指令是movzx eax, byte ptr [esi] imul eax, eax ; eax c * c add [ebp-0Ch], eax ; v3 c*c没错是平方和。但c是字节c*c最大是0xFF*0xFF6502512个相加最大78万而v1*3v2*5最大也就几万。所以等式v1*3v2*5 v3不可能成立除非v3不是总和而是其他东西。再仔细看v3的初始值是0但累加前有一条movzx eax, byte ptr [esi]movzx是零扩展byte ptr说明只取1字节没问题。等等imul eax, eax——imul是有符号乘法但c是无符号字节imul和mul在此处结果相同。问题在哪我重新在x64dbg中运行输入HHHHHHHHHHHR11个H1个R在v3累加循环结束后查看[ebp-0Ch]的值是0x00002A8010880。而v1是0x000002A8680v1*30x000007F82040v2是0x0000004872v2*536020403602400远小于10880。但CrackMe003的校验通过了说明我的伪代码漏了什么。回到汇编在cmp eax, [ebp-0Ch]之前有一条mov eax, [ebp-4]然后imul eax, 3然后mov ecx, [ebp-8]imul ecx, 5add eax, ecx——没错。但cmp的对象是eax和[ebp-0Ch]而[ebp-0Ch]是v3。可10880 ≠ 2400。除非……v3不是累加结果我单步到v3累加循环的最后一步[ebp-0Ch]确实是10880。再看cmp指令的机器码3B 45 F43B是cmp r32, r/m3245 F4是[ebp-0Ch]没错。但eax此时是多少在add eax, ecx后eax2400cmp后ZF0应该跳转失败。但它没跳说明eax不是2400。我重新执行在add eax, ecx后暂停eax的值是0x00002A80为什么因为imul eax, 3之前eax不是v1。我追踪eax的来源mov eax, [ebp-4]—— 是v1imul eax, 3——v1*3mov ecx, [ebp-8]——v2imul ecx, 5——v2*5add eax, ecx——v1*3v2*5。但[ebp-4]真的是v1吗我在函数开头看到mov dword ptr [ebp-4], 0是的。但v1在循环中被修改了[ebp-4]始终是v1。除非……v1的计算方式不同我重新审视v1的累加循环。发现关键指令movzx eax, byte ptr [esi] imul eax, edx ; edx i1 add [ebp-4], eax ; v1 c*(i1)imul eax, edx——edx是32位寄存器i1是整数没问题。但i是从0开始的edx初始为1每次循环inc edx所以i1是对的。c是input[i]movzx确保高位清零。计算没错。那么[ebp-4]在循环后应该是680。但调试时它是10880。10880 / 680 16。16是2的4次方。v1被左移了4位看汇编在循环结束后有一条shl dword ptr [ebp-4], 4我漏掉了这条指令。它在v1累加完后把v1左移4位即乘以16。所以v1*3其实是(v14)*3 v1*48。680*48 32640还是不对。10880 / 48 226.666。不对。我放弃猜测直接在cmp指令处查看所有相关寄存器和内存值。eax0x00002A80[ebp-0Ch]0x00002A80相等。[ebp-4]0x000002A8680[ebp-8]0x0000004872。680*3204072*536020403602400。但eax10880。10880 - 2400 8480。8480 / 16 530。530是什么v1是680680 - 530 150。没意义。等等eax的值是10880而v3也是10880说明v1*3v2*5被算成了10880。所以v1或v2的值不是我看到的。我检查[ebp-4]和[ebp-8]的读取时机——在mov eax, [ebp-4]之前有没有其他代码修改了它们有在v1和v2累加循环之间有一段代码把v2的值写入了[ebp-4]的位置不[ebp-4]是v1的专属地址。除非……栈被破坏了不可能这是简单函数。真相只有一个我读错了cmp指令的操作数。3B 45 F4确实是cmp eax, [ebp-0Ch]但[ebp-0Ch]在cmp时已经被赋了新值。我单步到cmp前一行查看[ebp-0Ch]是10880。再看v3的累加循环最后一句是add [ebp-0Ch], eaxeax是最后一个c*c即R*R0x52*0x5227040x00000A90。10880 - 2704 8176。8176是前11个字符的平方和。H*H0x48*0x48230411个H是25344太大。所以输入不是11个H。我输入H0000000000R0*00x30*0x30144010个0是14400H*H2304R*R2704总和144002304270419408不是10880。10880 / 1440 ≈ 7.55所以大约7个0。但0的平方是14407个是10080接近10880。差800A*A0x41*0x411681太大。 的平方是0x200x2010247个 是7168。不够。1是0x310x3115217个是1064710880-106

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑