从零手写 ARM64 VMP

· 2026-11-09 17:59 · 2 阅读

北袅 2026-11-09 17:59 上海

看雪论坛作者ID:北袅

VMP(Virtual Machine Protection,虚拟机保护)简单说就是把.so中的关键代码转换成自定义的虚拟指令,交给程序内置的虚拟机执行,以此来增加逆向分析的难度。这里的VMP泛指代码虚拟化保护。

程序在正常情况下的执行顺序是,ARM64原生指令-> CPU直接执行。

经过虚拟化保护后,将关键代码转换成自定义字节码,再将字节码交由内置的解释器进行读取并且执行,结果保持不变。

简单来说就是将原本走直线的路径改成了多绕几圈。

实现后的解释器效果可以参考下图(图片来自某加固厂商)。

image

本文的目标就是自己手搓一个可以在安卓APP上运行的mini版本,以此来提升对虚拟化保护的理解。

被保护函数

下面这个就是将要受到保护的函数,可以很清晰的看出整体的逻辑非常的简单,对a、b两个参数进行加减乘除运算后将结果交由print_value函数打印日志。

image

运行后的结果长这样

image

虚拟化保护实现

存储位置

target函数经由编译器编译后的汇编大概长这样。

image

但是现在我想过要换一种组织方式去实现。

这样的话我需要一份数据:用来描述要执行的计算步骤。
还需要一段执行程序:程序通过读取这份数据,按照里面描述的步骤进行计算。

简单来说据就是我希望x=a+b不再只是一个固定写在target里的C++操作,而是可以通过一份数据表达出来。

假设说我要给另外一段程序发送一条计算要求,让他完成这句。

x = a + b

这条要求至少需要说明:做什么操作(加法)、第一个数从哪里取(从a取)、第二个数从哪里取(从b取)、计算结果存放在哪里(放到x)。

如果说不知道这些的话,执行者就不知道该算什么、用什么算、算完存哪里。

当然也可以直接在数据里写a、b、x。设计完后指令长这样:

ADD xab

但是需要再去做一个按名字查找的表保存值

名字       值
"a"10
"b"4
"x"0

执行器读取这条指令时:

根据 "a" 查出 10;

根据 "b" 查出 4;

相加得到 14;

把名字 "x" 对应的值更新为 14。

这条路线是成立的 。 只是需要处理字符串,以及名字对应哪个值的查找。

所以说不论是用名字还是数字,都得自己规定:执行器去哪里取这些值 。

既然已经知道执行器需要拿到a、b,还需要一个地方保存结果x。

一个很普通的办法就是数组:

int values[3] = {};

然后做一个约定

values[0]:保存 a 的值
values[1]:保存 b 的值
values[2]:保存 x 的值

再把入参放进去:

values[0] = a;
values[1] = b;

此时,例如调用target(10, 4)

数组就是:

下标       保存的值
 0            10
 1             4
 2             0

于是这里不需要再实现字符串查找,直接用数组下标就行。

虚拟寄存器

现在已经有了int values[3] 。执行器会用这些位置保存输入和中间结果。如果说规定:

R0 表示 values[0]
R1 表示 values[1]
R2 表示 values[2]

就可以把:values[2] = values[0] + values[1];

写成一种方便阅读的表示:R2 = R0 + R1

这里的虚拟寄存器并非是每一个虚拟机都是我这种的实现,也可以使用前面提到的按照名字保存值的表,操作数栈等其他存储方式。

操作数

原来由 a、b、x 承担的存储工作,现在可以由三个数组元素承担。但是它还没有实现由数据描述计算步骤。

因为这里values[2] = values[0] + values[1],仍然把加法和操作的位置都写死在了 C++ 代码里。

#include <cstdio>
int target(int a, int b) {
intvalues[3] = {};
values[0] = a;
values[1] = b;
values[2]=values[0]+values[1];
returnvalues[2];
}
int main() {
    std::printf("%d\n", target(10, 4));
return0;
}

