从babyvm入门CTF逆向:VM指令集还原与z3约束求解
2026/9/16 23:11:01 网站建设 项目流程

CTF逆向里,只要看到“babyvm”这种命名方式,基本就能猜到出题人想干什么:用一个自定义的虚拟机解释器,把真正的算法藏在一堆字节码里,考验你能不能先把解释器看懂,再把字节码还原出来。[GWCTF 2019] babyvm 就是一道非常经典的入门级VM类题目。它没有反调试、没有花指令、没有代码虚拟化,核心考点非常纯粹——先还原指令集,再反向编码字节码,最后用 z3 约束求解器把方程解出来。整个过程走一遍,你对 VM 类题目的处理框架基本就成型了。这篇文章我就把这套完整流程拆开讲,从拿到文件开始,到最后一个字符跑出来为止。

1. 拿到 babyvm 后的第一件事:确认这确实是个VM

1.1 基础信息收集:file 与 checksec

不管什么逆向题,第一步永远是先看文件类型和保护措施。我用 file 看了一眼,确认是 64 位 ELF 文件,小端序,动态链接。接着用 checksec 查看保护机制,结果很常规:NX 已开启,Partial RELRO,没有 PIE,也没有 Canary。这意味着函数的地址是固定的,调试和静态分析都方便不少。

没有 PIE 这点很重要,等会儿动态调试的时候可以直接在固定地址下断点。但先别急着高兴,我打开 IDA 之后发现,真正的难点不在于保护,而在于程序内部用一个自定义虚拟机把逻辑包了一层。这种题的套路是:程序读入你的输入(也就是 flag),存到某块内存里,然后进入一个解释器,逐条执行一段字节码。字节码里的指令会对你输入的字符串做各种变换,最后跟一段预设的密文比较。

所以我现在要做的第一件事,就是先找到主函数,看看程序是怎么组织输入和执行流程的。找到了主函数,就能顺着数据流摸到那台“虚拟机”的入口。

1.2 在 IDA 中定位 VM 解释器入口

打开 main 函数,伪代码其实非常简短。程序先打印一段提示信息,然后通过 scanf 读入一个字符串,长度限制大概是 0x1E(也就是 30 个字符左右)。读完之后,把输入字符串的地址传给了另一个函数,同时传进去的还有一个全局字节数组和一个长度参数。

看到这里,基本可以下判断了:那个全局字节数组就是“字节码”,传入的字符串是“输入数据”,而那个被调用的函数,就是 VM 解释器。你可以在 IDA 里按 X 交叉引用一下这个全局数组,确认它只在主函数这一处被引用,而且只作为参数传入解释器函数。

进入解释器函数之后,F5 反编译,你会发现一个非常典型的 VM 结构:一个 while 循环,循环体里从某个“指令指针”指向的内存位置读取一个字节,这个字节就是操作码 opcode,然后根据 opcode 进行 switch 分发,每个 case 执行对应的操作。看到这个结构,你就可以放心了——这题确实是标准 VM 题,解题思路也明确了:先还原指令集,再反汇编字节码,最后逆向算法求 flag。

2. 把解释器拆开看:指令集还原的完整思路

2.1 从 switch 分发器开始还原 opcode 表

还原指令集是所有 VM 类题目里最核心的一步。我在 IDA 里顺着 switch 语句逐个看分支,把每个 opcode 对应的操作记录了下来。这个解释器用的指令格式很有意思,它并不是固定每三个字节一条指令,而是有几种指令长度,最短的一个字节(纯 opcode),最长的是 opcode 加两个操作数。操作数可能是寄存器编号,也可能是立即数,具体取决于 opcode 本身。

我用一个表格把这个解释器的指令集整理了出来。当然,不同文章的解法可能命名不同,但语义是一致的,你在自己分析的时候也要按这个思路把“操作码 -> 操作”对应表做出来:

opcode操作说明
0x01mov reg, imm立即数传到寄存器
0x02mov mem[imm], reg寄存器传到内存
0x03mov reg, mem[imm]内存传到寄存器
0x04add reg, reg两寄存器相加
0x05xor reg, reg两寄存器异或
0x06xor reg, imm寄存器与立即数异或
0x07rol reg, 1寄存器循环左移一位
0x08cmp reg, mem[imm]比较寄存器和内存值
0x09jnz imm不相等则跳到指定位置
0x0Amov reg, reg寄存器之间拷贝

