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