ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

网鼎杯signal逆向题:VM虚拟机完整分析从指令还原到angr/z3求解

网鼎杯signal逆向题:VM虚拟机完整分析从指令还原到angr/z3求解

2020年网鼎杯青龙组的逆向题 signal,当时在比赛里卡了我挺久,赛后复盘才发现这是一道非常典型的虚拟机(VM)逆向题。它没有复杂的反调试,也不考算法骚操作,真正考的是你面对一段“伪字节码”时,能不能快速把执行语义抽出来,再通过符号执行或者约束求解得到flag。这篇文章就把我当时完整的分析过程、踩过的坑和两套解法都写出来,给准备打CTF逆向、尤其是想啃VM类题目的朋友做个参考。

这类题的意义在于,它几乎涵盖了VM逆向的全部基本功:识别分发器、还原指令语义、抽象栈机模型、用angr或者z3求解。你能从这道题里带走一套方法论,以后碰到长得再花哨的VM题,都可以照这个思路拆。

1. 初探signal:运行、查壳与核心函数定位

1.1 先跑起来,看它葫芦里卖的什么药

拿到题目文件,我习惯性的第一步不是直接扔进IDA,而是先丢到Linux环境里跑一下,看看程序的基本行为。终端执行./signal,程序提示输入,随便敲一串123456,回车之后程序没有任何输出,直接退出。这个现象其实透露了不少信息:程序大概率是“输入一串字符,内部做变换,然后和密文比较”,比较失败时静默退出,没有任何提示。

接着用file signal查看文件类型,是64位ELF,动态链接。再用checksec看了一下保护,只开了NX和Partial RELRO,没有canary,也没有PIE。这里没有PIE是个好消息,说明IDA里的地址就是运行时的真实地址,后面写angr脚本时find地址能直接用。

查壳方面用Detect It Easy扫了一下,没有加壳,干干净净。也就是说这道题不需要脱壳,重点完全在程序逻辑本身。很多VM题都喜欢加壳或者搞花指令来抬门槛,signal没有这么做,反而让选手能把精力集中在VM指令分析上,这一点在比赛里算是友好度比较高的。

1.2 进入IDA:main、handler与vm的三角关系

用IDA64打开signal,找到main函数,F5反编译一看,逻辑非常短。main函数做了两件关键的事:第一,用signal(SIGILL, handler)注册了一个针对非法指令异常的信号处理器;第二,调用vm()函数。整个main干净得有点不真实,真正的核心逻辑全被藏进了vm()里。

这个signal(SIGILL, handler)是个很有意思的设计。SIGILL是“Illegal Instruction”,也就是CPU执行了非法指令时触发的信号。程序故意注册handler,说明它在运行时肯定做了一些事情来触发SIGILL,然后由handler接管后续流程。这是一种很常见的混淆手法,目的有两个:一是让静态分析者在跟踪执行流时被信号处理器绕晕,二是给符号执行工具找麻烦——angr这类工具在模拟执行时遇到非法指令,默认会卡住或者直接报错,需要手动hook才能继续往下走。

进入vm()函数,一眼就能看到两个核心数据结构:一个很大的opcode[]数组,里面装着一串看起来毫无规律的整数;另一个encode[]数组,大概十五六个元素,内容也是数字。看到这两个数组,基本可以断定这就是一个解释器结构:opcode[]是待执行的字节码,encode[]是最终用来比对的结果。我们的目标是搞清楚opcode[]里的每条指令做了什么,然后反推出输入字符应该满足什么条件。

2. 把VM拆开:指令集分析与栈机建模

2.1 在IDA中识别VM分发器

VM逆向的第一步永远是找到“分发器”(dispatcher)。所谓分发器,就是解释器里反复执行的那段逻辑:从opcode[]中取出一个操作码,根据操作码的值跳转到对应的处理代码,处理完再回来取下一条指令。signal的vm()函数反编译之后是一个大的switch结构,这已经相当于直接把分发器喂到你嘴边了,省了很多逆向的力气。