现在就是先把values[2] = values[0] + values[1]这里的三个下标拿出来。

把他们保存成一份数据

unsigned char instruction[] = {2, 0, 1};

这里的顺序是按照下面约定排序的

第 0 项:结果位置
第 1 项:左边来源
第 2 项:右边来源

所以{2, 0, 1},意思就是:

结果放在位置 2
左边从位置 0 取值
右边从位置 1 取值

这里还要区分两个数组:

数组

保存什么

values

运算中的实际数值,例如 10、4、14

instruction

如何选择这些位置的说明,例如 2、0、1

代码长这样

int target(int a, int b) {
intvalues[3] = {};
values[0] = a;
values[1] = b;
// [结果位置, 左边来源, 右边来源]
    unsigned char instruction[] = {2, 0, 1};
int dst = instruction[0];
int lhs = instruction[1];
int rhs = instruction[2];
values[dst] = values[lhs] + values[rhs];
returnvalues[2];
}

现在实现的是由数据决定操作数来自哪里,以及结果写到哪里。还是没有做到由数据决定做说明运算,因为

values[dst] = values[lhs] + values[rhs];

这里的 + 任然写死了。如果说下一步要减法,不可能再去写一个只会减法的执行器,这里需要在说明数据里面再去放一个字段,交代清楚执行器这次执行到底是加还是减。

操作码

还是最朴素的方式,约定

1 表示加法
2 表示减法

这两个数字没有特殊含义。

不是 CPU 规定 1 必须是加法,也不是 C++ 规定的。换成:

17 表示加法
29 表示减法

同样可以。只要写数据的人和读取数据的执行器遵守同一份约定就行。

给原来的格式增加一个字段

原来是这样的约定:

[结果位置, 左边来源, 右边来源]

现在新增字段后站这样:

[操作码, 结果位置, 左边来源, 右边来源]

于是unsigned char instruction[] = {1, 2, 0, 1}; 就表示values[2] = values[0] + values[1];

而 unsigned char instruction[] = {2, 2, 0, 1}; 就表示values[2] = values[0] - values[1];

现在有了操作码,就可以写成这样了

int op  = instruction[0];
int dst = instruction[1];
int lhs = instruction[2];
int rhs = instruction[3];

但是还需要去解决怎么根据不同的操作码做不同的事情。

现在操作码是int op = instruction[0];那么只需要最普通的if判断就能实现

if (op == 1) {
values[dst] = values[lhs] + values[rhs];
} elseif (op == 2) {
values[dst] = values[lhs] - values[rhs];
}

但是用1、2这种名称来记录对应关系不太行,可以起个别名。

enumOpcode : unsignedchar {
    ADD = 1,
    SUB = 2
};

现在就可以写成这样了

unsigned char instruction[] = {ADD, 2, 0, 1};

image

在就拥有一条完整的、能够被执行器理解的指令(操作码,结果位置,左边来源,右边来源)

但是目前还是只能执行一条,在原函数中 需要先加、再减、再乘、再除。这就产生了一个新的问题,如果把多条指令接在统一数组里面,执行器怎么知道当前读到哪条了,以及下一条从哪里开始?

PC

在前面我们只能描述int x = a + b;,现在可以新增一点,操作码在前面以及定义好了

int x = a + b;
x = x - b;

第一步表示为

values[2] = values[0] + values[1];
ADD, 2, 0, 1

第二步表示为

values[2] = values[2] - values[1];
SUB, 2, 2, 1

接下来,一个直接的想法就是:把两条指令依次放进同一个数组。

unsigned char code[] = {
ADD, 2, 0, 1,
SUB, 2, 2, 1
};

这里从instruction改名为code,是因为他现在保存了一组指令,不再只是一条。

程序运行的时候知道前四个元素是一条指令,是因为我们做了每条指令固定占据四个元素。

这里的每个元素的类型是unsigned char,占一个C++字节,因此也可以说每条指令占据了四个字节。

