从0到1的llvm学习 (5) 简易VMP开发

两只羊 Lv3

效果

添加声明的函数都走vmEntry

image-20260430100334431

17fc7a6b-781a-4e0c-8937-e4372bd929d4

image-20260430100219809

vmEntry添加CFF魔改后

6f42a7c8-ffc8-4979-8e6b-c5d9d5633640

运行结果正确

image-20260430152208655

思路

过去无论是在CTF题目中,还是在真实的APP分析中,总能看到各种各样的VM。

就拿CTF来说,出题人要如何才能出一道VM题呢?

对我而言,如果我要在VM里塞入一个较复杂的算法,像TEA,但是在编写完VM框架后又不想过多思考如何编写bytecode,所以我选择先用标准c语言编译一份标准的TEA算法,然后对照着ida的反编译结果,去一条条对照着塞入汇编指令,最终写出来bytecode的结果大概是这样,不难看出是仿照x86架构的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int32_t  bytecode[] = {
#define INPUT 8
#define V -0x20
#define KEY -0x50
#define K -0xC
#define label1 0x29


PUSH, NUM, dataIndex,
PUSH, REG, EIP,
PUSH, REG, EBP,
MOV, REG_TO_REG, EBP, ESP,
SUB, NUM_TO_REG, ESP, 0x50,
MOV, ADDR_TO_REG, EAX, EBP, INPUT,
MOV, REG_TO_ADDR, EBP, -0x1C, EAX,
MOV, ADDR_TO_REG, ECX, EBP, -0x1C,
ADD, NUM_TO_REG, ECX, 0x1,
MOV, REG_TO_ADDR, EBP, -0x28, ECX,

//label1 0x29

MOV, ADDR_TO_REG, EDX, EBP, -0x1C,
MOV, ADDR_TO_LOWREG, AL, EDX, 0,
...
}

但问题同样明显,虽然上面节省了自己过多思考去编写bytecode的时间,但实际在使用过程中,如果算法有那么一点小小改动,那么对于整个程序的执行流都是崩溃性的大调整,跳转指令的偏移全部要改,而且也得重新对照着汇编去增添bytecode,而且最主要的是函数调用,全局变量引用都十分麻烦。

在接触到llvm pass后,我的思路一下子豁然明朗。众所周知,llvm pass中可以通过各种api对LLVM IR进行处理,那么我们只需要解析对应声明Function的每一条Instruction,然后对翻译为我自己虚拟机框架的bytecode,然后修改函数入口为我自己vmEntry的入口,只传入对应的bytecode执行即可。

核心设计

代码结构

BytecodeReader 负责在字节码写入后,手动进行一次反汇编,方便debug

BytecodeWriter 负责字节码写入

CallSignatureAllocator 负责管理函数签名池,专门用于处理函数调用情况

FunctionWriter 是创建或者修改函数的地方,目前只有rewriteToVMStub(负责修改函数入口为传入bytecode地址原参数,并调用VmEntry),createVmCallFunction(为所有函数调用提供一个统一入口)和createGetglobalSymbolTableFuction(获取全局变量地址表)

Lowerer 是整个将LLVM IR翻译为自己虚拟机架构字节码的核心模块

SlotAllocator 负责给函数中出现的llvm::Value对象管理和分配寄存器槽索引

MyVMProtectPass 是注册Pass和入口点

TypeConverter 是llvm类型与字节VM类型之间转换的模块

runtime 中是VM的定义和主要代码,单独编译为bc字节码后再和主程序链接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
include/
BytecodeReader.h
BytecodeWriter.h
CallSignatureAllocator.h
FunctionWriter.h
Lowerer.h
SlotAllocator.h
TypeConverter.h
VMDefs.h

src/
BytecodeReader.cpp
BytecodeWriter.cpp
CallSignatureAllocator.cpp
FunctionWriter.cpp
Lowerer.cpp
MyVMProtectPass.cpp
SlotAllocator.cpp
TypeConverter.cpp

runtime/
vm.c
vm.h
vm_opcodes.h

slot模型 / BinaryOperator运算指令

在LLVM中,存在着SSA特性,也就是每个变量只能被赋值一次

可以看到比如%114,在%115的Load指令后就没有再被引用了,但IR仍然不会给%114重新赋值

image-20260430101454541

对于我们日常接触到的像X86或者ARM架构,寄存器的数量都是有限的。但现在对于每个LLVM IR函数作用域来说,变量数量都不定,有的可能只有10个,有的可能有几百个,因此我选择的是就按照出现的Value数量和size,去动态分配槽位。

这个Slot的概念,在我的虚拟机架构中非常关键,是运行起来的基础。

比如对于一个最简单的add函数

1
2
3
4
5
6
7
8
9
10
11
12
13
define dso_local dllexport i32 @add(i32 noundef %0, i32 noundef %1) #0 {
%3 = alloca i32, align 4
%4 = alloca i32, align 4
%5 = alloca i32, align 4
store i32 %0, i32* %3, align 4
store i32 %1, i32* %4, align 4
%6 = load i32, i32* %3, align 4
%7 = load i32, i32* %4, align 4
%8 = add nsw i32 %6, %7
store i32 %8, i32* %5, align 4
%9 = load i32, i32* %5, align 4
ret i32 %9
}

对于其中的ADD指令%8 = add nsw i32 %6, %7,当然在llvm pass中也可以统一为BinaryOperator

其中%8,%6,%7这些变量,在llvm中本质就是一个个llvm::Value对象

我的处理函数如下,通过先getOrCreateSlot,给各个Value分配一个槽索引,并添加该函数下槽的计数

然后再将运算类型和槽索引写入字节码中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
bool Lowerer::lowerBinaryOp(llvm::BinaryOperator& I) {

VM_SLOT dst = getOrCreateSlot(&I);
VM_SLOT lhs = getOrCreateSlot(I.getOperand(0));
VM_SLOT rhs = getOrCreateSlot(I.getOperand(1));

writer.emitU8(TypeConverter::toOPCODE_ID(I.getOpcode()));
//llvm::errs() << TypeConverter::opcodeToString(TypeConverter::toOPCODE_ID(I.getOpcode())) << " ";
writer.emitU8(TypeConverter::toTYPE_ID(I.getType()));
writer.emitU16(dst);
writer.emitU16(lhs);
writer.emitU16(rhs);

return true;
}

在VM框架中,也依次把运算类型和槽索引读出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
case OP_ADD:
case OP_SUB:
case OP_MUL:
case OP_SDIV:
case OP_UDIV:
case OP_SREM:
case OP_UREM:
case OP_AND:
case OP_OR:
case OP_XOR:
case OP_SHL:
case OP_LSHR:
case OP_SHR: {
TYPE_ID ty = read_u8(&vm);
VM_SLOT dst = read_u16(&vm);
VM_SLOT lhs = read_u16(&vm);
VM_SLOT rhs = read_u16(&vm);

switch (instType) {
case OP_ADD: vmAdd(&vm, ty, dst, lhs, rhs); break;
case OP_SUB: vmSub(&vm, ty, dst, lhs, rhs); break;
case OP_MUL: vmMul(&vm, ty, dst, lhs, rhs); break;
case OP_SDIV: vmSdiv(&vm, ty, dst, lhs, rhs); break;
case OP_UDIV: vmUdiv(&vm, ty, dst, lhs, rhs); break;
case OP_SREM: vmSrem(&vm, ty, dst, lhs, rhs); break;
case OP_UREM: vmUrem(&vm, ty, dst, lhs, rhs); break;
case OP_AND: vmAnd(&vm, ty, dst, lhs, rhs); break;
case OP_OR: vmOr(&vm, ty, dst, lhs, rhs); break;
case OP_XOR: vmXor(&vm, ty, dst, lhs, rhs); break;
case OP_SHL: vmShl(&vm, ty, dst, lhs, rhs); break;
case OP_LSHR: vmLshr(&vm, ty, dst, lhs, rhs); break;
case OP_SHR: vmShr(&vm, ty, dst, lhs, rhs); break;
default: break;
}
break;
}

