背景
花了點時間分析了下libffi的調用流程,做個總結。
什么是libffi
libffi是ffi的主流實現方式,其主要是用C和匯編來實現的。
原理和用法市面上已經很多,下面這兩篇是我覺得講得較為通俗易懂的,這里就不做過多的解釋了。
libffi的調用流程
PS:最近換了M1,所以以下的代碼都是ARM64架構下的邏輯,libffi版本3.4.2
1.ffi_call
直接上手,第一種動態調用方式 ffi_call
int fun1 (int a, int b) {
return a + b;
}
- (void)libffiCallTest{
ffi_type **types; // 參數類型
types = malloc(sizeof(ffi_type *) * 2) ;
types[0] = &ffi_type_sint;
types[1] = &ffi_type_sint;
// 返回類型
ffi_type *retType = &ffi_type_sint;
void **args = malloc(sizeof(void *) * 2);
int x = 1, y = 2;
args[0] = &x;
args[1] = &y;
int ret;
ffi_cif cif;
// 生成模板
ffi_prep_cif(&cif, FFI_DEFAULT_ABI, 2, retType, types);
// 動態調用fun1
ffi_call(&cif, fun1, &ret, args);
NSLog(@"libffi return func1 value: %d", ret);
}
來看看ffi_call
這個核心函數到底是如何幫我進行動態調用的,首先會進入ffi_call_int
方法,在該方法中,第一個核心的邏輯
0x01.拉伸SP,開辟棧空間
printf("----");
context = alloca (sizeof(struct call_context) + stack_bytes + 40 + rsize);// 拉伸sp
printf("----");//
stack = context + 1;
frame = (void*)((uintptr_t)stack + (uintptr_t)stack_bytes); // fp
rvalue = (rsize ? (void*)((uintptr_t)frame + 40) : orig_rvalue); // 返回值地址
alloca
和malloc
的區別在于,前者是在棧上開辟新的空間,后者是在堆上開辟新的空間。
通過匯編可以得知,在alloca
底層實現中會拉伸sp,context
的首地址就是新的sp的地址,開辟的空間就是接下來的匯編調用做準備。這里的硬編碼的40
,主要是放了放置lr, 原fp, 返回值rvalue, 返回值類型flags, 原sp。
0x02.參數入棧
for (i = 0, nargs = cif->nargs; i < nargs; i++)
{
ffi_type *ty = cif->arg_types[i];
size_t s = ty->size;
void *a = avalue[i];
int h, t;
t = ty->type;
switch (t)
{
...
case FFI_TYPE_SINT16:
case FFI_TYPE_UINT32:
case FFI_TYPE_SINT32:
case FFI_TYPE_UINT64:
case FFI_TYPE_SINT64:
case FFI_TYPE_POINTER:
do_pointer:
{
ffi_arg ext = extend_integer_type (a, t);
if (state.ngrn < N_X_ARG_REG)
context->x[state.ngrn++] = ext; // 參數小于8個,放在context->x中,從棧頂部開始分配
else
{
void *d = allocate_to_stack (&state, stack, ty->alignment, s);// 參數大于8個,從底部stack開始分配
state.ngrn = N_X_ARG_REG;
...
}
}
break;
...
可以看到參數的數量小于/大于寄存器數量(arm是x0-x7作為參數寄存器)還是略有區別,這是為了方便后面再次取出做準備。
0x03.ffi_call_SYSV
ffi_call_SYSV (context, frame, fn, rvalue, flags, closure);
有了函數地址和函數調用該有的環境,接下來進入真正調用的階段,這部分是匯編實現的,
CNAME(ffi_call_SYSV):
/* Sign the lr with x1 since that is where it will be stored */
...
/* x0 = context, x1 = frame ,x2 = fn ,x3 = rvalue ...*/
stp x29, x30, [x1] // fp和sp 相應入棧
mov x9, sp
str x9, [x1, #32]
mov x29, x1
mov sp, x0 // 這里sp又重新賦值,其實在alloc的時候sp已經變了。
...
mov x9, x2 /* x9 = fn */
mov x8, x3 /* x8 = rvalue */
#ifdef FFI_GO_CLOSURES
mov x18, x5 /* install static chain */
#endif
stp x3, x4, [x29, #16] /* save rvalue and flags */
/* Load the core argument passing registers, including
the structure return pointer. */
ldp x0, x1, [sp, #16*N_V_ARG_REG + 0] /* 把提前準備的參數存入寄存器中 */
ldp x2, x3, [sp, #16*N_V_ARG_REG + 16]
ldp x4, x5, [sp, #16*N_V_ARG_REG + 32]
ldp x6, x7, [sp, #16*N_V_ARG_REG + 48]
/* 參數已經存入寄存器了,銷毀context */
add sp, sp, #CALL_CONTEXT_SIZE
/* 調用真正的函數地址 */
BRANCH_AND_LINK_TO_REG x9 /* call fn */
/* 把放返回值的地址和類型標識地址重新取回 */
ldp x3, x4, [x29, #16] /* reload rvalue and flags */
/* 通過返回值的類型,計算不同邏輯 */
adr x5, 0f
and w4, w4, #AARCH64_RET_MASK
add x5, x5, x4, lsl #3
br x5
...
/* 把返回值放回x0 */
0: b 99f /* VOID */
nop
1: str x0, [x3] /* INT64 */
b 99f
2: stp x0, x1, [x3] /* INT128 */
b 99f
...
/* 結束 */
ret
黑色以上部分是函數調用環境準備之后的狀態。
2.ffi_closure
第二種動態創建函數進行調用
- (void)libffiBindTest {
//1.
ffi_type **argTypes;
ffi_type *returnTypes;
argTypes = malloc(sizeof(ffi_type *) * 2);
argTypes[0] = &ffi_type_sint;
argTypes[1] = &ffi_type_sint;
returnTypes = malloc(sizeof(ffi_type *));
returnTypes = &ffi_type_pointer;
ffi_cif *cif = malloc(sizeof(ffi_cif));
ffi_status status = ffi_prep_cif(cif, FFI_DEFAULT_ABI, 2, returnTypes, argTypes);
if (status != FFI_OK) {
NSLog(@"ffi_prep_cif return %u", status);
return;
}
//2.
char* (*funcInvoke)(int, int);
//3.
ffi_closure *closure = ffi_closure_alloc(sizeof(ffi_closure), &funcInvoke);
//4.
status = ffi_prep_closure_loc(closure, cif, bind_func, (__bridge void *)self, funcInvoke);
if (status != FFI_OK) {
NSLog(@"ffi_prep_closure_loc return %u", status);
return;
}
//5.
char *result = funcInvoke(2, 3);
NSLog(@"libffi return func value: %@", [NSString stringWithUTF8String:result]);
ffi_closure_free(closure);
}
// 6.
void bind_func(ffi_cif *cif, char **ret, int **args, void *userdata) {
//7.
int value0 = *args[0];
int value1 = *args[1];
const char *result = [[NSString stringWithFormat:@"str-%d", (value0 + value1)] UTF8String];
//8.
*ret = result;
}
可以看到,申明了一個函數char* (*funcInvoke)(int, int);
但開始沒有具體實現,ffi_prep_closure_loc
方法將申明的函數和一個通用的bind_func
進行了一個綁定,當funcInvoke(2, 3);
時,會來到我們的綁定函數bind_func
,你可以在這里做函數的真正實現和函數返回。
那么libffi是怎么幫我們做到這一點的呢?
申明的函數都會在庫的內部綁上統一函數實現,可以理解為一個跳板(trampoline),通過這個跳板函數,找到之前申明的函數調用上下文環境(如參數類型、返回值類型等等),和入參組裝之后,再一起跳轉丟給到bind_func
,接下來梳理下大致流程。
0x01.創建跳板頁
/* Allocate two pages -- a config page and a placeholder page */
config_page = 0x0;
kt = vm_allocate (mach_task_self (), &config_page, PAGE_MAX_SIZE * 2,
VM_FLAGS_ANYWHERE);
if (kt != KERN_SUCCESS)
return NULL;
/* Remap the trampoline table on top of the placeholder page */
trampoline_page = config_page + PAGE_MAX_SIZE;
vm_allocate
這個函數是linux底層分配的內存的函數,他只能以頁為單位來分配連續的內存,分配之后不會立即進行與物理內存的映射,在這里是開辟了兩個頁的虛擬內存,一個作為配置頁,一個作為占位頁。
0x02.vm_remap
/* Remap the trampoline table on top of the placeholder page */
trampoline_page = config_page + PAGE_MAX_SIZE;
#ifdef HAVE_PTRAUTH
trampoline_page_template = (vm_address_t)(uintptr_t)ptrauth_auth_data((void *)&ffi_closure_trampoline_table_page, ptrauth_key_function_pointer, 0);
#else
trampoline_page_template = (vm_address_t)&ffi_closure_trampoline_table_page;
#endif
#ifdef __arm__
/* ffi_closure_trampoline_table_page can be thumb-biased on some ARM archs */
trampoline_page_template &= ~1UL;
#endif
kt = vm_remap (mach_task_self (), &trampoline_page, PAGE_MAX_SIZE, 0x0,
VM_FLAGS_OVERWRITE, mach_task_self (), trampoline_page_template,
FALSE, &cur_prot, &max_prot, VM_INHERIT_SHARE);
if (kt != KERN_SUCCESS || !(cur_prot & VM_PROT_EXECUTE))
{
vm_deallocate (mach_task_self (), config_page, PAGE_MAX_SIZE * 2);
return NULL;
}
vm_remap的作用是內存映射,通過它,我們就能實現一個對象通過多個不同的地址來進行訪問(可以看Thunk程序的實現原理以及在iOS中的應用(二)進行理解),在這里是把上述0x01
中的占位頁的首地址映射到了一個函數上(ffi_closure_trampoline_table_page
),該函數由匯編實現。
0x03.創建跳板表
/* We have valid trampoline and config pages */
table = calloc (1, sizeof (ffi_trampoline_table));
table->free_count = FFI_TRAMPOLINE_COUNT;
table->config_page = config_page;
/* Create and initialize the free list */
table->free_list_pool =
calloc (FFI_TRAMPOLINE_COUNT, sizeof (ffi_trampoline_table_entry));
for (i = 0; i < table->free_count; i++)
{
ffi_trampoline_table_entry *entry = &table->free_list_pool[i];
entry->trampoline =
(void *) (trampoline_page + (i * FFI_TRAMPOLINE_SIZE));
#ifdef HAVE_PTRAUTH
entry->trampoline = ptrauth_sign_unauthenticated(entry->trampoline, ptrauth_key_function_pointer, 0);
#endif
if (i < table->free_count - 1)
entry->next = &table->free_list_pool[i + 1];
}
table->free_list = table->free_list_pool;
return table;
跳板表在這里創建。
table->config_page = config_page
表的config_page
指向跳板頁的第一頁。
entry->trampoline =
(void *) (trampoline_page + (i * FFI_TRAMPOLINE_SIZE));
表中的一個個entry
的trampoline
屬性指向跳板頁的第二頁 + 偏移。
*code = entry->trampoline; // funcInvoke = entry->trampoline
closure->trampoline_table = table;
closure->trampoline_table_entry = entry;
return closure;
可以看到,一開始申明的funcInvoke
的實際地址,其實就是指向了跳板表里entry->trampoline
,又trampoline
已經remap
到了ffi_closure_trampoline_table_page
上,來看下ffi_closure_trampoline_table_page
的實現
CNAME(ffi_closure_trampoline_table_page):
.rept PAGE_MAX_SIZE / FFI_TRAMPOLINE_SIZE
adr x16, -PAGE_MAX_SIZE
ldp x17, x16, [x16]
br x16
nop /* each entry in the trampoline config page is 2*sizeof(void*) so the trampoline itself cannot be smaller than 16 bytes */
.endr
.rept times
代表以下代碼要重復的次數,可以看到,其實這一整頁每16個字節都填充了重復的實現,為什么要這么做呢?后面會講到
所以到時候調用funcInvoke()
的時候,會跳兩次到ffi_closure_trampoline_table_page
上,最終會去做上面說的這個重復的實現。
到此為止一個closure
算是創建完畢了,里面具備了基本的調用環境。
0x04.ffi_prep_closure_loc
//...
start = ffi_closure_SYSV;
//...
void **config = (void **)((uint8_t *)codeloc - PAGE_MAX_SIZE); // *codeloc = funcInvoke
config[0] = closure;
config[1] = start; //ffi_closure_SYSV
//...
closure->cif = cif;
closure->fun = fun;
closure->user_data = user_data;
return FFI_OK;
0x03
說到,funcInvoke的實際地址是跳板頁的(第二頁 + 偏移),那么codeloc - PAGE_MAX_SIZE
就是我們創建的跳版頁第一頁 + 偏移,在第一頁 + 偏移的位置前后八個字節放了兩個東西,一個就是我們之前創建closure
,后八個字節放的是一個ffi_closure_SYSV
函數,該函數也由匯編實現。最后將方法簽名cif、綁定函數fun等進行保存,一切就準備就緒了。
下圖是這個階段大致的現狀。
0x05.funcInvoke(2, 3);
當真正發生函數調用時,發生了什么呢?
函數的調用的實際調用entey->trampoline
,該屬性又指向trampoline_page
中的某片區域,而trampoline_page
又因為remap到了ffi_closure_trampoline_table_page
,經過一系列的反復橫跳會來到這。又因為調用是帶偏移的,再貼一下
for (i = 0; i < table->free_count; i++)
{
// ...
entry->trampoline =
(void *) (trampoline_page + (i * FFI_TRAMPOLINE_SIZE));
}
這就是為什么ffi_closure_trampoline_table_page
里的都是重復的實現,因為調用都是攜帶偏移的,在工程里會有很多這樣的動態函數,哪個方法調進來事先是什么不知道,所以干脆整頁全部填充重復實現了
adr x16, -PAGE_MAX_SIZE // x16 = pc - PAGE_MAX_SIZE賦值
ldp x17, x16, [x16] /* x17 = closure ,x16 = start / ffi_closure_SYSV */
br x16
adr x16, -PAGE_MAX_SIZE
找到config page其中對應的內容。又因為當前的pc本身就是帶偏移的,所以可以在config page
找到entry
當時對應埋入的clousure
和start
函數分別賦給x16
和x17
。br x16
跳轉到start
(ffi_closure_SYSV),在這塊的實現思路跟第一部分的ffi_call_SYSV
基本就大同小異了:
CNAME(ffi_closure_SYSV):
SIGN_LR
stp x29, x30, [sp, #-ffi_closure_SYSV_FS]! // 拉伸sp,x29,x30入棧
cfi_adjust_cfa_offset (ffi_closure_SYSV_FS)
cfi_rel_offset (x29, 0)
cfi_rel_offset (x30, 8)
0:
mov x29, sp
/* Save the argument passing core registers. */
stp x0, x1, [sp, #16 + 16*N_V_ARG_REG + 0] //funcInvoke參數入棧
stp x2, x3, [sp, #16 + 16*N_V_ARG_REG + 16]
stp x4, x5, [sp, #16 + 16*N_V_ARG_REG + 32]
stp x6, x7, [sp, #16 + 16*N_V_ARG_REG + 48]
/* 從x17取出closure,讀取調用環境 */
ldp PTR_REG(0), PTR_REG(1), [x17, #FFI_TRAMPOLINE_CLOSURE_OFFSET] /* load cif, fn */
ldr PTR_REG(2), [x17, #FFI_TRAMPOLINE_CLOSURE_OFFSET+PTR_SIZE*2] /* load user_data */
#ifdef FFI_GO_CLOSURES
.Ldo_closure:
#endif
add x3, sp, #16 /* load context */
add x4, sp, #ffi_closure_SYSV_FS /* load stack */
add x5, sp, #16+CALL_CONTEXT_SIZE /* load rvalue */
mov x6, x8 /* load struct_rval */
/* 調用bindfun ,就會跳跳轉我們綁定的bindfun函數 */
bl CNAME(ffi_closure_SYSV_inner)
/* 根據返回值類型,跳轉相關邏輯 */
adr x1, 0f
and w0, w0, #AARCH64_RET_MASK
add x1, x1, x0, lsl #3
add x3, sp, #16+CALL_CONTEXT_SIZE
br x1
/* Note that each table entry is 2 insns, and thus 8 bytes. */
.align 4
0: b 99f /* VOID */
nop
1: ldr x0, [x3] /* INT64 */
b 99f
2: ldp x0, x1, [x3] /* INT128 */
b 99f
...
31: /* reserved */
/* 恢復棧幀環境 */
99: ldp x29, x30, [sp], #ffi_closure_SYSV_FS
cfi_adjust_cfa_offset (-ffi_closure_SYSV_FS)
cfi_restore (x29)
cfi_restore (x30)
AUTH_LR_AND_RET
cfi_endproc
3.總結
至此我們了解了libffi是怎么幫助我們實現動態調用的,在開發過程中,我們可以用libffi幫助我們去實現一些常規代碼無法進行的動態調用和動態創建調用,比如iOS中的block hook等。
題外話: 學會黑科技,一招搞定iOS 14.2的 libffi crash 字節的這篇文章中說到,在14.2 libffi會crash,原因是vm_remap導致的code sign error,通過靜態跳板去解決這個問題,所謂的靜態跳板,其實就是不再使用占位頁,從而不需要通過remap映射,函數直接放在call到跳版頁(text段),由于缺少了和config_page的關聯(之前是直接vm_allocate了連續兩頁,由占位頁 - page_size找到config_page),所以算出偏移還不夠,需要通過adrp找到config_page(在.data段通過匯編分配)的基地址相加,找到clouse和start。
不過,我個人認為還是要先搞清楚vm_remap為什么會失敗。當然了,這個問題咱也沒碰到過,所以咱也不敢說。