xVMP实现方案介绍

370 阅读19分钟

基础概念

LLVM数据结构

在LLVM中,基本数据结构包括:Module,Function,BasicBlock,Instruction之间的关系可以用以下的层次结构来描述:

Module
└── Function
    └── BasicBlock
        └── Instruction

以下是它们之间的关系的详细说明:

  • Module:是LLVM IR的最顶层结构,它包含了整个程序的所有函数、全局变量和其他模块级别的定义。一个模块可以有多个函数。
  • Function:是模块中的一个成员,它定义了一个可以调用的代码块。一个函数属于一个模块,并且包含多个基本块(BasicBlock)。函数还包括了参数列表和返回类型。
  • BasicBlock:是函数中的一个组件,它代表了一个连续的指令序列,这些指令在执行时不会被其他指令中断。每个基本块以一个标签开始,并且以一个控制流指令(例如分支、跳转或返回)结束。一个函数可以有多个基本块,这些基本块按照控制流指令的指向相互连接。
  • Instruction:是基本块中的元素,它代表了一个具体的操作或计算。每个指令都属于一个基本块,并且可以有零个或多个操作数(这些操作数可以是常数、其他指令的输出或者函数的参数)。指令的执行顺序由它们在基本块中的位置决定。

在这个层次结构中,模块包含了函数,函数包含了基本块,而基本块包含了指令。这种结构允许LLVM IR以树状形式表示程序的逻辑结构,同时也方便了编译器在各个层次上对代码进行优化和分析。

常量表达式

Constant Expressions,是一种特殊的表达式,它在编译时就可以确定其值,并且永远不会改变。这些表达式通常用于表示程序中的常量值,例如字面量、数组初始化器、结构体初始化器等。

; 整数字面量
%const_int = constant i32 42
; 浮点字面量
%const_float = constant float 3.14159
; 字符串字面量(以数组形式)
%const_string = constant [13 x i8] c"Hello, World!"
; 结构体初始化器
%const_struct = constant { i32, float } { i32 42, float 3.14159 }
; 数组初始化器
%const_array = constant [2 x i32] [i32 1, i32 2]
; 常量表达式计算(例如,加法)
%const_add = add i32 %const_int, 1

常用指令类型

1.算数运算

算数运算指令,包括add, sub, mul, sdiv, udiv, fadd, fsub, fmul, fdiv等。用于对两个数值进行算数运算,包括整型/浮点数的加减乘除。在LLVM中,该类指令的语法如下所示:

%result = opcode <type> <value1>, <value2>

其中,opcode表示运算符,包括add,sub,mul等。<type>表示要进行加法运算的值的数据类型,可以是整数、浮点数等;<value1><value2>分别表示相加的两个数,可以是常量、寄存器或者其他指令的结果。

在LLVM中,add指令的<type>参数指定了<value1><value2>的类型,同时也指定了<result>的类型。支持的类型包括:

  • 整数类型:i1, i8, i16, i32, i64, i128等;
  • 浮点类型:half, float, double, fp128等;
  • 向量类型:<n x i8>, <n x i16>, <n x i32>等;
  • 指针类型:i8*, i32*, float*等;
  • 标签类型:metadata

例如,如果我们想将两个整数相加并得到一个整数结果,可以使用以下指令:

%x = add i32 2, 3
%z = mul i32 %x, %y

这个指令将常量23相加,结果保存到寄存器%x中。随后将寄存器%x%y中的值相乘,结果保存到寄存器%z中。

2.位运算指令

位运算指令,包括and, or, xor, shl, lshr, ashr等。IR有多种位运算指令,包括位与(and)、位或(or)、位异或(xor)、位取反(not)等。这些指令可以对整数类型进行按位操作,并将结果存储到一个新的寄存器中。以下是 IR 中常见的位运算指令及其作用:

  • 位与(and):将两个整数的二进制表示进行按位与操作。
  • 位或(or):将两个整数的二进制表示进行按位或操作。
  • 位异或(xor):将两个整数的二进制表示进行按位异或操作。
  • 位取反(not):将一个整数的二进制表示进行按位取反操作。

