找回密码
立即注册
搜索
发回帖 发新帖

6744

积分

0

好友

852

主题
发表于 3 小时前 | 查看: 1| 回复: 0

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

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

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

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

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

VMP虚拟化保护后的控制流示意

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

被保护函数

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

被保护的target函数C++代码

运行后的结果长这样:

程序运行结果输出

虚拟化保护实现

存储位置

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

target函数ARM64汇编代码

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

要实现这种方案,我需要一份数据来描述要执行的计算步骤,还需要一段执行程序:读取这份数据,按照里面描述的步骤进行计算。

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

假设要给另外一段程序发送一条计算要求,让它完成这句:

x = a + b

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

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

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

ADD x a b

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

名字       值
"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) {
    int values[3] = {};

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

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

    return values[2];
}

int main() {
    std::printf("%d\n", target(10, 4));
    return 0;
}

现在先把 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) {
    int values[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];

    return values[2];
}

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

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

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

操作码

还是最朴素的方式,约定:

1 表示加法
2 表示减法

这两个数字没有特殊含义,既不是 CPU 规定的,也不是 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];
} else if (op == 2) {
    values[dst] = values[lhs] - values[rhs];
}

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

enum Opcode : unsigned char {
    ADD = 1,
    SUB = 2
};

现在就可以写成:

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

简易解释器读取指令并执行

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

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

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
}

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

带PC循环的字节码解释器

新指令

原函数前两步是:

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:暂时保存计算需要的常量

先给新操作分配一个编号:

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

然后增加一种判断即可。但是需要先把变量名调整一下。对于加减法,dst、lhs、rhs 这些名字很贴切;但对于 LOADI 来说,第三个字段不是左边来源而是一个常量,第四个字段也不是右边来源而是未使用字段。因此用中性的名称更不容易误解:

int op = code[pc];
int p1 = code[pc + 1];
int p2 = code[pc + 2];
int p3 = code[pc + 3];

p1、p2、p3 就是参数一、参数二、参数三。代码可以写成这样:

#include <cstddef>
#include <cstdio>

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

int target(int a, int b) {
    int values[4] = {};

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

    unsigned char 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("指令数据不完整");
            return 0;
        }

        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];
        } else if (op == SUB) {
            values[p1] = values[p2] - values[p3];
        } else if (op == LOADI) {
            values[p1] = p2;
        } else {
            std::puts("无法识别的操作码");
            return 0;
        }

        pc += 4;
    }

    return values[2];
}

int main() {
    std::printf("结果:%d\n", target(10, 4));
    return 0;
}

增加LOADI后的解释器代码

补齐乘法、除法操作码

结构跟加减法一样:

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

这里规定 MUL 是 multiplication(乘法),DIV 是 division(除法):

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

同样的,解释器也需要去做适配实现。先做乘法的实现,乘法的左侧是结果,右边是常量:

乘法操作代码

values[p1] = values[p2] * values[p3];

结果也没问题:

乘法运算结果36

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

DIV除法指令实现代码

} else if (op == DIV) {
    if (values[p3] == 0) {
        std::puts("除数不能为 0");
        return 0;
    }
    values[p1] = values[p2] / values[p3];
}

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

target函数原始实现

函数调用

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

但是我们想要的是:计算过程需要打印的时候,就在指令数据里写明在这里打印,执行器读到它的时候才进行打印。比如以后可能需要"算一步、打印一次、再算一步、再打印一次"这种顺序,如果把打印固定写在循环外,就表达不了这种灵活性。所以要把什么时候打印、打印哪个位置的值交给指令数据控制。

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

print_value函数实现

而当前使用的存储方式是 values[位置编号],因此只需要在指令里说明要把哪个位置保存的值交给打印函数。给这个操作起一个名称叫 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 依旧是填充作用。

完整的指令字节码数组

PRINT指令处理代码

RET 指令

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

完整解释器RET处理

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

} else if (op == RET) {
    return values[p1];
}

最终完整解释器与字节码

到这里原函数的行为已经全部能由指令描述。不过当前 target 里仍然混着两类东西:这个函数专属的指令数据,以及所有这些指令都可以共用的执行逻辑。

vm_execute

这里需要拆开:

target:
提供这次要执行的指令,以及输入参数。

vm_execute:
提供存储位置,读取指令,按照规则执行。

vm_execute 都需要什么参数?首先肯定是 code(指令),其次是指令的长度,最后是两个输入值 a 和 b。因此函数需要接受四项:

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

它执行结束后还需要将结果返回,因此返回类型还是 int。函数声明可以写成:

int vm_execute(
    const unsigned char* code,
    std::size_t code_size,
    int a,
    int b
);

调用方式:

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

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

#include <cstdio>
#include <cstddef>

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

void print_value(int value) {
    std::printf("%d\n", value);
}

int vm_execute(
    const unsigned char* 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("指令数据不完整");
            return 0;
        }

        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];
        } else if (op == SUB) {
            values[p1] = values[p2] - values[p3];
        } else if (op == LOADI) {
            values[p1] = p2;
        } else if (op == MUL) {
            values[p1] = values[p2] * values[p3];
        } else if (op == DIV) {
            if (values[p3] == 0) {
                std::puts("除数不能为 0");
                return 0;
            }
            values[p1] = values[p2] / values[p3];
        } else if (op == PRINT) {
            print_value(values[p1]);
        } else if (op == RET) {
            return values[p1];
        } else {
            std::puts("无法识别的操作码");
            return 0;
        }

        pc += 4;
    }

    std::puts("指令数据缺少 RET");
    return 0;
}

int target(int a, int b) {
    const unsigned char 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
    };

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

int main() {
    std::printf("%d\n", target(10, 4));
    return 0;
}

最终运行结果18

双重保护

原函数:

target函数反汇编分析

虚拟化后:

vm_execute函数控制流图

再配合上简单的 OLLVM,可以实现这个效果:

虚拟化保护后的控制流

混淆拉满后长这样。不过这种程度大部分时候也遇不到,实际中常见的是上面那种:

OLLVM混淆后的控制流

至此我们就实现了一个 mini 版本的 VMP(Virtual Machine Protection,虚拟机保护),距离真正的商用版完成度可能不到百分之十。不过作为理解虚拟化保护原理的起点已经足够,更多逆向工程与代码虚拟化相关的实践内容,也欢迎在云栈社区与同好继续探讨。




上一篇:Discord客户端弃Electron转Rust:内存省70%的GPUI重构路径解析
下一篇:OpenSSH SSHole漏洞遭利用,超长密码绕过SSH认证
您需要登录后才可以回帖 登录 | 立即注册

关闭

站长推荐上一条 /1 下一条

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-11-13 17:02 , Processed in 0.123390 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表