RISC-V虚拟机解释器的工程实践与混淆技术

本文详细探讨了基于RISC-V架构开发低占用虚拟机解释器的工程挑战,包括工具链构建、字节码加密、线程化处理和LLVM IR重定向等核心技术,旨在实现高效且安全的代码保护方案。

RISC-Y Business: 对抗精简机器的愤怒

mrexodia, oopsmishap
2023年12月24日

摘要

近年来,对代码混淆的兴趣日益增长,主要原因是人们希望保护其知识产权。不幸的是,大多数现有文献都集中在理论方面。在本文中,我们将讨论开发低占用虚拟机(VM)解释器时遇到的实际工程挑战。该VM易于嵌入,基于开源技术构建,并具有多种加固功能,这些功能以最小的努力实现。

引言

除了保护知识产权外,最小化虚拟机还有其他用途。您可能希望有一个可嵌入的解释器来执行业务逻辑(shellcode),而无需处理RWX内存。它也可以作为教育工具,或仅仅为了娱乐。

创建自定义VM架构(类似于VMProtect/Themida)意味着我们必须处理二进制重写/提升或编写自己的编译器。相反,我们决定使用一个现有的架构,该架构由LLVM支持:RISC-V。该架构已经广泛用于教育目的,并具有非常易于理解和实现的优势。

最初,主要的竞争者是WebAssembly。然而,现有的解释器非常臃肿,并且还需要处理二进制格式。此外,WASM64似乎非常不成熟,而我们的内存模型需要64位指针支持。SPARC和PowerPC也被考虑过,但RISC-V似乎更受欢迎,并且有更多的资源可用。

WebAssembly是为沙箱化而设计的,因此严格分离了客户机和主机内存。因为我们将编写自己的RISC-V解释器,我们选择在客户机和主机之间共享内存。这意味着RISC-V执行上下文(客户机)中的指针在主机进程中有效,反之亦然。

因此,负责读/写内存的指令可以实现为简单的memcpy调用,我们不需要额外的代码来转换/验证内存访问(这有助于实现小代码大小的目标)。利用这一特性,我们只需要实现两个系统调用来在主机进程中执行任意操作:

1
2
uintptr_t riscvm_get_peb();
uintptr_t riscvm_host_call(uintptr_t rip, uintptr_t args[13]);

riscvm_get_peb是Windows特定的,它允许我们解析导出,然后我们可以将其传递给riscvm_host_call函数来执行任意代码。此外,可以实现一个可选的host_syscall存根,但这并不是严格必要的,因为我们可以直接调用ntdll.dll中的函数。

工具链和CRT

为了尽可能减少解释器的占用空间,我们决定开发一个输出独立二进制文件的工具链。目标是将此二进制文件复制到内存中,并将VM的程序计数器指向那里以开始执行。因为我们处于独立模式,没有C运行时可用,这需要我们自行处理初始化。

例如,我们将使用以下hello.c文件:

1
2
3
4
5
6
7
int _start() {
    int result = 0;
    for(int i = 0; i < 52; i++) {
        result += *(volatile int*)&i;
    }
    return result + 11;
}

我们使用以下命令编译程序:

1
clang -target riscv64 -march=rv64im -mcmodel=medany -Os -c hello.c -o hello.o

然后通过反汇编对象文件来验证:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
$ llvm-objdump --disassemble hello.o

hello.o:        file format elf64-littleriscv

0000000000000000 <_start>:
       0: 13 01 01 ff   addi    sp, sp, -16
       4: 13 05 00 00   li      a0, 0
       8: 23 26 01 00   sw      zero, 12(sp)
       c: 93 05 30 03   li      a1, 51