我用的是IDA的Graph视图,把vm函数整体看了一遍,确认了分发器逻辑。它的形式很典型:一个循环,维护一个指令指针(一般叫index或者pc),每次循环取出opcode[index],然后switch(opcode[index])分发到各个case分支。每个case都对应一个具体操作,有的操作会额外从数组里读取立即数,取完立即数之后index会相应增加。

这里有个非常关键的观察点:一个VM的操作码表到底有多少个case,决定了这个VM复杂度的上限。signal的case数量并不多,我数了一下大概在10个左右,具体来说有压栈、弹栈、加减乘除、异或、比较这些常见操作。对一道比赛题来说,这个复杂度非常合理——太少了解题太简单,太多了工作量太大,10来个case刚好能考察选手的基本功。

2.2 操作码语义还原与栈机建模

还原指令语义的时候,我习惯不看单个case,而是整体先把所有case扫一遍,理出每个操作码对应的动作,做成一张语义表。signal的VM是一个典型的栈式虚拟机:绝大多数指令的操作数来自栈顶,运算结果压回栈顶,只有少数指令能从opcode[]或者变量区读取数据。

我整理出的语义表大概是这样的:

操作码语义说明
0x0Apush imm把紧随其后的立即数压入栈
0x0Bpop var栈顶弹出并存入本地变量表
0x0Cadd弹出两个元素,相加后压回
0x0Dsub弹出两个元素,相减后压回
0x0Emul弹出两个元素,相乘后压回
0x0Fdiv弹出两个元素,整除后压回
0x10mod弹出两个元素,取模后压回
0x11xor弹出两个元素,异或后压回

后面还应该有一个“比对”或者“输出”操作码,用来把计算结果和encode[]里的密文进行比较。这个操作码位次比较靠后,具体数值以你实际拿到的题目文件为准。

这段还原过程爽点很多,但也有个容易翻车的点:减法、除法、取模这三个操作必须搞清楚栈顶元素的顺序。比如sub,是先弹出A再弹出B,计算的是B - A还是A - B?很多VM在这上面做文章,顺序反了求解出来的表达式完全不一样,最后flag也就不对。我的做法是顺着opcode[]里一条含有sub指令的片段,手算一遍,看看结果和encode[]里的某个密文能否对上,用“结果比对”来验证顺序假设。这个方法很笨,但是在VM逆向里非常有效,相当于用一个已知答案的小例子来校准你的模拟器。

2.3 动手写一个指令追踪小脚本

当case的数量确认、语义表也整理好之后,我就不再手动模拟了,而是直接用Python写一个极简的栈机模拟器,把opcode[]从头到尾跑一遍,打印出每一步操作对栈的影响。这个脚本非常短,但能救命。

伪代码大概是这样的:

opcodes = [0x0A, 0x02, 0x0B, 0x0C, 0x11, ...] # 实际数据以题目文件为准 stack = [] vars = [0] * 20 pc = 0 while pc < len(opcodes): op = opcodes[pc] if op == 0x0A: # push imm imm = opcodes[pc+1] stack.append(imm) pc += 2 print(f'push {imm}, stack={stack}') elif op == 0x0B: # pop var idx = opcodes[pc+1] vars[idx] = stack.pop() pc += 2 print(f'pop vars[{idx}]={vars[idx]}, stack={stack}') elif op == 0x0C: # add a = stack.pop() b = stack.pop() stack.append(b + a) pc += 1 print(f'add: {b}+{a}={b+a}, stack={stack}') # 后续case同理

通过这个小脚本,我就能把整个VM执行过程完整打印出来,观察变量表是怎么被填充的。signal这题比较友好,它把输入的多个字符分别存到不同的变量里,后面再用这些变量做运算,最终和encode[]比对。这意味着每个密文值都对应一个由输入字符构成的表达式,反推起来思路就非常清晰。

3. 两套解法落地:angr快速求解与z3手动还原

3.1 angr符号执行直接梭

在做VM逆向题时,我一般会优先考虑一个问题:这个VM有多复杂?如果操作码不多、指令长度有限,直接用符号执行往往是最省力的。signal就是一个经典例子,它没有复杂的系统调用,整个程序只依赖Linux信号处理,指令序列又短,非常适合angr一把梭。

