聊聊libffi的调用流程

背景

花了点时间分析了下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):
    /* 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

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

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

表中的一个个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     /* 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

//...
  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] /* 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基本就大同小异了:

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,457评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,837评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,696评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,183评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,057评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,105评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,520评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,211评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,482评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,574评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,353评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,897评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,489评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,683评论 2 335

推荐阅读更多精彩内容