这里先不用循环,用笨办法写一下看看。

目前执行一条指令时,我们这样读取:

int op  = code[0];
int dst = code[1];
int lhs = code[2];
int rhs = code[3];

如果要读第二条,很容易想到:

int op  = code[4];
int dst = code[5];
int lhs = code[6];
int rhs = code[7];

这里就有规律了,第一条的起点是0,第二条的起点是4,如果有第三条那么他的起点就是8,以此类推。

因为每条指令占据四个字节,所以每执行完一条,下一条的起点就向后移动四个字节。

我们不需要为每一条指令复制一套读取代码,只需要让读取起点发生变化。

比如说把当前读取起点保存成一个变量

std::size_t offset = 0;

offset就是偏移量。在这里,他表示当前这条指令的开头,距离code数组开头有多少个字节。

现在读取指令可以改成

int op  = code[offset];
int dst = code[offset + 1];
int lhs = code[offset + 2];
int rhs = code[offset + 3];

当offset = 0;时,读的是

code[0]、code[1]、code[2]、code[3]

当offset = 4;时,读的是

code[4]、code[5]、code[6]、code[7]

读取代码没有变,但它能通过起点变化读取不同的指令。

所以我们可以把它改名为:

std::size_t pc = 0;

此pc非彼pc,在当前设计中,它只是字节码数组中的一个偏移量。之所以叫虚拟PC是因为在当前这套规则当中,他是负责跟踪虚拟指令的位置。

所以现在的执行顺序变成了

从PC指向的位置读取一条指令。
执行这条指令
PC+4,指向下一条。

对应

int op  = code[pc];
int dst = code[pc + 1];
int lhs = code[pc + 2];
int rhs = code[pc + 3];
// 执行……
pc += 4;

因此

开始时 pc =0
执行第一条
pc 变成 4
执行第二条
pc 变成 8

可以通过std::size_t code_size = sizeof(code);得到整个数组的字节数,也就是8。来做终止条件

while (pc < code_size) {
// 读取
// 执行
// pc += 4
}

只要没有走到数组末尾,就继续处理下一条指令。

image

运行结果:

新指令

原函数前两步是:

int x = a + b;
x = x - 2;

第一步目前已经会表达,但是第二步是一个常量,根据前面定义的规则,没办法对这个常量进行计算。

要解决也很简单,为这个常量准备一个存储位置,然后从这个存储位置取值,就可以参与运算了。

最简单的办法就是直接values[3] = 2,然后

values[2] = values[2] - values[3];
就能完成
x= x - 2;

但是我们之前的目标是尽量让具体计算步骤由指令数据描述。那么往一个位置放入常量也可以由指令描述。

这里增加一种操作:

把指令中直接给出的数值,写入指定位置。

例如:把数值2写入位置3。

可以为这个指令起名叫做LOADI。数值直接写在指令里面,不需要根据位置编号再去别的地方读取。

新指令可以规定为

[LOADI, 目标位置, 直接给出的数值, 暂时不用]

例如

LOADI, 3, 2, 0

表示

values[3] =2;

因为给LOADI的第三个字段规定的含义是直接数值,而不是来源位置。

数据里的一个字节到底表示位置还是数值,是由指令格式决定的,不是数字自己决定的。

最后一个放0是因为每条指令固定占据四个字节,而LOADI只需要三个字段,那么就需要多放一个作为填充。

在之前这里只有三个有效位置,现在想要使用位置3,就必须新增一个元素,最开始的约定也需要改变。

int values[4] = {};
位置 0:保存 a
位置 1:保存 b
位置 2:保存中间结果 x
位置 3:暂时保存计算需要的常量

需要先给新操作分配一个编号

enumOpcode : unsignedchar {
    ADD   = 1,
    SUB   = 2,
    LOADI = 3
};

然后增加一种判断即可。但是需要先把变量名调整一下