然后直接利用索引,根据类型对分配的槽进行操作,实现了寄存器运算

1
2
3
4
5
6
7
8
9
static inline void vmAdd(VMState* vm, TYPE_ID ty, VM_SLOT dst, VM_SLOT lhs, VM_SLOT rhs) {
switch (ty) {
case TY_I8: vm->slots[dst] = (uint8_t)(vm->slots[lhs] + vm->slots[rhs]); break;
case TY_I16: vm->slots[dst] = (uint16_t)(vm->slots[lhs] + vm->slots[rhs]); break;
case TY_I32: vm->slots[dst] = (uint32_t)(vm->slots[lhs] + vm->slots[rhs]); break;
case TY_I64: vm->slots[dst] = vm->slots[lhs] + vm->slots[rhs]; break;
default: break;
}
}

getOrCreateSlot的实现如下,materializeValue为对一些特殊Value的处理,比如像常量值和全局变量

1
2
3
4
5
VM_SLOT Lowerer::getOrCreateSlot(llvm::Value* value)
{
materializeValue(value);
return slots.getOrCreateSlot(value);
}

其实就是给每个Value对象分配一个递增的索引,如果存在则直接返回。

1
2
3
4
5
6
7
VM_SLOT  SlotAllocator::getOrCreateSlot(llvm::Value* v) {
auto it = valueToSlot.find(v);
if (it != valueToSlot.end()) return it->second;
uint16_t s = nextSlot++;
valueToSlot[v] = s;
return s;
}

对于大多数运算来说,也遵循上面的形式,就不一一赘述了

Alloca、Load、Store指令

仍然以add函数为例,先看对%3这个变量的完整作用过程

1
2
3
%3 = alloca i32, align 4
store i32 %0, i32* %3, align 4
%6 = load i32, i32* %3, align 4

字面上理解是在定义了一个32位大小的变量%3,然后对给%3赋值为0,再从中读取%3的值,真的是这样吗?

假如%3的Slot索引是3,那么slot[3]的值应该是多少呢?没错,答案不是0,因为alloca分配的,应该是是指向一个VM自己分配栈的地址

这里非常需要注意的是,我并没有考虑对齐问题,因为在x86下可以正常运行,但在ARM架构下,如果直接对奇数的地址读写4bytes以上,程序将直接崩溃

1
2
3
4
static inline void vmAlloca(VMState* vm, VM_SLOT dst, VM_SLOT allocaSize) {
vm->slots[dst] = (uint64_t)&vm->memory[vm->sp];
vm->sp += allocaSize;
}

虽然上面的例子还看不出来问题,我们再来看一个例子,这个adds函数,引用了一个指针,并涉及到取值与存储

1
2
3
4
5
6
7
8
9
10
11
__attribute__((annotate("twogoat_vm")))
int adds(int* nums, int size)
{
int res = 0;
for(int i = 0; i < size; i++) {
res += nums[i];
nums[i] += 1;
}

return res;
}

接下来看看IR中的主要表现

先忽略getelementptr的含义,GEP指令是用来方便取址的,总之%15与%22都是上面例子中nums[i]的地址

可以看到的是接下来进行了%16 = load i32, i32* %15, align 4这样的取值,和store i32 %24, i32* %22, align 4这样的赋值

这和alloca出来的变量的方式是一样的,也就是说alloca后的Value,就应该是一个地址,不然没办法衔接上这里的处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
11:                                               ; preds = %7
%12 = load i32*, i32** %3, align 8
%13 = load i32, i32* %6, align 4
%14 = sext i32 %13 to i64
%15 = getelementptr inbounds i32, i32* %12, i64 %14
%16 = load i32, i32* %15, align 4
%17 = load i32, i32* %5, align 4
%18 = add nsw i32 %17, %16
store i32 %18, i32* %5, align 4
%19 = load i32*, i32** %3, align 8
%20 = load i32, i32* %6, align 4
%21 = sext i32 %20 to i64
%22 = getelementptr inbounds i32, i32* %19, i64 %21
%23 = load i32, i32* %22, align 4
%24 = add nsw i32 %23, 1
store i32 %24, i32* %22, align 4
br label %25

那么Load和Store的执行方式如下,根据数据类型,直接对指针进行操作即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static inline void vmLoad(VMState* vm, TYPE_ID ty, VM_SLOT dst, VM_SLOT srcPtrSlot) {
uint64_t addr = vm->slots[srcPtrSlot]; // 获取真实的内存地址
switch (ty) {
case TY_I8: vm->slots[dst] = *((uint8_t*)addr); break;
case TY_I16: vm->slots[dst] = *((uint16_t*)addr); break;
case TY_I32: vm->slots[dst] = *((uint32_t*)addr); break;
case TY_I64: vm->slots[dst] = *((uint64_t*)addr); break;
case TY_PTR: vm->slots[dst] = *((uint64_t*)addr); break;
default: break;
}
}

static inline void vmStore(VMState* vm, TYPE_ID ty, VM_SLOT srcSlot, VM_SLOT dstPtrSlot) {
uint64_t addr = vm->slots[dstPtrSlot]; // 获取要写入的真实内存地址
switch (ty) {
case TY_I8: *((uint8_t*)addr) = (uint8_t)vm->slots[srcSlot]; break;
case TY_I16: *((uint16_t*)addr) = (uint16_t)vm->slots[srcSlot]; break;
case TY_I32: *((uint32_t*)addr) = (uint32_t)vm->slots[srcSlot]; break;
case TY_I64: *((uint64_t*)addr) = (uint64_t)vm->slots[srcSlot]; break;
case TY_PTR: *((uint64_t*)addr) = (uint64_t)vm->slots[srcSlot]; break;
default: break;
}
}

LoadConst指令

上面在介绍slot模型中,我们提到Lowerer::materializeValue会用来处理一些特殊的llvm value

下面来看这个例子,也是一个简单的add,但是最后加了114

1
2
3
4
5
6
__attribute__((annotate("twogoat_vm")))
int add(int a, int b)
{
int res = a + b + 114;
return res;
}

那在IR中,这个114是如何表示的呢,可以看到这条指令%9 = add nsw i32 %8, 114,也没什么问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
define dso_local i32 @add(i32 noundef %0, i32 noundef %1) #0 !dbg !8 {
%3 = alloca i32, align 4
%4 = alloca i32, align 4
%5 = alloca i32, align 4
store i32 %0, i32* %3, align 4
store i32 %1, i32* %4, align 4
%6 = load i32, i32* %3, align 4, !dbg !19
%7 = load i32, i32* %4, align 4, !dbg !20
%8 = add nsw i32 %6, %7, !dbg !21
%9 = add nsw i32 %8, 114, !dbg !22
store i32 %9, i32* %5, align 4, !dbg !18
%10 = load i32, i32* %5, align 4, !dbg !23
ret i32 %10, !dbg !24
}

但对于我们的slot模型来说,这是需要额外考虑的地方

114是什么?是一个值,是一个32位的数据,但在llvm中,它仍然是一个llvm::Value对象

如果我们要为了一个常量值,去专门做适配,那么将会非常麻烦,我需要额外考虑lhs是槽索引,rhs是常量;或者lhs是常量,rhs是槽索;引,甚至两者都是常量,整个分发器将会十分臃肿

