御网杯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_HALT
- 0x1: VM_PUSH
- 0x2: VM_POP
- 0x3: VM_LOAD(从内存读数据到栈)
- 0x4: VM_STORE(从栈写数据到内存)
- 0x5: VM_ADD
- 0x6: VM_SUB
- 0x7: VM_XOR
- 0x8: VM_NOT
- 0x9: VM_CMP
- 0xA: VM_JMP
- 0xB: VM_JZ
Handler表的结构和分发逻辑找出来后,这个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预加载一个so,hook掉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。具体做法:在函数开头写入c3(ret指令),一劳永逸。
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_LOAD+VM_XOR+VM_ADD+VM_STORE,这段组合被执行了很多次。我提取出重复的执行片段,发现它本质上在做这样一件事:
char[i] = ((char[i] ^ key1[i]) + key2[i]) & 0xFF char[i+1] = ((char[i+1] ^ 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类题目时快速对照:
| 项目 | 要点 |
|---|---|
| 加密数据 | 高熵数据段=运行时解密,优先动态dump |
| Handler表 | 跳转表/函数指针数组,IDA里非常明显 |
| 栈式VM | 运算从栈顶取数,结果压回栈顶,相当于“逆波兰” |
| 寄存器式VM | 有虚拟寄存器标识,运算结果写入虚拟寄存器 |
| 反调试 | 别绕,直接patch函数全体ret(c3) |
| 字节码翻译 | 写脚本,把opcode映射成伪汇编,批量处理 |
| 模拟执行 | 自己写个20行Python模拟器,单步跟踪每一条VM指令 |
| 求解策略 | 能逆则逆,不能逆就爆破;VM往往方便黑盒爆破 |
| 验证 | 真实运行,看输出与分支 |
这个表我打印了一份贴在显示器边上,以后打比赛也打算按这个checklist走。遇到VM题不再慌,先跑流程,后谈技巧。
复盘这一道题,损失了一场胜利,但换来了一个完整的VM逆向方法论。如果再遇到类似题目,我有把握说,不会再0解题了——至少这道题的套路,我已经彻底吃透了。