对于加减法这些名字很贴切
int op  = code[pc];
int dst = code[pc + 1];
int lhs = code[pc + 2];
int rhs = code[pc + 3];
但对于 LOADI来说第三个字段不是左边来源,而是一个常量。第四个字段也不是右边来源,而是未使用字段。
因此,用中性的名称更不容易误解:
int op = code[pc];
intp1 = code[pc + 1];
intp2 = code[pc + 2];
intp3 = code[pc + 3];
p1、p2、p3 就是参数一、参数二、参数三。

代码就可以写成这样

#include<cstddef>
#include<cstdio>
enumOpcode : unsignedchar {
    ADD   = 1,
    SUB   = 2,
    LOADI = 3
};
inttarget(int a, int b){
int values[4] = {};
    values[0] = a;
    values[1] = b;
unsignedchar code[] = {
        ADD,   2, 0, 1,  
        LOADI, 3, 2, 0,  
        SUB,   2, 2, 3
    };
    std::size_t code_size = sizeof(code);
    std::size_t pc = 0;
while (pc < code_size) {
if (code_size - pc < 4) {
            std::puts("指令数据不完整");
return0;
        }
int op = code[pc];
int p1 = code[pc + 1];
int p2 = code[pc + 2];
int p3 = code[pc + 3];
if (op == ADD) {
            values[p1] = values[p2] + values[p3];
        } elseif (op == SUB) {
            values[p1] = values[p2] - values[p3];
        } elseif (op == LOADI) {
            values[p1] = p2;
        } else {
            std::puts("无法识别的操作码");
return0;
        }
        pc += 4;
    }
return values[2];
}
intmain(){
    std::printf("结果:%d\n", target(10, 4));
return0;
}

补齐乘法、除法操作码

结构保持跟加减法一样

[操作码,结果,左边来源,右边来源]

这里规定MUL 是 multiplication,乘法;DIV 是 division,除法。

enum Opcode : unsigned char {
ADD   = 1,
SUB   = 2,
    LOADI = 3,
MUL   = 4,
DIV   = 5
};

同样的解释器也需要去做适配实现。

先做乘法的实现,乘法的左侧是结果,右侧是常量,那么就这样写。

image

image

结果也没问题

image

接下来是除法的实现,相较于乘法,除法在解释器的实现需要判断除数是否为0。其他的都一样。

image

运行结果:

到这里就实现了源程序的运算部分,只剩下一个print_value(x);没有做处理了

image

函数调用

在这里经过前面的计算后,最终的运算结果保存在values[p2]中。如果只想要把结果打印出来可以直接这样去写print_value(values[2]);这是一条完全正常的 C++ 函数调用,可以正常运行。那么为什么还要新增指令?因为直接把他写在循环后面,就意味着无论指令数组里描述的是什么过程,执行器都会在最后固定打印位置2的值。

但是我们想要的是,计算过程需要打印的时候,就在指令数据里写明在这里打印,执行器读到他的时候才进行打印。

比如说以后可能需要

算一步
打印一次
再算一步
再打印一次

如果把打印固定写在循环外,就表达不了这种顺序。所以要把什么时候打印,打印哪个位置的值交给指令数据控制。

打印函数只需要一个整数参数。

image

而当前我们使用的存储方式是,values[位置编号]。因此只需要在指令里说明要把哪个位置中保存的值,交给打印函数。

比如说打印位置 2 中的值。给这个操作起一个名称叫做PRINT

enum Opcode : unsigned char {
ADD   = 1,
SUB   = 2,
    LOADI = 3,
MUL   = 4,
DIV   = 5,
    PRINT = 6
};

这里的 PRINT 专门表示:调用我们指定的那个 print_value 函数。

之前我们约定每条指令四个字节:[操作码, 参数1, 参数2, 参数3]

打印只需要一个位置编号。因此规定:[PRINT, 来源位置, 未使用, 未使用]

例如:PRINT, 2, 0, 0

表示:从位置 2 取出值,交给 print_value。后面两个0依旧是填充作用。