我的angr脚本思路是这样的:把程序加载进来,将输入字符缓冲区声明为符号变量,然后让angr模拟执行,直到抵达程序里“用encode比对”的那段代码,再让求解器给出满足约束的输入。

写过很多次之后,我的脚本已经固化成模板了,核心结构如下:

import angr proj = angr.Project('./signal', auto_load_libs=False) state = proj.factory.entry_state() # 假设程序从指定函数读取输入,这里直接把标准输入流符号化 # 实际使用时要根据main函数读取方式调整start地址 simgr = proj.factory.simulation_manager(state) # 找到比较逻辑所在地址,以及失败分支地址 # 地址需要按实际IDA分析结果填写 find_addr = 0x400xxx avoid_addr = 0x400yyy simgr.explore(find=find_addr, avoid=avoid_addr) if simgr.found: found = simgr.found[0] flag = found.posix.dumps(0) print(flag)

这里有两个关键的坑。第一个是地址问题,光有find还不够,必须把所有会导致程序异常退出的路径都加进avoid里,否则angr可能在错误分支上疯狂探索。第二个就是开头提到的SIGILL信号处理。因为程序注册了handler,模拟执行中一旦遇到非法指令会触发信号处理逻辑,angr默认的模拟方式可能会卡死。解决办法是hook掉handler函数,或者直接hook触发SIGILL的那条指令,让执行流直接跳到正常流程上。

angr跑的话,一般几十秒到几分钟就能出结果,具体时间取决于机器性能。我当时跑的时候大概用了不到一分钟,就求出了一串看起来像flag的字符串。这再次验证了一个经验:遇到VM题,先别急着手工一条条分析,先让angr跑着,同时再手工分析VM逻辑,两手准备永远是最稳的策略。

3.2 z3手动约束验证

angr虽然快,但它像个黑盒,跑出结果你并不知道内部到底发生了什么。如果比赛时间充裕,又想确认自己的理解对不对,z3手动约束求解是一条更“扎实”的路。

手动约束的思路是:把整个VM执行过程翻译成一组等式。输入字符是未知的,每做一次运算,就等于在符号表达式上做一次变换;最后与encode[]比较,就形成一组约束。用z3声明16个字节符号变量,按照VM指令一步步构建表达式,最后求解。

我当时的做法是写了一个Python脚本,把opcode[]从头到尾模拟一遍,但栈和变量里存的不是具体数值,而是z3表达式。这样做的好处是,VM执行完,约束表达式也就自然构造好了,完全不用手动推导。

核心代码风格是这样的:

from z3 import * # 假设输入为15个ASCII字符 flag = [BitVec(f'c{i}', 32) for i in range(15)] # 对字符加约束,必须是可打印字符 solver = Solver() for c in flag: solver.add(c >= 0x20, c <= 0x7e) stack = [] vars = [0] * 20 # 按opcode顺序执行,遇到运算指令就生成z3表达式 # 例如 add: stack.append(stack.pop() + stack.pop()) # 例如 xor: stack.append(stack.pop() ^ stack.pop()) # 最终执行到与encode比较的地方 for i in range(len(encode)): solver.add(vars[i] == encode[i]) if solver.check() == sat: model = solver.model() solution = bytes([model.eval(flag[i]).as_long() for i in range(15)]) print(solution)

这里有个常见的字节序陷阱值得单独拿出来说。如果输入是按字符一个一个BitVec声明,然后逐个参与运算,最后拼接,就没有顺序问题。但如果你偷懒直接把一整块内存导入成一个大BitVec,再拆成字节,就要格外小心大小端顺序。VM里实际上是一个字符一个字符运算的,我遇过不少选手z3写了半天解不出来,最后发现是拼接顺序反了。最好的习惯是老老实实逐字节声明,宁可代码长一点,也要避免低级的字节序Bug。

3.3 两套方案的取舍
对比维度angr符号执行z3手动约束还原
前置投入只需定位地址,代码量小需要完整还原指令语义
运行速度受路径爆炸影响,可能很慢通常秒级出结果
可解释性黑盒,解出来并不理解原理白盒,每步都知道在算什么
适用场景VM指令分布复杂、手动分析耗时VM指令简短、手动模拟可完成

