聊聊libffi的调用机制

Posted by Wbq's Blog on May 15, 2022

背景

花了点时间分析了下libffi的调用机制,做个总结。

什么是libffi

libffi是ffi的主流实现方式,其主要是用C和汇编来实现的。

原理和用法市面上已经很多,下面这两篇是我觉得讲得较为通俗易懂的,这里就不做过多的解释了。

外部函数接口 FFI —— 虚拟机中重要但不起眼的组件

使用 libffi 实现 AOP

libffi的调用流程

PS:最近换了M1,所以以下的代码都是ARM64架构下的逻辑,libffi版本3.4.2

1.ffi_call

直接上手,第一种动态调用方式 ffi_call

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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,开辟栈空间

1
2
3
4
5
6
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.参数入栈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
 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

1
ffi_call_SYSV (context, frame, fn, rvalue, flags, closure);

有了函数地址和函数调用该有的环境,接下来进入真正调用的阶段,这部分是汇编实现的,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
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

image-20220510140445309.png

黑色以上部分是函数调用环境准备之后的状态。

2.ffi_closure

第二种动态创建函数进行调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
- (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.创建跳板页

1
2
3
4
5
6
7
8
9
/* 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* 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的作用是内存映射,通过它,我们就能实现一个对象通过多个不同的地址来进行访问,在这里是把上述0x01中的占位页的首地址映射到了一个函数上(ffi_closure_trampoline_table_page),该函数由汇编实现。

0x03.创建跳板表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/* 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;

跳板表在这里创建。

1
table->config_page = config_page

表的config_page指向跳板页的第一页。

1
2
entry->trampoline =
	(void *) (trampoline_page + (i * FFI_TRAMPOLINE_SIZE));

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

1
2
3
4
*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的实现

1
2
3
4
5
6
7
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个字节都填充了重复的实现,为什么要这么做呢?后面会讲到

image-20220512105954981.png

所以到时候调用funcInvoke()的时候,会跳两次到ffi_closure_trampoline_table_page上,最终会去做上面说的这个重复的实现。

到此为止一个closure算是创建完毕了,里面具备了基本的调用环境。

0x04.ffi_prep_closure_loc

1
2
3
4
5
6
7
8
9
10
11
12
//...
  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,经过一系列的反复横跳会来到这。又因为调用是带偏移的,再贴一下

1
2
3
4
5
6
for (i = 0; i < table->free_count; i++)
    {
 	// ...
  entry->trampoline =
	(void *) (trampoline_page + (i * FFI_TRAMPOLINE_SIZE));
}

这就是为什么ffi_closure_trampoline_table_page里的都是重复的实现,因为调用都是携带偏移的,在工程里会有很多这样的动态函数,哪个方法调进来事先是什么不知道,所以干脆整页全部填充重复实现了

1
2
3
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当时对应埋入的clousurestart函数分别赋给x16x17br x16跳转到start(ffi_closure_SYSV),在这块的实现思路跟第一部分的ffi_call_SYSV基本就大同小异了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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为什么会失败。当然了,这个问题咱也没碰到过,所以咱也不敢说。

4.参考

外部函数接口 FFI —— 虚拟机中重要但不起眼的组件

使用 libffi 实现 AOP

学会黑科技,一招搞定iOS 14.2的 libffi crash

Thunk程序的实现原理以及在iOS中的应用(二)

libffi/libffi