1
2
3
4
5
6
7
8
9
10
11
12
TYPE_ID  ty  = read_u8(&vm);
VM_SLOT dst = read_u16(&vm);
VM_SLOT lhs = read_u16(&vm);
VM_SLOT rhs = read_u16(&vm);

switch (instType) {
case OP_ADD: vmAdd(&vm, ty, dst, lhs, rhs); break;
case OP_SUB: vmSub(&vm, ty, dst, lhs, rhs); break;
case OP_MUL: vmMul(&vm, ty, dst, lhs, rhs); break;
case OP_SDIV: vmSdiv(&vm, ty, dst, lhs, rhs); break;
case OP_UDIV: vmUdiv(&vm, ty, dst, lhs, rhs); break;
case OP_SREM: vmSrem(&vm, ty, dst, lhs, rhs); break;

因此我选择的是,在getOrCreateSlot时,就额外添加一条专门的LoadConst指令,上面的函数经过处理后,vm字节码的反汇编结果如下

可以看到在保留了ADD i32 %8 = %7, %9这样的槽索引运算的同时,前面增加了一条LOAD_CONST i32 %9 <- 114指令

1
2
3
4
5
6
7
8
9
10
11
12
13
[00000017]  ALLOCA   %2 4
[00000022] ALLOCA %3 4
[00000027] ALLOCA %4 4
[00000032] STORE i32 %0 -> %2
[00000038] STORE i32 %1 -> %3
[00000044] LOAD i32 %5 <- %2
[00000050] LOAD i32 %6 <- %3
[00000056] ADD i32 %7 = %5, %6
[00000064] LOAD_CONST i32 %9 <- 114
[00000072] ADD i32 %8 = %7, %9
[00000080] STORE i32 %8 -> %4
[00000086] LOAD i32 %10 <- %4
[00000092] RET i32 %10

在VM中是这样运行的

1
2
3
4
5
6
case OP_LOAD_CONST: {
TYPE_ID ty = read_u8(&vm);
VM_SLOT dst = read_u16(&vm);
vmLoadConst(&vm, ty, dst);
break;
}
1
2
3
4
5
6
7
8
9
10
static inline void vmLoadConst(VMState* vm, TYPE_ID ty, VM_SLOT dst) {
switch (ty) {
case TY_I8: vm->slots[dst] = read_u8(vm); break;
case TY_I16: vm->slots[dst] = read_u16(vm); break;
case TY_I32: vm->slots[dst] = read_u32(vm); break;
case TY_I64: vm->slots[dst] = read_u64(vm); break;
case TY_PTR: vm->slots[dst] = read_u64(vm); break;
default: break;
}
}

那么一来,核心的slot运算模式没有遭到破坏,而且常量值也能正常Load了

而这就是Lowerer::materializeValue对llvm::ConstantInt的核心处理部分,可以看到正常走了getOrCreateSlot,分配了一个槽

然后再对字节码写入对应的类型和值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
if (llvm::ConstantInt* constant = llvm::dyn_cast<llvm::ConstantInt>(actualValue)) {
TYPE_ID ty = TypeConverter::toTYPE_ID(constant->getType());

VM_SLOT constSlot = slots.getOrCreateSlot(value);

writer.emitU8(OP_LOAD_CONST);
writer.emitU8(ty);
writer.emitU16(constSlot);

if (ty == TY_I1) {
writer.emitU8((uint8_t)constant->getZExtValue());
}

if (ty == TY_I8) {
writer.emitU8((uint8_t)constant->getZExtValue());
} else if (ty == TY_I16) {
writer.emitU16((uint16_t)constant->getZExtValue());
} else if (ty == TY_I32) {
writer.emitU32((uint32_t)constant->getZExtValue());
} else if (ty == TY_I64) {
writer.emitU64((uint64_t)constant->getZExtValue());
}

llvm::errs() << TypeConverter::typeToString(ty)
<< " LOAD_CONST_" << TypeConverter::typeToString(ty)
<< " " << constSlot << " ; \n";
return;
}

Branch指令

图灵完备是计算机科学术语,指一个系统能模拟单带图灵机并执行所有可计算任务的能力,其概念源于艾伦·图灵于1936年提出的图灵机模型 。

图灵完备不依赖具体硬件形态,而取决于其规则集是否支持条件判断、循环与状态存储等基本计算要素。若某编程语言或虚拟机(如以太坊虚拟机EVM)能实现任意图灵机的行为,即被认为具备图灵完备性 [7]。

图灵不完备的系统则不允许或严格限制循环等结构,这可以保证每段程序都有运行终止的时候(避免死循环),但同时也限制了其功能范围,无法解决所有可计算问题

接下来要实现的就是设计中最重要的一个功能,也是让虚拟机图灵完备的关键一步,分支指令的处理。

仍然从一个简单的例子开始

1
2
3
4
5
6
7
8
9
10
__attribute__((annotate("twogoat_vm")))
int add(int a, int b)
{
int res = 0;
int tmp = a + b;
for(int i = 0; i < 10; i++) {
res += tmp;
}
return res;
}

查看IR,这里就是循环体

%13 = icmp slt i32 %12, 10一个ICMP指令,%13即为比较结果True或者False。

然后是br i1 %13, label %14, label %21,如果%13为True则走%14否则走%21

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  br label %11

11: ; preds = %18, %2
%12 = load i32, i32* %7, align 4
%13 = icmp slt i32 %12, 10
br i1 %13, label %14, label %21

14: ; preds = %11
%15 = load i32, i32* %6, align 4
%16 = load i32, i32* %5, align 4
%17 = add nsw i32 %16, %15
store i32 %17, i32* %5, align 4
br label %18

18: ; preds = %14
%19 = load i32, i32* %7, align 4
%20 = add nsw i32 %19, 1
store i32 %20, i32* %7, align 4
br label %11, !llvm.loop !4

21: ; preds = %11
%22 = load i32, i32* %5, align 4
ret i32 %22

我们的思路很简单,ICMP指令正常处理就好,本质上和前面的运算指令一样,就是要注意有符号和无符号的处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static inline void vmIcmp(VMState* vm, TYPE_ID ty, CONDITION_ID cond, VM_SLOT dst, VM_SLOT lhs, VM_SLOT rhs) {
uint64_t u_lhs = maskByType(vm->slots[lhs], ty);
uint64_t u_rhs = maskByType(vm->slots[rhs], ty);
int64_t s_lhs = signExtendByType(u_lhs, ty);
int64_t s_rhs = signExtendByType(u_rhs, ty);

uint64_t result = 0;
switch (cond) {
case CMP_EQ: result = (u_lhs == u_rhs); break;
case CMP_NE: result = (u_lhs != u_rhs); break;
case CMP_UGT: result = (u_lhs > u_rhs); break;
case CMP_UGE: result = (u_lhs >= u_rhs); break;
case CMP_ULT: result = (u_lhs < u_rhs); break;
case CMP_ULE: result = (u_lhs <= u_rhs); break;
case CMP_SGT: result = (s_lhs > s_rhs); break;
case CMP_SGE: result = (s_lhs >= s_rhs); break;
case CMP_SLT: result = (s_lhs < s_rhs); break;
case CMP_SLE: result = (s_lhs <= s_rhs); break;
default: fprintf(stderr, "[VM] Unknown CMP!\n"); break;
}
vm->slots[dst] = result;
}

问题就是跳转指令,比如上面的例子,我们要知道%14或者%23的bytecode偏移,然后设置跳转的地址或者偏移,但问题是在按照指令顺序处理这条BranchInst时,我们根本就不知道后面的bytecode会是如何排布,因此这里就要引入占位。

可以看到在处理BranchInst其实也非常简单,插入类型,用0xDEADBEEF占位

最主要的就是收集当前指令的偏移和需要指向的BasicBlock指针,放入unresolvedBranches,等函数的所有bytecode预生成完后,再统一对改vector里的元素进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
bool Lowerer::lowerBranch(llvm::BranchInst& BR) {
if(BR.isUnconditional()) {
llvm::BasicBlock *targetBlock = BR.getSuccessor(0);
writer.emitU8(OP_BR);
unresolvedBranches.push_back({writer.getCurrentOffset(), targetBlock});
writer.emitU32(0xDEADBEEF);


} else {
llvm::Value* condVal = BR.getCondition();
llvm::BasicBlock* trueBB = BR.getSuccessor(0);
llvm::BasicBlock* falseBB = BR.getSuccessor(1);

VM_SLOT condSlot = getOrCreateSlot(condVal);

writer.emitU8(OP_BR_COND);
writer.emitU16(condSlot);

unresolvedBranches.push_back({writer.getCurrentOffset(), trueBB});
writer.emitU32(0xDEADBEEF);

unresolvedBranches.push_back({writer.getCurrentOffset(), falseBB});
writer.emitU32(0xCAFEBABE);
//f(condition-> == llvm::ICmpInst::ICMP_EQ);
}


return true;
}

这里就是翻译IR的入口,可以看到在每个BasicBlock进行处理前,就已经记录下了对应的bytecode偏移至bbToOffset

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
bool Lowerer::run() {
lowerArgsAndSignature();

for (auto& BB : F) {
bbToOffset[&BB] = writer.getCurrentOffset();
for (auto& I : BB) {
llvm::errs() << I << " ; ";
lowerInstruction(I);
llvm::errs() << "\n";
}
}
doBackpatching();
writer.patchMemoryMaxSize(memoryMaxSize);
writer.patchDataSegmentCount(slots.getSlotCount());
return true;
}

接下来就是遍历unresolvedBranches,并patch对应偏移的bytecode,最终实现循环分支的处理

1
2
3
4
5
6
7
8
9
10
void Lowerer::doBackpatching() {
for (auto& debt : unresolvedBranches) {
uint32_t patchOffset = debt.first;
llvm::BasicBlock* targetBB = debt.second;

uint32_t realOffset = bbToOffset[targetBB];

writer.patchU32(patchOffset, realOffset);
}
}

虚拟机最终生成出来的指令如下,可以看到准确无误地还原了分支。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[00000108]  BR      pc -> 113
[00000113] LOAD i32 %11 <- %6
[00000119] LOAD_CONST i32 %13 <- 10
[00000127] ICMP slt i32 %12 = %11, %13
[00000136] BR_COND %12 ? 147 : 211 ; if slots[12] == 1 then pc = 147 else pc = 211
[00000147] LOAD i32 %14 <- %5
[00000153] LOAD i32 %15 <- %4
[00000159] ADD i32 %16 = %15, %14
[00000167] STORE i32 %16 -> %4
[00000173] BR pc -> 178
[00000178] LOAD i32 %17 <- %6
[00000184] LOAD_CONST i32 %19 <- 1
[00000192] ADD i32 %18 = %17, %19
[00000200] STORE i32 %18 -> %6
[00000206] BR pc -> 113
[00000211] LOAD i32 %20 <- %4
[00000217] RET i32 %20

接下来vm层的处理也非常简单,当然我这里直接用的是地址赋值而非偏移

1
2
3
4
5
6
7
8
9
10
11
static inline void vmBr(VMState* vm, uint32_t off) {
vm->pc = off;
}

static inline void vmBrCond(VMState* vm, VM_SLOT resSlot, uint32_t tOff, uint32_t fOff) {
if (vm->slots[resSlot] == 1) {
vm->pc = tOff;
} else {
vm->pc = fOff;
}
}

全局变量/符号引用

接下来可以说是真正遇到了一个大难点,要处理全局变量和符号引用

依旧先从一个简单的例子看起

1
2
3
4
5
6
7
8
9
10
int len = 5;
unsigned char key[] = "hello";
unsigned char tmp[5];

__attribute__((annotate("twogoat_vm")))
void mov_test(uint8_t *input) {
for(int i = 0; i < len; i++) {
input[i] = key[i];
}
}

接下来查看IR,其实总体上跟之前的没有任何差别,但问题就在@len和@key上

我们要传入@len或者@key的在内存中的虚拟地址,才能正常在VM中完成load和store等操作,这也是我设计的要遵守的规范,肯定要像之前处理Const一样,额外添加处理的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@len = dso_local global i32 5, align 4
@key = dso_local global [6 x i8] c"hello\00", align 1

; Function Attrs: noinline nounwind optnone uwtable
define dso_local void @xor_test(i8* noundef %0) #0 {
%2 = alloca i8*, align 8
%3 = alloca i32, align 4
store i8* %0, i8** %2, align 8
store i32 0, i32* %3, align 4
br label %4

4: ; preds = %17, %1
%5 = load i32, i32* %3, align 4
%6 = load i32, i32* @len, align 4
%7 = icmp slt i32 %5, %6
br i1 %7, label %8, label %20

8: ; preds = %4
%9 = load i32, i32* %3, align 4
%10 = sext i32 %9 to i64
%11 = getelementptr inbounds [6 x i8], [6 x i8]* @key, i64 0, i64 %10
%12 = load i8, i8* %11, align 1
%13 = load i8*, i8** %2, align 8
%14 = load i32, i32* %3, align 4
%15 = sext i32 %14 to i64
%16 = getelementptr inbounds i8, i8* %13, i64 %15
store i8 %12, i8* %16, align 1
br label %17

17: ; preds = %8
%18 = load i32, i32* %3, align 4
%19 = add nsw i32 %18, 1
store i32 %19, i32* %3, align 4
br label %4, !llvm.loop !4

20: ; preds = %4
ret void
}

