背景
花了点时间分析了下libffi的调用流程,做个总结。
什么是libffi
libffi是ffi的主流实现方式,其主要是用C和汇编来实现的。
原理和用法市面上已经很多,下面这两篇是我觉得讲得较为通俗易懂的,这里就不做过多的解释了。
外部函数接口 FFI —— 虚拟机中重要但不起眼的组件
使用 libffi 实现 AOP
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
的区别在于,前者是在栈上开辟新的空间,后者是在堆上开辟新的空间。
image-20220510000544075.png
通过汇编可以得知,在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):
...
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
mov x8, x3
#ifdef FFI_GO_CLOSURES
mov x18, x5
#endif
stp x3, x4, [x29, #16]
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]
add sp, sp, #CALL_CONTEXT_SIZE
BRANCH_AND_link_TO_REG x9
ldp x3, x4, [x29, #16]
adr x5, 0f
and w4, w4, #AARCH64_RET_MASK
add x5, x5, x4, lsl #3
br x5
...
0: b 99f
nop
1: str x0, [x3]
b 99f
2: stp x0, x1, [x3]
b 99f
...
ret
image-20220510140445309.png
黑色以上部分是函数调用环境准备之后的状态。
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.创建跳板页
config_page = 0x0;
kt = vm_allocate (mach_task_self (), &config_page, PAGE_MAX_SIZE * 2,
VM_FLAGS_ANYWHERe);
if (kt != KERN_SUCCESS)
return NULL;
trampoline_page = config_page + PAGE_MAX_SIZE;
vm_allocate
这个函数是linux底层分配的内存的函数,他只能以页为单位来分配连续的内存,分配之后不会立即进行与物理内存的映射,在这里是开辟了两个页的虚拟内存,一个作为配置页,一个作为占位页。
0x02.vm_remap
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__
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.创建跳板表
table = calloc (1, sizeof (ffi_trampoline_table));
table->free_count = FFI_TRAMPOLINE_COUNT;
table->config_page = config_page;
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
.endr
.rept times
代表以下代码要重复的次数,可以看到,其实这一整页每16个字节都填充了重复的实现,为什么要这么做呢?后面会讲到
image-20220512105954981.png
所以到时候调用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等进行保存,一切就准备就绪了。
下图是这个阶段大致的现状。
未命名文件.jpg
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]
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
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]
ldp PTR_REG(0), PTR_REG(1), [x17, #FFI_TRAMPOLINE_CLOSURE_OFFSET]
ldr PTR_REG(2), [x17, #FFI_TRAMPOLINE_CLOSURE_OFFSET+PTR_SIZE*2]
#ifdef FFI_GO_CLOSURES
.Ldo_closure:
#endif
add x3, sp, #16
add x4, sp, #ffi_closure_SYSV_FS
add x5, sp, #16+CALL_CONTEXT_SIZE
mov x6, x8
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
.align 4
0: b 99f
nop
1: ldr x0, [x3]
b 99f
2: ldp x0, x1, [x3]
b 99f
...
31:
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为什么会失败。当然了,这个问题咱也没碰到过,所以咱也不敢说。
4.参考
外部函数接口 FFI —— 虚拟机中重要但不起眼的组件
使用 libffi 实现 AOP
学会黑科技,一招搞定iOS 14.2的 libffi crash
Thunk程序的实现原理以及在iOS中的应用(二)
libffi/libffi