文章总结: 本文基于Flare-OnCTF题目解析逆向VM字节码保护的技术难点。内容涵盖VM架构优势、指令集定义及C++反汇编器的编写实现。文章演示了将二进制字节码转为可读汇编的过程,并总结出针对VM保护程序的关键策略:必须先理解解释器逻辑,再构建专属反汇编工具以还原核心算法。 综合评分: 90 文章分类: 逆向分析,二进制安全,CTF,安全工具
“逆向VM字节码程序”的学习
原创
MicroPest MicroPest
MicroPest
2026年1月29日 19:47 安徽
这两天,碰到并学习了一个2016年Flare-On挑战赛3决赛中的一个CTF逆向题:一个包含了虚拟机字节码的smokestack.exe,真是开了眼,长了见识。
完全不同x86系列的代码,它是VM 字节码 = “虚拟机字节码”(Virtual Machine Bytecode),不是 x86/ARM 这类硬件指令,而是某个软件自己定义的一套“中间指令集”。由源码 → 编译成字节码 → 由一段“解释器/虚拟机”循环取 Opcode → 查表 → 执行对应 C/C++ 函数(或 JIT 成机器码)。通俗比喻:就像 Java 的 .class 、Python 的 .pyc ,只不过这里是作者自己写的“迷你 CPU”。
1、VM 字节码的用处
(1).体积/跨平台:只要带 2-3 KB 解释器,就能跑同一份字节码。
(2).抗逆向:硬件指令变成“自定义 Opcode”,IDA 默认认不出;得先写反汇编器(就是你贴的那段 C++)才能看逻辑。
(3).CTF/病毒常用:把关键校验(flag 检查、许可证)藏进字节码,分析者必须先逆解释器,再逆字节码,两步缺一不可。
2、指令格式
(1).每条指令固定 2 byte
+——–+———————–+
| opcode | 可选立即数 / 寄存器编号
+——–+————————+
(2). 指令集(14 条,RISC 风格)
0 push imm16 ; 把 16 位常数压栈
1 pop ; 弹栈
2 add ; 栈顶两数相加
3 sub ; 相减
4 trm1 ; 作者自定义运算
5 trm2 ; 同上
6 xor ; 异或
7 not ; 按位取反
8 eq ; 栈顶两数相等?置 0/1
9 sel ; 三目选择
10 jmp imm16 ; 无条件跳
11 push reg16 ; 把 ax/bp/sp/ip 压栈
12 mov reg16, ST(0) ; 栈顶值写到寄存器
13 nop
(3). 执行模型
三个寄存器:ax, bp, sp, ip(ip 指字节码偏移)。
一条小栈:push/pop 操作都在这条栈上完成。
解释器就是一个大 while(1) { fetch(); switch(opcode) { … } } 循环——对应你逆向时看到的 sub_401610 / sub_402EE0 之类函数。
3、编写字节码反汇编器程序
#include<iostream>
#include<fstream>
#include<vector>
#include<stdexcept>
#include<string>
#include<iomanip>
conststd::vector<std::string>mnemonics=
{
"push","pop","add","sub",
"trm1","trm2","xor","not",
"eq","sel","jmp","push",
"mov","nop"
};
//{这个数组定义了14个VM操作码对应的助记符:
- 索引0 = “push”
- 索引2 = “add”
- 索引10 = “jmp”
- 等等…}
int main(intargc,char*argv[]);
constchar*GetRegisterName(std::uint16_tregister_id);
int main(intargc,char*argv[])
{
static_cast<void>(argc);
static_cast<void>(argv);
//参数检查
if(argc!=2)
{
std::cout<<"Usage:\n";
std::cout<<"smokestack_disasm dump"<<std::endl;
//需要一个参数:字节码文件路径
return1;
}
//读取字节码文件到内存
std::vector<std::uint8_t>buffer;
try
{
std::fstreaminput_file;
input_file.open(argv[1],std::ios_base::in|
std::ios_base::binary);
if(!input_file)
throwstd::runtime_error("Failed to open the input file");
input_file.seekg(0,std::ios_base::end);
if(!input_file)
throwstd::runtime_error("Seek failed");
std::streamsizeinput_file_size=input_file.tellg();
if(!input_file)
throwstd::runtime_error("Failed to get the file size");
input_file.seekg(0);
if(!input_file)
throwstd::runtime_error("Seek failed");
buffer.resize(input_file_size);
if(buffer.size()!=input_file_size)
throwstd::runtime_error("Memory allocation failed");
input_file.read(reinterpret_cast<char*>(buffer.data()),
input_file_size);
if(!input_file)
throwstd::runtime_error("Failed to read the file");
input_file.close();
}
catch(conststd::exception&exception)
{
std::cout<<exception.what()<<std::endl;
return1;
}
//逐条解析字节码
conststd::uint8_t*ptr=buffer.data();
//解析并输出每条指令
while(ptr<buffer.data()+buffer.size())
{
//计算当前指令地址(以16位字为单位)
std::uint32_tinstruction_pointer=(ptr-buffer.data())/2;
//输出格式:地址+操作码+助记符
std::cout<<std::hex<<std::setfill('0')<<std::setw(4)
<<instruction_pointer;//地址
std::cout<<"\t\t";
std::cout<<std::hex<<std::setfill('0')<<std::setw(2)<<
static_cast<int>(*ptr);//操作码
std::cout<<"\t"<<mnemonics[*ptr]<<" ";//助记符
// opcodes that require immediate parameters needs to
// increment the instruction pointer twice
switch(*ptr)
{
// push <immediate>
case0:
{
ptr+=2;//跳过操作码,指向参数
//读取16位立即数
std::uint16_tvalue=
*reinterpret_cast<conststd::uint16_t*>(ptr);
std::cout<<"0x"<<std::hex<<std::setfill('0')<<
std::setw(4)<<value;
// 如果是可打印ASCII字符,显示字符形式
if(value>=0x20&&value<=0x7D)
{
std::cout<<" ; '"<<static_cast<char>(value)
<<"'";
}
break;
}
// push <register_id>
case11:
{
ptr+=2;
std::uint16_tvalue=
*reinterpret_cast<conststd::uint16_t*>(ptr);
std::cout<<GetRegisterName(value);//转换寄存器ID为名称
break;
}
// mov <register_id>, stack[sp]
case12:
{
ptr+=2;
std::uint16_tvalue=
*reinterpret_cast<conststd::uint16_t*>(ptr);
std::cout<<GetRegisterName(value);
std::cout<<", ST(0)";
break;
}
default:
break;
}
std::cout<<std::endl;
//跳转指令后添加空行,增加可读性
if(*ptr==10)//操作码10 = jmp
{
std::cout<<std::hex<<std::setfill('0')<<std::setw(4)
<<instruction_pointer<<std::endl;
}
ptr+=2;
}
return0;
}
constchar*GetRegisterName(std::uint16_tregister_id)
{
switch(register_id)
{
case0:
return"ax";
case1:
return"bp";
case2:
return"sp";
case3:
return"ip";
default:
throwstd::runtime_error("Invalid register id");
}
}
将二进制的VM字节码文件转换成可读的汇编指令,方便逆向分析。
4、使用方法
(1). 提取字节码
从Smokestack.exe的虚拟地址0x0040A140处提取VM字节码数据段,保存为文件。
(2). 编译反汇编器
g++ smokestack_disasm.cpp -o smokestack_disasm
(3). 运行反汇编
./smokestack_disasm vm_bytecode.bin > disassembly.txt
5、反汇编器的输出
如:
0000 00 push 0x0021
0002 02 add ; \ adds 0x21 to the last character in the
0003 00 push 0x0091 ; / program argument
0005 08 eq
0006 00 push 0x0016
0008 00 push 0x000c ; \ this is what we should take. last char
000a 09 sel ; / is: 0x91 - 0x21 = 'p'
000b 0a jmp
000b
6、CTF程序:smokestack.exe
这个反汇编器是逆向VM保护程序的标准工具,它的价值在于:
- ✅ 降低分析难度 – 将二进制转为可读代码
- ✅ 理解程序逻辑 – 看清楚VM在做什么运算
- ✅ 手工求解 – 根据反汇编结果推导约束条件
- ✅ 验证猜测 – 确认理解的VM指令集是否正确
本质上,这是为Smokestack的自定义VM编写的”IDA Pro”!
没有这个工具,逆向工程师只能看着一堆数字发呆;有了它,就能像阅读普通汇编代码一样分析VM逻辑。这就是为什么作者说”写一个反汇编器”是解决这道题的关键步骤!
7、逆向策略对比
| 目标 | 普通EXE | VM保护EXE |
|——–|———————-|——————|
|**工具**| IDA Pro直接反编译 | 需要理解VM架构 |
|**难度**| 中等 | 高 |
|**方法**| 直接分析汇编 |1. 识别VM2. 提取字节码3. 写反汇编器4. 分析虚拟汇编 |
|**调试**| 直接下断点 | 需要在VM引擎下断点 |
8、总结
**最关键的区别**:
VM架构本质上是”程序中的程序” – 一个用机器码实现的软件CPU,运行自定义的指令集!
这就是为什么Smokestack这道题需要先理解VM架构,再编写反汇编器,最后才能分析真实逻辑的原因。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:MicroPest MicroPest MicroPest《“逆向VM字节码程序”的学习》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论