0000000000000010 <.LBB0_1>:
      10: 03 26 c1 00   lw      a2, 12(sp)
      14: 33 05 a6 00   add     a0, a2, a0
      18: 9b 06 16 00   addiw   a3, a2, 1
      1c: 23 26 d1 00   sw      a3, 12(sp)
      20: 63 40 b6 00   blt     a2, a1, 0x20 <.LBB0_1+0x10>
      24: 1b 05 b5 00   addiw   a0, a0, 11
      28: 13 01 01 01   addi    sp, sp, 16
      2c: 67 80 00 00   ret

hello.o是一个常规的ELF对象文件。要获得独立二进制文件,我们需要使用链接器脚本调用链接器:

 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
ENTRY(_start)

LINK_BASE = 0x8000000;

SECTIONS
{
    . = LINK_BASE;
    __base = .;

    .text : ALIGN(16) {
        . = LINK_BASE;
        *(.text)
        *(.text.*)
    }

    .data : {
        *(.rodata)
        *(.rodata.*)
        *(.data)
        *(.data.*)
        *(.eh_frame)
    }

    .init : {
        __init_array_start = .;
        *(.init_array)
        __init_array_end = .;
    }

    .bss : {
        *(.bss)
        *(.bss.*)
        *(.sbss)
        *(.sbss.*)
    }

    .relocs : {
        . = . + SIZEOF(.bss);
        __relocs_start = .;
    }
}

这个脚本是大量咒骂和实验的结果。格式是.name : { ... },其中.name是目标节,括号中的内容是要粘贴在那里的内容。特殊的.运算符用于引用二进制文件中的当前位置,我们定义了一些特殊符号供运行时使用:

符号 含义
__base 可执行文件的基础地址。
__init_array_start C++初始化数组的开始。
__init_array_end C++初始化数组的结束。
__relocs_start 重定位的开始(二进制文件的结束)。

这些符号在C代码中声明为extern,它们将在链接时解析。虽然一开始可能令人困惑,我们有目标节,但一旦你意识到链接器必须输出一个常规的ELF可执行文件,这就开始有意义了。然后,该ELF可执行文件传递给llvm-objcopy以创建独立的二进制blob。这使得调试变得容易得多(因为我们获得了DWARF符号),并且由于我们不会实现ELF加载器,它还允许我们提取重定位以嵌入到最终二进制文件中。

要链接中间ELF可执行文件,然后创建独立的hello.pre.bin

1
2
ld.lld.exe -o hello.elf --oformat=elf -emit-relocs -T ..\lib\linker.ld --Map=hello.map hello.o
llvm-objcopy -O binary hello.elf hello.pre.bin

为了调试目的,我们还输出hello.map,它告诉我们链接器将代码/数据放在哪里:

 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
             VMA              LMA     Size Align Out     In      Symbol
               0                0        0     1 LINK_BASE = 0x8000000
               0                0  8000000     1 . = LINK_BASE
         8000000                0        0     1 __base = .
         8000000          8000000       30    16 .text
         8000000          8000000        0     1         . = LINK_BASE
         8000000          8000000       30     4         hello.o:(.text)
         8000000          8000000       30     1                 _start
         8000010          8000010        0     1                 .LBB0_1
         8000030          8000030        0     1 .init
         8000030          8000030        0     1         __init_array_start = .
         8000030          8000030        0     1         __init_array_end = .
         8000030          8000030        0     1 .relocs
         8000030          8000030        0     1         . = . + SIZEOF ( .bss )
         8000030          8000030        0     1         __relocs_start = .
               0                0       18     8 .rela.text
               0                0       18     8         hello.o:(.rela.text)
               0                0       3b     1 .comment
               0                0       3b     1         <internal>:(.comment)
               0                0       30     1 .riscv.attributes
               0                0       30     1         <internal>:(.riscv.attributes)
               0                0      108     8 .symtab
               0                0      108     8         <internal>:(.symtab)
               0                0       55     1 .shstrtab
               0                0       55     1         <internal>:(.shstrtab)
               0                0       5c     1 .strtab
               0                0       5c     1         <internal>:(.strtab)

