转载:https://zhuanlan.zhihu.com/p/64713140
JIT是“Just In Time”的首字母缩写。每当一个程序在运行时创建并运行一些新的可执行代码,而这些代码在存储于磁盘上时不属于该程序的一部分,它就是一个JIT。
我认为JIT技术在分为两个不同的阶段时更容易解释:
阶段1:在程序运行时创建机器代码。
阶段2:在程序运行时也执行该机器代码。
第1阶段是JITing 99%的挑战所在,但它也是这个过程中不那么神秘的部分,因为这正是编译器所做的。众所周知的编译器,如gcc和clang,将C/C++源代码转换为机器代码。机器代码被发送到输出流中,但它很可能只保存在内存中(实际上,gcc和clang / llvm都有构建块用于将代码保存在内存中以便执行JIT)。第2阶段是我想在本文中关注的内容。
运行动态生成的代码
现代操作系统对于允许程序在运行时执行的操作可以说是非常挑剔。过去“海阔凭鱼跃,天高任鸟飞”的日子随着保护模式的出现而不复存在,保护模式允许操作系统以各种权限对虚拟内存块的使用做出限制。因此,在“普通”代码中,你可以在堆上动态创建新数据,但是你不能在没有操作系统明确允许的情况下从堆中运行其内容。
在这一点上,我希望机器代码只是数据 - 一个字节流,比如:
unsigned char[] code = {0x48, 0x89, 0xf8};
不同的人会有不同的视角,对某些人而言,0x48, 0x89, 0xf8只是一些可以代表任何事物的数据。 对于其他人来说,它是真实有效的机器代码的二进制编码,其对应的x86-64汇编代码如下:
mov %rdi, %rax
将机器代码放入内存是容易的,但是如何让它获得可执行权限,然后运行它呢?
首先我们创建一个函数:
long add4(long num) {
return num + 4;
}
然后在内存中动态地执行它:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
// Allocates RWX memory of given size and returns a pointer to it. On failure,
// prints out the error and returns NULL.
void* alloc_executable_memory(size_t size) {
void* ptr = mmap(0, size,
PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (ptr == (void*)-1) {
perror("mmap");
return NULL;
}
return ptr;
}
void emit_code_into_memory(unsigned char* m) {
unsigned char code[] = {
0x48, 0x89, 0xf8, // mov %rdi, %rax
0x48, 0x83, 0xc0, 0x04, // add $4, %rax
0xc3 // ret
};
memcpy(m, code, sizeof(code));
}
const size_t SIZE = 1024;
typedef long (*JittedFunc)(long);
// Allocates RWX memory directly.
void run_from_rwx() {
void* m = alloc_executable_memory(SIZE);
emit_code_into_memory(m);
JittedFunc func = m;
int result = func(2);
printf("result = %d\n", result);
}
此代码执行的主要3个步骤是:
- 使用mmap在堆上分配可读,可写和可执行的内存块。
- 将实现add4函数的汇编/机器代码复制到此内存块中。
- 将该内存块首地址转换为函数指针,并通过调用这一函数指针来执行此内存块中的代码。
请注意,步骤3能发生是因为包含机器代码的内存块是可执行的,如果没有设置正确的权限,该调用将导致OS的运行时错误(很可能是segmentation fault)。如果我们通过对malloc的常规调用来分配内存块,则会发生这种情况,malloc分配可读写但不可执行的内存。而通过mmap来分配内存块,则可以自行设置该内存块的属性【1】。
安全问题
上面显示的代码其实有一个安全漏洞,那就是它所分配的RWX(可读,可写,可执行)大块内存,这种内存对于漏洞攻击者来说可是可以大展身手,兴风作浪的天堂。所以让我们对它更负责任,进行一些略微的修改:
// Allocates RW memory of given size and returns a pointer to it. On failure,
// prints out the error and returns NULL. Unlike malloc, the memory is allocated
// on a page boundary so it's suitable for calling mprotect.
void* alloc_writable_memory(size_t size) {
void* ptr = mmap(0, size,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (ptr == (void*)-1) {
perror("mmap");
return NULL;
}
return ptr;
}
// Sets a RX permission on the given memory, which must be page-aligned. Returns
// 0 on success. On failure, prints out the error and returns -1.
int make_memory_executable(void* m, size_t size) {
if (mprotect(m, size, PROT_READ | PROT_EXEC) == -1) {
perror("mprotect");
return -1;
}
return 0;
}
// Allocates RW memory, emits the code into it and sets it to RX before
// executing.
void emit_to_rw_run_from_rx() {
void* m = alloc_writable_memory(SIZE);
emit_code_into_memory(m);
make_memory_executable(m, SIZE);
JittedFunc func = m;
int result = func(2);
printf("result = %d\n", result);
}
内存块首先被分配了RW权限,因为我们需要将函数的机器代码写入该内存块。然后我们使用mprotect将块的权限从RW更改为RX,使其可执行但不再可写,所以最终效果是一样的,但是在我们的程序执行过程中,没有任何一个时间点,该内存块是同时可写的和可执行的。
本文介绍的这种技术几乎是真正的JIT引擎(例如LLVM和libjit)从内存中发出和运行可执行机器代码的方式,剩下的只是从其他东西合成机器代码的问题。LLVM有一个完整的编译器,所以它实际上可以在运行时将C和C ++代码(通过LLVM IR)转换为机器码,然后执行它。
注【1】:传统上(即很久以前)malloc使用sbrk系统调用,但是现在大多数malloc的实现在很多情况下使用的是mmap,通常mmap用于大块内存,sbrk用于小块内存,这取决于从OS请求更多内存的两种方法的相对效率。