在LLVM中,该类指令与算数指令的语法类似,如下所示:

%result = opcode <type> <value1>, <value2>

其中<type>表示要进行位运算的整数的数据类型,可以是i1、i8、i16、i32、i64等;<value1><value2>分别表示要进行位运算的整数,可以是常量、寄存器或其他指令的结果。例如:

%result = and i32 %x, %y
%result = or i32 %x, %y
%result = xor i32 %x, %y
%result = xor i32 %x, -1

第一个指令将%x%y进行按位与操作,并将结果保存到%result中;第二个指令将%x%y进行按位或操作,并将结果保存到%result中;第三个指令将%x%y进行按位异或操作,并将结果保存到%result中;最后一个指令将%x和二进制全为1的数进行按位异或操作,即将%x的每一位取反,结果同样保存到%result中。

3.转换指令

该类型指令用于将一种数据类型转换成另一种数据类型,包括如下:

  • trunc: 将一个整数或浮点数截断为比原来小的位数,即去掉高位的一些二进制位。
  • zext: 将一个整数或布尔值的位数增加,新位数的高位都填充为零,即进行零扩展。
  • sext: 将一个整数的位数增加,新位数的高位都填充为原有的最高位,即进行符号扩展。
  • fptrunc: 将一个浮点数截断为比原来小的位数,即去掉高位的一些二进制位。这是一种舍入操作,可能会丢失一些精度。
  • fpext: 将一个浮点数的位数增加,新位数的高位都填充为零,即进行浮点零扩展。
  • fptoui: 将一个浮点数转换为一个无符号整数。如果浮点数是负数,则结果为零。
  • fptosi: 将一个浮点数转换为一个带符号整数。如果浮点数是负数,则结果为负的最小整数。
  • uitofp: 将一个无符号整数转换为一个浮点数。
  • sitofp: 将一个带符号整数转换为一个浮点数。
  • ptrtoint: 将一个指针类型转换为一个整数类型。该指令通常用于将指针转换为整数进行计算。
  • inttoptr: 将一个整数类型转换为一个指针类型。该指令通常用于将整数转换为指针进行内存地址计算。
  • bitcast: 将一个值从一种类型转换为另一种类型,但是这些类型必须具有相同的位数。这个指令可以用来实现底层内存操作,例如将浮点数转换为整数以进行位运算。

该类指令的语法如下所示:

%result = zext <source type> <value> to <destination type>

其中,<source type><destination type>分别表示源类型和目标类型,表示要转换的值。例如,下面的代码将一个64位整数截断为32位整数:

%a = add i64 1, 2
%b = trunc i64 %a to i32

在这个例子中,%a是一个64位整数,它的值是3(1+2)%b是一个32位整数,它的值是3。由于%a被截断为32位整数,因此只有低32位的值保留下来。

4.内存指令

内存指令,包括:alloca、load、store,getelementptr等,这些指令可以用于内存分配、初始化和复制操作。

  • alloca指令,用于在栈上分配内存,并返回一个指向新分配的内存的指针。alloca指令的使用格式如下:
%ptr = alloca <type>

其中,<type>是要分配的内存块的类型。例如,下面的代码分配一个包含5个整数的数组:

%array = alloca [5 x i32]
  • load指令,用于从内存中读取数据,并将其加载到寄存器中。load指令的使用格式如下:
%val = load <type>* <ptr>

其中,<type>是要读取的数据的类型,<ptr>是指向要读取数据的内存块的指针。例如,下面的代码将一个整数数组的第一个元素加载到寄存器中:

%array = alloca [5 x i32]
%ptr = getelementptr [5 x i32], [5 x i32]* %array, i32 0, i32 0
%val = load i32, i32* %ptr

在这个例子中,%array是一个整数数组,%ptr是指向数组第一个元素的指针,load指令将%ptr指向的内存块中的数据加载到%val寄存器中。

  • store指令,用于将数据从寄存器中写入内存。store指令的使用格式如下:
store <type> <val>, <type>* <ptr>

其中,<type>是要写入的数据的类型,<val>是要写入的数据的值,<ptr>是指向要写入数据的内存块的指针。例如,下面的代码将一个整数存储到一个整数数组的第一个元素中:

%array = alloca [5 x i32]
%ptr = getelementptr [5 x i32], [5 x i32]* %array, i32 0, i32 0
store i32 42, i32* %ptr

在这个例子中,%array是一个整数数组,%ptr是指向数组第一个元素的指针,store指令将整数值42存储到%ptr指向的内存块中。

  • getelementptr指令,用于计算指针的偏移量,以便访问内存中的数据。getelementptr指令的使用格式如下:
%ptr = getelementptr <type>, <type>* <ptr>, <index type> <idx>, ...

其中,<type>是指针指向的数据类型,<ptr>是指向数据的指针,<index type>是索引的类型,<idx>是索引的值。

getelementptr指令可以接受多个索引,每个索引都可以是任意类型的。索引类型必须是整数类型,用于计算偏移量。例如,下面的代码计算一个二维数组中的一个元素的指针:

%array = alloca [3 x [4 x i32]]
%ptr = getelementptr [3 x [4 x i32]], [3 x [4 x i32]]* %array, i32 1, i32 2

在这个例子中,%array是一个二维数组,%ptr是指向第二行第三列元素的指针。

5.比较指令

比较指令,包括:icmp、fcmp等,这两个指令根据比较规则,比较两个操作数的关系。

  • icmp指令的操作数类型是整型或整型向量、指针或指针向量。对于指针或指针向量,在做比较运算的时候,都会将其指向的地址值作为整型数值去比较,所以归根结底也还是整型。
  • fcmp指令要求操作数是浮点值或者浮点向量,这个没有指针类型。

该类指令语法格式如下:

<result> = icmp <cond> <ty> <op1>, <op2>   ; yields i1 or <N x i1>:result
<result> = fcmp [fast-math flags]* <cond> <ty> <op1>, <op2>     ; yields i1 or <N x i1>:result
  • <op1>, <op2>这两个是操作数。

  • <cond>这是比较规则,icmpfcmp指令的比较规则不一样,包括:eq,ne,ult,sgt等。

相关例子如下:

icmp:
<result> = icmp eq i32 4, 5          ; yields: result=false
<result> = icmp ne float* %X, %X     ; yields: result=false
<result> = icmp ult i16  4, 5        ; yields: result=true
<result> = icmp sgt i16  4, 5        ; yields: result=false
<result> = icmp ule i16 -4, 5        ; yields: result=false
<result> = icmp sge i16  4, 5        ; yields: result=false
fcmp:
<result> = fcmp oeq float 4.0, 5.0    ; yields: result=false
<result> = fcmp one float 4.0, 5.0    ; yields: result=true
<result> = fcmp olt float 4.0, 5.0    ; yields: result=true
<result> = fcmp ueq double 1.0, 2.0   ; yields: result=false

6.条件跳转指令

条件跳转指令,br指令用于执行条件分支,根据条件跳转到不同的基本块。它的语法如下:

br i1 <cond>, label <iftrue>, label <iffalse>

其中<cond>是条件值,如果其值为真,则跳转到标记为<iftrue>的基本块;否则跳转到标记为<iffalse>的基本块。下面是一个简单的示例:

define i32 @test(i32 %a, i32 %b) {
  %cmp = icmp eq i32 %a, %b
  br i1 %cmp, label %equal, label %notequal
equal:
  ret i32 1

notequal:
  ret i32 0
}

在这个示例中,我们定义了一个函数test,它接受两个整数参数%a%b。首先,我们使用icmp指令比较这两个值是否相等,并将结果保存在%cmp中。然后,我们使用br指令根据%cmp的值跳转到不同的基本块,如果它们相等,则返回1;否则返回0

7.函数返回指令

函数返回指令,ret用于从函数中返回一个值。它的语法如下:

ret <type> <value>

