看雪学院 10月02日 19:41
从零构建 Android VMP:LLVM Pass 实现虚拟机保护
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文详细记录了作者如何利用 LLVM Pass 技术,从零开始为 Android Native 函数实现 VMP(虚拟机保护)的全过程。作者秉持“通过创造来学习”的理念,设计并实现了一个小型虚拟机(VM),包括 CPU 状态模拟、指令集设计和解释器实现。在探索过程中,作者分享了在处理函数调用和全局变量时遇到的两次认知误区及失败尝试,最终通过借鉴社区方案,成功构建了一个可用的原型 SmallVMP,并探讨了其局限与未来展望。这篇文章是一次关于 VMP 技术深度实践与复盘的宝贵分享。

💡 **核心驱动:通过创造学习 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:逆天而行

本文记录了一次从零开始,利用 LLVM Pass 技术为 Android Native 函数实现 VMP(Virtual Machine Protection)的完整心路历程。文章从一个核心观点出发:逆向工程的深度与正向开发的能力紧密相连。为了真正理解 VMP 的工作原理,笔者选择亲手“造轮子”,设计并实现了一个小型的虚拟机(VM)。文中详细阐述了从最初构思、CPU 状态模拟、指令集(ISA)设计,到解释器实现的全过程,并坦诚地分享了在处理函数调用、全局变量等复杂问题时遇到的两大认知误区与失败尝试。最终,通过借鉴社区成熟方案的思路,成功构建了一个可用的原型 SmallVMP。这不仅是一篇技术实现指南,更是一次关于“通过创造来学习”的深度思考与复盘。
一、源起:为何要亲手造一个VMP?

近来,VMP 技术在软件保护领域的讨论热度居高不下。作为一名技术探索者,与其临渊羡鱼,不如退而结网。我坚信,一个人的逆向功底始终与其开发水平呈现正相关性,若能洞悉其底层原理,那么逆向分析时必将如虎添翼。

本着“我学会,就等于大家学会”的分享精神,我决定开启这次 VMP 的探索之旅。这篇文章旨在纯粹的技术交流,记录我如何一步步领略 VMP 的风采。若能抛砖引玉,得到前辈大佬的指点,那更是幸事一桩。

核心思路:能否利用强大的 LLVM 框架来构建一个 VMP?经过一番调研,我发现这恰恰是业界许多成熟方案的选择。我的初步构想是:

1.在 C/C++ 层实现一个微型虚拟 CPU,包含解释器,负责执行自定义的字节码。

2.编写一个 LLVM Pass,在编译期间将目标函数的 LLVM IR (Intermediate Representation) 翻译成我们的自定义字节码。

3.同时,将原函数体清空,替换为一个“跳板”(Stub),负责引导程序流程进入我们的虚拟 CPU 解释器。

理论上,这个方案完全可行。那么,让我们开始吧!

二、探索之路第一步:设计一个极简的虚拟CPU

万丈高楼平地起,VMP 的核心在于那个“VM”。我们需要先设计一个虚拟的 CPU。参考 ARM64 架构,我们可以定义出它的核心组成部分。

1. CPU 核心状态 (VMState)一个 CPU 最核心的就是它的寄存器状态。我们将其极度简化,只保留通用寄存器和状态旗标。

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;

没错,一个极简 CPU 的模型就是这么纯粹。

2. 基础辅助函数为了方便操作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);
}

三、 自定义指令集(ISA)与字节码格式

有了 CPU,就需要它能理解的语言——指令集。我们设计一套定长的 32 位指令格式(LearnVMP ISA),便于处理。

1. 操作码 (Opcode)

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_
};

2. 指令编码与解码我们将 32 位的指令字划分成不同字段,用于表示操作码、寄存器索引和立即数。

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){...}
// ... 其他编码函数

3. 字节码容器为了让解释器能识别和加载我们的字节码,定义一个文件头结构。

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;

四、 解释器的实现:让指令动起来

解释器是 VM 的大脑,它是一个巨大的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 获取返回值并返回给宿主
}

至此,一个简单的 VM 框架已经搭建完成。我曾天真地以为,下一步只需将 LLVM IR 直接翻译成这套指令就大功告成了。然而,现实很快给了我沉重的一击。

五、 认知迭代:两次失败的尝试与深刻教训

失败尝试 1:天真的直接翻译当我尝试直接翻译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);
// ... 处理返回值 ...
    }
}

这个方案看似可行,但很快又遇到了新的、更棘手的问题:全局变量静态变量。如果被 VMP 的函数引用了全局变量,我该如何处理?难道要再维护一个全局变量表吗?如果一个外部调用本身又依赖了其他全局状态呢?这种手动模拟链接器行为的复杂度呈指数级增长,很快就让我意识到,这又是一条歧路。

