逆天而行 2025-10-02 18:00 安徽
看雪论坛作者ID:逆天而行

💡 **核心驱动:通过创造学习 VMP** 作者坚信逆向工程能力与正向开发能力相辅相成,选择亲手构建一个小型虚拟机(VM)来深入理解 VMP 的工作原理。这一“造轮子”的过程,从构思、CPU 状态模拟、指令集(ISA)设计,到解释器实现,都旨在通过实践来获取最深刻的技术认知,并最终形成了一个名为 SmallVMP 的可用原型。
⚠️ **两次失败的尝试与深刻教训** 在实现过程中,作者经历了两次主要的失败尝试:第一次是试图直接翻译 LLVM IR 中的 call 指令,但因 IR 的符号化特性和缺乏重定位能力而受阻;第二次是尝试手动维护符号表,试图通过 dlsym 等方式动态解析,但很快发现处理全局变量和静态变量的复杂性呈指数级增长。这两次失败揭示了 VMP 不仅仅是指令翻译,更需要一个自洽的运行时环境。
🚀 **柳暗花明:拥抱 LLVM 的真正力量** 在反思失败后,作者通过研究成熟项目,找到了正确的解决方案:将链接和符号解析等复杂任务重新交给 LLVM 处理。核心思路是利用 LLVM Pass 收集外部引用,生成原生“桥接函数”(Thunk),然后将 VMP 函数内的调用替换为对这些 Thunk 的调用。VM 解释器通过特定指令(如 OP_BL)调用这些原生 Thunk,从而巧妙地解决了符号解析和重定位问题,实现了 VM 世界与原生世界的无缝连接。
🛠️ **SmallVMP 的诞生与使用** 基于上述思路,SmallVMP 成功集成在一个定制版的 LLVM/Clang 中。用户可以通过引入 VMP.h 头文件,并使用 IRVM_SECTION 宏标记需要 VMP 加固的函数,然后通过 Clang 编译并传入特定的 LLVM 参数(如 `-mllvm -enable-cffobf`)来启用 VMP 保护。文章展示了混淆前后的代码对比,并提及了可以组合使用其他混淆指令(如伪控制流、基本块分割等)来增强保护效果。
📈 **局限与展望:面向未来的 VMP 发展** 目前,SmallVMP 仍处于实验阶段,对部分复杂的 LLVM IR 指令支持尚不完善,遇到时会选择跳过加固以保证编译稳定性。未来的发展方向包括:对生成的字节码进行加密存储以对抗静态分析(Code 加密)、动态生成 Handler 映射表以提升分析难度(动态分发),以及实现嵌套 VM 以进一步增强保护层级。
逆天而行 2025-10-02 18:00 安徽
看雪论坛作者ID:逆天而行
c
/*
* VMState:虚拟机的“CPU 状态”。
* - 32 个 64 位通用寄存器 (v0..v31),其中 v31 约定为零寄存器 (读恒为 0 / 写操作被忽略)。
* - NZCV 旗标:Negative/Zero/Carry/Overflow,语义与 ARM 保持一致。
*/
typedef struct {
uint64_t R[32]; // 通用寄存器堆
uint8_t N,Z,C,V; // 四个一位的状态旗标
} VMState;
VMState,我们封装一些工具函数,用于寄存器的读写和状态旗标的更新。c
#pragma once // 防止头文件被重复包含
#include <stdint.h> // 使用固定宽度整数类型
// --- 寄存器读写 (封装 v31=0 的约定) ---
staticinline uint64_tVR(const VMState* s, uint8_t i) { return i == 31 ? 0ull : s->R[i]; }
staticinline voidVW(VMState* s, uint8_t i, uint64_t v) { if (i != 31) s->R[i] = v; }
// --- 根据结果 r 更新 N/Z 旗标 ---
staticinline voidsetNZ(VMState* s, uint64_t r) { s->Z = (r == 0); s->N = (uint8_t)((r >> 63) & 1); }
/*
* 关键:按照 ARM 语义计算 C/V (进位/溢出)。
* - 加法:
* C = (r < a) // 无符号进位
* V = (~(a^b) & (a^r)) >> 63 // 有符号溢出
* - 减法:
* C = (a >= b) // ARM 约定:C=1 表示“无借位”
* V = ((a^b) & (a^r)) >> 63 // 有符号溢出
*/
staticinline voidsetNZ_add(VMState* s, uint64_t a, uint64_t b, uint64_t r) {
setNZ(s, r);
s->C = (r < a);
s->V = (uint8_t)((~(a ^ b) & (a ^ r)) >> 63);
}
staticinline voidsetNZ_sub(VMState* s, uint64_t a, uint64_t b, uint64_t r) {
setNZ(s, r);
s->C = (a >= b);
s->V = (uint8_t)(((a ^ b) & (a ^ r)) >> 63);
}
c
typedef uint32_tvm_insn_t; // 字节码中一条“指令”就是一个 32 位 word
// --- 操作码枚举:按功能分组,数值稳定利于调试 ---
enum vm_op {
OP_NOP=0, // 空操作
OP_LIMM, // 加载 64 位立即数
OP_MOVrr, // 寄存器拷贝
// 算术逻辑运算
OP_ADD, OP_ADDI, OP_SUB, OP_SUBI,
OP_AND, OP_ORR, OP_EOR,
OP_LSL, OP_LSR, OP_ASR,
// 比较
OP_CMPrr, OP_CMPri,
// 访存
OP_LDRB, OP_LDRH, OP_LDRW, OP_LDRX,
OP_STRB, OP_STRH, OP_STRW, OP_STRX,
// 控制流
OP_B, OP_BCC, // 无条件/条件分支
OP_BL, // 调用宿主函数 (thunk)
OP_RET, // 从 VM 返回
OP_TRAP, // 陷阱 (异常)
OP_MAX_
};
c
/*
* LearnVMP ISA 布局:
* [31..24]=opcode | [23..19]=rd | [18..14]=ra | [13..9]=rb | [8..0]=imm9 (signed)
* 分支类指令使用 21 位有符号相对位移。
*/
// --- 解码工具 (取字段) ---
staticinline uint8_top(vm_insn_t x) { return (x >> 24) & 0xFF; }
staticinline uint8_trd(vm_insn_t x) { return (x >> 19) & 0x1F; }
staticinline uint8_tra(vm_insn_t x) { return (x >> 14) & 0x1F; }
staticinline uint8_trb(vm_insn_t x) { return (x >> 9) & 0x1F; }
staticinline int32_timm9(vm_insn_t x){
int32_t v = (int32_t)(x & 0x1FF); return (v << 23) >> 23;
}
staticinline int32_tbr_off_se21(vm_insn_t x){
int32_t v = (int32_t)(x & 0x1FFFFF); return (v << 11) >> 11;
}
// --- 编码工具 (写字段) ---
staticinline vm_insn_tENC_RRR(uint8_t o, uint8_t d, uint8_t a, uint8_t b){...}
staticinline vm_insn_tENC_RI (uint8_t o, uint8_t d, uint8_t a, int32_t i){...}
// ... 其他编码函数
c
typedef struct __attribute__((packed)) {
char magic[4]; // 固定魔数 'L','V','M','P'
uint8_t version; // 版本号
uint8_t flags; // 控制标志位 (如 TRACE 开关)
uint16_t reserved; // 预留
uint32_t code_words; // 指令数量
} vmp_bc_header_t;
switch-case循环,根据 PC 指针取出指令,解码并执行。下面是部分关键指令的实现逻辑:c
// 加载64位立即数
case OP_LIMM: {
uint8_t dst = rd(ins);
// LIMM 指令占用 3 个 word: [opcode|dst] [imm_low32] [imm_high32]
uint64_t val = ((uint64_t)code[pc+1]) | (((uint64_t)code[pc+2]) << 32);
VW(&S, dst, val);
pc += 3;
break;
}
// 加法 (寄存器-寄存器)
case OP_ADD: {
uint8_t dst = rd(ins), a_reg = ra(ins), b_reg = rb(ins);
uint64_t a = VR(&S, a_reg), b = VR(&S, b_reg);
uint64_t r = a + b;
VW(&S, dst, r);
setNZ_add(&S, a, b, r); // 注意:真实实现中,是否更新旗标应由指令定义
pc++;
break;
}
// 比较 (寄存器-寄存器)
case OP_CMPrr: {
uint8_t a_reg = ra(ins), b_reg = rb(ins);
uint64_t a = VR(&S, a_reg), b = VR(&S, b_reg);
uint64_t r = a - b;
setNZ_sub(&S, a, b, r); // 只更新旗标,不写回结果
pc++;
break;
}
// 无条件分支
case OP_B: {
int32_t off = br_off_se21(ins);
int nxt_pc = pc + off;
// ...边界检查...
pc = nxt_pc; // 直接跳转
break;
}
// 条件分支
case OP_BCC: {
uint8_t cond = rd(ins) & 0xF;
bool take = false;
switch(cond) { // 根据 NZCV 旗标判断是否跳转
case 0: take = S.Z; break; // EQ
case 1: take = !S.Z; break; // NE
// ... 其他条件判断 ...
}
if (take) {
int32_t off = br_off_se21(ins);
pc += off;
} else {
pc++;
}
break;
}
// 调用宿主函数
case OP_BL: {
uint32_t thunk_idx = code[pc] & 0xFFFFu; // 函数在 thunk 表中的索引
if (is_valid(thunk_idx)) {
// R[0]..R[7] 作为参数
longlong ret = thunks[thunk_idx](S.R);
VW(&S, 0, (uint64_t)ret); // 返回值写入 v0
}
pc++;
break;
}
// 从 VM 返回
case OP_RET: {
return (longlong)VR(&S, 0); // 从 v0 获取返回值并返回给宿主
}
call指令时,问题暴露了。LLVM IR 中的call是符号化的,例如:%call = call noalias ptr @fopen(ptr noundef @.str.38, ptr noundef @.str.39)它并没有提供@fopen的绝对地址。IR 是一种更高层的抽象,重定位(Relocation)是在链接阶段才完成的。我的第一版 VM 完全没有处理符号解析和重定位的能力,因此这条路走不通。失败尝试 2:手动维护符号表吸取教训后,我构思了第二版方案:1.LLVM Pass 负责收集所有遇到的外部调用符号(如fopen),并为它们生成唯一的 ID。2.VM 解释器端维护一个符号表,当遇到OP_CALL_SYM这样的指令时,根据 ID 查找函数名字符串。3.通过dlsym等方式在运行时动态解析符号地址,然后执行调用。c
// 伪代码
case OP_CALL_SYM: {
uint8_t sym_id = fetch_next_byte();
void* func_ptr = resolve_symbol(sym_id); // 运行时解析
if (func_ptr) {
// ... 准备参数 ...
ret = call_function(func_ptr, args);
// ... 处理返回值 ...
}
}
xvmp。学习其源码后,我恍然大悟:我应该把链接和符号解析这些脏活累活,再次交给 LLVM 自己来处理!正确的思路是:1.收集与桥接:LLVM Pass 在处理函数时,将所有对外部函数、全局变量的引用收集起来。为每一个引用生成一个“桥接函数”(Thunk)。这个桥接函数是原生的、未被VMP的,它的唯一作用就是执行原始的调用或访问。2.符号替换:在生成字节码时,将原来对@fopen的调用,替换为对__thunk_fopen的调用,并赋予其一个 ID。3.VM 调用:VM 解释器通过OP_BL指令,根据 ID 调用对应的原生 Thunk 函数。由于 Thunk 函数是编译器正常生成的,它自然就拥有了所有正确的链接信息和地址。通过这种方式,我们巧妙地将 VM 世界和原生世界连接起来,所有复杂的符号问题都迎刃而解。VMP.h头文件。3.使用IRVM_SECTION宏来标记需要 VMP 加固的函数。c
#include "VMP.h"
// 全局变量,用于测试
int gArr[10] = {0};
constchar* gMsg = "Hello, VMP!";
IRVM_SECTION
inttest_calls(int a, int b) {
puts("call puts(const)");
int *p = &gArr[4];
*p = a + b;
printf("gArr[4]=%d, gMsg=%s\n", gArr[4], gMsg);
return a + b;
}
-mllvm -enable-cffobf(以控制流平坦化为例) 等参数。bash
clang -mllvm -enable-cffobf test.c -o app
[irvm] emit code global: test_calls_code (266 bytes)
[irvm] + bytes initialized
[irvm] + rewritten to vm_exec stub: test_calls
-mllvm -enable-bcfobf(伪控制流)◆-mllvm -enable-splitobf(基本块分割)◆-mllvm -enable-subobf(指令替换)◆-mllvm -enable-allobf(开启所有)◆...等等效果展示:这是要混淆的函数:IRVM_SECTION
intprt(char *ptr) {
printf("ptr=%p\n str %s", ptr,ptr);
}
clang test.c -o app -mllvm -enable-bcfobf
select)尚未支持,遇到这类函数会自动跳过加固,保证编译的稳定性。未来的工作可以围绕以下几点展开:◆Code 加密:对生成的字节码进行加密存储,在解释执行前动态解密,执行后再加密回去,对抗静态分析。◆动态分发:动态生成 Handler 映射表,让操作码与处理函数的对应关系不再固定。◆嵌套 VM:实现二级 VM,即解释器本身也被另一层 VM 保护,进一步提升分析难度。*本文为看雪论坛精华文章,由 逆天而行 原创,转载请注明来自看雪社区
1.25折门票即将售罄
看雪·第九届安全开发者峰会(SDC 2025)
# 往期推荐
无"痕"加载驱动模块之傀儡驱动 (上)
为 CobaltStrike 增加 SMTP Beacon
隐蔽通讯常见种类介绍
buuctf-re之CTF分析
物理读写/无附加读写实验
AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。
鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