从零破解Windows CrackMe:静态分析与动态调试实战
作为一个常年在BUUCTF上刷REVERSE题的老人我对crackMe这种题感情很复杂说难不难说简单也不简单。BUUCTF上这类题收录了不少特点就是给你一个Windows小exe让你输入用户名和序列号通过校验就算破解成功最终目标是拿到flag。这类题最大的价值在于逼你把静态分析、动态调试、算法还原这一整套逆向基本功走一遍。这篇文章把我解这类题的完整流程拆开讲以一道典型的Windows平台crackMe为例从拿到exe开始到查壳、脱壳、静态定位、动态跟踪、逆向出注册码一步一步说清楚。当然这里的crackMe是CTF竞赛训练样本专门用于学习逆向原理不是让大家拿去对付商业软件。1. 拿到crackMe之后先别急着双击1.1 用file命令认清目标格式许多新手拿到附件后第一件事就是双击运行然后把输入框里的错误弹窗截个图发到群里问“这题怎么做”。我不是说不能先跑一遍而是说在跑之前你应该先搞清楚你拿到的是什么东西。CTF题目里逆向附件可能是Windows的exe、Linux的ELF、macOS的dmg甚至一个固件。不同平台用的工具链和调试手段完全不同。在Linux环境或WSL里一个file命令就能告诉你答案$ file crackMe.exe crackMe.exe: PE32 executable (console) Intel 80386, for MS Windows看到PE32 executable (console) Intel 80386说明这是一个32位Windows控制台程序不是GUI程序也不是.NET程序。这里有个容易忽略的点32位PE还是64位PE决定了后面用什么调试器、用多少位环境。如果是64位程序那入口和寄存器名会是rax/rbx等如果是32位则是eax/ebx。这道题是32位x64dbg和OD都能处理x64dbg更顺手。除了file我还会用DIE或Exeinfo PE看一眼区段名。常见加壳程序的区段名会有明显特征比如UPX壳的区段叫UPX0、UPX1ASPack会有.aspack。为什么这一步很重要因为带壳程序在IDA里看到的是一堆jmp和call根本没法静态分析提前识别壳能省下大量时间。1.2 查壳与脱壳UPX壳的一键处理与手工处理我用DIE检测了一下这个crackMe显示“UPX”。UPX是很经典的压缩壳遇到它先试一键脱壳$ upx -d crackMe.exe -o crackMe_unpacked.exe如果提示“NotPackedException”说明它被改过壳特征或者不是标准UPX那就需要手工处理。更常见的场景是题目作者给UPX壳改过头upx -d不认这时候就需要ESP定律。ESP定律的原理一句话能讲明白UPX加壳程序在被调试器加载后入口处通常先执行pushad把通用寄存器全部压栈保存然后才开始解压缩。pushad执行完后栈顶即ESP寄存器的值指向所有被保存寄存器的区域这个值在后续解压过程中很少被改动。你可以在此时对ESP指向的内存下一个硬件访问断点等壳解压完、执行popad恢复寄存器前断点就会命中。具体操作流程x64dbg加载crackMe.exe默认停在系统断点按F9运行到程序入口OEP前。入口第一条指令通常就是pushad。单步执行pushad此时查看ESP值比如0019FF74。在命令框输入hr 0019FF74对这块内存下硬件访问断点。按F9运行程序会停在某个popad指令附近。删除硬件断点继续单步跟踪几步会看到一个jmp指令跳到真正的OEP即原来程序的入口代码。这时候可以dump内存生成新的exe但dump出来的文件导入表往往是坏的需要用Scylla等工具修复IAT。这里我先说结论UPX壳能自动脱就自动脱自动脱不了再手脱手脱后一定记得修复导入表否则后面IDA分析会看到一堆无意义的指针。2. 静态分析在IDA里梳理程序脉络2.1 字符串是第一线索脱壳后的exe用IDA打开。这里我用的是IDA 64位版本打开32位PE选择“Binary”加载也可以但用默认的Portable executable for Intel 80386更省事。打开后会进入入口点先不急着看汇编切换到字符串窗口ShiftF12。这个操作几乎是所有逆向题的起手式因为正常程序为了提示用户总会有一堆可见字符串。crackMe必然有类似Name:、Serial:、Wrong、Right之类的东西。果然我看到几个感兴趣的.data:00404080 aName: db Name:,0 .data:00404090 aSerial: db Serial:,0 .data:004040A0 aFail: db Try Again!,0 .data:004040B0 aSuccess: db Congratulations!,0在IDA中点中aSuccess按CtrlX查看交叉引用会跳到引用它的代码地址通常就是成功分支。顺着这个分支往上翻能看到若干cmp、jz、jnz指令和调用函数。这个“从结果字符串倒推比较逻辑”的方法比从入口函数一步步跟下来快得多。这里有个技巧很多程序的字符串不是直接明文存储而是先异或加密运行时再还原到缓冲区备用。如果ShiftF12里搜不到关键提示词别急着认定“没有字符串”后面第5章我会专门说怎么处理。2.2 从入口函数到疑似校验函数的调用链定位到成功分支后我用F5看一下它所在函数的伪代码。伪代码经过反编译器的翻译虽然不符合真实汇编顺序但逻辑能看得很清楚。拿这道题举例简化后大致是这样的int __cdecl sub_401250(HWND hDlg, ...) { char name[64]; char serial[64]; GetDlgItemTextA(hDlg, 1001, name, 64); GetDlgItemTextA(hDlg, 1002, serial, 64); if ( check_serial(name, serial) ) MessageBoxA(hDlg, Congratulations!, Success, 0); else MessageBoxA(hDlg, Try Again!, Fail, 0); return 0; }这说明核心校验在一个叫check_serial的独立函数里。在IDA里找到这个函数双击进入会发现它就是整个题目的心脏。顺着这个函数继续分析我要做的事情变成了搞清它接受了哪几个参数做了哪些运算最后和什么值比较。一个小建议如果你看到的是MFC程序或者有大量this指针、CString封装不要被吓到。MFC的CWnd::GetDlgItemText本质还是调GetDlgItemTextA你用bp GetDlgItemTextA断下来往上找调用栈一样能定位到关键代码。关键是先有一条“输入被读走 - 数据被处理 - 弹窗提示”的链路再顺着链路往深处走。3. 动态调试x64dbg里的关键断点3.1 下断点拦截关键API静态分析能告诉你代码大概在哪但动态调试才能真正验证你的猜测。我在x64dbg里重新加载脱壳后的程序先不急着运行设置几个关键断点bp GetDlgItemTextA拦截程序读取用户名和序列号的动作。bp MessageBoxA拦截弹窗结果的动作。输入假数据之后程序先是命中了GetDlgItemTextA按F8步过观察GetDlgItemTextA返回后在栈或局部变量里能看到用户输入被存放的缓冲区地址。我们可以直接跳到缓冲区看内存中的字符串确认输入是否正确进入校验流程。然后继续运行程序弹“Try Again!”命中断点MessageBoxA。此时按CtrlF9直到返回调用处就是那个调用成功/失败弹窗的分支附近。再往上翻几行就能看到类似call sub_401480 test eax, eax jz short loc_4015A0 ; eax0则跳去失败 mov ... ; 成功流程 jmp short loc_4015C0这段逻辑翻译成人话就是调用校验函数返回值eax是0就失败非0就成功。我们在jz short loc_4015A0这里下个断点运行到这里时修改eax为1或者直接nop掉这个跳转然后继续运行程序就进入成功分支了。这个“爆破”操作能快速确认我们对分叉点的判断是否正确。但要注意CTF里很多时候爆破成功并不等于得到flag。有些题的flag藏在成功弹窗后的代码里有些则要求你输对一串特定字符串才打印flag。所以爆破只适合用来验证分支判断最终还是要还原算法。3.2 单步跟踪看清数据怎么被“加工”爆破只是辅助真正核心的是看校验函数里每一步对数据做了什么。我在call sub_401480这一行按F7步入开始单步跟踪。这段汇编的常见套路如下计算输入字符串长度call strlen返回值在eax。如果发现长度小于某个值直接返回失败那说明存在长度校验。循环取字符串每个字节做运算。比如movzx ecx, byte ptr [esi]取出当前字符的ASCII值然后imul ecx, ecx, 1Fh乘以31再累加到ebx。循环结束后对累加值做一次异或/加法/乘法变换存到局部变量。把序列号字符串转成整数call atoi或sscanf拿转换后的整数与刚才算出的累加值比较。如果相等返回1否则返回0。跟踪的时候我习惯每隔几步就看一次寄存器eax里放了什么ebx是多少esi指向的字符串是哪一段。如果发现esi指向的地址是输入框缓冲区我还能在内存窗口里看到字符串的字节。这样一边走一遍推算等循环结束后算法的骨架基本就成型了。还有一个很实用的技巧在关键比较指令比如cmp eax, ebx执行前先在寄存器窗口看一眼两个操作数的具体值。拿我这次跟踪的log来说程序算出ebx 0x1D4B3而我输入的序列号转出来是123456两者不等跳到失败。我直接把ebx的值作为序列号输入程序就提示成功了。可见这个crackMe的算法确实是我上面猜测的累加校验而不是更复杂的加密。4. 算法还原把校验过程变成能跑的脚本4.1 核心校验逻辑的逆向推导既然已经在动态调试中看出了大致算法接下来可以用IDA的伪代码和调试结果互相印证把算法完整还原出来。这道题的校验函数逻辑经过我的整理用C语言写出来大概是int check_serial(const char *name, const char *serial) { int calc 0; size_t len strlen(name); if (len 5 || len 16) return 0; for (size_t i 0; i len; i) calc (unsigned char)name[i] * 0x1F; calc ^ 0x541B; if (atoi(serial) calc) return 1; return 0; }这里有几个容易被忽略的点name[i]是char类型在C里可能是带符号的乘0x1F前要转成unsigned char否则高位字节为负累加结果就不一样。calc累加时用的是32位int溢出是保留低32位。我跟踪时看到循环里没有单独处理溢出所以C语言里的int自然溢出在这里正好符合程序行为。atoi(serial)只会转换开头的十进制数字如果序列号里混入了字母或短横线atoi会在非数字处截断。这本身就是算法的一部分。在IDA里看伪代码时这类“累加后比较”的算法在crackMe里占了一半以上但同一种模式可以换很多花样有的用异或有的用每次相乘后再加有的用两个累加值交叉运算。我的经验是看到循环里频繁读写同一块内存并做算术运算就应该立刻想到“这是在对字符串做累加或多项式哈希”。4.2 用Python验证并生成注册码算法还原了注册机就是几行Python的事。直接写脚本def calc_serial(name: str) - int: s 0 for ch in name.encode(): s (s ch * 0x1F) 0xFFFFFFFF s ^ 0x541B s 0xFFFFFFFF return s name Admin serial calc_serial(name) print(fname{name}, serial{serial})注意我加了 0xFFFFFFFF。为什么一定要加因为Python里的整数是无限精度而C里的int是32位有符号。如果不做截断当累加值超过0x7FFFFFFF时C程序里可能直接变成负数而Python里还是一个正的大整数两边对不上。我踩过这个坑所以现在写注册机一律先做位宽限制。如果题目校验用的是无符号比较那保留低32位就够了万一遇到有符号比较还需要把大于0x7FFFFFFF的结果再转成负数但绝大多数crackMe的累加值不会推到那么高这里先不展开。运行脚本生成一个序列号把它填进程序弹窗变成“Congratulations!”。紧跟着程序还会输出一段信息这段信息就是BUUCTF环境里我们要提交的flag。不同题目输出方式不同有的是直接打印在控制台有的是进一步要求输入某个隐藏字符串。总之走到这一步这道crackMe的核心就解完了。5. 常见问题与排查技巧实录5.1 字符串搜不到怎么办先讲最常遇到的状况你在IDA的字符串窗口里翻了三页都没看到Name或Serial怀疑自己脱壳脱坏了。其实不一定现在很多题会故意把关键字符串做加密处理程序运行到某个时机才会动态解密。遇到这种情况我常用的做法是把程序跑起来在MessageBoxA或printf上下断等弹窗触发时再看调用者传入的字符串。如果弹窗能正常显示“Wrong”说明字符串在运行过程中已经被解密了只是静态分析时看不到明文而已。另一个办法是用FLOSSFireEye Labs Obfuscated String Solver它能模拟程序执行流并提取出运行时才解开的字符串虽然不能100%应对所有混淆方式但对付常见的异或解密足够。如果连这个都不行那就放弃字符串直接找API调用。用GetDlgItemTextA、scanf、fgets这类函数的下断点方式来定位输入入口从输入一路往下跟踪。路径虽然长一点但肯定能找到校验逻辑。5.2 反调试与反虚拟机应付指南这道crackMe没有加反调试但BUUCTF其他题目里经常会遇到这里一并分享一下。最常见的反调试是IsDebuggerPresentif (IsDebuggerPresent()) exit(0);对付它最粗暴也最有效的办法是在x64dbg里对IsDebuggerPresent下断点命中后看返回地址把调用处的跳转patch掉或者修改它的返回值为0。更省事的是直接启用ScyllaHide插件它会把常见调试器检测的API返回值全部伪装好相当于一个反反调试开关。另一种容易忽略的是时间检测程序在解密关键数据前后各取一次系统时间如果差值过大就认为被调试器单步拖慢了直接崩溃。遇到这种情况把GetTickCount、rdtsc的返回值改成固定值或者用插件绕过。反虚拟机一般用cpuid检测是否运行在虚拟机里遇到这种我只能说要么点进代码把检测分支绕过要么换到实体机里调试没有特别通用的办法。反调试手段常见表现处理思路IsDebuggerPresent调试器一附加就退出断点API后改返回值或patch跳转时间检测单步几次后程序异常退出修改时间API返回值或用ScyllaHideTLS回调还没到入口就退出在系统断点处静态查看TLS回调地址并提前patch自校验修改文件后运行崩溃先绕过文件校验再动态dump内存5.3 脱壳后的导入表修复手动脱壳之后用IDA重新打开dump出来的文件经常能正常反编译主体逻辑但看导入函数时会发现一堆off_指针是空的或错乱的这就是导入表没修好的典型症状。解决办法是在x64dbg中继续调试脱壳后的进程到了OEP后用Scylla插件在Scylla窗口选择当前调试的进程。填上OEP地址。点“IAT Autosearch”它会自动搜索导入表的位置。点“Get Imports”如果识别出的API都是有效名称说明找到的IAT是对的。点“Fix Dump”选择你刚才dump的文件Scylla会生成一个修复了导入表的新文件。之后再用新文件做静态分析所有API调用就能正常显示名称了。其实用upx -d自动脱壳通常不会遇到这个问题手动脱壳才会所以我的建议是能自动化处理的壳尽量自动化把精力留给算法而不是脱壳。最后说点个人体会。crackMe这类题我第一次刷的时候也是到处搜教程后来发现与其看十篇别人的题解不如自己动手走一遍字符串定位、API断点、单步跟踪、算法还原的流程。步骤不难难的是把每一步的目的搞清楚你下这个断点是为了看什么你改这个寄存器是为了证明什么。我的习惯是每到一个关键比较点先拿当前寄存器里的值推测算法然后改数据验证验证对了再继续往下。这个习惯帮我省了不少瞎折腾的时间。下次你再遇到BUUCTF的REVERSE题目不管它套了多少层壳、藏了多少字符串记住先把程序当成一个黑盒跑一遍再沿着输入到输出的链路逐层扒开最终总能走到那个藏在深处的比较指令。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →