
如果你刚接触逆向分析第一次把目标程序拖进调试器十有八九会碰到“加壳”这两个字。程序停在一个极短的入口处一个push接一个call再跳去一个陌生地址明显不是正常的程序开头这就是加壳后的典型外观。想继续分析就必须“脱壳”而“加壳”“脱壳”这四个字也成了逆向入门路上绕不过去的第一道坎。最近又看到不少人在问UPX和VMP相关的话题我就结合自己折腾的经验把最基础的那层原理彻底讲透。1. 先弄明白壳改了什么入口点的替换与两段式执行1.1 正常程序的起点OEP一个Windows PE程序在双击以后系统加载器会读PE头申请内存映射各区块然后跳到一个叫AddressOfEntryPoint的地址去执行。这个地址指向的就是程序员看到的程序入口点逆向里习惯叫OEPOriginal Entry Point。不同编译工具生成的OEP代码特征差别很大。VC6写的界面程序入口通常是一串push ebp; mov ebp,esp; push -1; push ...; call的启动代码Delphi程序有自己独特的运行时初始化易语言编译出来的程序入口也有一眼能认出的特征。这些特征代码是编译器自带的它负责初始化运行库、设置堆栈、准备全局变量最后才调用你的main或WinMain。这里有个很重要的直觉OEP是给程序员看的起点但不是程序唯一重要的东西。真正重要的是这个入口之后的代码流和数据流是否完整。1.2 加壳以后入口变成了什么加壳工具做的事情其实非常直白它把原始程序的OEP从PE头里换掉指向自己的一段代码这段代码通常叫Loader或Stub。同时原始代码段被压缩或加密处理塞进新的区块。比如UPX会把原始代码压缩后放进UPX1区段再准备一个空的UPX0区段留给运行时解压出来的代码使用。所以你打开加壳程序时入口不再是你熟悉的push ebp而是一个看起来很像汇编写的小段子常见的有pushad call 00401006 popad jmp 004010??pushad把所有寄存器的值压栈保存call跳到壳代码深处开始解压随后popad恢复现场最后一条jmp跳回真正的OEP。这个“跳回OEP”的动作极其关键它是我们后面所有脱壳手法的猎捕目标。1.3 壳的两段生命周期先还原再交还执行权壳代码必须做的事情概括起来就三件在内存中还原原始代码段解压或解密重建原始导入表让程序能正常调用系统API执行一条跳转把控制权交还到原始OEP。也就是说任何壳在加载运行后都要经历“先运行壳自己、再运行原始程序”的两段式生命周期。壳不是把原始代码物理上抹掉而是让它暂时不能用然后在运行时把它变回来。正因为它必须在运行时变回来我们才有机会在它变回来的那一刻把内存状态完整地抓下来。这个认知是脱壳的全部哲学基础。你不需要真的“拆掉”壳只需要等它在内存里把所有还原工作做完然后把还热乎的完整内存镜像保存成一个新文件。谁来做还原壳自己做。你只负责抓时机。2. 三种主流加壳路线从UPX压缩到VMP虚拟化2.1 压缩壳UPX代表的“体积党”压缩壳的核心思路是用通用压缩算法UPX常用NRV和LZMA变种把原始代码段和数据段压缩一遍体积确实能减小很多尤其是早期磁盘紧张、分发靠软盘的年代UPX这类工具非常受欢迎。UPX的典型特征用PE查看工具一扫就能看出来区段名是UPX0和UPX1其中UPX0在磁盘文件里几乎是空的运行时才有内容入口代码非常简洁通常就是pushad加一个call再无其他文件熵值很高因为压缩数据近似随机分布。UPX还有一个独特之处它自带了脱壳功能加壳后的程序直接用upx -d命令就能还原。网上讨论得很热烈的“upx 5.10脱壳”之类的帖子不管版本号多新底层原理还是同一套。即便新版增加了各种参数和抗误报措施只要它还叫UPX代码还原的规律就不会变。2.2 加密壳把“一次性还原”改成“边跑边还原”压缩壳最大的弱点是还原时机太集中壳执行完一团压缩数据原地展开内存里立刻出现完整原始代码。这对脱壳者太友好了。于是加密壳的思路就变成了不搞一次性解压而是把原始代码分成小块每块单独加密程序执行到哪一块就解密哪一块执行完甚至可以再加密回去。这类壳的代表有ASProtect、Themida等。它们带来的直接后果是你几乎找不到一个“所有代码都已还原”的时刻。因为任何时刻都只有当前执行的那一小块是明文的其他部分仍然是密文。这对dump策略提出了完全不同的要求也解释了为什么加密壳的脱壳难度明显高于UPX。在实际分析中加密壳的头疼之处还在于它经常配合反调试手段比如检测调试器进程名、校验PE头、插入异常处理干扰单步。它们的逻辑很明白既然你靠调试器观察内存我就让调试器不好用。2.3 虚拟机保护壳VMP式的“代码翻译”VMProtect已经是这个话题里绕不开的名字它的思路跟压缩壳、加密壳完全不是一类。VMP会把原始x86指令取出翻译成一段只有它自己解释器才能读懂的字节码程序运行时由内置的虚拟机解释器逐条“翻译”执行。这已经不叫加壳了更像把程序“编译”成另一种指令集只是这个指令集没有公开文档。比如一条简单的mov eax, 1经过VMP虚拟化后静态反汇编里看到的是一堆查表、异或、跳转原始指令完全消失。动态调试时你能看到解释器在一个字节码缓冲区里循环取指、分派、模拟执行但很难把执行效果对应回原来的指令。易语言程序和VMP的组合在现实中很常见。原因不难理解易语言程序运行时特征比较明显用VMP处理后静态层面的特征几乎全部消失。这个做法本身没有立场从防御角度看它能有效对抗外部恶意利用者的静态分析从分析者角度看它确实显著抬高了工作量。这里我只讨论机制不评判动机。2.4 三种路线对比类型代表工具核心机制对分析者难度典型特征压缩壳UPX整体压缩运行后一次性展开低适合入门区段名特殊、入口极短加密壳ASProtect、Themida分块加解密边执行边恢复高需要处理反调试区段多、入口复杂、有反调试代码虚拟机保护壳VMP指令翻译为私有字节码解释执行极高接近代码还原静态反汇编几乎无有效指令对初学者我强烈建议先把UPX吃透再去看加密壳最后碰VMP。VMP这种壳已经不是“脱壳”两个字能概括的它属于代码虚拟化对抗很多老逆向面对VMP也选择绕开而不是硬碰。3. 跟着手走一遍UPX脱壳识别、定位OEP、Dump、修复这一节我们用一个自己写的测试程序走完整流程。目标程序自己写一个最简单的窗口程序用UPX加壳。为什么强调“自己写”因为研究自己的程序没有任何边界问题你可以随便折腾出了任何错都有完整的过程记录。3.1 准备工具链我的建议是按这个组合来配x64dbg现在主流调试器自带反汇编和断点功能Scylla导入表重建工具独立版或x64dbg插件版都行Detect It EasyDIE识别壳型和编译器特征CFF Explorer或PE-bear查看PE结构、修改区段属性。这套组合免费、强大而且社区资料多。不要一开始就追求高端商业工具基础流程用这些完全够。3.2 第一步识别壳型别上来就调试很多人一拿到样本就直接拖进调试器这是要改掉的坏习惯。先打开DIE看一眼结果会直接告诉你这是不是UPX壳甚至给出区段名和入口信息。UPX加壳的程序在DIE里通常会显示类似这样的信息入口点指向的区段是UPX1存在名为UPX0的区段且原始大小远大于虚拟大小熵值接近或超过7.0。这些特征一旦齐全基本可以确定是压缩壳。识别这一步花不了几秒钟但它决定了后续策略。拿到壳型之后再进调试器你的每一步都有预期而不是瞎试。3.3 第二步用调试器在入口处找规律把加壳后的程序载入x64dbg停下来的位置就是壳入口。你大概率会看到类似这样的指令排列00401000 pushad 00401001 call 00401006 00401006 pop ebp ...这时候先别急着乱点。注意pushad已经把8个寄存器的值压进栈了栈顶的ESP指向的位置保存着现场数据。接下来壳会执行解压代码等到解压完毕它会用popad恢复寄存器。而popad一定会去读取刚才ESP指向的那块内存。我们的思路是利用这个必然行为对当前ESP栈指针指向的内存地址下一个硬件访问断点。执行到popad时硬件断点自然会命中而popad之后往往再跟几步就是跳向OEP的jmp。这就是俗称的ESP定律。在x64dbg里操作顺序是在入口处执行到pushad后暂停在命令行执行dd esp查看栈地址选中该地址右键选择Breakpoint Hardware, On Access按F9运行等待断点命中命中后会停在popad附近单步几次能看到一个长跳转指令目标地址就是OEP。这个方法的成功率极高前提是壳采用了“保存现场、恢复现场”的结构。UPX恰好就是这种结构加密壳就不一定了。3.4 第三步停在OEP再做Dump当你单步到那条跳向OEP的jmp时别直接跳进去继续跑先做两件事第一在这条jmp上下一个断点让程序运行并停在OEP处。因为我们需要OEP地址而且dump时必须确保程序已经完成了代码展开但还没跑完整逻辑。第二打开Scylla选择当前进程把OEP字段填成刚才记录的地址注意填的是实际地址不是文件偏移。然后点Dump按钮Scylla会从ImageBase开始把整个内存镜像保存成一个新的exe文件。此时dump出来的文件比原程序大得多因为壳在内存里已经展开了所有原始代码。但别急着运行它现在还缺一条腿导入表。3.5 第四步用Scylla重建导入表加壳时原始导入表基本已经被摧毁或加密dump下来的文件头里并没有能用的导入表信息。程序运行后一旦调用系统API就会跳到错误地址导致崩溃。Scylla就是干这个的。操作顺序在Scylla选中刚才dump的进程点IAT Autosearch它会扫描内存中可能的API调用地址区域点Get Imports解析扫描结果并列出API检查列表里是否有红色无效项UPX这种壳通常没有点Fix Dump选择之前dump出来的文件。修复完成后新文件里会写入重建的导入表它的IAT会被重定向到新分配的区段。最后用DIE再看一次壳的特征已经没了程序也能正常运行。整个过程跑下来大概十分钟但建议你重复三次直到不需要看笔记也能操作。这个熟练度后面处理其他壳时非常有用。4. ESP定律、内存断点、单步跟踪三板斧背后的原理4.1 ESP定律的适用条件与边界ESP定律能成立依赖一个前提壳在入口处用pushad保存了全部寄存器在执行跳转OEP前用popad恢复。pushad把寄存器值按固定顺序压栈popad按相反顺序弹栈。我们在pushad后对栈顶地址下的是内存访问断点硬件断点在数据被读取或写入时触发。当popad被执行它会读取那块栈内存断点因此命中。这等于我们找到了壳的“交接仪式”现场。命中时壳的还原工作已经完成下面就是交接时机完美。但ESP定律不是万能的。某些壳不会一次性恢复全部寄存器它们可能分段保存、分段恢复或是在入口使用自定义的栈混淆指令破坏栈平衡还有一些壳检测到栈区域的硬件断点后会用异常干扰。遇到这些情况ESP定律就会失灵。这也是为什么它叫“定律”而不是“定理”——它是经验规律不是逻辑必然。另外ESP定律主要针对32位程序的pushad机制。64位程序没有pushad这种一次性保存所有寄存器的指令栈平衡的观察方式完全不同需要寻找更细微的特征比如push与pop的对称关系。4.2 内存断点的价值等壳自己撞上来内存断点的思路比ESP定律更通用对原始代码区段下内存访问断点当壳解压完成后第一次跳回原始代码去执行时断点就会命中。想象一场捉迷藏你不知道壳藏在哪但你确信它最终会回到“家”原始代码区段来。你不需要跟踪它走的每一步只要在门口装一个感应器等它推门进来。在x64dbg里可以进入内存布局窗口选中存放代码的区段通常是.text或UPX解压后的UPX0右键设置内存访问断点。程序运行时壳读取或执行这段内存都会触发断点。壳跳向OEP的瞬间断点命中你看到的位置通常离OEP非常近。这个方法的优点是对壳的具体指令不敏感应对压缩壳和部分加密壳都很有效。坏处是内存断点在面对某些反调试技术时会失效因为这些壳会在自检时故意触发大量内存访问来干扰你或者用内存虚拟化手段让断点失效。4.3 单步跟踪最笨也最可靠的手艺单步跟踪就是从壳入口开始一步一步地跟下去观察每条指令的执行效果。遇到支流跳转就记下方向遇到循环就观察循环条件。整个过程像在做一笔一笔的手工账很费时间但你对程序行为的理解会非常深入。我早年学脱壳时常干的事是开着调试器从入口一直F8按到底看它什么时候开始解压、什么时候出现大段loop、什么时候跳转方向发生变化。效率很低但正是这种笨功夫让我建立了对壳代码的直觉看到call后面跟着pop就知道是在计算自身位置看到大量xor和mov就知道在解密数据。现在工具强大了但我仍然建议每个新手至少完整地单步跟踪一遍UPX壳。不需要跟完整个解压过程跟到解压循环出现、理解了它在干什么就可以停了。这个过程带来的收益远大于跟着教程背三板斧。4.4 为什么三板斧会失效壳也在进化前面所有方法都有一个共同点利用壳的还原行为来预测时机。反过来壳的设计者同样知道你在利用这些行为。所以现代壳会故意设计反制措施检测调试器进程和调试端口的存在对关键跳转和栈操作做混淆在解压过程中混入大量垃圾指令使用多线程分散还原逻辑让单线程断点失效检测到dump行为后在文件中写入破坏性数据。这些不是玄学而是真实存在的对抗策略。所以请记住一个心态脱壳不是一个“一招鲜”的领域而是一个持续博弈的领域。你今天学会的三板斧明天可能就失效了但你对PE结构、汇编执行、操作系统加载机制的理解不会失效。5. Dump只是第一步导入表重建和壳残留才是真坎5.1 为什么dump出来的程序常常跑不起来很多人在OEP处dump完兴奋地双击运行结果程序直接报错或者毫无反应。于是觉得自己脱壳失败了。其实dump本身只是复制了内存镜像真正决定程序能不能跑的是后面几步。跑不起来的典型原因有三个OEP填错——填成了壳入口或错误地址程序从错误位置开始执行IAT没有修复——程序一调用API就跳到无效地址区段属性不对——某些区段没有写入执行权限解压后的代码无法运行。其中IAT问题是绝大多数失败案例的根源。理解了IAT你就理解了为什么Scylla这类工具如此重要。5.2 IAT到底是什么壳把它怎么了PE程序要调用Windows的API不能直接写死在代码里因为每次加载时系统API地址可能不同。程序在文件里保留一个导入表Import Table里面记录要调用的函数名比如MessageBoxW。系统加载时读取这个表找到函数地址填进一块叫IATImport Address Table导入地址表的内存区域。程序运行时代码通过call [IAT地址]来间接调用API。加壳时壳为了隐藏程序用到的API会把导入表加密或者抹掉。壳自己接管了加载过程等到程序运行时它动态地调用GetProcAddress和LoadLibrary解析API地址再把地址填进一块自己准备的内存然后修改代码里的跳转目标让程序在调用API时跳到自己已经准备好的地址。这样带来的问题是dump下来的文件虽然有内存里那坨API地址但文件头里的导入表结构还是坏掉的。如果直接运行系统按文件头加载找不到正确的导入信息程序自然起不来。5.3 Scylla重建导入表的原理Scylla做的事是替你把内存里的IAT信息读出来重新生成一份合法的导入表写回dump文件并且把所有调用了这些API的代码位置重新指向新的IAT表。它的工作原理大致是扫描内存寻找一段结构像IAT的区域——连续排列的指针且每个指针都指向某个系统DLL的函数。找到区域后通过GetImports验证指针解析到的模块和函数名然后重建表结构。Fix Dump则把这些信息写入文件中并修改PE头。遇到红叉或者无效导入条目通常说明那个API被壳用代理方式解析或者是个延迟加载函数。手动处理无效条目时可以尝试右键改为用原始API名实在不行就跳过——前提是确认程序实际运行时不会调用到那个条目。5.4 壳区段与自校验最后两件麻烦事即使IAT修复完成脱壳后的文件也还可能存在两个残留问题。一个是壳区段。UPX的UPX0和UPX1在dump后仍然存在于文件里里面的壳代码已经没用了但区段占空间。你可以用CFF Explorer手动删除这些区段改小PE头里的区段数量让文件更“干净”。这个操作有一定风险改坏了入口或区段数据程序就废了。我通常建议脱壳后先保留壳区段确认功能正常后再考虑清理。另一个是自校验。程序在运行时会对自己的文件计算哈希CRC32、MD5等如果发现和出厂值不一致就拒绝运行。这是很多商业软件用来防止脱壳的招数。脱壳后文件内容对不上自校验就会触发。处理自校验需要定位校验代码并修改判断逻辑这已经进入补丁技术的范畴。对于初学者遇到自校验程序时我的建议是先换一个没有自校验的目标练手。自校验涉及动态补丁是下一步的内容不要一口气吃成胖子。6. 入门路线与工具的取舍从UPX到强壳该怎么安排6.1 建议的学习顺序如果你是零基础最合理的路径是这样的先学PE结构基础区段、入口点、导入表、虚拟地址与文件偏移的换算关系再学汇编基础能看懂push、pop、call、jmp、mov即可用UPX壳完整走一遍识别、ESP定律、dump、修复IAT的流程重复三遍以上找三五个不同的压缩壳练手观察它们入口代码的差异尝试一个加密壳体验反调试和分块解密带来的难度变化最后接触VMP分析到能说清楚它的解释器循环在哪就够了不必追求完全还原。花时间把第1到第3步走扎实后面所有东西都会有根基。跳过基础直接去看VMP越看越糊涂。6.2 工具选型的几个体会调试器方面我目前的主力是x64dbg它跨32位和64位支持插件生态也够用。OllyDbg在32位老样本分析中仍有价值但已经明显落后于时代。Scylla是导入表修复的首选独立版比插件版更稳定。DIE和CFF Explorer是查看PE结构的标配两个都要会。还有一个容易被忽视的工具是系统自带的dumpbin或objdump它们可以在命令行快速查看exe的导入导出表和区段表写脚本批量分析时非常方便。很多人不知道VS装好以后自带dumpbin用起来比图形工具更直接。文件哈希校验工具也建议备一个脱壳前后对比哈希可以确认改动范围。Windows下用certutil -hashfile或者Get-FileHash都能做不用额外装软件。6.3 脱壳能力的正确打开方式说到底脱壳只是软件分析中的一环不是目的。它的正常用途包括分析恶意样本时剥离壳的干扰、审计自己的程序在加壳后是否仍能正常运行、研究软件自我保护措施的强度、进行漏洞挖掘前的准备工作。在学习过程中请保持一个意识你会学到通用脱壳手型也能理解反调试对抗技巧但拿这些能力去绕过商业软件的授权、破解注册码既违反相关法律条款也会让你在这个领域越走越偏。分析自己写的程序和明确授权的样本已经足够锻炼所有核心技能。我自己从UPX里学会了怎么看入口特征和栈平衡再到后来面对VMP这类强壳时能判断“这个活儿值不值得接”这个过程本身就是价值所在。老前辈跟我说过一句话我印象很深壳是程序加的一道门不是一堵墙。找门的过程中你真正学到的是PE结构、代码执行机制和调试对抗思维这些才是能沉淀下来的东西。壳会过时又会有新壳但这些底层知识不会浪费。