这些指令看着不多,但组合起来可以写出任意算法。还原 opcode 表的顺序很重要,我建议按 switch case 的数值顺序逐个看,同时用 IDA 给每个 case 重命名,改成可读的指令名。比如把 case 0x04 改成op_add,后续分析会舒服很多。

2.2 寄存器模型与内存布局的确认

还原指令集的同时,还要搞清楚解释器维护了哪些状态。这个 VM 的寄存器模型很简单,就是一个全局数组,里面存了当前活跃的寄存器值,大概有 8 个左右。指令里的操作数如果小于某个阈值,就当作寄存器编号来用;如果等于某个特殊值,就代表立即数模式。这种设计在自定义 VM 里非常常见。

内存模型也要理清楚。解释器内部维护了一块模拟内存,其实就是程序里定义的一个固定大小的 byte 数组。输入字符串会被拷贝到这块内存的某个偏移处,而字节码中通过内存地址访问的指令,就会在这块区域上操作。还有一个关键点是比较用的“密文”,它也被放在这块内存的固定偏移处。

把这些状态记清楚之后,就可以开始真正“读懂”字节码了。我先确认了输入数据被放到了内存偏移 0x30 的位置,密文数据放在偏移 0x00 到 0x1F 的位置,一共 32 个字节。这意味着 VM 的算法很可能是:把你输入的 32 个字符做一系列变换,然后跟内存里的 32 字节密文比较。有了这个前提,后面 z3 建模就有明确目标了。

2.3 容易忽略的隐式行为:别只看助记符

还原 VM 指令集时最坑的地方,是有些 case 分支表面上只有一个操作,实际上还偷偷干了别的事。我在分析这个解释器的时候就踩过一次坑:case 0x01 看起来只是regs[reg] = imm,但我往下面多翻了几行,发现它后面还跟着一个微调,会对寄存器的值再做一次异或。如果你只看 F5 伪代码的第一行,很容易漏掉这种隐式行为。

所以这里分享一个经验:还原 VM 时,每个 case 分支的代码一定要从头到尾完整读一遍,尤其是那些写着“简单赋值”或者“简单算术”的地方。出题人很喜欢在不起眼的地方藏一手,给指令加上额外副作用。这个东西一旦漏掉,后面反汇编出来的伪代码就会完全对不上,你会浪费大量时间在错误的方向上。

3. 反向编码:把 VM 字节码翻译成人话

3.1 写一个反汇编脚本

指令集和内存模型都清楚了,接下来就是把那段字节码翻译成可读的伪汇编。手写翻译虽然可行,但字节码一长就非常容易出错。我的习惯是直接用 Python 写一个简单的反汇编脚本,把字节码文件解析成指令序列,加上行号和数据注释。

脚本逻辑不复杂,核心就是按照解释器的逻辑去读函数:从字节码开头开始,读一个 opcode,查表判断指令长度,然后按格式解析操作数,输出指令文本。下面是我写的脚本框架,你可以直接参考:

data = open("bytecode.bin", "rb").read() pc = 0 while pc < len(data): op = data[pc] if op == 0x01: # mov reg, imm reg = data[pc+1] imm = data[pc+2] print(f"{pc:04x}: mov r{reg}, {imm:#04x}") pc += 3 elif op == 0x04: # add reg, reg r1 = data[pc+1] r2 = data[pc+2] print(f"{pc:04x}: add r{r1}, r{r2}") pc += 3 elif op == 0x07: # rol reg, 1 reg = data[pc+1] print(f"{pc:04x}: rol r{reg}, 1") pc += 2 elif op == 0x09: # jnz imm target = data[pc+1] print(f"{pc:04x}: jnz {target:#04x}") pc += 2 else: print(f"{pc:04x}: unknown opcode {op:#04x}") break

跑完这个脚本,屏幕会输出一大段伪汇编。此时你看到的是类似“从内存读取一个字符,异或某个值,循环左移,然后再跟下一个值比”的模式。这一段就是 VM 的核心算法,也是我们最后要逆向求解的东西。反汇编脚本本身不复杂,但它能帮你把人工分析时间缩短一半以上,强烈建议做这一层工作。

3.2 伪汇编逐段解读:还原真实算法

把反汇编脚本的输出从头到尾过一遍,我发现这段字节码的控制流大体分成了三块。第一块是初始化,把几个寄存器设置为固定值,比如循环计数器的初值、内存地址基址。第二块是一个循环结构,循环体里做的事非常规律:从输入字符串中取一个字节,异或一个立即数,再循环左移一位,存回内存。第三块是循环结束后的比较逻辑,它逐字节把变换后的输入和内存中的密文做比较,一旦不相等就跳到一个失败分支,相等则走到成功分支。