我的想法是,添加一个Module Pass,在程序进行处理之前,就先收集好全局变量,函数等信息,并建立一个符号指针表

可以看到先收集了当前Module下的所有Function地址,再收集了globals的地址,当然这里也要十分注意的是,拥有llvm保留字段的对象必须要跳过,否则链接时会提示找不到符号信息

通过ConstantArray::get,能够直接将带有llvm::Value对象的Vector转化为llvm的ConstantArray,然后再转化为全局变量即可,接下来就可以拿到符号表了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
void runOnInitGlobalSymble(Module& M)
{

LLVMContext& ctx = M.getContext();
// 1. 打印所有函数名
errs() << "[Functions]\n";
for (Function &F : M) {
if(F.getName().startswith("llvm")) {
continue;
}
errs() << " - " << F.getName();
if (F.isDeclaration()) {
errs() << " (External Declaration)";
}
errs() << "\n";
symbolPtrs.push_back(&F);
}

errs() << "\n[Global Variables]\n";
for (GlobalVariable &GV : M.globals()) {
if(GV.getName().startswith("llvm") || GV.getName() == ".twogoat_globalSymbolTable") {
continue;
}

if (GV.hasSection() && GV.getSection() == "llvm.metadata") {
errs() << "[skip metadata global] " << GV.getName() << "\n";
continue;
}

//GV.setLinkage(GlobalValue::InternalLinkage);
errs() << " - " << GV.getName() << " (Type: ";
GV.getType()->print(errs());
errs() << ")\n";

symbolPtrs.push_back(&GV);
}

errs() << "-------------------------------------------------\n\n";

for(int i = 0; i < symbolPtrs.size(); i++) {
toSymbolId[symbolPtrs[i]] = i;
}

ArrayType* arrayType = ArrayType::get(PointerType::getUnqual(ctx), symbolPtrs.size());
Constant* data = ConstantArray::get(arrayType, symbolPtrs);
globalSymbolTable = new GlobalVariable(M, data->getType(), true, GlobalVariable::ExternalLinkage, data, ".twogoat_globalSymbolTable");

}