工具链的最后一个组成部分是一个小的Python脚本(relocs.py),它从ELF文件中提取重定位并将其附加到hello.pre.bin的末尾。自定义重定位格式仅支持R_RISCV_64,并由我们的CRT解析如下:

 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
typedef struct
{
    uint8_t  type;
    uint32_t offset;
    int64_t  addend;
} __attribute__((packed)) Relocation;

extern uint8_t __base[];
extern uint8_t __relocs_start[];

#define LINK_BASE    0x8000000
#define R_RISCV_NONE 0
#define R_RISCV_64   2

static __attribute((noinline)) void riscvm_relocs()
{
    if (*(uint32_t*)__relocs_start != 'ALER')
    {
        asm volatile("ebreak");
    }

    uintptr_t load_base = (uintptr_t)__base;

    for (Relocation* itr = (Relocation*)(__relocs_start + sizeof(uint32_t)); itr->type != R_RISCV_NONE; itr++)
    {
        if (itr->type == R_RISCV_64)
        {
            uint64_t* ptr = (uint64_t*)((uintptr_t)itr->offset - LINK_BASE + load_base);
            *ptr -= LINK_BASE;
            *ptr += load_base;
        }
        else
        {
            asm volatile("ebreak");
        }
    }
}

如您所见,这里使用了__base__relocs_start魔术符号。这之所以有效,唯一的原因是我们编译对象时使用了-mcmodel=medany。您可以在此文章和RISC-V ELF规范中找到更多详细信息。简而言之,此标志允许编译器假设所有代码将在2 GiB地址范围内发出,这允许更自由的PC相对寻址。当您将指针放入.data节时,会发出R_RISCV_64重定位类型:

1
2
3
4
void* functions[] = {
    &function1,
    &function2,
};

当在C++中使用vtable时也会发生这种情况,我们希望在早期就正确支持这些,而不是后来与可怕的错误作斗争。

CRT的下一个部分涉及处理初始化数组(由具有构造函数的类的全局实例发出):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
typedef void (*InitFunction)();
extern InitFunction __init_array_start;
extern InitFunction __init_array_end;

static __attribute((optnone)) void riscvm_init_arrays()
{
    for (InitFunction* itr = &__init_array_start; itr != &__init_array_end; itr++)
    {
        (*itr)();
    }
}

令人沮丧的是,我们无法在没有__attribute__((optnone))的情况下使此函数生成正确的代码。我们怀疑这与别名假设有关(开始/结束在技术上可以引用相同的内存),但我们没有进一步调查。

解释器内部

注意:解释器最初基于edubart的riscvm.c。然而,我们后来用C++完全重写了它,以更好地满足我们的目的。

基于RISC-V调用约定文档,我们可以为32个寄存器创建一个枚举:

 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
enum RegIndex
{
    reg_zero, // always zero (immutable)
    reg_ra,   // return address
    reg_sp,   // stack pointer
    reg_gp,   // global pointer
    reg_tp,   // thread pointer
    reg_t0,   // temporary
    reg_t1,
    reg_t2,
    reg_s0,   // callee-saved
    reg_s1,
    reg_a0,   // arguments
    reg_a1,
    reg_a2,
    reg_a3,
    reg_a4,
    reg_a5,
    reg_a6,
    reg_a7,
    reg_s2,   // callee-saved
    reg_s3,
    reg_s4,
    reg_s5,
    reg_s6,
    reg_s7,
    reg_s8,
    reg_s9,
    reg_s10,
    reg_s11,
    reg_t3,   // temporary
    reg_t4,
    reg_t5,
    reg_t6,
};

我们只需要添加一个pc寄存器,我们就有了表示RISC-V CPU状态的结构:

1
2
3
4
5
struct riscvm
{
    int64_t  pc;
    uint64_t regs[32];
};

重要的是要记住,零寄存器始终设置为0,我们必须使用宏防止写入它:

