热点新闻
聊聊libffi的调用流程
2023-07-17 20:31  浏览:2819  搜索引擎搜索“混灰机械网”
温馨提示:信息一旦丢失不一定找得到,请务必收藏信息以备急用!本站所有信息均是注册会员发布如遇到侵权请联系文章中的联系方式或客服删除!
联系我时,请说明是在混灰机械网看到的信息,谢谢。
展会发布 展会网站大全 报名观展合作 软文发布

背景

花了点时间分析了下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); // 返回值地址

allocamalloc的区别在于,前者是在栈上开辟新的空间,后者是在堆上开辟新的空间。




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));

表中的一个个entrytrampoline属性指向跳板页的第二页 + 偏移。

*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当时对应埋入的clousurestart函数分别赋给x16x17br 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

发布人:35a6****    IP:101.229.64.***     举报/删稿
展会推荐
让朕来说2句
评论
收藏
点赞
转发