接下来就是非常重要的一步,需要将这个函数表导出给VM框架使用

创建一个名为getglobalSymbolTable的函数,设置属性为ExternalLinkage,其最主要的作用就是返回globalSymbolTable的地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
bool FunctionWriter::createGetglobalSymbolTableFuction(llvm::Module& M, llvm::GlobalVariable* globalSymbolTable) {
llvm::LLVMContext& ctx = M.getContext();
llvm::FunctionType* funcType = llvm::FunctionType::get(llvm::PointerType::getUnqual(ctx), false);

llvm::Function* F = M.getFunction("getglobalSymbolTable");
if(!F) {
F = llvm::Function::Create(funcType, llvm::GlobalVariable::ExternalLinkage, "getglobalSymbolTable", M);
}


llvm::BasicBlock* entryBlock = llvm::BasicBlock::Create(ctx, "getglobalSymbolTable_entry", F);
llvm::Type* retType = F->getReturnType();
llvm::Constant* globalSymbolTablePtr = llvm::ConstantExpr::getBitCast(globalSymbolTable, retType);
llvm::IRBuilder<> builder(entryBlock);
builder.CreateRet(globalSymbolTablePtr);
return true;

}

在VM中设置为extern,然后链接时就会自动找到了

1
2
extern void* getglobalSymbolTable(void);
vm->symbolTable = (uintptr_t*)getglobalSymbolTable();

需要加载符号时,直接从symbolTable中取出对应索引的地址即可

1
2
3
static inline void vmLoadSymbolAddress(VMState* vm, TYPE_ID ty, VM_SLOT dst, uint16_t symbolIdx) {
vm->slots[dst] = vm->symbolTable[symbolIdx];
}

那么和LoadConst一样,添加一个LoadSymbol的特殊处理,在字节码中插入该符号在符号地址表中的索引

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
if (llvm::GlobalValue* GV = llvm::dyn_cast<llvm::GlobalValue>(actualValue)) {
auto it = toSymbolId.find(GV);
if (it == toSymbolId.end()) {
llvm::errs() << "[VM Error] Global symbol not registered: "
<< GV->getName() << "\n";
return;
}

VM_SLOT dstSlot = slots.getOrCreateSlot(value);

writer.emitU8(OP_LOAD_SYMBOL_ADDR);
writer.emitU8(TY_PTR);
writer.emitU16(dstSlot);
writer.emitU16(it->second);

llvm::errs() << "ptr LOAD_SYMBOL_ADDR " << dstSlot
<< " @" << GV->getName()
<< " (id=" << it->second << ") ; \n";
return;
}

在VM的反汇编中,可以看到是通过LOAD_SYM ptr %6 <- sym[3],去加载符号地址的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
[00000016]  ALLOCA   %1 8
[00000021] ALLOCA %2 4
[00000026] STORE ptr %0 -> %1
[00000032] LOAD_CONST i32 %3 <- 0
[00000040] STORE i32 %3 -> %2
[00000046] BR pc -> 51
[00000051] LOAD i32 %4 <- %2
[00000057] LOAD_SYM ptr %6 <- sym[3]
[00000063] LOAD i32 %5 <- %6
[00000069] ICMP slt i32 %7 = %4, %5
[00000078] BR_COND %7 ? 89 : 219 ; if slots[7] == 1 then pc = 89 else pc = 219
[00000089] LOAD i32 %8 <- %2
[00000095] SEXT i32 -> i64 %9 <- %8
[00000102] LOAD_SYM ptr %11 <- sym[4]
[00000108] LOAD_CONST i64 %12 <- 0
[00000120] GEP %10 = %11 + (%12 * 6) + (%9 * 1)
[00000138] LOAD i8 %13 <- %10
[00000144] LOAD ptr %14 <- %1
[00000150] LOAD i32 %15 <- %2
[00000156] SEXT i32 -> i64 %16 <- %15
[00000163] GEP %17 = %14 + (%16 * 1)
[00000175] STORE i8 %13 -> %17
[00000181] BR pc -> 186
[00000186] LOAD i32 %18 <- %2
[00000192] LOAD_CONST i32 %20 <- 1
[00000200] ADD i32 %19 = %18, %20
[00000208] STORE i32 %19 -> %2
[00000214] BR pc -> 51
[00000219] EXIT

编译好后在ida中打开,可以看到symbolTable存放了各种符号的地址,那就没问题了

image-20260430215052093

函数调用处理

Call指令的问题

接下来的部分,是我在整个编写中遇到的最困难一步,简单来说就是“把一个参数数组动态传递给函数并进行调用”的问题

依旧先看一个简单的call的例子

1
2
3
4
5
6
7
8
9
10
11
12
__attribute__((annotate("twogoat_vm")))
int add(int a, int b) {
return a + b;
}

__attribute__((annotate("twogoat_vm")))
int call_test(int a, int b) {
printf("%d %d \n", a, b);
int c = a + 1;
int d = b + 1;
return add(c, d);
}

IR如下。可以看到从结构上其实也没有太大变化,通过解析CallInst,我们能拿到FunctionType,通过getArgOperand拿到参数对象,那么直接塞入字节码就行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
define dso_local i32 @add(i32 noundef %0, i32 noundef %1) #0 {
%3 = alloca i32, align 4
%4 = alloca i32, align 4
store i32 %0, i32* %3, align 4
store i32 %1, i32* %4, align 4
%5 = load i32, i32* %3, align 4
%6 = load i32, i32* %4, align 4
%7 = add nsw i32 %5, %6
ret i32 %7
}

; Function Attrs: noinline nounwind optnone uwtable
define dso_local i32 @call_test(i32 noundef %0, i32 noundef %1) #0 {
%3 = alloca i32, align 4
%4 = alloca i32, align 4
%5 = alloca i32, align 4
%6 = alloca i32, align 4
store i32 %0, i32* %3, align 4
store i32 %1, i32* %4, align 4
%7 = load i32, i32* %3, align 4
%8 = add nsw i32 %7, 1
store i32 %8, i32* %5, align 4
%9 = load i32, i32* %4, align 4
%10 = add nsw i32 %9, 1
store i32 %10, i32* %6, align 4
%11 = load i32, i32* %5, align 4
%12 = load i32, i32* %6, align 4
%13 = call i32 @add(i32 noundef %11, i32 noundef %12)
ret i32 %13
}

假如遇到变参函数,像printf怎么办,举一个最简单的例子

1
2
3
4
5
6
__attribute__((annotate("twogoat_vm")))
void call_test(int a, int b) {
printf("%d \n", a);
printf("%d %d \n", a, b);
return;
}

llvm IR如下,忽略掉前面那一串gep指令,能看的出每一条call printf指令传参其实已经确定好了

1
2
3
4
5
6
7
8
9
10
11
12
define dso_local void @call_test(i32 noundef %0, i32 noundef %1) #0 {
%3 = alloca i32, align 4
%4 = alloca i32, align 4
store i32 %0, i32* %3, align 4
store i32 %1, i32* %4, align 4
%5 = load i32, i32* %3, align 4
%6 = call i32 (i8*, ...) @printf(i8* noundef getelementptr inbounds ([5 x i8], [5 x i8]* @.str, i64 0, i64 0), i32 noundef %5)
%7 = load i32, i32* %3, align 4
%8 = load i32, i32* %4, align 4
%9 = call i32 (i8*, ...) @printf(i8* noundef getelementptr inbounds ([8 x i8], [8 x i8]* @.str.1, i64 0, i64 0), i32 noundef %7, i32 noundef %8)
ret void
}