1
2
3
4
5
6
7
8
#define reg_write(idx, value)        \
    do                               \
    {                                \
        if (LIKELY(idx != reg_zero)) \
        {                            \
            self->regs[idx] = value; \
        }                            \
    } while (0)

指令(忽略可选的压缩扩展)始终是32位长度,并且可以干净地表示为联合:

 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
78
79
80
81
82
union Instruction
{
    struct
    {
        uint32_t compressed_flags : 2;
        uint32_t opcode           : 5;
        uint32_t                  : 25;
    };

    struct
    {
        uint32_t opcode : 7;
        uint32_t rd     : 5;
        uint32_t funct3 : 3;
        uint32_t rs1    : 5;
        uint32_t rs2    : 5;
        uint32_t funct7 : 7;
    } rtype;

    struct
    {
        uint32_t opcode : 7;
        uint32_t rd     : 5;
        uint32_t funct3 : 3;
        uint32_t rs1    : 5;
        uint32_t rs2    : 5;
        uint32_t shamt  : 1;
        uint32_t imm    : 6;
    } rwtype;

    struct
    {
        uint32_t opcode : 7;
        uint32_t rd     : 5;
        uint32_t funct3 : 3;
        uint32_t rs1    : 5;
        uint32_t imm    : 12;
    } itype;

    struct
    {
        uint32_t opcode : 7;
        uint32_t rd     : 5;
        uint32_t imm    : 20;
    } utype;

    struct
    {
        uint32_t opcode : 7;
        uint32_t rd     : 5;
        uint32_t imm12  : 8;
        uint32_t imm11  : 1;
        uint32_t imm1   : 10;
        uint32_t imm20  : 1;
    } ujtype;

    struct
    {
        uint32_t opcode : 7;
        uint32_t imm5   : 5;
        uint32_t funct3 : 3;
        uint32_t rs1    : 5;
        uint32_t rs2    : 5;
        uint32_t imm7   : 7;
    } stype;

    struct
    {
        uint32_t opcode   : 7;
        uint32_t imm_11   : 1;
        uint32_t imm_1_4  : 4;
        uint32_t funct3   : 3;
        uint32_t rs1      : 5;
        uint32_t rs2      : 5;
        uint32_t imm_5_10 : 6;
        uint32_t imm_12   : 1;
    } sbtype;

    int16_t  chunks16[2];
    uint32_t bits;
};
static_assert(sizeof(Instruction) == sizeof(uint32_t), "");

有13个顶级操作码(Instruction.opcode),其中一些操作码有另一个字段进一步专门化功能(即Instruction.itype.funct3)。为了保持代码可读性,操作码的枚举在opcodes.h中定义。解释器的结构是为顶级操作码提供处理程序函数,形式如下:

1
bool handler_rv64_<opcode>(riscvm_ptr self, Instruction inst);

例如,我们可以查看lui指令的处理程序(注意处理程序本身负责更新pc):

1
2
3
4
5
6
7
8
ALWAYS_INLINE static bool handler_rv64_lui(riscvm_ptr self, Instruction inst)
{
    int64_t imm = bit_signer(inst.utype.imm, 20) << 12;
    reg_write(inst.utype.rd, imm);

    self->pc += 4;
    dispatch(); // return true;
}

解释器执行直到其中一个处理程序返回false,表示CPU必须停止:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
void riscvm_run(riscvm_ptr self)
{
    while (true)
    {
        Instruction inst;
        inst.bits = *(uint32_t*)self->pc;
        if (!riscvm_execute_handler(self, inst))
            break;
    }
}

已经有很多文章讨论了RISC-V的语义,所以如果您对单个指令的实现细节感兴趣,可以查看源代码。解释器的结构还允许我们轻松实现混淆功能,我们将在下一节中讨论。

目前,我们将处理程序函数声明为`attribute((always_inline

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计