在实际比赛中,我的选择逻辑是这样的:如果这个VM的指令集在十分钟内就能理清楚,我直接手动模拟+z3,求稳且快。如果VM异常复杂,case动辄几十个,指令还嵌套循环,我绝对优先angr。signal属于前者中的典型,操作码少、无循环,手动模拟不会超过半小时。当然更聪明的做法是并行:先挂一个angr跑着,同时手动分析,哪个先出结果用哪个。CTF玩的就是时间管理,多一手准备总没错。

4. 常见问题与解题心法

4.1 常见问题排查速查表

每次给后辈讲这道题,我都会整理一份踩坑清单,都是实际跑题时最容易卡住的地方:

现象原因解决办法
angr跑了很久没有结果没有设置avoid地址,符号执行在错误路径上疯狂探索仔细分析整体流程,把失败分支全部加入avoid;必要时开启veritesting=True
angr在某个位置卡死触发了SIGILL信号,handler的执行流不明确hook信号处理函数,让程序跳过异常继续执行
z3解出来一串乱码输入字符没有加可打印ASCII约束对每个符号变量添加c >= 0x20 && c <= 0x7e
z3解出来但flag不对减法/除法运算的栈顶顺序理解反了用一小段已知密文手算验证操作顺序
最终比对不上encode数组VM还会对结果做额外处理(如取模、反转)重新检查opcode末尾部分,往往有容易被漏掉的case

信号处理这一个坑是我最常强调的。很多人用angr跑signal跑不动,以为是路径爆炸,其实问题出在SIGILL的handler上。我的做法是直接hook掉handler入口,让它什么都不做直接返回。x86平台上触发SIGILL通常是因为执行了ud2指令,angr对这种指令的处理特别容易触发异常分支,提前hook能省一大半时间。

4.2 VM逆向的几个通用心法

解决signal这道题的经验,完全可以迁移到其他VM类题目上。这里我总结几条比较通用的方法论。

第一条,永远先找分发器。不管题目包装得多花哨——是用状态机还是自修改代码——你都需要找到那个“取出指令并跳转”的核心循环。找到分发器,就找到了VM的入口,剩下的就是一个个case的体力活。没有分发器概念的VM基本不存在,这是这类题目的命门。

第二条,case不用急着全部读懂,先建语义表。很多初学者看case代码,总觉得要完全搞清楚每条汇编的每一个细节才敢继续。实际上VM逆向的正确姿势是:大而化之地看每个case在逻辑上做了什么操作,记录成push、pop、add这样的语义符号,再回到opcode序列里去跟踪。汇编细节看太多反而容易被绕晕。

第三条,善用模拟器做半自动化分析。把case翻译成Python的栈操作之后,所有的分析都能在脚本层面完成。我见过不少选手还在一格一格地手推栈的变化,那样既慢又容易出错。写一个几十行的模拟器,把程序执行轨迹打印出来,比你盯屏幕一小时都管用。

4.3 从signal延伸到更多VM题

把signal吃透之后,再碰到其他VM逆向题,你会发现自己上了一个台阶。比如有些题会在VM里塞循环,那你就得考虑怎么把循环展开成约束;有些题会有多条指令组合成复杂操作,那你可以把若干条指令合并成一个函数来缩短表达式;还有些题会把VM藏在动态代码里,那就得结合调试器动态转储。

我个人的建议是,拿signal当模板,把整套流程——文件分析、识别分发器、指令语义建模、angr脚本、z3脚本——完整地走两到三遍,直到不需要看笔记也能写出来为止。CTF逆向的核心能力就是“遇到未知执行引擎时快速建模”的能力,这个能力只能通过大量重复练习来培养。

最后再分享一个实战小心得:当你用angr成功解出一个VM题时,一定要再用z3手动解一次。不是闲得没事,而是用z3的过程能帮你把VM的指令语义彻底刻进脑子里。比赛里VM题千变万化,但底层思路也就那么几种。signal这道题最难得的,是它以一道比赛题的体量,把VM逆向的整个流程完整演练了一遍,值得反复回味。

RELATED READING

延伸阅读

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