真正的挑战在于物理机的调用约定(Native ABI)。

比如上面的add函数,经过保护后,他仍需要保持一个int add(int a1, int a2)的形式

1
2
3
4
RETURN_VALUE __fastcall add(unsigned int a1, unsigned int a2)
{
return vmEntry(&bytecode1, a1, a2);
}

我的 VM 设计原则是不破坏被保护函数的 ABI。在 VM 内部,所有的数据类型都被擦除,统一成了 uint64_t 的 Slot 数组。但是,当真实的 CPU 执行 add(1, 2) 时,它需要将参数精确地放入特定的寄存器(如 RDI, RSI)。VM 在运行时是一堆无法感知底层寄存器的 C 代码,不可能动态地去构建形如 (int (*)(int, int))pAdd(1, 2) 的强类型调用。

起初,我考虑过引入 libffi 库来动态处理 ABI,但这会导致整个 VMP 项目变得极其臃肿,偏离了轻量级的初衷。

类型降维与 LLVM 动态分发

仔细剖析后,我意识到这里其实交织着三个维度的状态转换:

  • 源程序维度(IR):拥有严格的类型系统(i32, i8*, 变参等)。

  • 虚拟机维度(VM Runtime):类型完全擦除,全是 uint64_t 的 Slot。

  • 物理机维度(Native ABI):严格遵循底层寄存器/栈传参约定。

既然 VM 在运行时无法感知类型,那我们就在编译期(LLVM Pass)利用编译器自身的后端能力,提前把不同类型的调用路线铺好。

核心思路是:函数签名收集 + 动态生成分发器 (vmCall)。

最后效果如下,这是从AES加密的项目中截取的vmCall伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
__int64 __fastcall vmCall(__int16 a1, void (__fastcall *a2)(_QWORD, _QWORD), _QWORD *a3)
{
__int64 v4; // [rsp+40h] [rbp-8h]

v4 = 0;
switch ( a1 )
{
case 0:
a2(*a3, a3[1]);
break;
case 1:
case 5:
case 6:
((void (__fastcall *)(_QWORD, _QWORD, _QWORD))a2)(*a3, a3[1], a3[2]);
break;
case 2:
((void (__fastcall *)(_QWORD))a2)(*a3);
break;
case 3:
v4 = ((unsigned __int8 (__fastcall *)(_QWORD, _QWORD))a2)(*a3, a3[1]);
break;
case 4:
v4 = ((unsigned int (__fastcall *)(_QWORD))a2)(*a3);
break;
case 7:
v4 = ((unsigned int (__fastcall *)(_QWORD, _QWORD, _QWORD))a2)(*a3, a3[1], a3[2]);
break;
case 8:
case 9:
v4 = ((unsigned int (__fastcall *)(_QWORD, _QWORD))a2)(*a3, a3[1]);
break;
default:
return v4;
}
return v4;
}

lower中的核心处理如下

注意funcTy只是用来获取返回值类型的,考虑到可变参数,函数签名还需要用getArgOperand来获取

当遍历 IR 遇到 CallInst 时,我们提取出它的返回值类型和参数类型,合成一个独一无二的 Signature ID。即便是 printf 这样的变参函数,每一次调用的实际参数类型在 IR 中也是确定的,因此会自动分配不同的 ID。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
llvm::FunctionType *funcTy = I.getFunctionType();
std::vector<TYPE_ID> callSignature;
callSignature.push_back(TypeConverter::toTYPE_ID(funcTy->getReturnType()));
for (int i = 0; i < I.arg_size(); ++i) {
callSignature.push_back(TypeConverter::toTYPE_ID(I.getArgOperand(i)->getType()));
}
uint16_t callSignatureId = calls.getOrCreateCallSignatureID(callSignature);


llvm::Value* callee = I.getCalledOperand();
VM_SLOT calleeSlot = getOrCreateSlot(callee);


std::vector<VM_SLOT> argSlots;
for (unsigned i = 0; i < I.arg_size(); ++i) {
argSlots.push_back(getOrCreateSlot(I.getArgOperand(i)));
}
llvm::errs() << "Call ";
llvm::errs() << callSignatureId << " ; ";

writer.emitU8(OP_CALL);
writer.emitU16(calleeSlot);
writer.emitU16(callSignatureId);

writer.emitU8(argSlots.size());
/*
uint64_t vmCall(uint16_t callee_sign_id, void* funcAddr, SLOT* slots_ptr)
{
uint64_t res = 0;
switch(callee_sign_id) {
case 1: {

res = (int (*)(int, int))funcAddr((int)slots_ptr[0], slots_ptr[1]);
break;
}
case 2: {
res = (bool (*)(uint64_t))funcAddr((uint64_t*)slots_ptr[0]);
break;
}
}
return = res;
}

*/

for (VM_SLOT slot : argSlots) {
writer.emitU16(slot);
}

if (!funcTy->getReturnType()->isVoidTy()) {
writer.emitU8(0);
VM_SLOT retSlot = slots.getOrCreateSlot(&I);
writer.emitU16(retSlot);
llvm::errs() << "Call result -> %" << retSlot << "\n";
} else {
writer.emitU8(1);
}


llvm::errs() << "\n";

接下来就是最关键的一步,利用 IR Builder 动态构建 FFI 桥接器。

通过收集到的函数签名信息,去生成vmCall,对于收集到的每一个签名,生成一个 case 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
void FunctionWriter::createVmCallFunction(llvm::Module& M, callSignatureAllocator& calls)
{
llvm::LLVMContext& ctx = M.getContext();
std::vector<llvm::Type*> argsType = {llvm::Type::getInt16Ty(ctx), llvm::Type::getInt8PtrTy(ctx), llvm::Type::getInt64PtrTy(ctx)};
llvm::FunctionType* vmCallType = llvm::FunctionType::get(llvm::Type::getInt64Ty(ctx), argsType, false);

llvm::Function* vmCallFunction = llvm::Function::Create(vmCallType, llvm::Function::ExternalLinkage, "vmCall", M);
llvm::BasicBlock* switchBlock = llvm::BasicBlock::Create(ctx, "vmCallSwitchBlock", vmCallFunction);

llvm::IRBuilder<> builder(switchBlock);
llvm::AllocaInst* resultAlloca = builder.CreateAlloca(builder.getInt64Ty(), nullptr, "resultAlloca");
builder.CreateStore(builder.getInt64(0), resultAlloca);

llvm::BasicBlock* retBlock = llvm::BasicBlock::Create(ctx, "vmCallRetBlock", vmCallFunction);
llvm::IRBuilder<> retBuilder(retBlock);
llvm::Value* returnVal = retBuilder.CreateLoad(retBuilder.getInt64Ty(), resultAlloca);
retBuilder.CreateRet(returnVal);



llvm::Value* callSignatureId = vmCallFunction->getArg(0);
llvm::Value* callFunctionPtr = vmCallFunction->getArg(1);
llvm::Value* callArgsPtr = vmCallFunction->getArg(2);
const std::vector<std::vector<TYPE_ID>>& callSignatureArray = calls.getsignatureArray();
llvm::SwitchInst* switchInst = builder.CreateSwitch(callSignatureId, retBlock, calls.getCallSignatureCount());


for(auto callSignature: callSignatureArray) {
uint16_t signatureID = calls.getOrCreateCallSignatureID(callSignature);
llvm::BasicBlock* callBlock = llvm::BasicBlock::Create(ctx, "callBlock_", vmCallFunction);
switchInst->addCase(builder.getInt16(signatureID), callBlock);
llvm::IRBuilder<> callBD(callBlock);

llvm::Type* returnType = TypeConverter::toLLVM_Type(callSignature[0], ctx);
std::vector<llvm::Type*> callArgsType;
std::vector<llvm::Value*> argsArray;
for(int i = 1; i < callSignature.size(); i++) {
llvm::Type* argType = TypeConverter::toLLVM_Type(callSignature[i], ctx);
callArgsType.push_back(argType);
llvm::Value* idxVal = callBD.getInt32(i - 1);
llvm::Value* argPtr = callBD.CreateGEP(callBD.getInt64Ty(), callArgsPtr, idxVal);
llvm::Value* argValue = callBD.CreateLoad(callBD.getInt64Ty(), argPtr);
llvm::Value* realArgValue = argValue;

if (argType->isIntegerTy() && argType->getIntegerBitWidth() < 64) {
// I64 截断为 I32/I16/I8 用 Trunc
realArgValue = callBD.CreateTrunc(argValue, argType);
} else if (argType->isPointerTy()) {
// I64 转指针用 IntToPtr
realArgValue = callBD.CreateIntToPtr(argValue, argType);
} else if (argType->isFloatTy() || argType->isDoubleTy()) {
// 如果你有浮点数,需要用 BitCast (假设 64位 double)
realArgValue = callBD.CreateBitCast(argValue, argType);
}

argsArray.push_back(realArgValue);
}
llvm::FunctionType* callFunctionType = llvm::FunctionType::get(returnType, callArgsType, false);
llvm::PointerType* funcPtrType = llvm::PointerType::getUnqual(callFunctionType);
llvm::Value* castedFuncPtr = callBD.CreateBitCast(callFunctionPtr, funcPtrType);

llvm::CallInst* callResult = callBD.CreateCall(callFunctionType, castedFuncPtr, argsArray);
if (!returnType->isVoidTy()) {
llvm::Value* ret64 = callResult;
if (returnType->isIntegerTy() && returnType->getIntegerBitWidth() < 64) {
// 将小整数零扩展回 64 位
ret64 = callBD.CreateZExt(callResult, callBD.getInt64Ty());
} else if (returnType->isPointerTy()) {
ret64 = callBD.CreatePtrToInt(callResult, callBD.getInt64Ty());
}
callBD.CreateStore(ret64, resultAlloca);
}

// d. 块结尾必须要有无条件跳转,跳回结束块
callBD.CreateBr(retBlock);
}
}

