ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

CTF逆向虚机保护入门:从babyvm看自定义虚拟机分析

CTF逆向虚机保护入门:从babyvm看自定义虚拟机分析 在CTF逆向圈子里提到“虚机保护”这四个字很多刚入门的朋友第一反应就是头疼。确实自定义虚拟机指令集、复杂的handler分发、堆栈操作和反调试技巧混在一起能让一份本来很简单的逻辑变得面目全非。但如果你真的沉下心拆过两三道类似的题目会发现这类题型的套路其实相当固定甚至可以说它考的不是“逆向技巧”而是“工程拆解能力”和“耐心”。我今天想拿[GWCTF 2019]的babyvm这道题完整走一遍解题流程。这道题算是虚机模拟方向里非常经典的一题难度中等偏上但胜在逻辑干净、没有冗余垃圾代码特别适合用来建立虚机逆向的完整方法论。整道题的破题思路可以浓缩成三个关键词虚机模拟流程分析、反向编码提取算法、z3约束求解器解方程。我会从拿到文件的第一个动作开始一步步讲到flag怎么出来顺便把我在复现过程中踩过的坑和容易卡壳的地方一并列出来希望能给正在刷题的朋友一些实质性的帮助。1. 题目初探拿到babyvm先干什么1.1 文件识别与防护检查拿到这个附件之后我的习惯是先做三件套file、checksec、然后丢进IDA看一眼入口点。这个习惯听起来朴素但真的能帮你少走很多弯路。先看文件类型。这道题给的是一个64位的ELF文件动态链接、非strip状态用file命令一眼就能确认。非strip这个信息特别重要意味着符号表还在很多函数名可以直接看到不需要靠猜。不过这道题比较阴的一点在于虽然符号没删但它把真正干活的逻辑全部塞进了一个自定义虚拟机里主函数的逻辑被压缩得非常短。然后是checksec看防护状态。你可能会觉得CTF逆向题又不是pwn题看防护状态有什么意义有意义的点在于如果栈上开了Stack Canary那说明缓冲区处理的代码可能比较规范如果NX和PIE都开了动态调试时的地址会变来变去你就得考虑用相对地址或者干脆以静态分析为主。babyvm这道题没开PIE栈保护和其他项也不是重点整体来说对静态分析非常友好。提示遇到任何CTF逆向题先花30秒看保护和文件信息能直接决定你后续是动态调试为主还是静态分析为主。这道题的防护很弱但虚机逻辑复杂所以本质上是“静态为主、动态辅助”的思路。1.2 主函数定位与静态分析初探用IDA打开后main函数直接就摆在那里。你大概率会看到一副“人畜无害”的画面程序接收输入字符串、调用一个初始化函数、然后进入一个执行函数、最后有个检查函数。代码量少得可怜整个主流程加起来可能不到几十行汇编。但这就是典型的虚机保护题迷惑性所在。表面上看代码简单但程序真正的逻辑被你“看不到”的东西接管了——那些看起来平平无奇的函数调用内部是在跑一段字节码。所谓虚机本质上就是写了一段解释器它读取一段自定义字节码模拟CPU寄存器、内存、指令集然后执行你完全看不懂的运算过程。我当时的分析流程是这样的先把main函数里的每一个sub_xxx函数都命名清楚区分出初始化、执行、校验三个阶段。然后进入初始化函数看它做了什么——通常就是给虚拟机的寄存器、栈、指令指针EIP赋初值或者把编码后的字节码数据拷贝到某个内存区域。这个过程可以理解成“装系统”装好之后解释器才能真正运行。分析完初始化下一步就是顺着执行函数的汇编找到dispatch循环。这是虚机题的关键所有的指令执行都是从这个循环开始跳转到对应的handler再跳回来。1.3 一眼看穿题目考点的思考路径在动手深挖之前我通常先问自己一个问题这道题如果不用虚机裸逻辑是什么样babyvm这个题目名称里的“baby”其实在暗示难度没到地狱级。它大概率就是把一段加密算法塞进了虚机里执行加密算法的核心可能也就是异或、加减、移位、查表这几种操作。真正想清楚这一点后面面对一堆莫名其妙的opcode时你心里就有底了不会慌。用一句话概括我对这类题型的理解虚机只是壳算法才是核。壳再厚耗的只是你的分析时间核才是决定flag怎么出来的根本。因此后半程的工作重心必然会落在“把壳剥掉、把裸算法还原出来”上面而这个还原出来的算法往往需要借助z3这类约束求解器来做反向推理。2. 虚机结构分析从dispatch到handler2.1 VM入口与dispatch循环定位找虚机入口的那一步我是在IDA伪代码窗口里完成的。执行函数里大概率会有一个while循环循环条件可能是eip 字节码长度也可能是当前opcode不等于退出指令。这个while循环就是整个虚机的“心脏”专业上叫dispatch循环。这个循环的通用形态大概是while ( vm-eip code_len ) { opcode code[vm-eip]; vm-eip; switch ( opcode ) { case 0xF1: handle_mov(vm); break; case 0xF2: handle_add(vm); break; // ... 更多case case 0xFF: return; default: vm-eip; break; } }但为了增加逆向难度这道题的作者做了一点变形他没有直接用一条break结构而是把opcode的取值、查表、跳转都写在了一个handler table里。也就是说你看到一个switch里嵌套着一个handler_table[opcode]每个handler都接收VM结构体指针真正干活的部分是这些handler。所以我的建议是不要一上来就沉浸到单个handler的具体实现里那样很容易“一叶障目”。正确的顺序是先确认三条信息——指令指针是哪个寄存器、指令读取的宽度是多大通常是一个字节、opcode落在哪个区间会被识别。确认完这三个点你就可以进入handler逐条分析了。实操心得在IDA里给VM结构体的关键字段改名是我每次做虚机题都必做的一步。比如把*(this 8)改成eip、*(this 12)改成r1、*(this 16)改成r2这样读伪代码时完全不用在脑子里做“偏移量到语义”的翻译效率翻倍。2.2 指令集逆向opcode与操作数格式等你摸到所有handler之后最核心的体力活就来了逐个分析opcode对应的语义。这一步有点像是你在考古现场捡到了一堆碎片你得拼出它们各自代表什么意思。我以babyvm为例整理了一张opcode映射表。需要注意不同题目这张表完全不同下面给出的是我在这道题里实际分析出的结果opcode十六进制含义伪代码表示0xF1将立即数移动到寄存器rA imm0xF2寄存器间拷贝rB rA0xF3寄存器与立即数相加rA imm0xF4寄存器异或立即数rA ^ imm0xF5寄存器左移指定位数rA imm0xF6从内存读入寄存器rA mem[base]0xF7从寄存器写回内存mem[base] rA0xF8寄存器比较相等则跳过指定字节if (rA rB) eip offset0xF9循环开始标记与某个loop指令搭配0xFA循环结束标记eip跳回对应起始地址0xFF虚拟机退出return看似种类不少但仔细看无非就是加减、异或、移位、mov、内存读写这些最基础的操作组合。只是套上了自定义opcode的外壳显得吓人而已。分析单个handler时我推荐用IDA的F5反编译然后手动把反编译结果“翻译”成上面这种可读性强的伪代码。翻译的过程要特别注意操作数的顺序**是opcode后面直接跟立即数还是先读寄存器编号再读立即数立即数是1字节还是4字节**这些细节直接决定了你后续写脚本提取算法时能否一次性把指令序列解析对。2.3 寄存器与内存模型我在这道题里还原出的VM结构体大概长这样struct VM { unsigned char *code; // 字节码区 int eip; // 指令指针 int r1, r2, r3; // 三个虚拟寄存器 int sp; // 虚拟栈指针 unsigned char *mem; // 模拟的内存空间 int flag_offset; // 输入flag在模拟内存中的存放位置 };这里的“内存”用现代话讲就是一个大数组程序把输入字符串存进去虚拟机在运行时就通过F6/F7这类内存读写指令反复读取输入数据进行变换。理解这一点后你就不难做出一个判断这道题从表面看是在执行一段“看不懂的指令流”但实际上它就像一个加了密的脚本引擎在执行一枚加了密的“算法胶囊”。而我们逆向的核心目标之一就是把胶囊里的算法胶囊壳切开看清里面的每一步运算。2.4 初学者容易卡壳的指令边界问题有些朋友在分析虚机时会遇到一个非常让人抓狂的问题怎么知道一条指令占几个字节比如看到0xF1 0x00 0x30有的人以为是两条指令有人以为是一条带两个操作数的指令。我的经验是回到handler的汇编里去看它读取操作数时用了多少位移量。如果读到第一个操作数后eip只加了1那么说明每个操作数就是1字节如果加了4那说明是dword。这道题里操作数几乎全是1字节压缩成这种紧凑格式也是为了模拟真实CPU的编码风格。建议你在分析阶段就写一个“半自动解析脚本”先把手头的字节码按opcode表逐条切分。如果切出来的结果能跑通所有字节码且没有非法opcode那说明你的指令边界理解大致正确。这一步做完虚机分析就从“猜”变成了“验证”。3. 反向编码把字节码翻译成人话3.1 从二进制里提取字节码序列虚机结构搞清楚了接下来我干的事是“反向编码”。听起来玄乎其实就是把二进制里的那一串opcode字节提取出来逐个翻译成人类能读懂的指令序列。之所以叫“反向”是因为作者当初是把自己的算法“编码”成了字节码我们现在做的是把这个过程反过来。提取字节码有两种办法一种是在IDA里直接找到存放字节码的数组看它的初值另一种是动态调试在虚拟机执行到第一条字节码时把内存里的数据整体dump出来。两种方法我都试过这道题里用第一种更省事——数组就明文躺在.data段里。我提取出来的字节码大致像下面这样具体数值有所脱敏只展示格式F1 00 30 F2 01 00 F4 01 41 F6 02 01 F3 02 01 F7 02 00 F8 00 01 0C E1 02 33 ... FF这里我直接用两列十六进制表示。每一条字节码由opcode 操作数组成。像F1 00 30就是“把0x30赋给r0”F4 01 41是“r1异或0x41”F6 02 01是“从内存地址[r1]取值到r2”。整段跑完之后程序再拿虚拟内存中某个位置的数据和一个比较数组比对。3.2 还原出来的核心算法流程在做完整段字节码翻译后我把它“优化”成了一段等价的Python伪代码这样能很直观地看出作者对输入做了什么# 假设输入存入mem[0]开始的区域 r0 0 # 对每个输入字符进行一轮变换 for i in range(24): r3 input[i] r3 0x41 r3 ^ 0x55 r3 ((r3 2) | (r3 6)) 0xFF # 再加上一个依赖于位置的异或 r3 ^ key[i] encrypted[i] r3当然实际的字节码可能会比我这里写的更绕比如会加一些无意义的寄存器搬运来迷惑你。但核心操作逃不出那几类加法、异或、移位循环、查表。你会发现真是把虚机这层壳剥掉后里面的逻辑甚至比很多普通逆向题还要简单。3.3 我在反向编码过程中踩过的一个坑这里必须分享一个我真实踩过的坑。在分析到某个handler时我以为F6指令是“从立即数地址读数据”所以想当然地认为操作数是内存地址于是把后续的指令序列完全解读错了导致推导出的算法和实际执行对不上。后来用动态调试单步跟踪才发现F6从内存取数时操作数不是绝对地址是相对于某个基地址的偏移。也就是说你得先把VM结构体里的mem基址加到操作数上才是真正的内存地址。这一下就让前面的分析全部推倒重来。避坑提示遇到内存读写类handler一定要先搞明白地址是用绝对地址还是相对偏移。验证的土办法很简单——在动态调试里输入一串特征明显的字符比如全0、全1单步执行到内存读写后看虚拟内存里哪个位置的字节变了。这样能快速锁定地址映射关系。3.4 反编码结果的正确性校验翻译完所有字节码后别急着进入下一步。我习惯做一个快速校验随便构造一组输入用我翻译出来的Python伪代码跑一遍再用动态调试跟一下真实的二进制对比两者在某个中间步骤的结果是否一致。这个校验动作能帮你过滤掉95%的低级错误。如果结果对不上别怀疑是玄学老老实实回头查你翻译的指令序列——大概率是某条指令的opcode或者操作数顺序搞反了。这一步虽然烦但非常值得因为它能确保后续z3求解的约束条件是正确的否则求解器就算出结果也解不出正确答案。4. z3约束求解把方程交给求解器4.1 为什么要用z3而不是手动逆推反向编码完成之后理论上我们可以手工把每个加密步骤逆推回去。比如加密是r3 0x41逆推就是r3 - 0x41如果是异或逆推就是再异或一遍。为什么还要用z3因为实际的算法往往会混合左移和右移、循环移位、多个字符之间的相互影响甚至可能有多轮迭代。一旦轮数变多手工逆推就非常容易出错尤其是一些非双射运算比如截断、与操作逆推起来极其痛苦。z3是微软出品的SMT求解器它做的事情很简单你告诉它哪些是变量、哪些是约束条件它自动找一组变量取值让所有约束成立。在CTF里这类工具的使用场景非常统一就是解约束方程。你只需要按“原算法正向建模”让z3自己去反推不需要你动脑子做逆运算这是它最大的价值。4.2 用z3正向建模的详细过程现在展示我实际的解题脚本。当时我根据反向编码得到的算法原文重新写了一遍z3建模。核心思路是把flag的每一个字节都设成未知的8位向量BitVec本质是8位的位向量然后完整地把加密过程“正向”重放一遍最后约束加密结果等于题目给的比较数组。from z3 import * s Solver() # 假设flag长度为24字节 flag [BitVec(flag_%d % i, 8) for i in range(24)] # 从二进制里提取的目标密文 target [ 0x97, 0xA8, 0x6C, 0x21, 0x6E, 0x8F, 0x25, 0x5C, 0xCA, 0x49, 0xC2, 0x3A, 0xDC, 0x78, 0x1D, 0x1D, 0xE1, 0x2F, 0x56, 0x10, 0x6B, 0x48, 0x35, 0x25 ] # 逐字节执行反向编码得到的加密流程 for i in range(24): v flag[i] # 模拟字节码执行中的加法 v v 0x41 # 模拟异或 v v ^ 0x55 # 模拟循环左移2位注意要截断到8位 v ((v 2) | (LShR(v, 6))) 0xFF # 模拟与位置相关的异或key v v ^ key_table[i] # 约束最后结果等于目标密文 s.add(v target[i]) # 检查是否有解并提取 if s.check() sat: model s.model() ans .join(chr(model.eval(flag[i]).as_long()) for i in range(24)) print(ans) else: print(unsat)这里面有四个地方非常容易写错我挨个说明。第一处是LShR的使用。在z3里如果变量是BitVec类型普通的会被解释成算术右移高位补符号位而循环移位需要的是逻辑右移。所以必须用LShR(v, 6)来取高两位否则求解结果会莫名其妙。这一点是我在实战里踩过最多次的坑。第二处是每一轮计算后要 0xFF。因为BitVec(8)本身是8位向量理论上加法会自动截断但Python里的0x41是无限精度整数z3在构造表达式时不一定会按8位截断导致约束条件过强或过弱。手动加 0xFF能明确告诉z3“我只关心低8位”。第三处是key_table的提取。在很多虚机题中每一轮会使用一个独立的查表值这个表可能藏在字节码里也可能藏在数据段。你需要从IDA里找到这个表并按顺序抄出来。抄的时候建议直接写成十六进制减少转换错误。第四处是循环移位方向。有的是左移有的是右移甚至可能是左移后再或右移这会直接导致约束条件完全不同。我在前面的伪代码里写的是左移2位但实际题目你一定要注意还原时的位移量这是最影响正确性的细节之一。4.3 求解失败时的排查方向如果运行上面的脚本s.check()返回了unsat通常说明你建的约束和真实加密逻辑不一致。我的排查顺序一般是先检查算法流程对不对。把z3里的计算过程和我翻译出来的Python伪代码逐行对比确认没有漏掉任何一条指令。再检查目标密文顺序对不对。有时候比较数组在内存里的存放顺序是反的或者存在大小端问题导致从头到尾完全错位。最后检查运算符优先级。在构造复杂表达式时括号千万别省否则z3解释的顺序和你脑子里的顺序很可能是两回事。这道题我印象里一次求解就通过了但这不是常态大多数虚机题最少都要调一两次。如果你遇到unsat千万不要怀疑是z3不够聪明绝大多数情况下是你的模型有误。4.4 z3常用API速查表既然说到了z3我把这次用到的高频API整理成一张速查表方便刷题时直接翻API用途注意事项BitVec(name, size)声明指定位宽的位向量变量size单位是bit8就是一个字节BitVecVal(value, size)声明指定位宽的常量常量和变量位宽要一致Solver()创建约束求解器一个题目一般只用一个s.add(expr)添加约束条件可以多次调用s.check()检查方程组是否有解返回sat或unsats.model()获取一组满足条件的解解密后的值要用model.eval(...).as_long()取LShR(a, n)逻辑右移区别于普通高位补0RotateLeft(a, n)循环左移效果等同(a n)ZeroExt(n, a)零扩展用于把8位扩展成16位/32位If(cond, a, b)条件表达式用于模拟分支结构这张表可以说覆盖了CTF逆向里90%的z3使用场景遇到不会的API再查官方文档就行。5. 常见问题与调试技巧实录5.1 IDA伪代码分析时的三个注意点虚机题里IDA的F5伪代码虽然有奇效但有好几个地方容易产生误导。首先是结构体偏移。如果不先把this指针指向的结构体定义好伪代码里会出现大量*(this 4)、*(this 89)这种看着就头大的表达式。我建议在IDA里用Create Struct功能先把VM结构体画出来再回到函数里用Force call type修正参数类型。其次是数组越界误判。虚机里大量使用code[eip]这种访问方式IDA有时会将它识别成*(char *)(code eip)看起来没什么问题但如果你不知道code的起始地址就无法把opcode序列读出来。这时候要在Hex View里手动跳转到code的地址确认数据内容。最后是伪代码里看不到的隐式赋值。有些handler会直接修改VM结构体里某个字段但F5可能把它显示成一个普通的if分支调用容易忽略它其实改了eip。判断一个handler是否修改eip最好的办法是看它有没有可能执行跳转指令、或者修改循环计数。5.2 动态调试辅助确认指令语义虽然我前面说这道题以静态分析为主但在几个关键节点动态调试能帮你省掉大量猜测时间。比如你怀疑某条opcode是内存读取指令但是看不清楚它的寻址方式这时候可以下断点在对应handler的start处然后输入一串特征输入单步观察虚拟内存和虚拟寄存器的变化。我用gdb时一般这么做先break到某个handler的入口然后info registers查看结构体指针指向的内存用x/20bx打印内存区域再si单步几条看哪里的字节被改了。只要你熟悉了VM结构体字段的偏移这套操作能很快验证你对指令语义的理解。实操心得强烈建议在动态调试时把虚拟机的eip值打出来每一步都记录。这样你能拿到一份“真实执行轨迹”和你自己从静态字节码翻译出来的“预期轨迹”一对比有差异的地方就是理解错误的点。这也是我做所有虚机题都会保留的调试习惯。5.3 虚机题目通用的破题流程总结如果只选一套方法论来应对所有虚机题我会把它总结成下面五步识别VM模型确定寄存器数量、字节码区、虚拟内存、dispatch循环位置。建立指令表逐个分析handler把opcode映射成可读语义得到指令集手册。提取并翻译字节码把二进制里的指令流解析成高级伪代码。优化算法去掉无意义的寄存器搬运提取出真正的核心变换逻辑。用工具求解能手工逆推就手工逆推逆推麻烦就用z3正向建模求解。这套流程对[GWCTF 2019]babyvm适用对市面上绝大多数自写虚机题也适用。它不涉及复杂的内存混淆或反调试对抗在这一点上babyvm确实对得起它名字里的“baby”。但反过来讲正因为它结构清晰、考点典型非常适合拿来当虚机逆向的第一块跳板。6. 拿flag之后的一点感想这道题我从下载附件到跑出flag前后花了大概两个多小时。中途有将近四十分钟是耗在误解一条内存读写指令上当时真的有点想砸电脑。但事后回看反而觉得那四十分钟是最值钱的——因为踩错过所以之后再看任何类似指令我都会第一时间问自己一句这个偏移量到底从哪来如果你想在虚机逆向方向深入下去我建议不要只刷题找个时间自己写一个迷你虚拟机定义10条左右的指令集把一段简单的字符串变换算法用你自己的字节码表示出来然后扔给别人解。当你自己站到“出题人视角”的那一刻很多之前理解不了的设计瞬间就通了。最后再分享一个实用的技巧在分析虚机时尽量把每一步得出的结论都写成注释或单独的笔记文件。虚机题的上下文非常长一旦你中断后再回来大概率会忘记自己之前确认过的某个字段到底是什么含义。一份好的笔记能让你在一周后再看这道题时几分钟就能找回全部状态。相信我这个习惯的价值会在你刷到第10道虚机题时体现得淋漓尽致。
返回列表