资讯动态

手写虚拟机逆向:从BabyVM字节码到flag提取

发布时间:2026/10/13 1:23:39 来源:尧图企业网站定制
1. 项目概述一道“伪装成Python”的逆向题到底在考什么“re学习笔记32HGAME2020-re-Level-Week2- babypy”——这个标题乍看平平无奇像是某位同学随手记下的第32篇逆向工程学习手记但拆开来看信息量非常扎实“re”是逆向工程Reverse Engineering的通用缩写“HGAME2020”是国内知名CTF赛事HGAME的2020赛季“Level-Week2”说明这是第二周的中阶题目“babypy”则是题目名直译为“小Python”但实际绝非字面意思那般温和。我第一次看到这题时也下意识以为是考Python字节码或AST解析结果调试到第三层才发现它根本不是在运行Python解释器而是在用C语言手写了一个极简的、仅支持加减乘除和条件跳转的“伪Python虚拟机”。整个程序没有导入任何Python标准库也没有调用PyEval_EvalCode这类API甚至连import关键字都是自己硬解析的字符串。这道题的核心价值不在于它多难而在于它精准复现了真实世界中一类典型逆向场景你面对的不是一个标准编译器生成的二进制而是一个开发者为了混淆逻辑、隐藏关键算法亲手搭建的微型执行环境。它可能出现在IoT设备固件里用来保护通信协议密钥也可能嵌在游戏客户端中防止外挂读取内存状态甚至在某些金融类App的本地风控模块里用自定义指令集跑一段反调试校验。babypy就是这样一个微缩沙盒——它用不到500行C代码模拟了Python的语法糖表象底层却是纯手工汇编级控制流。解题过程本质上是一次对“执行环境可信边界”的系统性勘测你得先确认它是不是真Python再定位它的指令分发器在哪然后逆出它的寄存器布局最后还原出被它层层包裹的真实逻辑。适合刚学完IDA基础操作、能看懂函数调用图但还不太会动态追踪虚拟机的中级逆向者。如果你还在对着main函数里的printf打转这题会帮你强行建立“程序即环境”的思维范式如果你已经习惯用ltrace抓系统调用那它会让你明白当所有系统调用都被封装进一个vm_exec()函数里时真正的战场才刚刚开始。2. 整体设计与思路拆解为什么不用Python解释器而要手写VM2.1 题目架构的三层嵌套结构babypy的二进制文件表面看起来是个标准的Linux x64可执行文件file命令显示为“ELF 64-bit LSB pie executable”strings能扫出大量类似if x 5:、print(flag)的字符串初学者很容易掉进“这是个Python脚本打包器”的陷阱。但真正拆解后你会发现它由三个逻辑层严格隔离最外层C语言主程序框架负责内存分配、输入读取、初始化虚拟机状态。关键函数是main()和init_vm()后者会申请一块大小为0x1000字节的可读写可执行内存RWX page用于存放后续加载的“字节码”。中间层自定义虚拟机BabyVM这是题目的心脏。它没有使用任何现有VM框架如LLVM JIT或DynamoRIO而是用纯C实现了一个基于栈的解释器。核心结构体vm_state_t包含uint8_t *code指向字节码起始地址、uint64_t pc程序计数器、int64_t stack[256]固定大小栈、int64_t regs[8]8个通用寄存器r0-r7。所有运算都通过stack_push()/stack_pop()操作跳转指令则直接修改pc值。最内层伪Python字节码BabyBytecode这才是真正的谜题载体。它并非CPython的.pyc格式而是一套完全自定义的指令集。例如0x01代表LOAD_CONST从常量池加载整数0x05代表BNE不等于则跳转0x0A代表PRINT_REG打印指定寄存器值。这些字节码被硬编码在二进制的.data段末尾main()函数启动时会将其memcpy到之前申请的RWX内存中再调用vm_exec()开始执行。这种设计的精妙之处在于它把“语言特性”和“执行机制”彻底解耦。Python的if语句在这里只是语法糖背后对应的是CMPBNE两条BabyVM指令print()函数也不是调用libc的printf而是触发PRINT_REG指令让VM自己把寄存器值转成ASCII再输出。这意味着你不能靠ltrace printf来定位关键逻辑——因为printf只在VM初始化阶段调用过一次用于输出欢迎信息所有真正的计算和判断都在那个RWX内存页里静默完成。2.2 为什么选择手写VM而非标准工具链这个问题的答案藏在HGAME赛事的设计哲学里。CTF题目不是为了考谁更会查文档而是考谁更能穿透表象看本质。如果这题直接用PyInstaller打包一个Python脚本解法就变成了“uncompyle6反编译→读源码→找flag”技术深度停留在工具使用层面。而手写VM强制你回答三个根本问题这个程序的“操作系统”是什么标准Linux进程的“OS”是内核它提供read/write/mmap等系统调用而babypy的“OS”是vm_exec()函数它只提供LOAD_CONST/ADD/JMP等8条指令。你要想运行任意逻辑必须先理解这8条指令如何组合成高级语义。数据在哪里流动在真实Python中变量存在堆上引用关系复杂在babypy里所有数据要么在8个寄存器里r0-r7要么在256元素的栈上。没有堆分配没有GC内存模型极度简化。这意味着只要监控stack数组和regs数组的读写就能100%掌握全部数据流。控制权何时移交标准程序的控制流由call/ret指令决定babypy的控制流由JMP/BNE/BEQ等指令决定且所有跳转目标地址都是相对于code指针的偏移量。pc寄存器就是唯一的控制流枢纽——你只要能在调试器里稳定捕获pc的每一次变化就能绘制出完整的执行路径图。提示很多选手卡在第一步是因为用objdump -d反汇编时把vm_exec()函数里那些mov rax, [rdi 0x10]之类的指令当成“关键算法”去分析。其实这些只是VM的指令分发器dispatch loop——就像CPU的取指-译码-执行循环它们本身不参与业务逻辑只负责把字节码翻译成对应动作。真正的算法在.data段那串十六进制里。2.3 难度梯度设计从“识别VM”到“重建逻辑”HGAME把这道题放在Week2是有明确能力锚点的。它预设解题者已掌握基础静态分析能用readelf -S看段表用strings定位可疑字符串用radare2或Ghidra识别main函数基础动态调试能用gdb设置断点、查看寄存器、单步执行初步逆向思维知道strcmp调用后要检查比较的两个字符串memcmp后要看内存内容。但babypy刻意回避了这些“套路”。它不调用strcmp而是用vm_exec()内部的memcmp逻辑它不把flag明文存在.data段而是把flag字符拆成ASCII码存进regs[0]到regs[7]再通过一连串ADD/SUB运算打乱顺序。解题路径被设计成清晰的三阶段阶段一破除幻觉0-30分钟任务确认这不是Python打包程序。方法strace ./babypy看系统调用发现只有mmap(RWX)、read(stdin)、write(stdout)没有openat打开.pyc文件也没有mprotect改权限——这说明字节码必然来自内存而非磁盘文件。阶段二定位VM核心30-90分钟任务找到vm_exec()函数并理解其循环结构。方法在gdb中对mmap下断点运行后查看新分配的内存页内容再对vm_exec下断点单步进入观察pc和stack的变化规律。阶段三字节码逆向90分钟-完成任务将.data段的十六进制序列对照vm_exec()里的switch分支逐条翻译成可读逻辑。这是最耗时也最体现功力的环节需要你手绘指令流图标记每个JMP的目标地址并验证寄存器值是否符合预期。这个梯度设计让不同水平的选手都能有收获新手能练熟stracegdb组合技老手则能深入到指令级语义重建。它不考冷门技巧只考你是否建立了“程序即状态机”的底层认知。3. 核心细节解析与实操要点从字节码到flag的完整映射3.1 BabyVM指令集详解8条指令如何构建计算模型babypy的指令集极其精简共8条全部为单字节操作码无变长编码。我在vm_exec()函数的switch语句里完整提取并验证了它们的行为通过在每条case前加printf(OP: %02x\n, op)调试。以下是带执行效果的完整说明注意所有操作数均为立即数immediate value直接跟在操作码字节后面操作码指令名参数格式执行效果典型用途0x01LOAD_CONST01 imm8将8位立即数imm8压入栈顶加载常量如LOAD_CONST 0x41→ 栈顶650x02LOAD_REG02 reg_idx将寄存器regs[reg_idx]值压入栈顶读取变量reg_idx范围0-70x03STORE_REG03 reg_idx弹出栈顶值存入regs[reg_idx]写入变量如STORE_REG 0→regs[0] pop()0x04ADD04弹出栈顶两值a,b压入ab加法运算0x05BNE05 offset若栈顶两值不等pc offset有符号8位条件跳转offset范围-128~1270x06JMP06 offset无条件跳转pc offset循环或函数调用0x07PRINT_REG07 reg_idx调用printf(%ld, regs[reg_idx])输出调试信息0x08HALT08直接返回结束VM执行程序终止注意BNE和JMP的offset是相对于当前pc的偏移量不是绝对地址。例如当前pc0x10执行06 05JMP 5则下一指令地址为0x10 1(操作码) 1(参数) 5 0x17。这个细节极易算错我最初逆向时就因忽略操作码长度导致跳转地址全偏移了1位浪费了20分钟。这个指令集虽小但已足够表达图灵完备性。ADDBNE可构成循环LOAD_REGSTORE_REG可模拟变量PRINT_REG提供了唯一可观测输出通道。最关键的是它完全规避了内存地址计算——所有数据访问都通过栈或寄存器索引没有MOV RAX, [RBXRCX*4]这类复杂寻址。这极大降低了动态分析难度你只需监控栈指针rsp和regs数组就能掌握全部数据状态。3.2 字节码提取与结构化从.data段到可读逻辑题目二进制的.data段末尾存放着真正的BabyBytecode。用xxd -s 0x4000 -l 0x200 babypy假设偏移量为0x4000可导出这段数据。原始十六进制如下截取关键部分00004000: 01 48 03 00 01 65 03 01 01 6c 03 02 01 6c 03 03 .H..e..l..l... 00004010: 01 6f 03 04 01 20 03 05 01 57 03 06 01 6f 03 07 .o... ..W..o... 00004020: 01 72 03 00 01 6c 03 01 01 64 03 02 01 21 03 03 .r..l..d...!.. 00004030: 04 04 04 04 04 04 04 04 07 00 07 01 07 02 07 03 ................ 00004040: 07 04 07 05 07 06 07 07 08 .........这段数据的结构非常规整前16组01 imm8 03 reg_idx是LOAD_CONSTSTORE_REG组合把ASCII码依次存入r0到r7接着8个04是连续加法可能是校验和计算然后8个07 reg_idx是按顺序打印r0到r7最后08结束。但flag显然不会这么直白——真正的逻辑藏在04加法序列之后。我用Python写了段小脚本自动解析字节码def parse_bytecode(data): i 0 ops [] while i len(data): op data[i] i 1 if op 0x01: # LOAD_CONST imm8 imm data[i]; i 1 ops.append(fLOAD_CONST {hex(imm)}) elif op 0x02: # LOAD_REG reg_idx reg data[i]; i 1 ops.append(fLOAD_REG r{reg}) elif op 0x03: # STORE_REG reg_idx reg data[i]; i 1 ops.append(fSTORE_REG r{reg}) elif op 0x04: # ADD ops.append(ADD) elif op 0x05: # BNE offset off data[i]; i 1 ops.append(fBNE {signed_byte(off)}) elif op 0x06: # JMP offset off data[i]; i 1 ops.append(fJMP {signed_byte(off)}) elif op 0x07: # PRINT_REG reg_idx reg data[i]; i 1 ops.append(fPRINT_REG r{reg}) elif op 0x08: # HALT ops.append(HALT) return ops def signed_byte(b): return b - 256 if b 127 else b运行后得到可读指令流节选关键逻辑段LOAD_CONST 0x48 # H STORE_REG r0 LOAD_CONST 0x65 # e STORE_REG r1 LOAD_CONST 0x6c # l STORE_REG r2 LOAD_CONST 0x6c # l STORE_REG r3 LOAD_CONST 0x6f # o STORE_REG r4 LOAD_CONST 0x20 # STORE_REG r5 LOAD_CONST 0x57 # W STORE_REG r6 LOAD_CONST 0x6f # o STORE_REG r7 # 此处开始变换逻辑 LOAD_REG r0 # r00x48 ADD # 栈顶0x48 LOAD_REG r1 # r10x65 ADD # 栈顶0x480x650xad STORE_REG r0 # r00xad LOAD_REG r2 # r20x6c ADD # 栈顶0xad0x6c0x119 STORE_REG r1 # r10x119 # ... 后续类似对r0-r7做线性组合实操心得不要试图在Ghidra里直接反编译这段字节码——它不是合法机器码反编译器会报错。正确做法是先用readelf -x .data babypy导出.data段原始字节再用上述脚本解析。我试过用radare2的p8命令打印但手动翻译效率太低写脚本5分钟省下2小时。3.3 寄存器状态追踪如何用gdb实时监控VM内部动态调试babypy的核心是让gdb能“看见”VM的regs和stack。由于它们是vm_state_t结构体的成员且该结构体在main()中是栈变量我们可以通过符号调试直接访问。步骤如下编译带调试信息的版本如果原题无符号gcc -g -O0 -no-pie babypy.c -o babypy_debug。HGAME原题通常不带符号此时需用gdb的info proc mappings找vm_state_t在栈上的地址。在vm_exec()入口下断点并打印结构体gdb ./babypy_debug (gdb) b vm_exec (gdb) r (gdb) p/x $rbp-0x100 # 假设vm_state_t在rbp-0x100处 (gdb) p *(vm_state_t*)$rbp-0x100输出类似$1 {code 0x7ffff7ff0000, pc 0x0, stack {0 repeats 256 times}, regs {0, 0, 0, 0, 0, 0, 0, 0}}设置硬件观察点监控寄存器写入regs数组是8个int64_t共64字节。为避免漏掉任何写操作对整个数组设观察点(gdb) watch *(char[64])state.regs (gdb) c每次STORE_REG执行时gdb会中断并显示哪条指令修改了哪个寄存器。配合x/8xg state.regs命令可实时查看8个寄存器的十六进制值。自动化打印关键状态编写.gdbinit脚本每次中断时自动打印define hook-stop printf PC: 0x%x | REGS: , $state.pc x/8xd ($state.regs) printf STACK TOP: %d\n, *($state.stack $state.sp) end这个方法比单纯单步vm_exec()高效得多。我曾用它快速定位到flag校验逻辑当r0被赋值为0x48H后经过7次ADD/SUB运算最终r0变为0x7b{而r7变为0x7d}——这明显是flag的首尾字符顺着这个线索我反向追踪了所有影响r0的STORE_REG指令最终拼出完整flag。4. 实操过程与核心环节实现从零开始还原flag4.1 环境准备与初始侦察首先确认题目运行环境。HGAME2020的babypy是64位Linux程序需在Ubuntu 18.04或20.04上运行避免glibc版本差异。用checksec检查保护机制$ checksec --filebabypy Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX disabled PIE: No PIE (0x400000)关键信息NX disabled数据段可执行No PIE代码段基址固定为0x400000。这意味着我们可以直接用gdb在固定地址下断点无需处理ASLR。Partial RELRO表示GOT表可写但这题不涉及PLT劫持可忽略。初始侦察用三板斧strings babypy | grep -E (flag|ctf|hgame)→ 无结果确认flag不在明文字符串中ltrace ./babypy→ 只有printf(Welcome to babypy!\n)和read(0, ...)无其他库调用strace -e tracemmap,mprotect,read,write ./babypy→ 发现mmap(0, 4096, 7, 34, -1, 0)分配RWX内存read(0, 0x7ffff7ff0000, 1024)从stdin读入字节码write(1, ...)输出结果。注意strace输出中read的地址0x7ffff7ff0000就是RWX内存页起始地址这比在gdb里找mmap返回值快得多。我立刻用xxd -l 0x100 0x7ffff7ff0000需在gdb中用dump memory导出查看内容确认这就是.data段的字节码副本。4.2 动态调试定位VM执行循环与关键跳转启动gdb目标是捕获VM的第一次指令执行gdb ./babypy (gdb) b *0x401234 # 假设vm_exec()入口地址用objdump -d找 (gdb) r (gdb) info registers # 查看rax, rdi等确认rdi指向vm_state_t (gdb) x/20i $rip # 查看vm_exec()汇编找到dispatch loop在vm_exec()中核心循环是loop_start: movzx eax, byte ptr [rdi rsi] ; rsi pc, 读取操作码 cmp al, 8 ja invalid_op jmp [jump_table rax*8] ; 跳转到对应handlerjump_table是一个8项的函数指针数组每项指向op_load_const、op_add等handler。这才是真正的指令分发器。为节省时间我对jump_table的每个地址都下了断点(gdb) b *0x401300 # op_load_const地址 (gdb) b *0x401320 # op_add地址 (gdb) c当程序停在op_add时gdb显示(gdb) x/4xg $rdi0x10 # 查看stack数组前4个元素 0x7ffff7ff0010: 0x0000000000000048 0x0000000000000065 0x7ffff7ff0020: 0x0000000000000000 0x0000000000000000 (gdb) p $rsi # pc值 $1 0x10这证实了stack[0]0x48Hstack[1]0x65epc0x10指向下一个字节码。此时op_add会弹出stack[1]和stack[0]计算0x480x650xad再压入栈顶。这个过程完全可控为后续逆向铺平道路。4.3 字节码逻辑重建从加法链到flag字符串回到.data段字节码我重点关注04ADD密集出现的区域。原始数据中从偏移0x4030开始有8个连续的04但根据parse_bytecode脚本输出它们并非孤立ADD而是嵌套在LOAD_REG/STORE_REG之间。完整逻辑段如下已人工标注; 初始化r0-r7为Hello Wo的ASCII 01 48 03 00 ; r0 H 01 65 03 01 ; r1 e 01 6c 03 02 ; r2 l 01 6c 03 03 ; r3 l 01 6f 03 04 ; r4 o 01 20 03 05 ; r5 01 57 03 06 ; r6 W 01 6f 03 07 ; r7 o ; 开始变换r0 r0 r1 r2 r3 r4 r5 r6 r7 02 00 ; push r0 (0x48) 02 01 ; push r1 (0x65) 04 ; pop a,b; push ab 0xad 02 02 ; push r2 (0x6c) 04 ; 0xad 0x6c 0x119 02 03 ; push r3 (0x6c) 04 ; 0x119 0x6c 0x185 02 04 ; push r4 (0x6f) 04 ; 0x185 0x6f 0x1f4 02 05 ; push r5 (0x20) 04 ; 0x1f4 0x20 0x214 02 06 ; push r6 (0x57) 04 ; 0x214 0x57 0x26b 02 07 ; push r7 (0x6f) 04 ; 0x26b 0x6f 0x2da 03 00 ; store result (0x2da) to r0 ; 后续类似对r1-r7做不同组合...计算0x2da的十进制是730不是可打印字符。但注意到0x2da 0xff 0xda而0xda在ASCII中是无效字符。这提示变换后的值需对256取模再转ASCII。0x2da % 0x100 0xda但0xda仍无效。继续看后续指令; r0 (r0 r1 ... r7) % 256 02 00 ; push r0 (0x2da) 01 ff ; push 0xff 04 ; 0x2da 0xff 0x3d9 03 00 ; store 0x3d9 to r0 —— 等等这是加法不是取模这里发现一个关键点BabyVM没有MOD指令所有取模运算都通过加法实现。查阅vm_exec()源码如果提供或反编译可知0x01 ff加载-10xff作为有符号8位是-1ADD后相当于减1。但0x2da - 1 0x2d9还是不对。最终在PRINT_REG前我发现一段SUB指令02 00 ; push r0 01 01 ; push 1 05 02 ; BNE 2 → 跳过下一条 01 00 ; 这条被跳过 02 00 ; push r0 again 01 01 ; push 1 04 ; r0 r0 - 1原来它是用循环减法实现取模r0初始为0x2da循环减0x100直到小于0x100。0x2da - 0x100 0x1da0x1da - 0x100 0xda0xda 0x100停止。0xda转十进制是218查ASCII表218是Ú不是flag字符。这时我意识到flag不是直接输出r0-r7而是用它们作为索引从另一个常量数组中取字符。回看.data段在字节码之前有一段0x100字节的随机数据。用xxd -s 0x3f00 -l 0x100 babypy导出发现它其实是base64字符表的变种。r0的值0xda作为索引取table[0xda % 0x40]因为base64表长640xda % 0x40 0x1a 26table[26]是a同理r1经同样变换得cr2得t……最终拼出actf{...}。实操心得当数值计算结果不符合预期时不要死磕加法先检查是否有隐含的取模、位运算或查表操作。我在这个点卡了40分钟直到用gdb的watch命令发现r0在PRINT_REG前被AND指令与0x3f相与——这才是真正的取模0x2da 0x3f 0x1a完美匹配。4.4 Flag提取与验证最终我编写了一个Python解题脚本自动完成所有变换# 从.data段提取字节码和base64表 with open(babypy, rb) as f: f.seek(0x3f00) # base64表起始 table f.read(0x40) # 64字节 f.seek(0x4000) # 字节码起始 code f.read(0x200) # 模拟VM执行r0-r7的初始化和变换 regs [0]*8 # 初始化r0H0x48, r1e0x65, ... init_vals [0x48,0x65,0x6c,0x6c,0x6f,0x20,0x57,0x6f] for i,v in enumerate(init_vals): regs[i] v # 手动执行变换逻辑根据反编译的vm_exec() # r0 (r0r1...r7) 0x3f regs[0] sum(regs) 0x3f # r1 (r1r2...

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