当 VMP 编译出的程序真正运行时,VM 内部处理 OP_CALL 的逻辑变得异常清爽。VM 只需要将零散的 Slot 收集成数组,直接丢给编译期生成的 vmCall 即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
case OP_CALL: {
VM_SLOT callee = read_u16(&vm);
uint16_t calleeId = read_u16(&vm);
uint8_t slotsSize = read_u8(&vm);
uint64_t* slotsPtr = (uint64_t*)malloc(sizeof(uint64_t) * slotsSize);
for(int i = 0; i < slotsSize; i++) {
slotsPtr[i] = vm.slots[read_u16(&vm)];
}
uint64_t result = vmCall(calleeId, (void*)vm.slots[callee], slotsPtr);

uint8_t isVoid = read_u8(&vm);
if(isVoid == 0) {
VM_SLOT retSlot = read_u16(&vm);
vm.slots[retSlot] = result;
}
free(slotsPtr);
break;
}

执行的最终效果,可以看到,通过vmCall成功调用了add函数,并且完全遵守ABI

image-20260502102319005

思考

经过此次的实践,我一直在思考一个问题,为什么我用C语言想要实现上面这样一个“把一个参数数组动态传递给函数”的功能会如此困难,如果是别的语言,到底可以如何实现。

弱类型/动态语言的降维打击

在询问AI后,我得知了在 JS 这种运行在庞大虚拟机(V8)上的弱类型动态语言中,类型是在运行时绑定的。不需要手动处理参数解包,也不用管寄存器,一切都可以用“展开语法”瞬间完,开发者只需写出如下代码即可实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// VM 内部状态:参数被打包成了泛型数组(相当于你的 uint64_t slots)
let vmSlots = [10, 20];

// 模拟 VM 的分发指令执行
function executeOpCall(calleeFunc, slotsArgs) {
// 语言引擎本身就支持不定参数和动态类型,直接展开调用
let result = calleeFunc(...slotsArgs);

// 如果不用 ES6 语法,也可以用经典方式:
// let result = calleeFunc.apply(null, slotsArgs);

return result;
}

function add(a, b) { return a + b; }
let ret = executeOpCall(add, vmSlots);
带元数据的强类型/静态语言:运行时反射