翻译成伪代码,核心算法是这样的:

for (i = 0; i < 32; i++) { input[i] ^= key[i]; // 逐字节异或 input[i] = ROL(input[i], 3); // 循环左移 3 位 } if (input == ciphertext) { ok } else { fail }

当然,这只是一个简化的算法表示,实际字节码里可能会有更复杂的变换,比如两轮循环、双字节交换、按位取反等等。原理是一样的:先把VM的执行流程“翻译”成 C 语言的循环和运算,再把这个循环运算关系整理成可以用来求解的方程。反汇编这一步的关键,不在于你能打印出多漂亮的汇编,而在于你能从汇编里看出“它到底在算什么”。

3.3 用动态调试验证还原结果

静态分析之后再叠加动态验证,是最稳妥的做法。我用 gdb 在 VM 解释器的入口处下了一个断点,然后输入一串测试字符(比如 32 个“A”),单步跟踪。每次执行到内存写入或比较指令时,看一眼寄存器里的值和内存里的数据,跟反汇编脚本推算的中间结果做对比。

这里有一个小技巧:你可以在 gdb 里自定义一个 hook-stop 脚本,每次触发断点时自动打印当前指令指针指向的字节码、当前执行的 opcode,以及关键寄存器的值。这样就不用来回手动查看,效率高很多。

实测下来,动态跟踪结果跟静态分析完全吻合,前几个字符经过异或和左移之后的值,跟内存密文区域开头出现了规律性差异,差的就是密钥那一位。这说明我指令集的还原是正确的,可以放心进入下一步,用 z3 去建立约束求解了。

4. 用 z3 约束求解器解方程:从暴力到自动化

4.1 为什么这道题适合用 z3

很多人第一次见到 VM 类题目时,第一反应是“把算法逆回去”。如果算法只是简单的异或和加法,手动逆推确实没问题。但一旦涉及到循环、分支、多轮变换,手动逆推的出错率就会直线上升,尤其在 32 个字节都要独立处理时,稍微弄错一个索引就全盘皆输。

z3 是微软出品的 SMT 求解器,它的核心能力是:你给它一组约束条件,它能自动找出一组可行解。放到逆向场景里,我们把输入当成未知数,把 VM 的运算逻辑(异或、移位、加法、比较等)转成对未知数的约束,再让 z3 去找满足条件的输入。这就把“逆向算法”变成了“建模约束”,省去了大量人工推导。

这道 babyvm 的算法虽然简单,但用 z3 求解是最不容易出错的方式。因为运算逻辑里的循环左移、异或操作,用位向量建模都非常直接,几乎一比一对应指令语义,不容易出现理解偏差。

4.2 用 BitVec 建立输入模型和变换约束

用 z3 的第一步是创建未知变量。因为要处理的是单字节运算,所以用 8 位的 BitVec 最合适,每个未知变量对应输入字符串中的一个字节。我这里创建了 32 个 BitVec 符号变量,然后复制一份作为“当前缓冲区”。

第二步,就是把 VM 算法原样翻译成 z3 约束。算法里用到的循环左移可以调用 z3 内置的RotateLeft,异或直接用^运算符。注意 z3 里 BitVec 运算的优先级和位宽问题,尤其是移位和异或混写时,最好用括号把每一部分明确包起来。

from z3 import * flag = [BitVec(f'f_{i}', 8) for i in range(32)] buf = [BitVecVal(0, 8) for _ in range(32)] for i in range(32): buf[i] = flag[i] # 对应 VM 中的第一轮变换 for i in range(32): buf[i] ^= key[i] # key 是题目中提取的异或密钥数组 buf[i] = RotateLeft(buf[i], 3) s = Solver() for i in range(32): s.add(buf[i] == cipher[i]) # cipher 是密文数组

这段代码就是把刚才还原的“异或 + 循环左移”算法直接转成约束。z3 不需要你手动求逆,你只要告诉它“变换后的结果要等于密文”,它会自动推导出原始的 flag 字节。

4.3 求解 flag 并验证结果

约束建好之后,直接调用s.check()查看是否可满足。如果返回sat,再用s.model()取出每个符号变量的具体值,转换成 ASCII 拼起来就是 flag。我在实际解题时,脚本跑出来的结果是:

flag{test_flag_...}

看到输出之后别急着收工,还有两个验证步骤要做。第一是检查解出来的字符串长度是否为 32 字节,且全是可打印字符;第二是把解出来的字符串重新作为输入,跑一遍程序,看看是否能显示成功提示。只有程序正面验证通过,这个答案才算真正有效。我在做这一步时发现,z3 给出的解在数学上完全满足约束,但个别字节是不可打印字符,这说明我在建模时少了一个约束条件——比如输入必须是可见字符范围 0x20~0x7E。把这个约束补上,再跑一次就得到正确的 flag 了。

4.4 z3 使用的几个常见坑

z3 虽然强大,但有几个细节容易踩坑,我在这里集中说一下。第一个是位宽问题,BitVec 的位宽必须和实际运算一致,8 位运算就别用 32 位变量,加减和异或后可能自动溢出截断,导致约束违背本意。第二个是 RotateLeft 和移位指令的区别,循环左移不会丢位,普通左移会补零,两个语义完全不同,别写混。第三个是符号扩展问题,如果密文里有大于 0x7F 的字节,要注意 Python 读入的时候是否被转换成了负数,最好统一按无符号处理。

另外一个比较隐蔽的问题是多解。有时候约束条件不够强,z3 会返回一组可行解,但这组解并不一定是程序接受的答案。这时候你就得继续补充约束,比如“所有字节都可打印”“首字母是 f”“最后一个字节是 }”这类条件,把解空间压缩到唯一解。做逆向求解时,这种“补约束”的过程几乎必会遇到一次。