教训总结:这两次失败让我深刻理解到,VMP 的本质不仅仅是指令翻译,更是一个微型的、自洽的运行时(Runtime)环境。我们不应该试图手动模拟编译、链接过程中的所有复杂工作。

六、 柳暗花明:拥抱 LLVM 的真正力量

在陷入困境后,我开始研究社区的成熟项目,如xvmp。学习其源码后,我恍然大悟:我应该把链接和符号解析这些脏活累活,再次交给 LLVM 自己来处理!

正确的思路是:

1.收集与桥接:LLVM Pass 在处理函数时,将所有对外部函数、全局变量的引用收集起来。为每一个引用生成一个“桥接函数”(Thunk)。这个桥接函数是原生的、未被VMP的,它的唯一作用就是执行原始的调用或访问。

2.符号替换:在生成字节码时,将原来对@fopen的调用,替换为对__thunk_fopen的调用,并赋予其一个 ID。

3.VM 调用:VM 解释器通过OP_BL指令,根据 ID 调用对应的原生 Thunk 函数。由于 Thunk 函数是编译器正常生成的,它自然就拥有了所有正确的链接信息和地址。

通过这种方式,我们巧妙地将 VM 世界和原生世界连接起来,所有复杂的符号问题都迎刃而解。

七、SmallVMP 的诞生与使用

基于上述思路,我的 SmallVMP 终于诞生了。它集成在一个修改版的 LLVM (内置 Hikari 混淆框架) 中。

使用方法:

1.编译并配置好定制版的 LLVM/Clang 环境变量。

2.在代码中引入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;
}

使用 Clang 编译,并附带-mllvm -enable-cffobf(以控制流平坦化为例) 等参数。

bash
clang -mllvm -enable-cffobf test.c -o app

编译时,你会看到类似如下的日志,表明 VMP Pass 已经生效:

[irvm] emit code global: test_calls_code (266 bytes)
[irvm]   + bytes initialized
[irvm]   + rewritten to vm_exec stub: test_calls

1.由于集成了 Hikari,你还可以组合使用其他混淆指令,增强保护效果:

-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);
}

vm后变成了这样:

这是未混淆的vm解释器

加一点 混淆 后 这里只 添加伪控制流 , 平坦化开启 ida 就无法 显示cfg图了

clang test.c -o app -mllvm -enable-bcfobf

八、 局限与展望

目前 SmallVMP 仍处于实验阶段,它成功实现了对目标函数核心逻辑的抽取和解释执行。但它仍有局限,例如对某些复杂的 LLVM IR 指令(如select)尚未支持,遇到这类函数会自动跳过加固,保证编译的稳定性。

未来的工作可以围绕以下几点展开:

Code 加密:对生成的字节码进行加密存储,在解释执行前动态解密,执行后再加密回去,对抗静态分析。

动态分发:动态生成 Handler 映射表,让操作码与处理函数的对应关系不再固定。

嵌套 VM:实现二级 VM,即解释器本身也被另一层 VM 保护,进一步提升分析难度。

九、结语

从最初一个简单的想法,到经历两次失败,再到最终实现一个可用的原型,这个“造轮子”的过程让我对 VMP 的理解产生了质的飞跃。我不再仅仅是知道 VMP“是什么”,而是深刻体会到它“为什么是这样”。

我已将这个过程中的代码开源,包括那些失败的尝试,希望能为同样在探索路上的朋友们提供一些参考。代码尚不完美,欢迎各位大佬批评指正。

项目地址:[https://github.com/NiTianErXing666/SmallVmp]

看雪ID:逆天而行

https://bbs.kanxue.com/user-home-957038.htm

*本文为看雪论坛精华文章,由 逆天而行 原创,转载请注明来自看雪社区

1.25折门票即将售罄

看雪·第九届安全开发者峰会(SDC 2025)

# 往期推荐

无"痕"加载驱动模块之傀儡驱动 (上)

为 CobaltStrike 增加 SMTP Beacon

隐蔽通讯常见种类介绍

buuctf-re之CTF分析

物理读写/无附加读写实验

球分享

球点赞

球在看

点击阅读原文查看更多

阅读原文

跳转微信打开

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

VMP LLVM Pass 虚拟机保护 Android Native 逆向工程 编译器 SmallVMP 代码混淆 软件保护 Virtual Machine Protection LLVM Compiler Reverse Engineering Obfuscation Software Security
相关文章