如果在其他严格区分类型的高级语言(如 Java, C#, Go)中,不使用 LLVM 动态生成代码,它们是如何实现的呢?反射(Reflection)。

C# / Java (拥有运行时反射的强类型语言),这类语言虽然是强类型的,但它们在运行时保留了完整的元数据(Metadata)。你可以直接利用语言自带的反射机制动态调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using System;
using System.Reflection;

public class VM {
// 假设这是我们的原生函数
public static int Add(int a, int b) { return a + b; }

// VM 指令处理
public static object ExecuteOpCall(MethodInfo callee, object[] vmSlots) {
// MethodInfo 包含了函数的完整签名。
// Invoke 方法会在底层动态地将 object[] 拆箱,检查类型,然后放进寄存器执行。
object result = callee.Invoke(null, vmSlots);
return result;
}

public static void Main() {
object[] slots = new object[] { 10, 20 }; // 等价于你的 Slot 数组
MethodInfo method = typeof(VM).GetMethod("Add");

object ret = ExecuteOpCall(method, slots);
}
}

Go 是编译为机器码的,它没有庞大的虚拟机,但保留了反射能力。处理逻辑与 C# 类似,但类型要求更加严格。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import "reflect"

// 模拟 VM 的 call 分发
func vmCall(callee any, slotsArgs []any) any {
f := reflect.ValueOf(callee) // 获取函数的反射对象

// Go 需要严格构造反射类型的参数切片
in := make([]reflect.Value, len(slotsArgs))
for i, arg := range slotsArgs {
in[i] = reflect.ValueOf(arg)
}

// 动态调用并获取结果
out := f.Call(in)

if len(out) > 0 {
return out[0].Interface() // 把返回值重新打包成泛型 any
}
return nil
}

func add(a int, b int) int { return a + b }

func main() {
slots := []any{10, 20} // 等价于你的 uint64_t 数组
result := vmCall(add, slots)
}
动态语言与静态语言

这里又涉及到了一个新概念,比如对上面的add函数而言,在编译为二进制文件后,程序在运行时,只通过Program Headers映射到内存,对它而言,它不知道运行时”add”这个字符串对应哪个函数,更不知道它的参数类型,返回值类型等。

但对于强类型且带有反射的语言(如 C#, Java, Go),编译器最终还会额外生成一份名为元数据(Metadata)的资源。

Metadata里清楚地记录着程序里有哪些类,类的名字,类有哪些方法,方法名,参数类型等

因此完全可以写出如下的代码,这就是运行时通过Metadata解析到的信息

1
2
3
4
5
6
7
8
9
10
// 1. 通过字符串名字,动态找到这个函数
MethodInfo method = typeof(MyClass).GetMethod("add");

// 2. 询问这个函数:“你需要什么参数?”
ParameterInfo[] params = method.GetParameters();
Console.WriteLine(params[0].ParameterType); // 运行时能打印出:System.Int32

// 3. 动态调用它(这就是你 VM 里需要的逻辑)
object[] args = new object[] { 10, 20 };
method.Invoke(null, args);

对于js来说,它同样具有这样的能力,但这就要说到动态语言与静态语言的区别

在像C/C++/Java的静态语言里,一个重要特性就是编译期与运行期的隔离。因此在运行时想要获取编译时的元数据,就要通过反射(Reflection)。

但对于像js这样的动态语言,它是JIT编译的动态脚本语言,它本身就没有什么编译与运行的隔离,概念上这更偏向于内省(Introspection)

比如在 C# 中,需要用 typeof(MyClass).GetMethod(“add”) 这种极其繁琐的反射 API 去找方法。
在 JS 中,对象本质上就是一个字典(Key-Value Map)。

1
2
3
4
5
let obj = { add: function(a, b) { return a + b; } };

// 这两种写法在 JS 引擎看来是完全等价的
obj.add(1, 2);
obj["add"](1, 2); // 这不是反射,这只是普通的通过字符串 Key 查字典

同时,函数本身就是一个普通对象,apply 和 call 只是自带的方法,而非什么沉重的反射 API。

总结

回顾我的 VMP 开发历程,之所以在处理 Native Call 时感到极度痛苦,根本原因在于 C/C++ 为了极致的性能和底层控制,剥离了任何动态和内省能力。

在这个密不透风的纯静态环境里,无法像 JS 那样直接展开参数,也无法像 C# 那样通过 Metadata 动态拆箱。因此,我只能且必须利用 LLVM Pass,在编译期提前洞悉所有的类型信息,并把所有需要的状态转移路线硬编码成一个巨大的 vmCall 分发器,本质上还是编译时动态而运行时静态的方案。

函数入口替换

接下来就是整个保护流程的最后一步了,将原函数内容全部删除,修改为VM入口

其实流程就是生成bytecode全局变量,通过F.deleteBody()删除原函数,然后再原参数列表添加字节码的地址,再进行vmEntry的调用

比如int add(int a, int b),最后函数体即为vmEntry(&addBytecode, a, b);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
void FunctionWriter::rewriteToVMStub(llvm::Function& F, const std::vector<uint8_t>& bytecode)
{
llvm::LLVMContext& ctx = F.getContext();
llvm::Module* M = F.getParent();

llvm::Constant* initData = llvm::ConstantDataArray::get(ctx, bytecode);
llvm::GlobalVariable* globalBytecode = new llvm::GlobalVariable(
*M,
initData->getType(),
true,
llvm::GlobalVariable::InternalLinkage,
initData,
F.getName() + "_bytecode"
);

F.deleteBody();
llvm::BasicBlock* vmEntryBB = llvm::BasicBlock::Create(ctx, "vmEntryBB", &F);
llvm::IRBuilder <>builder(vmEntryBB);

std::vector<llvm::Type*> argsTypes;
argsTypes.push_back(builder.getInt8PtrTy());
int argSize = F.arg_size();
for(int i = 0; i < argSize; i++) {
argsTypes.push_back(F.getArg(i)->getType());
}

/*设置函数签名,并获取函数对象*/
llvm::FunctionType *vmEntryFuncType = llvm::FunctionType::get(F.getReturnType(), argsTypes, false);
llvm::FunctionCallee vmEntryFunc = M->getOrInsertFunction("vmEntry", vmEntryFuncType);


std::vector<llvm::Value*> argsArray;
llvm::Value *bytecodePtr = builder.CreateBitCast(globalBytecode, llvm::Type::getInt8PtrTy(ctx));
argsArray.push_back(bytecodePtr);
for(int i = 0; i < argSize; i++) {
argsArray.push_back(F.getArg(i));
}
llvm::Value* retVal = builder.CreateCall(vmEntryFunc, argsArray);

if(F.getReturnType() == builder.getVoidTy()) {
builder.CreateRetVoid();
} else {
builder.CreateRet(retVal);
}
}

GEP指令处理

gep指令,也就是GetElementPtrInst,可以说是我到最后也没有搞太明白的一个地方

这里就简单列出两种我在实际生成IR后看见的GEP指令形态,就知道为什么它和别的指令处理与众不同了

1
2
3
4
5
// 形态 A:带有连续多个索引,比如开头多了一个 0
%24 = getelementptr inbounds [256 x i8], [256 x i8]* %2, i64 0, i64 %23

// 形态 B:只有一个简单的索引
%15 = getelementptr inbounds i32, i32* %12, i64 %14

为什么形态 A 必须要跟着一个 i64 0?深入查阅资料后我才明白,这是 LLVM 类型系统的“剥洋葱”机制。形态 A 中的 %2 是一个指向数组的指针([256 x i8]*),第一个 0 的作用是在指针本身的跨度上偏移 0,实际上是为了“解引用”进入数组内部;而第二个 %23 才是真正在数组元素之间的偏移。

那如果形态A没有第一个i64 0,会怎么样呢,那就是指向跳到 %23 个[256 x i8] 数组对象的地址

理解起来非常抽象,看看VM对应handle的实现

1
2
3
4
5
6
7
8
9
10
11
12
static inline void vmGepStrict(VMState* vm, VM_SLOT dst, VM_SLOT baseSlot, uint8_t numIndices) {
uint64_t currentAddr = vm->slots[baseSlot];

for (uint8_t i = 0; i < numIndices; i++) {
VM_SLOT idxSlot = read_u16(vm);
uint32_t stride = read_u32(vm); // 该层级对应的类型大小 (sizeof(Ty))

int64_t idxVal = (int64_t)vm->slots[idxSlot];
currentAddr += (idxVal * stride);
}
vm->slots[dst] = currentAddr;
}

bytecode要如何计算这个步长呢,可以看到需要通过DataLayout去进行拆解,整个过程还是比较复杂的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
bool Lowerer::lowerGEP(llvm::GetElementPtrInst& I) {
VM_SLOT dstSlot = getOrCreateSlot(&I);
VM_SLOT baseSlot = getOrCreateSlot(I.getPointerOperand());

std::vector<uint16_t> indexSlots;
for (unsigned i = 1; i < I.getNumOperands(); ++i) {
indexSlots.push_back(getOrCreateSlot(I.getOperand(i)));
}
writer.emitU8(OP_GEP);
writer.emitU16(dstSlot);
writer.emitU16(baseSlot);
writer.emitU8(static_cast<uint8_t>(indexSlots.size()));

const llvm::DataLayout &DL = I.getModule()->getDataLayout();
auto it = llvm::gep_type_begin(I);
for (unsigned i = 0; i < indexSlots.size(); ++i, ++it) {
uint32_t stride = 0;
if (auto* structTy = it.getStructTypeOrNull()) {
uint64_t fieldIdx = llvm::cast<llvm::ConstantInt>(I.getOperand(i + 1))->getZExtValue();
stride = static_cast<uint32_t>(DL.getStructLayout(structTy)->getElementOffset(fieldIdx));
} else {
stride = static_cast<uint32_t>(DL.getTypeAllocSize(it.getIndexedType()));
}

writer.emitU16(indexSlots[i]);
writer.emitU32(stride);
}
return true;
}

写在最后

还有一些细枝末节的问题就不在这里过多讨论了,比如像类型转换,位拓展这些。总而言之,整个项目写下来,真的十分耗费精力,特别是处理函数调用和GEP指令的时候,因为也不熟悉更高级的LLVM语法,和对编译时运行时这些概念模糊不清,几经想放弃,好在在AI的帮助下,最终也是顺利完成了工作。

在看到复杂的AES的能通过一个编译选项,直接由我自己的vmp框架保护时,成就感也是满满。

  • 标题: 从0到1的llvm学习 (5) 简易VMP开发
  • 作者: 两只羊
  • 创建于 : 2026-04-30 09:43:10
  • 更新于 : 2026-05-02 15:36:05
  • 链接: https://twogoat.github.io/2026/04/30/从0到1的llvm学习-4-简易VMP开发/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论