其中,<type>是返回值的类型,<value>是返回的值。如果函数没有返回值,则<type>应该是void。下面是一个示例:

define i32 @test(i32 %a, i32 %b) {
  %sum = add i32 %a, %b
  ret i32 %sum
}

在这个示例中,我们定义了一个函数test,它接受两个整数参数%a%b。首先,我们使用add指令将它们相加并将结果保存在%sum中。然后,我们使用ret指令返回%sum的值。

8.函数调用指令

函数调用指令,call用于调用函数。它的语法如下:

%result = call <type> <function>(<argument list>)

其中,<type>是函数返回值的类型,<function>是要调用的函数的名称,<argument list>是函数参数的列表。下面是一个示例:

declare i32 @printf(i8*, ...)
define i32 @test(i32 %a, i32 %b) {
  %sum = add i32 %a, %b
  %format_str = getelementptr inbounds [4 x i8], [4 x i8]* @.str, i64 0, i64 0
  call i32 (i8*, ...) @printf(i8* %format_str, i32 %sum)
  ret i32 %sum
}

在这个示例中,我们首先使用add指令计算a+b的值,然后使用getelementptr指令获取全局字符串@.str的指针,该字符串包含格式化字符串%d\n。最后,我们使用call指令调用函数printf,将%format_str%sum作为参数传递给它。

备注

详情见官方文档,链接

xVMP介绍

开源代码仓库:github.sheincorp.cn/GANGE666/xV…

函数虚拟化

xVMP对某个特定函数进行指令虚拟化时,会将函数指令全部抽除掉,生成对应虚拟化后的指令集,存放到变量gv_code_seg_$funcname中。如果当前函数有返回值,则将其存放到gv_code_seg_$funcname数组的第一个元素中,从第二个元素开始用于存放函数输入参数;如果当前函数没有返回值,则将输入参数从数组第一个元素开始存放。虚拟化前后函数实例如下:

  • 原函数
__attribute__((annotate(("xvmp, nosplit"))))
__attribute__((noinline))
long f1(long a, long b, long c) {
  long d = 100;
  if (a + b > 10){
        c = c + d;
  }else{
        c = d - c;
  }
  a = c;
  return a;
}
  • 虚拟化后函数
__int64 __fastcall f1(__int64 a1, __int64 a2, __int64 a3)
{
  data_seg_addr_f1 = (__int64)&gv_data_seg_f1;
  data_seg_addr_f1[1] = a1;
  data_seg_addr_f1[2] = a2;
  data_seg_addr_f1[3] = a3;
  code_seg_addr_f1 = (__int64)&gv_code_seg_f1;
  vm_interpreter_f1();
  return data_seg_addr_f1[0];
}

数据结构图

xVMP解析器输入参数主要有两个,分别为:虚拟后代码指令集gv_code_seg,数据段gv_data_seg

  • gv_code_seg_xx,存放函数虚拟化后的指令集,并且对所有指令进行加密,每个代码块使用独立的密钥。

flowchart.png

  • gv_data_seg,存放函数执行过程中所需用到的各种变量,包括函数返回值,函数输入参数,临时变量,全局变量等。

flowchart.png

指令虚拟化

1.  Alloca指令虚拟化

alloca指令,用于分配某种数据类型的存储空间,如下:

%ptr = alloca <type>  //例子: %3 = alloca i64, align 8  ; 分配一个int64位的数据,变量名命名为%3。

虚拟化后指令格式:

flowchart.png

  • var_size:代表当前alloca开辟的空间的指针ptr大小(小于等于8)。
  • var_type:代表当前变量数据类型。
  • var_offset:代表当前alloca开辟的空间的指针在gv_data_seg中的偏移位置。
  • area_offset:代表当前alloca开辟的空间的在gv_data_seg中的偏移位置。
  • ptr:虚拟机解析到alloca指令时,会将area_offset的值赋值到var_offset所指的gv_data_seg所在位置。