image

image

⁠RET 指令

当前运算完毕是通过return values[2];固定写在这里,并没有交由指令控制

image

这里的实现与前面的PRINT差不多,也是定义[RET, 来源位置, 未使用, 未使用],然后再去解释器内实现。

image

到这里原函数的行为已经全部能由指令描述。

不过,当前 target 里仍然混着两类东西:这个函数专属的指令数据;所有这些指令都可以共用的执行逻辑。

⁠vm_execute

这里需要拆开

target:
提供这次要执行的指令,以及输入参数。
vm_execute:
提供存储位置,读取指令,按照规则执行。

这里要去写vm_execute,都需要什么参数,首先肯定是需要code也就是指令,其次就是需要指令的长度,最后就是两个输入值a跟b。

因此函数需要接受四项

指令数据的位置
指令数据的长度
输入 a
输入 b

它执行结束后,还需要将结果返回回来,因此返回类型还是int

由此函数声明就可以写成这样

intvm_execute(
constunsignedchar* code,
        std::size_t code_size,
int a,
int b
);

调用方式就可以写成这样

vm_execute(code, sizeof(code), a, b);

最终的实现就是这个样子的,运行后的结果依旧保持不变

#include<cstdio>
#include<cstddef>
enumOpcode : unsignedchar {
    ADD = 1,
    SUB = 2,
    LOADI = 3,
    MUL = 4,
    DIV = 5,
    PRINT = 6,
    RET = 7
};
voidprint_value(int value){
    std::printf("%d\n", value);
}
intvm_execute(
constunsignedchar* code,
        std::size_t code_size,
int a,
int b
){
int values[4] = {};
    values[0] = a;
    values[1] = b;
    std::size_t pc = 0;
while (pc < code_size) {
if (code_size - pc < 4) {
            std::puts("指令数据不完整");
return0;
        }
int op = code[pc];
int p1 = code[pc + 1];
int p2 = code[pc + 2];
int p3 = code[pc + 3];
if (op == ADD) {
            values[p1] = values[p2] + values[p3];
        } elseif (op == SUB) {
            values[p1] = values[p2] - values[p3];
        } elseif (op == LOADI) {
            values[p1] = p2;
        } elseif (op == MUL) {
            values[p1] = values[p2] * values[p3];
        } elseif (op == DIV) {
if (values[p3] == 0) {
                std::puts("除数不能为 0");
return0;
            }
            values[p1] = values[p2] / values[p3];
        } elseif (op == PRINT) {
print_value(values[p1]);
        } elseif (op == RET) {
return values[p1];
        } else {
            std::puts("无法识别的操作码");
return0;
        }
        pc += 4;
    }
    std::puts("指令数据缺少 RET");
return0;
}
inttarget(int a, int b){
constunsignedchar code[] = {
            ADD,   2, 0, 1,
            LOADI, 3, 2, 0,
            SUB,   2, 2, 3,
            LOADI, 3, 3, 0,
            MUL,   2, 2, 3,
            LOADI, 3, 2, 0,
            DIV,   2, 2, 3,
            PRINT, 2, 0, 0,
            RET,   2, 0, 0
    };
returnvm_execute(code, sizeof(code), a, b);
}
intmain(){
    std::printf("%d\n", target(10, 4));
return0;
}

image

双重保护

原函数:

image

虚拟化后:

image

再配合上简单的ollvm呢?可以实现这个效果

image

混淆拉满后长这样,这种程度也遇不到其实,大部分都是上面的这种。

image

至此我们就实现了一个mini版本的VMP,距离真正的商用版完成度可能不到百分之十吧。

*本文为看雪论坛优秀文章,由 北袅原创,转载请注明来自看雪社区

图片

球分享

球点赞

球在看

点击阅读原文查看更多

阅读原文

跳转微信打开

本文转载自微信公众号「看雪学院」,查看原文。版权归原作者所有,如有侵权请联系删除。