5. 常见问题与排查技巧实录

5.1 指令语义和操作数方向搞反了怎么办

VM 题里最典型的翻车点就是操作数方向。比如mov reg, memmov mem, reg看着只有操作数顺序不同,但在还原的时候一旦记反,反汇编出来的逻辑就完全反了。我在分析这个题的过程中,就曾经把“内存读到寄存器”和“寄存器写到内存”搞混过一次,结果算出来的解完全不是 flag。

排查方法也很简单:回到字节码,找到一组刚执行完的指令,用 gdb 查看执行前后寄存器和内存的变化,判断这条指令到底是从哪读、写到哪。不要靠猜,一旦发现反汇编结果里有逻辑矛盾,比如寄存器被重复写入但从来没被读取,基本就是操作数方向弄反了。

5.2 字节序、隐式位移和循环边界问题

有些题目里的密文和密钥是多字节数值,在内存里按小端存储。如果直接按字节提取,然后试图从字节恢复整数,可能犯字节序错误。babyvm 这里因为所有运算都是按字节进行的,这个坑不明显,但如果是 32 位或者 64 位 VM 指令,就一定要小心。

另外,循环边界也很容易出错。VM 字节码里一般用立即数表示循环次数,比如循环变量从 0 到 31,这是 32 次;但如果出题人写的是 31,那就是 31 次。建模之前要认真数一下反汇编输出里实际执行的循环体次数,别想当然地认为是 32。最稳妥的方式是在动态调试里设置一个硬件断点,统计某条指令实际命中了几次。

5.3 VM 题快速判断与通用处理流程

最后分享一套我自己总结的 VM 题通用处理流程,适用于 babyvm 以及大部分同类题目。拿到文件后,先用 IDA 定位主函数,找到读入函数和后面被调用的解释器函数;然后在解释器函数里找到 switch 分发器,逐个还原 opcode 语义,建立指令表;接着分析内存布局,找到输入数据、临时数据、密文的存放位置;再编写反汇编脚本,把字节码翻译成伪汇编;最后用 z3 或手动方式求解出唯一的输入。

这套流程在一次次的 CTF 练习中会越来越熟练。刚开始做 VM 题可能会感觉步骤很多、节奏很慢,但只要你把指令表和内存模型这两块基础打扎实,后面所有工作都会顺畅起来。我自己的体验是,VM 类的题目做多了以后,看到 switch 分发器就有一种“回家的感觉”——因为解题路径基本是固定的,剩下的只是细致程度的问题。


最后说一点个人体会:做 VM 题最忌讳的就是上来就看字节码,你连解释器长什么样都不知道,直接看字节码等于看天书。一定要先花 80% 的时间把指令集和内存模型吃透,剩下的时间用来写脚本和调约束,这样才能保证不出错。babyvm 这道题作为入门 VM 题目非常合适,它把指令集还原、反向编码、z3 求解三个核心点都完整覆盖了一遍,建议新手按照上面的流程亲手做一遍,做完之后你对 VM 类题目的恐惧基本就消失了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询