轉載: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請求更多內存的兩種方法的相對效率。