uint8_t * ptr = (uint8_t*)(data_seg_addr+var_offset);
uint64_t = data_seg_addr+area_offset;
for (int i = 0; i < var_size; i++) {
    *ptr = value & 0xFF;
    ptr ++;
    value = value >> 8;
}
  • alloac value:生成虚拟指令时,会在gv_data_seg中预留所开辟空间的大小。

2.  Load指令虚拟化

load指令,用于从内存中读取数据,并将其加载到寄存器中。load指令的使用格式如下:

%val = load <type>* <ptr> //例子:%val = load i32, i32* %ptr ;将%ptr指向的内存块中的数据加载到%val寄存器中。 

虚拟化后指令格式:

flowchart.png

  • val_size:存放被赋值变量的长度,如上图所示,代表变量在gv_data_seg中值的长度。
  • val_type:存放被赋值变量的类型(未被使用到)。
  • val_offset:存放被赋值变量在gv_data_seg中的偏移。
  • ptr_size:存放被加载变量的长度(未被使用到)。
  • ptr_size:存放被加载变量的类型(未被使用到)。
  • ptr_offset:存放被加载变量指针在gv_data_seg中的偏移。

该指令最终将ptr value赋值到load value中。

3.Store指令虚拟化

store指令,用于将数据从寄存器中写入内存。store指令的使用格式如下:

store <type> <val>, <type>* <ptr>  //例子:store i32 42, i32* %ptr ;store指令将整数值42存储到%ptr指向的内存块中。

虚拟化后指令格式:

flowchart.png

  • val_size:存放要存储值val的大小。
  • val_type:存放要存储值val的类型,0表示其是个存放于gv_data_seg中的变量,非0表示其是个常量数值,该值存放于指令中。
  • value/val_offset:如果val_type值为0,则此处存放要存储值在gv_data_seg的偏移;否则,此处存放具体的常量数值。
  • ptr_size:存放被赋值的内容大小(未被使用到)。
  • ptr_type:存放被赋值的内容类型(未被使用到)。
  • ptr_offset:存放被赋值的指针在gv_data_seg的偏移。

该指令最终将value赋值到ptr value中。

4.数值/位运算指令虚拟化

算数运算指令,包括add, sub, mul, sdiv, udiv, fadd, fsub, fmul, fdiv, and, or, xor, shl, lshr, ashr等。用于对两个数值进行算数运算,包括整型/浮点数的加减乘除,异或移位等。该类指令的语法如下所示:

%result = opcode <type> <value1>, <value2>  //例子:%z = mul i32 %x, %y

虚拟化后指令格式:

flowchart.png

  • code:表示数值运算符类型,包括:add, sub, mul, sdiv, udiv, fadd, fsub, fmul, fdiv, and, or, xor, shl, lshr, ashr
  • res:用于存放计算后结果。
  • op1_value:表示第一个操作数,如果type类型为0,则该数值存放于gv_data_seg中,通过op1_offset偏移获取到对应的值;否则,该值存放于指令段中,大小为op1_size
  • op2_value:表示第二个操作数,与op1_value一致。

5.比较运算指令虚拟化

比较指令,包括:icmpfcmp等,这两个指令根据比较规则,比较两个操作数的关系。该类指令语法格式如下:

<result> = icmp <cond> <ty> <op1>, <op2>   ; yields i1 or <N x i1>:result
<result> = fcmp [fast-math flags]* <cond> <ty> <op1>, <op2>     ; yields i1 or <N x i1>:result

//例子:
// <result> = icmp eq i32 4, 5          ; yields: result=false
//<result> = icmp ne float* %X, %X

虚拟化后指令格式:

flowchart.png

指令格式与数值/位运算指令类似。

6.getelementptr指令虚拟化

getelementptr指令,用于计算指针的偏移量,以便访问内存中的数据。getelementptr指令的使用格式如下:

%ptr = getelementptr <type>, <type>* <ptr>, <index type> <idx>, ...
//例子:%ptr = getelementptr [3 x [4 x i32]], [3 x [4 x i32]]* %array, i32 1, i32 2

虚拟化后指令格式:

flowchart.png

  • 如果gep_size !=0 && gep_type != 0,则表示为数组类型,此时:res_value = ptr_value + gep_size * idx_value
  • 否则,表示为结构体类型,此时:res_value = ptr_value + gep_size

7.Cast转换指令虚拟化

该类型指令用于将一种数据类型转换成另一种数据类型,该类指令的语法如下所示:

%result = zext <source type> <value> to <destination type>
//例子:
//%b = trunc i64 %a to i32

虚拟化后指令格式:

flowchart.png

value值赋值到result中。

8.条件跳转指令虚拟化

条件跳转指令,br指令用于执行条件分支,根据条件跳转到不同的基本块。它的语法如下:

br i1 <cond>, label <iftrue>, label <iffalse>
//例子:
//define i32 @test(i32 %a, i32 %b) {
//  %cmp = icmp eq i32 %a, %b
//  br i1 %cmp, label %equal, label %notequal
//equal:
//  ret i32 1
//notequal:
//  ret i32 0
//}

虚拟化后指令格式:

flowchart.png

  • ip:当前代码段指令集gv_code_seg执行到的指令位置,通过修改该值,可以实现指令跳转。
  • br_type:表示跳转指令类型,分为有条件跳转和无条件跳转,分别入上图所示。

9.返回指令虚拟化

函数返回指令,ret用于从函数中返回一个值。它的语法如下:

ret <type> <value>
//例子:
//define i32 @test(i32 %a, i32 %b) {
//  %sum = add i32 %a, %b
//  ret i32 %sum
//}

虚拟化后指令格式:

flowchart.png

  • 执行到ret指令后,直接return退出虚拟函数,回到原函数出口。
  • res_value:位于gv_data_seg的第一个元素,用于存放函数返回值,虚拟机中将ret指令中的value赋值到res_value中。

10.函数调用指令虚拟化

函数调用指令,call用于调用函数。它的语法如下:

%result = call <type> <function>(<argument list>)
//例子:
//declare i32 @printf(i8*, ...)
//define i32 @test(i32 %a, i32 %b) {
//  %sum = add i32 %a, %b
//  %format_str = getelementptr inbounds [4 x i8], [4 x i8]* @.str, i64 0, i64 0
//  call i32 (i8*, ...) @printf(i8* %format_str, i32 %sum)
//  ret i32 %sum
//}

xVMP会为每个函数$funcname,生成对应的用于执行call指令调用的虚拟函数"vm_interpreter_callinst_dispatch_$funcname(uint64_t targetfunc_id)",该函数有一个uint64_t类型参数targetfunc_id,用于表示$funcname中第几个call指令。

以printf函数调用为例,如下,f1函数中有两处调用到printf函数。

__attribute__((annotate(("xvmp, nosplit"))))
__attribute__((noinline))
long f1(long a, long b, long c) {
  long d = 100;
  char* format0 = "f1 d: %ld\n";
  printf(format0, d);
  if (a + b > 10){
        c = c + d;
  }else{
        c = d - c;
  }
  char* format = "f1 c: %ld\n";
  printf(format, c);
  a = c;
  return a;
}

因此对应生成后的vm_interpreter_callinst_dispatch_f1函数中会根据targetfunc_id调用到不同位置的printf,虚拟化后代码如下。

void __fastcall vm_interpreter_callinst_dispatch_f1(int64 targetfunc_id)
{
  if ( result )
  {
    if ( result == 1 )
    {
      long arg1 = data_seg_addr_f1[136];
      long arg2 = data_seg_addr_f1[144];
      result = printf(arg1, arg2);
      data_seg_addr_f1[152] = result;
    }
  }
  else
  {
    long arg1 = data_seg_addr_f1[237];
    long arg2 = data_seg_addr_f1[245];
    result = printf(arg1, arg2);
    data_seg_addr_f1[253] = result;
  }
  return;
}

虚拟化后指令格式:

flowchart.png

  • 调用vm_interpreter_callinst_dispatch_$funcname函数,并以targetfunc_id作为参数,完成函数调用。如果被调用的函数有返回值,则将其存储到ret_value中。