反汇编对抗实战:恶意代码分析 Labs-15 花指令破解与反调试绕过详解
发布时间:2026/10/19 15:00:47 锦皓数字建站

做《恶意代码分析实战》的配套实验做到Labs-15我一开始是有点懵的。前面十几个实验虽然也有加壳、有字符串混淆但只要耐心跟进导入表和API调用大多能顺藤摸瓜看完整条执行链。这一关不一样我打开样本的第一眼基本等于看见一张被墨水泼过的地图代码里到处是看似合理的指令可顺着反汇编窗口往下读每走几步就撞进空白或者一串乱码里。后来才明白这个实验的主题就是反汇编对抗anti-disassembly它并不打算在功能上为难你而是专门让反汇编器和调试器产生错误认知。这个实验做完我觉得它是整本书里最值得反复做的一关。前几关你会的是看懂恶意代码这一关逼你学会在别人故意不让你看懂的情况下依然把它看懂。下面我把整个分析过程、踩过的坑、用到的工具和一些事后复盘完整写出来希望能给卡在Labs-15的人省下一点时间。1. Labs-15在考什么一道以反汇编对抗为主题的关卡1.1 这个实验的定位与适合人群Labs-15对应书里关于反汇编对抗的章节实验样本本身并不庞大行为逻辑也谈不上多复杂。它真正花的心思全在如何让分析工具失效上。恶意代码作者写这段代码目的不是让功能难以理解而是让静态分析得到错误结果让动态分析在错误位置打断让你在反复试错中浪费时间。我个人的判断是这个实验最适合已经完成前14个实验、能熟练使用反汇编器和调试器的分析人员。如果前面的实验都是跳着做的直接来做这道题会非常痛苦因为你不仅要同时面对花指令、反调试、还有数据混淆还可能在第一步就被入口点附近的反汇编假象带偏。1.2 反汇编对抗到底对抗的是什么要理解这个实验得先说清楚一个基础概念反汇编器并不是长生不老的。它把机器码转成汇编主要靠两种思路。一种是线性扫描从某个地址开始一路把每个字节都解析成指令典型的代表就是一些命令行反汇编工具另一种是递归下降遇到跳转和调用才跟踪目标地址IDA就偏这类。两类方式都有盲区而恶意代码恰好可以利用这些盲区制造错误。比如在一条无条件跳转指令后面放一堆看似指令的垃圾字节线性扫描会傻乎乎地把垃圾解析成有效代码又比如利用call指令会把返回地址压栈的特性在call后面放垃圾数据运行时call会跳过垃圾可反汇编器却把垃圾当成数据或指令继续解析。Labs-15的样本里这类手法几乎被用了个遍。1.3 动手前的实验环境准备先交代环境我用的是隔离的Windows虚拟机系统是真实的商业恶意代码分析类书籍里最常见的那种旧Windows环境配置完所有工具后打了一个干净快照。工具清单如下每一项都有用处工具用途IDA Pro静态反汇编、函数识别、脚本修正花指令x64dbg / OllyDbg动态调试、Run Trace、内存断点ScyllaHide反反调试自动隐藏常见调试器痕迹PEiD / Detect It Easy查壳、熵值判断、编译特征识别Process Monitor监控文件、注册表、进程行为Process Explorer查看进程加载的模块、线程、句柄Wireshark捕获网络流量观察是否存在外联Regshot对比运行前后注册表与文件变化010 Editor十六进制查看、提取密文、手写解密脚本快照这一点我要单独强调。恶意代码分析里越往后越会碰到带反虚拟机和反调试的样本稍不注意就会让系统卡死、崩溃或者被改得面目全非。Labs-15虽然不至于把系统搞坏但样本会创建注册表项、释放文件如果没有快照每跑一次都要手动清理一次效率极低。我自己的习惯是分析一个阶段就打一个快照干净环境永远留着。2. 初步静态分析入口点附近就给我上了一课2.1 查壳与熵值第一眼的判断拿到样本后我按老规矩先丢进Detect It Easy查壳。结果比较意外没有识别出任何已知壳但熵值偏高节区名完全无意义入口点位置也不在常见编译器生成的位置。这个组合通常说明几件事样本要么做了自定义混淆要么是手写汇编要么在原有程序上加了一层自解码外壳。用IDA加载后入口点一带的反汇编非常不正常。正常的编译器入口一般是先压栈、再调用初始化函数可这个样本直接来了一段极其别扭的跳转加call组合后面跟着大量无法理解的字节。如果不懂反汇编对抗你很容易在这里死磕很久试图把所有字节都解释成指令然后发现越解释越乱。2.2 导入表与字符串相互矛盾的信息我接着看导入表。样本只导入了少数几个API基本都是LoadLibraryA、GetProcAddress这类动态加载函数。这意味着真正的功能API文件操作、注册表操作、网络连接全都是运行时解析出来的静态导入表里根本看不见。字符串提取结果也很有意思。样本里有一些看起来正常的文本比如窗体标题、提示消息之类的但数量极少同时还有一小段类似Base64却带乱码的数据块。这种表面正常、深层加密的组合基本可以断定数据是经过编码或加密后再使用的。单纯靠strings已经拿不到什么有效信息了必须回到代码层去找解密逻辑。2.3 入口点反汇编乱象用IDA看入口点附近的汇编有几段会让你哭笑不得。举个例子代码里有这样一段示意push ebp mov ebp, esp jmp short loc_10001050 call $5 db 0E8h, 0FFh, 0FFh, 0FFh loc_10001050: ; ...这个call $5是典型的把戏运行时call会把下一条指令的地址压栈然后跳到一个看起来是有效代码的位置可那个位置其实是垃圾字节。静态反汇编器看到call会顺着追踪目标于是把目标处的垃圾字节解析成了指令真正执行时call结束后通过栈上的返回地址跳回正确地方垃圾根本没有被执行。还有更阴的比如用两个条件跳转做无条件跳转。反汇编器看到jz会分析两边分支看到jnz又分析另外两边分支但实际上jz和jnz的条件互为相反不管标志位如何最终都落到同一个地址。静态分析时你会以为存在多个分支、多条路径实际上执行流只有一条。IDA在这些地方画出的流程图简直像一团乱麻。这一阶段我最大的收获是不要试图在静态视图里把每一条指令都读通Labs-15的样本里存在大量故意制造的假指令真正执行的路径可能只有静态视图的一半不到。带着这个认识我才开始正经破解花指令。3. 花指令的破解这个实验耗时最长也最有价值的部分3.1 花指令为什么能骗过反汇编器花指令的本质是在真实执行流里插入一段运行时不会被执行但静态反汇编时会被解析的字节。反汇编器分不清哪些路径是真实的就会把这些垃圾也画进代码区。常见形态有好几种我列一下Labs-15里实际遇到的; 形态1call 垃圾数据运行时依靠返回地址跳过垃圾 call $5 db 00h, 0FFh, 0FFh ; 形态2无条件跳转后接垃圾 jmp short real_code db 90h, 90h, 0C3h ; 形态3jz/jnz双跳转条件相反但目标相同 jz short real_code jnz short real_code db 0E8h, 00h, 00h, 00h, 00h第一种形态是最容易识别的。运行时call把返回地址压栈随后自然落到有效代码静态反汇编器却跟着call目标走把后面的垃圾字节当作代码。第二种更直白jmp之后的那几个字节永远不会执行但线性扫描会解析它们。第三种则充分利用了人类的惯性思维两个跳转指令中间藏了垃圾数据让反汇编器多画出两条假分支。3.2 用执行轨迹识别真实路径理解了原理之后我的做法发生了转变不再纠结静态视图里哪条是假路径而是直接让调试器跑一遍真实流程。x64dbg里有一个Run Trace功能可以记录程序从指定位置开始实际执行过的每一条指令。跑一小段流程后导出来跟IDA的静态反汇编对比差异就非常清楚凡是真实执行流经过的地址都是有效代码凡是静态视图里有、执行轨迹里没有的基本都可以标记为花指令或垃圾字节。这不是偷懒而是更符合恶意代码分析的工作逻辑。真实样本里你根本不可能保证自己手动识别每一处花指令以执行为准把所有判断建立在代码到底跑没跑过上是最可靠的。当然Run Trace也有缺陷如果样本带有强烈反调试和反虚拟机能检测Trace过程中它可能提前走另一条路径。所以这一步要和后面的反调试处理配合着来。3.3 在IDA里手动修正花指令拿到真实执行路径后回到IDA修正静态视图。我的操作流程是这样的在错误区域按U取消定义把那些垃圾字节重新标记为数据或者未定义然后手工在有效指令位置按C强制反汇编如果某些位置是数据就按D标记成字节。这里有个重要的经验不要一上来就把花指令用NOP填充。NOP掉之后地址关系会发生变化后面分析解密循环、计算偏移时会非常容易出错。最好是保留原始字节只修正定义方式。这样代码里看起来还有垃圾数据但至少执行流画对了。另外可以用一点小技巧在IDAPython里写个简单脚本扫描反汇编里那些落在jmp/call目标地址范围内的指令自动标记可疑花指令。我这一关没有写太复杂的脚本主要是手动修正但如果你要批量处理大批样本这步自动化是值得投入时间的。3.4 一个让我反复纠结的案例样本里有一段代码表面上看是连续三个字节的0F 0B这个是ud2指令属于未定义指令。静态反汇编到这里会直接报错IDA会认为代码区断了。当时我以为样本故意用了非法指令后来仔细看才发现这个ud2前面有一个条件跳转运行时条件永远不会满足所以非法指令根本不会执行。也就是说这里的ud2也是花指令用来打断静态分析的连续性让分析人员在它面前停住误以为程序进入了异常分支。这个案例给我提了个醒花指令不一定是无害的NOP它可以是任何指令包括非法指令、会产生异常的指令、甚至看起来会修改内存的指令。判断标准始终只有一个——它到底在真实执行流里跑不跑。4. 调试器检测样本的第二道防线比想象中密4.1 从时间检测开始当我信心满满打开调试器准备跟进细节时样本立刻露出了第二张脸它开始主动检测自己是否被调试。第一种手法是时间攻击。代码在执行某个关键步骤前后调用rdtsc读取CPU时间戳计数器如果两次差值过大说明期间有调试器单步、暂停、设置断点等操作拖慢了执行速度。这类检测非常烦人你单步跟到一半它忽然跳转进一个错误分支然后整个行为就变了。对付rdtsc我一般是直接用调试器脚本在返回前改写EAX:EDX或者干脆在比较指令处下条件断点把比较结果改成未调试。4.2 PEB与系统API层面的调试器痕迹时间检测只是开胃菜样本还检查了PEB里的BeingDebugged标志和NtGlobalFlag这两个都是最常见的反调试检查点。PEB是进程环境块系统创建进程时会初始化一些字段调试器附加时某些字段会被置位恶意代码读一下就知道有没有调试器。处理这类检查ScyllaHide是首选。它通过钩子或者修改返回结果的方式把所有常见的PEB和系统API检查掩盖掉。勾选哪些项有点讲究不要一股脑全勾有些样本会故意反过来检测你是否隐藏了所有调试迹象隐藏得太完美反而暴露。我实际操作时只勾了BeingDebugged、NtGlobalFlag、ProcessDebugPort和ProcessDebugObjectHandle这几项其余保持默认。4.3 异常陷阱故意让调试器断在错误位置样本里还有一类手法是利用异常。它会在代码里故意执行一条int3指令。正常情况下int3是调试器的软件断点指令当程序没有被调试时它会触发异常处理机制由SEH接管而被调试时调试器会默认把它当作断点暂停下来。恶意代码正是利用这一点一旦你暂停了说明当前进程正被调试于是代码通过SEH链里的特殊标记跳入对抗逻辑。面对这种陷阱需要在调试器里设置忽略int3异常让它直接交给应用程序处理。但这里有个矛盾点如果样本本身也想用异常来跳转你把所有异常都忽略掉反而可能错过真正的执行路径。我的经验是先观察这个异常是在什么上下文里触发的。如果前面有明显的检测逻辑那多半是反调试陷阱如果它出现在某个跳转表计算之后就更像是正常的异常驱动代码。Labs-15里两处都碰到了处理方式完全不同。4.4 我推荐的处理顺序整个反调试阶段给我的感受是先静态、后动态的顺序特别重要。如果一开始就开着调试器跑反调试逻辑会立刻触发样本走上对抗分支你看到的行为全是被污染过的。我做实验时的顺序是这样先用IDA把花指令标记出来把执行流修正到位用脚本或Trace记录的方式找出关键分支点开ScyllaHide把已知调试器检查全部隐藏从修正后的入口点开始调试遇到异常陷阱先判断上下文再决定是否忽略。这样一套流程下来反调试对分析的干扰能降到最低。不要小看这个顺序Labs-15这道题顺序错了你可能会在动态分析里耗上一整天。5. 绕过所有干扰后核心逻辑终于浮出水面5.1 清理完成后的代码骨架等到花指令被标清、反调试被处理掉样本的面目才第一次变得清晰。去除干扰后的执行流并不复杂大致可以分为五步先做环境检测再动态解析API然后解密一段内嵌数据接着执行文件与注册表操作最后是清理痕迹。整个过程在IDA里重新整理后函数边界基本能够画出来了。这里我特别想说一句恶意代码分析里的很多高深手法说穿了就是给一段简单逻辑套上层层障眼法。Labs-15样本的真实功能如果去掉对抗层可能就是一个会释放文件并设置自启动的小程序但作者通过反汇编对抗和反调试把它的分析成本放大了不止五倍。5.2 解密数据的还原过程样本里那串看起来像Base64的数据块实际上是先用一个单字节异或循环加过密的。解密逻辑大致是这样的for (int i 0; i cipher_len; i) { plain[i] cipher[i] ^ key[i % key_len]; }密钥不是藏在数据区而是藏在代码段里由几条指令直接拼出来静态提取时很容易忽略。我是在WriteFile参数上下断点运行时直接看到缓冲区里的明文然后才回溯到解密代码确认了密钥来源。这种做法顺序上很取巧不去猜它怎么解密只关注解密结果在哪使用再从结果反推过程效率会高很多。5.3 与行为监控结果互相印证解密出关键字符串后我用Process Monitor重新干净地跑了一遍样本这次看到的动作就非常清楚了样本在用户目录下创建一个隐藏目录释放一个DLL文件再通过注册表Run键实现自启动随后尝试连接一个远程地址并下载后续负载实验环境里该地址是模拟的未产生真实外联。到这里Labs-15样本的完整链条才真正闭合对抗层负责阻止分析功能层负责落地与持久化通信层负责后续扩展。如果不先把对抗层拆掉你很难把这些动作串起来。6. 复盘一套应对带对抗样本的固定打法6.1 控制变量把对抗当噪音剥离做完Labs-15我最深的一个体会是分析带对抗的样本最重要的不是某个具体技巧而是控制变量的思路。花指令是噪音反调试是开关数据混淆是锁你得一个个拆而不是一口气全解决。具体到操作上我给自己定了一套固定流程。第一步查壳和熵值判断有没有自解密第二步静态通读入口点和关键跳转把明显花指令标掉第三步上调试器开Run Trace比对执行轨迹完成最终标记第四步开反反调试插件处理所有检测点第五步在关键API下断观察行为第六步回到IDA把解密算法还原彻底。这套流程不一定最优但对我来说它可以把一片混乱的样本逐步拆解成可读的形式。6.2 我做这道实验犯过的三个错误第一个错误是在IDA里直接NOP花指令。看着清爽了但后续分析函数偏移全都错位浪费了不少时间。正确做法是保留原始字节只修正定义。第二个错误是开着调试器直接跑结果样本一开始就检测到调试器走入了对抗分支所有行为都是假的。后来才意识到反调试检测最密集的地方往往就在入口和关键函数开头必须先隐藏再跑。第三个错误是忽略了异常上下文的判断。第一次遇到int3时我想当然地把它当反调试忽略掉了结果样本的真实逻辑是通过异常跳转实现的忽略后直接跑飞。后来我养成了一个习惯遇到异常先看触发指令前后的代码判断它是陷阱还是机制再决定怎么处理。6.3 给后来者的一些建议如果你正准备做Labs-15我的建议就三条。第一先把反汇编器的工作原理吃透明白线性扫描和递归下降的盲区后面看花指令会快很多。第二调试时准备两份环境一个干净无调试器的快照用于行为监控一个配置好反反调试插件的快照用于代码跟进两边对照着分析。第三不要怕手动修正反汇编ID里按U、按C、按D这些操作看似笨实际是最可控的。最后分享一个很实用的小技巧遇到花指令导致反汇编混乱时直接把鼠标停在某个可疑指令上看它的字节长度和前后指令是否正常。如果一条指令的长度覆盖了另一条有效跳转的目标那它十有八九是垃圾。在Labs-15里我把这个规则重复用了十几次每次都验证成立。这个实验做完之后我再看之前遇到的普通加壳样本突然觉得它们温和了很多。Labs-15真正锻炼的是你在工具输出不可信的情况下依然保持判断力的能力这种能力在真实的恶意样本分析里比背一百个API都管用。如果你能做到这一步后面的实验再难也只会是时间问题而不是能力问题。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。