ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

VM逆向零解题复现:反调试绕过与模拟器爆破全记录

VM逆向零解题复现:反调试绕过与模拟器爆破全记录 御网杯2025线下赛结束那晚reverse方向交卷页面上齐刷刷的0分说实话当时心里挺不是滋味的。我坐在工位前又盯了三个小时ida最后也只能把半成品脚本存了个“rev_vm_draft”关电脑走人。第二天缓过劲来才决定认认真真做一次赛后复现——这题不是做不出来是战场上的自己活生生被题目设计者给“劝退”了。这场比赛其它方向的wp已经有人整理了我这边补一篇reverse方向的0解题复现记录把赛场上看漏的、做错的和赛后补出来的东西一次性讲清楚。这题的核心是一道自定义虚拟机VM类的逆向题嵌套了反调试、加壳、VM字节码加密三层防护。对想练VM类逆向、或者准备打线下赛的朋友来说这篇东西能帮你把“从0到1啃一道VM题”的完整链路梳理明白。赛场上为什么全场没人做出来赛后为什么又能做出来这两件事同样值得复盘。1. 赛题复盘一道Reverse为何全场零解1.1 比赛现场与题面印象线下赛当天reverse方向一共放了两道题我挑的这道题目名很普通就是一个英文单词连个花哨的修饰都没有。但一运行就发现不对劲——程序打印一行提示“input your flag:”然后无论你输入什么它都沉默着退出没有任何报错没有“wrong”提示退出码都是0。这种“静默失败”本身就是一种信号说明程序的校验逻辑不是简单的strcmp而是经过某种处理后做比较失败路径根本不打印任何内容。当时我第一反应是拖进IDA看看字符串结果连“input your flag”都搜不到只有一堆乱码。这个细节很关键它说明字符串表被加密了或者程序关键内容被加壳处理过静态起步就卡住了。这里想多说一句CTF的reverse题题面上最喜欢用“静默无反馈”来增加心理压力尤其是VM类题目。因为你不知道自己的输入错在哪一步也不知道程序执行到了哪里只能靠调试器一点一点抠。赛场上时间有限很多人就在这里耗掉了大量精力我们队当时也是耗到心态有点崩。1.2 题目文件识别与初判先做基础动作file、strings、checksec。这道题是一个Linux 64位ELF没有strip符号注意是保留了符号表的这其实给了一点希望但checksec显示开启了PIE和栈保护没有canary绕过压力因为根本不需要缓冲区溢出。有意思的是strings的结果——能看到程序里残留了ptrace、fork、syscall这些符号还有一段很可疑的.data段内容里面全是高熵随机数据。高熵数据基本可以断定是加密过的内容结合后面的分析确认这段数据就是加密后的VM字节码。看完这些我初步判断这题是“虚拟指令解释器反调试字节码加密”的复合结构。跑一遍执行流程程序会先自杀式地做一次ptrace检测检测到被调试就默默退出然后fork一个子进程作为“看门狗”监控父进程状态。赛场上我把大量时间耗在理解ptrace那套父子进程交互上其实方向偏了——关键不在反调试怎么实现而在于它背后保护的那套VM逻辑。1.3 三大难点拆解VM、反调试、混淆这题的难点我复盘后觉得可以拆成三个层面每个层面单独看都不算绝杀但组合起来就非常劝退第一个难点是VM架构的陌生感。题目没有用现成的开源虚拟机框架比如Unicorn、Qiling之类是作者自己手写的迷你VM。指令集、寻址模式、栈操作完全自定义你无法靠搜特征字符串找到任何公开资料。赛场上要从零逆向一个未知指令集本身就是个体力活。第二个难点是反调试组合拳。程序用了ptrace自跟踪、fork看门狗、时间差检测三套方案。最要命的是这三套还会联动你用调试器断在反调试检测处修改结果另一个检测点就会因为时间差异常触发退出。单看每一条都不难绕过但它们互相纠缠导致“按下葫芦浮起瓢”不断干扰分析节奏。第三个难点是代码混淆。主逻辑里塞了大量不透明谓词opaque predicate和冗余跳转IDA的F5伪代码会生成几百行的垃圾代码真正的有效指令淹没在其中。手动跟踪VM分发循环时80%的精力都花在跳过混淆代码上。这三个难点叠加起来的直接后果是赛场上绝大多数人连VM分发器dispatcher都没定位到自然就零解题了。2. 静态分析给虚拟机画一张草图2.1 入口定位与启动流程赛后复现的第一步我先把比赛现场没做完的静态分析补齐。既然程序保留了符号表那就顺着main入口进。main函数的大致流程是这样的先设置信号处理器SIGTRAP被接管调用init_decrypt()这个函数会动态解密.data段的一块区域调用check_debug()做反调试检测打印“input your flag:”读取输入到缓冲区调用vm_entry()进入虚拟机执行循环最后根据VM执行结果打印“success”或者直接退出。用IDA打开后在init_decrypt里能看到非常典型的自解密逻辑用一个固定key按字节异或一段数据异或后写回原地址再做一个简单的校验和确认解密成功。由于它有符号表关键函数名都是可读的这一步没有太多阻碍。但问题紧接着就来了vm_entry里的代码充满了各种jz/jnz乱跳、push/pop来回倒腾、还有插入的死代码块。F5生成的伪代码又臭又长一屏根本看不完。我赛后做了一个决定不开F5直接看汇编结合动态调试来理解。2.2 定位VM分发器与Handler表VM题目里最核心的东西有两个字节码和Handler表。字节码就是“程序”Handler就是“指令实现”。分发器dispatcher是一个循环它依次取出字节码的操作码查Handler表然后调用对应的处理函数。代码结构清晰之后我定位到vm_entry里有一个主要的循环循环体是从一个VM_IP寄存器取值实际上是一个指向字节码的指针取低4位作为操作码索引然后通过一个跳转表跳转。这个跳转表就是Handler表每个表项都是一个函数指针对应一个VM指令的实现。我数了一下Handler表一共16个表项但实际只用了12个剩下4个是空指针。有效Handler包括0x0: VM_HALT0x1: VM_PUSH0x2: VM_POP0x3: VM_LOAD从内存读数据到栈0x4: VM_STORE从栈写数据到内存0x5: VM_ADD0x6: VM_SUB0x7: VM_XOR0x8: VM_NOT0x9: VM_CMP0xA: VM_JMP0xB: VM_JZHandler表的结构和分发逻辑找出来后这个VM的基本框架就清楚了这是一个栈式虚拟机操作数通过VM_PUSH压栈运算指令从栈顶取两个操作数运算结果再压回栈顶。2.3 解密VM字节码的过程VM字节码存在.data段但刚加载时是加密状态。init_decrypt函数运行时将它解密成真实的指令序列。我赛后用gdb在init_decrypt返回之后、vm_entry调用之前把解密后的字节码整体dump了下来大概400多个字节。这个操作非常关键——直接读取解密后的字节码省去了自己实现解密算法的麻烦。dump出来之后我发现这400多个字节里其实包含了两个阶段的VM程序第一个阶段是初始化往内存里写入一些常量第二个阶段才是真正的flag校验逻辑。这里有一个很重要的工作习惯动态dump解密后的数据永远是最高效的方法比你逆向解密算法本身要快得多。除非题目要求你必须写一个静态解密脚本否则不要跟解密逻辑死磕。3. 动态对抗反调试绕过与指令流追踪3.1 反调试原理与绕过方案这题的反调试是三段式的我一个个说也把赛后验证通过的绕过方式写出来。第一层ptrace自跟踪。程序启动后立刻ptrace(PTRACE_TRACEME)如果调用失败说明已经有调试器在跟踪它直接退出。绕过方式很简单用LD_PRELOAD预加载一个sohook掉ptrace函数让它在PTRACE_TRACEME时直接返回0表示成功。比赛现场我也想到了这个方案但当时没提前准备so现场写耽误了不少时间。第二层fork看门狗。程序fork一个子进程子进程循环用waitpid监控父进程状态同时用ptrace的PTRACE_ATTACH尝试附加到父进程。如果附加成功说明父进程已经被调试器附加过了子进程就会把父进程kill掉。这东西非常膈应人。赛后我的绕法是把子进程的逻辑直接patch掉——找到fork调用把子进程分支改成直接exit(0)一了百了。第三层时间差检测。程序在关键路径上调用clock_gettime记录时间戳中间做大量无意义的循环最后比较时间差如果大于阈值说明执行被调试器拖慢了便直接退出。这个用gdb的ignore命令或者直接把时间比较的两个数patch相等即可。三层反调试不是各自独立而是穿插在VM执行流程中VM执行到某个关键点时会又一次调用反调试检测。所以你不能只绕一次就完事建议直接把反调试函数整个patch成ret。具体做法在函数开头写入c3ret指令一劳永逸。3.2 Handler逐个“验明正身”Handler表虽然定位到了但哪一个是哪个不能靠猜得靠动态调试一个一个确认。我的做法是在分发器入口下断点单步进去看每个Handler做了什么然后给它们起名字。这里分享一个实用的确认技巧对每个Handler先在栈和VM内存区设置已知的特殊数值比如0x11223344、0x55667788然后执行该Handler观察哪些值被移动、被修改、被弹出。这样反复几次每个Handler的语义就清楚了。拿VM_XOR这个Handler举例我在执行前把栈顶设为0x11223344次栈顶设为0x55667788单步执行后栈顶变成0x447711CC即两者异或结果——语义确认。12个Handler我全部确认了一遍VM_PUSH把立即数压栈VM_POP弹出栈顶但不保留结果VM_LOAD按索引从VM数据段取数压栈VM_STORE将栈顶值写入VM数据段指定索引VM_ADD、VM_SUB、VM_XOR、VM_NOT都是整数运算VM_CMP比较栈顶两个值设置VM的零标志VM_JMP无条件跳转VM_JZ根据零标志决定是否跳转。理清语义后下一步就是把这400多字节的VM字节码翻成伪汇编。3.3 用脚本把字节码翻译成人话靠人眼一条条翻译400多个字节太慢赛后我直接写了个Python脚本把VM的handler语义和opcode对应关系建模然后批量解析字节码输出伪汇编。这一步很值得写一下因为它是把逆向分析“自动化”的关键。脚本核心逻辑非常简单模拟VM取指过程每个字节是操作码如果操作码带立即数后面会跟1-2个字节的立即数。根据操作码和立即数输出一行可读的伪汇编比如0x0000: PUSH 0x00 0x0002: LOAD 0x01 0x0004: ADD 0x0005: XOR 0x0006: CMP 0x5A 0x0007: JZ 0x0010这个脚本一开始只是辅助分析用的但后来我意识到它还可以更进一步——直接模拟整个VM的执行流。于是我把脚本扩展成了一个简单的VM模拟器让它加载字节码、模拟每个Handler行为、输出每一步寄存器和栈的状态变化。这个模拟器帮了大忙后面解析flag校验逻辑全靠它。写脚本模拟VM本质上是让电脑代替你去执行那些“无聊但庞大”的重复工作把精力集中在逻辑分析上。4. 还原算法从VM执行流到flag求解4.1 分析核心加密Handler有了VM模拟器我可以在模拟器里跑一遍完整的VM程序观察它究竟对输入做了什么。VM程序从VM数据段取出一个预设的初始向量然后对输入字符串的每个字符做一轮混合运算。仔细追踪流程后我发现了一个关键的Handler组合VM_LOADVM_XORVM_ADDVM_STORE这段组合被执行了很多次。我提取出重复的执行片段发现它本质上在做这样一件事char[i] ((char[i] ^ key1[i]) key2[i]) 0xFF char[i1] ((char[i1] ^ key3[i]) ^ char[i]) 0xFF其中key1、key2、key3都是VM数据段里的固定常量数组。这个运算模式虽然简单但带了异或、加法、异或链式依赖单独看不算复杂可一旦放在VM的混淆执行里加上反调试干扰在赛场上就非常耗时间。不过到这里为止加密逻辑还只是第一步。程序最后是把处理后的输入和一个硬编码的密文数组做比较。我dump出VM数据段里的密文数组一共32个字节对应flag长度为32。4.2 模拟VM运行并爆破输入加密逻辑是逐字节的链式操作理论上可以直接推导。但它既然是一个VM有一个好处是我们不需要做复杂的数学逆运算直接暴力枚举每个字节反而是最简单的做法。因为flag的每个字符都是可打印ASCII通常在一个很小的字符空间内暴力枚举32次每次只需尝试95个字符完全可行。我改进了一下我的VM模拟器让它接受“待验证的flag字符数组”作为输入在模拟器里跑完VM逻辑然后和密文比较。如果一致就输出“yes”否则“no”。然后我简单写了个循环逐字节枚举对第0个字符试遍所有可打印ASCII找出能让第0个字节匹配的那个字符固定第0个字符继续枚举第1个字符依次类推。这里因为链式依赖每个字符的枚举只受前面字符影响所以顺序爆破是可行的。脚本跑起来不到一秒钟就把32个字符全部确定了。这就是模拟器加爆破的威力——不用去逆运算不用去理解每一行的深层含义直接把VM当黑盒来爆破。4.3 复现成功与验证爆破得到的字符串是一个32字节的可打印字符串以标准flag头开头格式完全符合CTF的flag规范。验证方式很简单把它作为输入运行真实的程序。程序打印出了“success”。我用gdb确认了比较逻辑确实走到了成功分支。这题的复现到此完成整个过程从0解到完全解出赛后大概花了四个多小时。如果赛场上我有现在这个清晰的思路和预先准备好的反调试绕过脚本应该可以在一个小时内解出。这也侧面说明不是题目难到无解而是赛场的精力分配和工具准备度决定了结果。5. 复盘方法论与VM类题目应对策略5.1 为什么现场会卡住赛后我跟一起参赛的几个朋友交流大家卡住的原因高度一致时间黑洞全部集中在反调试上没有尽早转向VM逻辑分析。线下赛的reverse题不同于线上没有网上的writeup可以参考每一步都得自己来。反调试作为一个“恶心人的过滤网”它的作用就是消耗你的时间——如果你选择跟它死磕CPU时间、心态、体力都会被消耗掉。复盘时我给自己定了一个规则遇到反调试第一时间patch掉不要想着“优雅绕过”。比赛中我们需要的是结果不是绕过的艺术。无论是一层还是三层直接改字节码让反调试函数失效永远是成本最低的方案。5.2 通用解题流程与工具链经过这次复现我给自己总结了一套VM类CTF题的通用解题路线写出来供大家参考文件识别与符号收集file、strings、checksec、导出表、导入表先捞一把基础信息。寻找解密函数如果数据段是高熵的几乎必然存在运行时解密。用gdb在解密后dump数据不要做静态解密。定位VM入口与Handler表找循环找跳转表IDA里看跳转表最直接。动态验证Handler语义在分发器处断点用已知数值测试每个Handler。给IDA中的函数重命名标注语义。写一个模拟器把字节码和Handler建模成Python模拟器让电脑替你执行。这一步能极大加速分析。黑盒或白盒求解如果加密逻辑简单直接逆运算如果复杂用模拟器加爆破。验证用真实程序跑一遍得到的flag确认成功。工具链方面我推荐组合使用IDA Pro静态分析 gdb动态调试 pwndbg或gef插件调试体验提升 Python的capstone/unicorn建模拟器和反汇编。如果不想折腾纯gdb IDA Python也够用关键不是工具多花哨而是流程高效。5.3 VM逆向速查表我把这次复现中觉得最核心的要点整理成了一张速查表方便以后再遇到VM类题目时快速对照项目要点加密数据高熵数据段运行时解密优先动态dumpHandler表跳转表/函数指针数组IDA里非常明显栈式VM运算从栈顶取数结果压回栈顶相当于“逆波兰”寄存器式VM有虚拟寄存器标识运算结果写入虚拟寄存器反调试别绕直接patch函数全体retc3字节码翻译写脚本把opcode映射成伪汇编批量处理模拟执行自己写个20行Python模拟器单步跟踪每一条VM指令求解策略能逆则逆不能逆就爆破VM往往方便黑盒爆破验证真实运行看输出与分支这个表我打印了一份贴在显示器边上以后打比赛也打算按这个checklist走。遇到VM题不再慌先跑流程后谈技巧。复盘这一道题损失了一场胜利但换来了一个完整的VM逆向方法论。如果再遇到类似题目我有把握说不会再0解题了——至少这道题的套路我已经彻底吃透了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进