Android动态二进制插桩与指令级追踪

Published: 2025年11月03日

In Reverse.

Frida Stalker

我在几年前的文章中简单提过Frida的Stalker,当时它对arm32的支持还很有限而很多apk只有32位版本,所以俺觉得它还不太能用,现在,尽管它依然不是一个主流的trace工具,但赖不住frida太受欢迎了,它还是有分析的意义的!

用法

作为frida的组件当然首先看它的JS API,使用Stalker.follow([threadId, options])即可开始跟踪,有两类跟踪代码的用法,下面使用官方文档里的样例说明:

Stalker.follow(mainThreadId, {
  // 在哪些位置发出事件
  events: {
    call: true, // 函数调用指令(call/bl等)
    ret: false, // 返回指令(bx/lr等)
    exec: false, // 每条指令
    block: false, // 进入基本块时
    compile: false // 块被编译时
  },


  onReceive(events) {
      const parsedEvents = Stalker.parse(events); // 解析事件,它是列表,每个元素是union类型,如_GumRetEvent含发生位置,返回的目标位置,调用栈深度等信息
      parsedEvents.forEach(function (event) {
          const eventType = event[0]; // 事件类型
          // 处理....
      }
  },

  onCallSummary(summary) { // 如果只想统计事件次数,它会更高效
    console.log(JSON.stringify(summary)); // summary是{地址:次数}数据
  },
});

这里onReceiveonCallSummary只能指定一个,它会在每个Stalker.queueDrainInterval周期内(默认是250ms)被调用一次,可以设置为0,之后用Stalker.flush()手动调用,另外需注意Stalker.queueCapacity表示事件对列的大小,默认是16384。 至此已经可以做个简单的跟踪了,但若要实现更高级的,自定义的跟踪,则需要用到StalkerTransformer,它能对每个块转换暴露单个指令,可选择性的修改/删除/插入指令:

Stalker.follow(mainThreadId, {
  events: {  },
  transform(iterator) { // 对每个要转换的基本块
   let instruction = iterator.next();

   const startAddress = instruction.address;
   const isAppCode = startAddress.compare(appStart) >= 0 &&
       startAddress.compare(appEnd) === -1;

   const canEmitNoisyCode = iterator.memoryAccess === 'open';

   do {
       // 是期待的地址范围 并且是ret指令
     if (isAppCode && canEmitNoisyCode && instruction.mnemonic === 'ret') {
       // 如果60 < retValue < 90
       iterator.putCmpRegI32('eax', 60);
       iterator.putJccShortLabel('jb', 'nope', 'no-hint');

       iterator.putCmpRegI32('eax', 90);
       iterator.putJccShortLabel('ja', 'nope', 'no-hint');

       iterator.putCallout(onMatch);    // 调用外部回调

       iterator.putLabel('nope');
       // iterator.putChainingReturn()可提前返回,它会插入一些指令来调整栈深度,若配置了则发出返回事件,以及执行返回操作
     }

     iterator.keep();    // 保留原始指令
   } while ((instruction = iterator.next()) !== null);
  },
  function onMatch (context) { // Callout 输出上下文信息
    console.log('Match! pc=' + context.pc +
        ' rax=' + context.rax.toInt32());
  }
});

另外大多情况下Trace是需要更高性能的,可用Stalker.exclude(range)来排除掉不需要的区域,以及用CModule开发来减少不必要的上下文切换:

Stalker.follow(mainThreadId, {
  events: {},
  const cm = new CModule(\`

   #include <gum/gumstalker.h>

   static void on_ret (GumCpuContext * cpu_context,
      gpointer user_data);

   void
   transform (GumStalkerIterator * iterator,
               GumStalkerOutput * output,
               gpointer user_data)
   {
      cs_insn * insn;

      while (gum_stalker_iterator_next (iterator, &insn))
      {
      if (insn->id == X86_INS_RET)
      {
         gum_x86_writer_put_nop (output->writer.x86);
         gum_stalker_iterator_put_callout (iterator,
            on_ret, NULL, NULL);
      }

      gum_stalker_iterator_keep (iterator);
      }
   }

   static void
   on_ret (GumCpuContext * cpu_context,
            gpointer user_data)
   {
      printf ("on_ret!\n");
   }

   void
   process (const GumEvent * event,
            GumCpuContext * cpu_context,
            gpointer user_data)
   {
      switch (event->type)
      {
      case GUM_CALL:
         break;
      case GUM_RET:
         break;
      case GUM_EXEC:
         break;
      case GUM_BLOCK:
         break;
      case GUM_COMPILE:
         break;
      default:
         break;
      }
   }
   `);

  transform: cm.transform,
  onEvent: cm.process,
  data: ptr(1337) /* user_data */
});

一个块可能在运行过程中发生变化,它会在每次执行前检查是否发生了变化,直到Stalker.trustThreshold次每变化,则不再检查,我们也可以用Stalker.invalidate强制让块不被检查而直接重新生成。 除此外,Stalker还有个类似Interceptor.attach的功能叫做Stalker.addCallProbe(address, callback[, data]),它只能在函数调用前被触发,用于读取/修改函数参数或上下文,但优点是它是在重写时被加进去的,没有额外的开销所以性能高,同时它能被添加多次,会被依次调用,当然了,它能同时使用Interceptor.attach,但Interceptor是修改指令跳蹦床所以务必让`Stalker编译hook后的代码,例如用invalidate去强制重编译。在需要停止跟踪时,可用Stalker.unfollow,它会设置标记位,待被追踪代码在安全点会检查并自己退出。

在实际使用中我们通常只想跟踪指定的函数调用,有两种方式,一种是自己写transformer过滤,另一种更高效的方式是配合Interceptor.replace,后者在下面的QBDI会演示,思路一致,千万不要用Interceptor.attach,除非你把下面底层细节弄清楚了,否则一不小心就要崩!

JS暴露的API是很简单易用的,但如果要做很精细化的修改或高性能还是需要用C开发,详请读源码,另外官方也开源了frida-itrace,听名字就知道它和frida-trace类似,但它是用来trace指令的,感觉用起来一般,可作为学习案例。

底层细节

Frida本身实现了重定位器和汇编器、拦截器等基础组件,再实现Stalker会简单很多,从源码就能发现单纯Stalker的源文件只有几个,大fu子在官网专门写了篇文章1来深入解释了它,咱就参照它讲讲,这里以v8运行时与arm64目标为例分析,Stalker.follow的入口点在/bindings/gumjs/gumv8stalker.cpp

GUMJS_DEFINE_FUNCTION (gumjs_stalker_follow)
{
  auto stalker = _gum_v8_stalker_get (module);

  gpointer user_data;
  // 解析js里的参数,如关心的事件,transformer等
  if (!_gum_v8_args_parse (args, "ZF*?uF?F?pp", &thread_id, &transformer_callback_js, &transformer_callback_c, &so.event_mask, &so.on_receive, &so.on_call_summary, &so.on_event, &user_data)) 
    return;

  so.user_data = user_data;

  GumStalkerTransformer * transformer = NULL;

  if (!transformer_callback_js.IsEmpty ())  // 如果是JS Transformer则封装为GumV8CallbackTransformer
  {
    auto cbt = (GumV8CallbackTransformer *)
        g_object_new (GUM_V8_TYPE_CALLBACK_TRANSFORMER, NULL);
    cbt->thread_id = thread_id;
    cbt->callback = new Global<Function> (isolate, transformer_callback_js);
    cbt->module = module;

    transformer = GUM_STALKER_TRANSFORMER (cbt);
  }
  else if (transformer_callback_c != NULL) // 如果是CModule写的,则直接使用,尽管它只作用于代码生成阶段,依然能提升很多效率
  {
    transformer = gum_stalker_transformer_make_from_callback (
        transformer_callback_c, user_data, NULL);
  }

  auto sink = gum_v8_event_sink_new (&so);  // 根据选项创建事件接收器
  if (thread_id == gum_process_get_current_thread_id ()) // 如果是当前线程,则设置为pending而不是立刻执行,否则会导致环境错乱
  {
    ScriptStalkerScope * scope = &core->current_scope->stalker_scope; 
    scope->pending_level = 1; // 这里绑定为1,表示有一个pending的follow请求,它在scope上,其实它会在scope销毁时执行 (ScriptStalkerScope::~ScriptStalkerScope -> _gum_v8_stalker_process_pending -> gum_stalker_follow_me)
    scope->transformer = transformer;
    scope->sink = sink;
  }
  else
  {
    gum_stalker_follow (stalker, thread_id, transformer, sink); // 如果是其它线程则直接调用,它会去感染该线程,立即生效(gum_process_modify_thread->gum_stalker_infect)
  }
}

无论是追踪当前线程还是其它线程最后做的事都差不多,这里以本线程为例,继续看gum_stalker_follow_me的实现,它是汇编写的:

.globl gum_stalker_follow_me ; 这里如果是apple符号前还要再加个_
.type gum_stalker_follow_me, %function
gum_stalker_follow_me:

  stp x29, x30, [sp, -16]!  ; 将FP和LR入栈
  mov x29, sp              ; 设置当前栈为新的桢
  mov x3, x30             ; 保存返回地址到x3 即作为第四个参数
  bl _gum_stalker_do_follow_me  ; 调用函数 (前三个函数在C里传入的 _gum_v8_stalker_get (self), scope->transformer, scope->sink)
  ldp x29, x30, [sp], 16  ; 恢复FP和LR
  br x0               ; 跳转到_gum_stalker_do_follow_me返回的地址去执行,注意这里没有直接返回上层的函数调用点

在也很简单:

gpointer _gum_stalker_do_follow_me (GumStalker * self, GumStalkerTransformer * transformer, GumEventSink * sink, gpointer ret_addr)
{
  // 创建执行上下文
  GumExecCtx * ctx = gum_stalker_create_exec_ctx (self, gum_process_get_current_thread_id (), transformer, sink);
  g_private_set (&gum_stalker_exec_ctx_private, ctx); // 设置线程本地存储
  // 编译第一个块
  ctx->current_block = gum_exec_ctx_obtain_block_for (ctx, ret_addr, &code_address);

  if (gum_exec_ctx_maybe_unfollow (ctx, ret_addr))  // 检查是否需要取消追踪
  {
    gum_stalker_destroy_exec_ctx (self, ctx);
    return ret_addr;
  }

  gum_event_sink_start (ctx->sink); // 事件接收器开始工作
  ctx->sink_started = TRUE;

  return (guint8 *) code_address + GUM_RESTORATION_PROLOG_SIZE; // 跳过序言(ldp x16, x17, [sp], #0x90),这两个寄存器会作为临时寄存器使用,下面会再见到,这里是不需要恢复所以跳过了它
}

但它引入了一个重要的数据结构,GumExecCtx,该结构被用于管理一个被跟踪线程(或“被 Stalker 跟踪的执行流”)的所有状态、代码缓存、事件处理、内存分配等资源,这里完整说下它的各个域:

struct _GumExecCtx
{
  volatile gint state;  // 生命周期状态(如 GUM_EXEC_CTX_ACTIVE, GUM_EXEC_CTX_UNFOLLOWING, GUM_EXEC_CTX_DESTROYED 等)
  GumExecCtxMode mode;  // 执行模式,如GUM_EXEC_CTX_NORMAL 或 GUM_EXEC_CTX_SINGLE_STEPPING_ON_CALL等
  gint64 destroy_pending_since; // 记录请求销毁的时间戳,调试或超时清理用

  GumStalker * stalker; // 所属的 Stalker 实例
  GumThreadId thread_id;  // 此上下文跟踪的线程ID

  GumX86Writer code_writer; // 快速路径的重写器
  GumX86Writer slow_writer; // 慢速路径的重写器
  GumX86Relocator relocator;  // 重定位器,将原始指令反汇编并重定位,以便插入桩代码

  GumStalkerTransformer * transformer;   // 转换器,上面提到它实现对一个块的指令做转换,如添加探针或修改指令流
  GumEventSink * sink;  // 事件接收器,用于接收 GUM_EXEC, GUM_CALL, GUM_RET, GUM_BLOCK 等事件
  gboolean sink_started; // 标记是否已调用 sink->start(),确保只启动一次
  GumEventType sink_mask; // 事件类型掩码,指定感兴趣的事件类型(event参数里指定的那些)
  GumStalkerObserver * observer; // 可选的观察者接口,用于监控 Stalker 内部行为(如代码块生成数量、性能统计等),下面要讲的GUM_ENTRYGATE会调用它

  gboolean unfollow_called_while_still_following; // 标记是否在仍处于 follow 状态时调用了 unfollow,用于安全清理
  GumExecBlock * current_block; // 当前正在执行的 instrumented 代码块
  gpointer pending_return_location; // 用于模拟 调用栈深度,它是最近一次 CALL 的返回地址
  guint pending_calls; // 记录未匹配的 CALL 指令数量

  gpointer resume_at; // 从 instrumented 代码返回到原始代码时的地址
  gpointer return_at; // 在某些 hook 场景中,指定返回目标(如从回调返回)
  gpointer app_stack; // 原始应用栈指针的快照(用于上下文切换或异常处理)
  gconstpointer activation_target; // 用于延迟激活跟踪的起始地址(如 follow_me 后从某地址开始跟踪)

  gpointer thunks;  // 指向一段预分配的“小函数”(thunk)内存,用于快速跳转、回调调用等,感染用的
  gpointer infect_thunk; // 一个特殊的 thunk,用于将执行流“注入”到 Stalker(即从原始代码跳转到 instrumented 代码)
  GumAddress infect_body; // infect_thunk 中实际跳转目标的地址,通常指向 gum_exec_ctx_infect 的机器码

  GumSpinlock code_lock;
  GumCodeSlab * code_slab; // 存放 instrumented 代码块(可执行)
  GumSlowSlab * slow_slab; // 存放慢路径代码(如回调 stub)
  GumDataSlab * data_slab; // 存放数据(如上下文、元信息)
  GumCodeSlab * scratch_slab; // 临时代码生成空间(如重定位中间结果)
  GumMetalHashTable * mappings; // 将原始代码地址映射到对应的 GumExecBlock,用于快速查找是否已有 instrumented 缓存
  // 下面5个是助手函数指针,对于一些经常用到的操作stalker不会内联实现,而是调用这些助手函数
  gpointer last_prolog_minimal; // 最小化序言,它只保存必要上下文信息,效率会高很多,用于内部代码,它们都遵循可控的寄存器使用约定,如X23寄存器不会被保存,它总是由被调用者按需保存
  gpointer last_epilog_minimal; // 最小化尾声,和last_prolog_minimal配对的逆操作
  gpointer last_prolog_full;  // 完整序言,它保存完整的上下文信息(如所有的通用寄存器状态),通常用于callout等跳到用户代码的场景,因为这个场景用户可能会修改寄存器或对寄存器的使用约定不可控
  gpointer last_epilog_full;  // 完整尾声,和last_prolog_full配对的逆操作
  gpointer last_invalidator; // 使缓存的代码块失效

  GumExecBlock * block_list; // 所有生成的 GumExecBlock 通过单链表连接,便于遍历、释放或失效

  gint depth; // x86 特有:当启用 GUM_CALL 或 GUM_RET 事件时,跟踪调用栈深度

#ifdef HAVE_LINUX
  gpointer last_int80; // 缓存 int 0x80(32-bit)和 syscall(64-bit)指令的 instrumented 版本,用于拦截系统调用
  gpointer last_syscall;
  GumAddress syscall_end; // 系统调用指令的结束地址(用于重定位)
# ifndef HAVE_ANDROID
  GumMetalHashTable * excluded_calls; // 哈希表,记录被排除的函数调用地址(如 gum_stalker_exclude() 指定的地址),Stalker 遇到这些地址时不进行插桩
# endif
#endif
};

注:在助手函数上处理也存在小细节,因为arm64的直接跳转范围有限(±128MB),如果重写块的slab地址与它太远它就调不到,所以它在gum_exec_ctx_new中就用gum_exec_ctx_ensure_inline_helpers_reachable来确保可达,后者做的事就是如果相距太远就复制一份新的(所以命名为last_xxx)

这里还要提一个接下来就要用到的重要结构,GumExecBlock,它封装了原始代码块(basic block)经过 Stalker 分析、重写、插桩后生成的所有相关信息,包括重写后的机器码、元数据、内存归属等:

struct _GumExecBlock
{
  GumExecBlock * next;  // 用于将所有 GumExecBlock 实例串成一个单向链表,由上面的GumExecCtx.block_list 指向头节点
  GumExecCtx * ctx; // 指向所属的 执行上下文(execution context)
  GumCodeSlab * code_slab; // 该块的 instrumented 快速路径代码 所在的代码 slab(可执行内存)
  GumSlowSlab * slow_slab; // 该块的 慢速路径代码(如回调 stub) 所在的 slab
  GumExecBlock * storage_block; // 该块自身的元数据(即 GumExecBlock 结构体) 被分配在哪个数据 slab 中

  guint8 * real_start; // 原始(未插桩)代码块的起始地址,即应用中的真实指令地址
  guint8 * code_start; // 插桩后快速路径代码的起始地址
  guint8 * slow_start; // 慢速路径代码的起始地址,例如用于调用 JS 回调的跳板代码
  guint real_size; //  原始代码块的字节大小
  guint code_size; // 生成的 instrumented 快速路径代码大小
  guint slow_size; // 慢速路径代码大小
  guint capacity; // 为该块在 slab 中预留的总空间(通常 ≥ code_size + slow_size),用于支持可能的扩展(如动态插入新 callout)
  guint last_callout_offset; // 记录最后一个 callout(回调插入点) 在 code_start 中的偏移,用于在块已存在时追加新的 callout(例如 transformer 动态修改),避免重复生成整个块

  GumExecBlockFlags flags; // 描述该块的属性?
  gint recycle_count; // 被复用次数,比如和trust_threshold比较来决定是否信任代码从而跳过原始代码比较,在预取中直接设置它来初始化信任级别,或在后补丁(下面会讲)中决定是否应用优化

  GumIcEntry * ic_entries; // 内联缓存(Inline Cache Entries,但俺觉叫Indirect Call Cache也合适)条目链表的头指针,该条目默认2个,可设置为最多32个,每个条目包含real_start和code_start,当命中时跳转到code_start以提高间接调用性能
};

这里需要注意code_real_这种命名方式分别代表重写的代码和原始代码,它们一般成对出现,别搞混了!现在继续看:

static GumExecBlock * gum_exec_ctx_obtain_block_for (GumExecCtx * ctx, gpointer real_address, gpointer * code_address)
{
  GumExecBlock * block = gum_metal_hash_table_lookup (ctx->mappings, real_address);  // 查缓存是否存在
  if (block != NULL)
  { 
    // 缓存存在,检查是否需要重新编译:循环次数有没有超过阈值,内存内容有没有变化
    const gint trust_threshold = ctx->stalker->trust_threshold;
    gboolean still_up_to_date = (trust_threshold >= 0 && block->recycle_count >= trust_threshold) || memcmp (block->real_start, gum_exec_block_get_snapshot_start (block), block->real_size) == 0;

    if (still_up_to_date)
    {
      if (trust_threshold > 0)
        block->recycle_count++;
    }
    else
    {
      gum_exec_ctx_recompile_block (ctx, block);  // 发生了变化或信任阈值为-1  重新编译
    }
  }
  else
  {
    block = gum_exec_block_new (ctx); // 缓存不存在,创建新的编译块
    block->real_start = real_address;
    gum_exec_block_maybe_inherit_exclusive_access_state (block, block->next); // 继承独占访问状态
    gum_exec_ctx_compile_block (ctx, block, real_address, block->code_start,
        GUM_ADDRESS (block->code_start), &block->real_size, &block->code_size,
        &block->slow_size);
    gum_exec_block_commit (block);  // 提交编译块
    gum_exec_block_propagate_exclusive_access_state (block); // 传播独占访问状态

    gum_metal_hash_table_insert (ctx->mappings, real_address, block); // 插入缓存

    gum_exec_ctx_maybe_emit_compile_event (ctx, block); // 触发编译事件
  }

  *code_address = block->code_start;

  return block;
}

结下来是重写的逻辑了,一定要分清楚哪些代码是重写器的,哪些是被生成将来执行的!

static void gum_exec_ctx_compile_block (GumExecCtx * ctx, GumExecBlock * block, gconstpointer input_code, gpointer output_code, GumAddress output_pc, guint * input_size, guint * output_size, guint * slow_size)
{
  // 重置快/慢速路径汇编器 重置重定位器
  gum_arm64_writer_reset (cw, output_code);
  gum_arm64_writer_reset (cws, block->slow_start);
  gum_arm64_relocator_reset (rl, input_code, cw);
  // 对安卓(10+)去确保页面有RWX权限
  gum_ensure_code_readable (input_code, ctx->stalker->page_size);

  // 恢复x16 x17寄存器,它是stalker的临时寄存器,比如用来存长距离跳转的目标地址,存块切换的控制流信息等
  gum_arm64_writer_put_ldp_reg_reg_reg_offset (cw, ARM64_REG_X16, ARM64_REG_X17, ARM64_REG_SP, 16 + GUM_RED_ZONE_SIZE, GUM_INDEX_POST_ADJUST);
  // 如果存在,插入callProbe调用代码
  gum_exec_block_maybe_write_call_probe_code (block, &gc);

  ctx->pending_calls++; // 防止递归编译
  ctx->transform_block_impl (ctx->transformer, &iterator, &output); // 处理每条指令
  ctx->pending_calls--;

  if (gc.continuation_real_address != NULL) // 是否还有后续指令(大多场景都有,除了exit等指令)
  {
    continue_target.absolute_address = gc.continuation_real_address;
    continue_target.reg = ARM64_REG_INVALID;
    gum_exec_block_write_jmp_transfer_code (block, &continue_target, GUM_ENTRYGATE (jmp_continuation), &gc);
  }

  gum_arm64_writer_put_brk_imm (cw, 14);    // 写入断点指令,正常情况它一定不会执行(如果有后续指令在上一步就跳走了,要么程序就执行完了,插入断点方便调试)
  all_labels_resolved = gum_arm64_writer_flush (cw);    // 写指令
  all_slow_labels_resolved = gum_arm64_writer_flush (cws);
}

先看看GUM_ENTRYGATE (jmp_continuation),这个宏展开其实就是当存在观察者(上面提到的调试/性能分析用)时则告知,然后调用gum_exec_ctx_switch_block (ctx, block, start_address, from_insn),gum_exec_ctx_switch_block和_gum_stalker_do_follow_me类似,都是跟踪新的块,只是少了执行上下文创建的环节,多了一些判断而已。 咱们回到transform_block_impl,如果没有指定则默认会是gum_default_stalker_transformer_transform_block,上面提了它会调用XX_nextXX_keep两个,前者无须多言,后者实现如下:

void gum_stalker_iterator_keep (GumStalkerIterator * self)
{
  switch (insn->id) // 标记独占指令,对独占指令要特殊处理,例如不插入事件/不切换上下文等,尽量避免独占监视器复位
  {
    case ARM64_INS_LDXR: // 这里省略了多种独占读写
      block->flags |= GUM_EXEC_BLOCK_HAS_EXCLUSIVE_LOAD;
      break;
    case ARM64_INS_STXR:  
      block->flags |= GUM_EXEC_BLOCK_HAS_EXCLUSIVE_STORE;
      break;
  }

  if ((self->exec_context->sink_mask & GUM_EXEC) != 0 &&(block->flags & GUM_EXEC_BLOCK_USES_EXCLUSIVE_ACCESS) == 0)
  { // 如果对exec事件感兴趣且不在独占访问指令间,则对每条指令发出执行事件
    gum_exec_block_write_exec_event_code (block, gc, GUM_CODE_INTERRUPTIBLE);
  }

  switch (insn->id) // 根据指令类型处理,默认直接重定位复制,但对三类指令特殊处理,因为他们会到达新块,要保证持续的trace能力
  {
    case ARM64_INS_B:    // 这里也是省略了很多同类指令,虚拟化(分支)跳转....
      requirements = gum_exec_block_virtualize_branch_insn (block, gc);
      break;
    case ARM64_INS_RET: // 虚拟化返回指令...
      requirements = gum_exec_block_virtualize_ret_insn (block, gc);
      break;
    case ARM64_INS_SVC: // 虚拟化系统调用指令
      requirements = gum_exec_block_virtualize_sysenter_insn (block, gc);
      break;
    case ARM64_INS_SMC:
    case ARM64_INS_HVC:
      g_assert_not_reached ();
      break;
    default:
      requirements = GUM_REQUIRE_RELOCATION;
  }

  gum_exec_block_close_prolog (block, gc, gc->code_writer);    // 闭合序言(就是写调尾声助手的代码,但它还有状态检查,保证和序言成对)

  if ((requirements & GUM_REQUIRE_RELOCATION) != 0)
    gum_arm64_relocator_write_one (rl); // 重定位复制一条

  self->requirements = requirements;
}

这里的虚拟化(virtualize)做的事差不多,挑一个RET的来讲,它既简单包含的内容有够多:

static GumVirtualizationRequirements gum_exec_block_virtualize_ret_insn (GumExecBlock * block,
                                    GumGeneratorContext * gc)
{
  if ((block->ctx->sink_mask & GUM_RET) != 0) // 关注ret则发出事件
    gum_exec_block_write_ret_event_code (block, gc, GUM_CODE_INTERRUPTIBLE);

  insn = gc->instruction;
  arm64 = &insn->ci->detail->arm64;

  if (arm64->op_count == 0) // 如果没指定默认从X30里返回
  {
    ret_reg = ARM64_REG_X30;
  }
  else
  {
    op = &arm64->operands[0];    // 否则使用指定的寄存器作为返回目的地
    ret_reg = op->reg;
  }
  gum_arm64_relocator_skip_one (gc->relocator); // 跳过该指令
  gum_exec_block_write_ret_transfer_code (block, gc, ret_reg); // 调gum_exec_block_write_chaining_return_code

  return GUM_REQUIRE_NOTHING;
}

static void gum_exec_block_write_chaining_return_code (GumExecBlock * block, GumGeneratorContext  * gc, arm64_reg ret_reg)
{
  gum_exec_ctx_write_adjust_depth (ctx, cw, -1);    // 调整调用层级深度
  gum_exec_block_close_prolog (block, gc, cw);  // 恢复上下文

  if (trust_threshold >= 0) // 如果信任阈值不为负,就是所缓存优化可用,走这里
  {
    result_reg = gum_exec_block_write_inline_cache_code (block, ret_reg, cw, cws);  // 这里面是生成快路径的代码,它会分配ic_entries条目,并生成判断如果缓存命中直接跳转否者转到慢路径的代码
    gum_arm64_writer_put_br_reg_no_auth (cw, result_reg);
  }
  else
  {
    gum_exec_block_write_slab_transfer_code (cw, cws);  // 从快路径跳到慢路径 ,就一句:gum_arm64_writer_put_branch_address (from, GUM_ADDRESS (to->code))
  }
  gum_arm64_writer_put_stp_reg_reg_reg_offset (cws, ARM64_REG_X16, ARM64_REG_X17, ARM64_REG_SP, -(16 + GUM_RED_ZONE_SIZE), GUM_INDEX_PRE_ADJUST); // 保存X16 X17,空出来接下来会用它

  if (ret_reg != ARM64_REG_X16)
    gum_arm64_writer_put_mov_reg_reg (cws, ARM64_REG_X16, ret_reg); // 将返回地址放X16里

  if ((ctx->stalker->cpu_features & GUM_CPU_PTRAUTH) != 0)
    gum_arm64_writer_put_xpaci_reg (cws, ARM64_REG_X16);    // 处理指针认证,如果再ios/mac等平台它会剥离PAC

  gum_arm64_writer_put_ldr_reg_address (cws, ARM64_REG_X17, GUM_ADDRESS (&ctx->return_at)); // 把return_at的地址放到X17里
  gum_arm64_writer_put_str_reg_reg_offset (cws, ARM64_REG_X16, ARM64_REG_X17, 0);   // 将X16,也就是返回地址放到return_at里

   gum_arm64_writer_put_ldp_reg_reg_reg_offset (cws, ARM64_REG_X16, ARM64_REG_X17, ARM64_REG_SP, 16 + GUM_RED_ZONE_SIZE, GUM_INDEX_POST_ADJUST); // 恢复临时寄存器

  gum_exec_block_open_prolog (block, GUM_PROLOG_MINIMAL, gc, cws);  // 打开序言(保存最小上下文)

  gum_arm64_writer_put_ldr_reg_address (cws, ARM64_REG_X0, GUM_ADDRESS (&ctx->return_at));  // 将刚保存的返回地址给X1
  gum_arm64_writer_put_ldr_reg_reg_offset (cws, ARM64_REG_X1, ARM64_REG_X0, 0);

  // 调用 ret_slow_path 查找目标块
  gum_arm64_writer_put_call_address_with_arguments (cws,
    GUM_ADDRESS (GUM_ENTRYGATE (ret)), 3,   // 这就是上面提到的宏,最后实际调的就是gum_exec_ctx_switch_block
    GUM_ARG_ADDRESS, GUM_ADDRESS (block),   
    GUM_ARG_REGISTER, ARM64_REG_X1, // 这里的 X1 是上面保存的 return_at,新的start_address
    GUM_ARG_ADDRESS, GUM_ADDRESS (gc->instruction->start)); // from_addr

  if (trust_threshold >= 0)
  { // 获取当前块的地址
    gum_arm64_writer_put_ldr_reg_address (cws, ARM64_REG_X0, GUM_ADDRESS (&ctx->current_block));
    gum_arm64_writer_put_ldr_reg_reg_offset (cws, ARM64_REG_X0, ARM64_REG_X0, 0);
    // 将当前块回填到ic_entries缓存
    gum_arm64_writer_put_call_address_with_arguments (cws,
        GUM_ADDRESS (gum_exec_block_backpatch_inline_cache), 3, // 它做的事是先判断是否已经结束追踪/是否有ic_entries/当前条目是否存在 然后将已有的整体后移一位,再将当前块放进去
        GUM_ARG_REGISTER, ARM64_REG_X0,
        GUM_ARG_ADDRESS, GUM_ADDRESS (block),
        GUM_ARG_ADDRESS, GUM_ADDRESS (gc->instruction->start));
    // 这里执行一个特殊的回填操作,gum_exec_block_backpatch_slab中判断返回地址是否在当前执行上下文之中(比如transformer中生成了BL),如果是就做类似的缓存操作 (不同的是code和real地址都是同一个,指向了ret_reg的地址,因为这一块已经被插桩了)
    gum_exec_ctx_load_real_register_into (ctx, ARM64_REG_X1, ret_reg, gc, cws);
    gum_arm64_writer_put_call_address_with_arguments (cws,
        GUM_ADDRESS (gum_exec_block_backpatch_slab), 2,
        GUM_ARG_ADDRESS, GUM_ADDRESS (block),
        GUM_ARG_REGISTER, ARM64_REG_X1);
  }

  gum_exec_block_close_prolog (block, gc, cws); // 生成恢复上下文代码
  gum_exec_block_write_exec_generated_code (cws, ctx); // 生成代码
}

至此,就有个完整的流程了!现在可以再想想几个问题:为什么它可以对抗完整性校验与检测?为什么在自修改/动态生成的代码上也会生效?每次函数返回时都会返回到stalker吗?当指定了排除范围,它执行完非跟踪的范围后为什么能继续回到追踪区域?指针认证会有影响吗?如何开发高效的追踪?

QBDI

QBDI是现在最常用的移动端动态二进制插桩工具,它的原理和Frida Stalker类似,理论上讲它们的能力是差不多的,但实际上QBDI直接提供了更强大的功能,先看一眼它的架构图:

api_architecture_simple.svg

它将我们外部的代码环境叫做host,比如我们开发的插桩回调函数在host里执行,而被插桩的代码会在guest里,这里先看看它的用法再深入分析~

用法

它直接支持两种插桩:

1.指令级插桩:包括addMnemonicCB/addCode[Range|Addr|]CB这类直接的指令插桩和addMemAccessCB/addMem[Range|Addr]CB这类内存访问,其实都是同样的类型,前者能在指令执行前/后添加回调,而后者能在内存读/写指令执行处添加回调

2.虚拟机事件插桩:它只有一个addVMEventCB能在如下虚拟机事件发生时调用回调:

typedef enum {
  _QBDI_EI(NO_EVENT) = 0,
  _QBDI_EI(SEQUENCE_ENTRY) = 1,            // 进入一个序列时,一个sequence由一个或多个基本块组成,表示一段连续的 无序重新翻译的机器码
  _QBDI_EI(SEQUENCE_EXIT) = 1 << 1,        // 从当前序列退出时
  _QBDI_EI(BASIC_BLOCK_ENTRY) = 1 << 2,    // 进入一个基本块时
  _QBDI_EI(BASIC_BLOCK_EXIT) = 1 << 3,     // 从当前基本块退出时
  _QBDI_EI(BASIC_BLOCK_NEW) = 1 << 4,      // 进入一个新的基本块(编译新的/未执行过的基本块)
  _QBDI_EI(EXEC_TRANSFER_CALL) = 1 << 5,   // ExecBroker执行一个执行转移时,比如切换到外部函数/回调时
  _QBDI_EI(EXEC_TRANSFER_RETURN) = 1 << 6, // ExecBroker从一个执行转换返回时
} VMEvent;

注:

1.虽然内存访问事件和指令级插桩似乎截然不同,但实际它们底层是一样的,后文会给出分析

2.如果看文档3会发现还一类叫做addInstrRule[Range|]的回调,它其实和上面两类的定位存在较大差别,它更像是上面Frida Stalker里的transformer,用来对每条指令编写自定义生成规则的,下面会详细分析。

QBDI支持多种开发方式,除了C/C++外,还有Frida(JS)/Python的绑定,先来感受下,以frida-qbdi为例,在解压压缩包后,将frida-qbdi.js复制到开发目录,将libQBDI.so推到手机的/data/local/tmp下(其它位置或名称需要修改frida-qbdi.js否则找不到),并修改权限chmod 755 ...,修改SE为chcon u:object_r:app_data_file:s0 ...或者直接禁用setenforce 0,之后就可以联合使用了,要用它,就是把原来直接调用的函数改成用qbdi虚拟机去调用,比如:

function traceExec(ctx, funcPtr, args, postSync) {
    vm = new VM(); // 创建虚拟机
    let state = vm.getGPRState(); // 获取通用寄存器状态
    state.synchronizeContext(ctx, SyncDirection.FRIDA_TO_QBDI); // 同步信息,将frida里直接拷过来
    let stack = vm.allocateVirtualStack(state, 0x100000); // 分配一个虚拟栈,要够大,后续trace的执行都在这个栈上

    if (config.targetLibrary) {
        const success = vm.addInstrumentedModule(config.targetLibrary); // 添加目标库的插桩范围
    } else {
        const success = vm.instrumentAllExecutableMaps(); // 插桩所有可执行内存区域
    }

    const callback = vm.newInstCallback(function (vm, gpr, fpr, data) { // 指令级trace的回调
        const pc = gpr.getRegister(REG_PC);
        if (!isTargetLibrary(pc)) { // 检查是否在目标库中
            return VMAction.CONTINUE;
        }

        // 获取指令分析,需要哪些分析数据需要手动指定,从性能角度只指定必要的类型
        const inst = vm.getInstAnalysis(AnalysisType.ANALYSIS_OPERANDS | AnalysisType.ANALYSIS_INSTRUCTION | AnalysisType.ANALYSIS_DISASSEMBLY);
        if (!inst) return VMAction.CONTINUE;
        // 调试:输出所有指令
        logColor(`指令信息:${inst.address.toString(16)}:${inst.disassembly}`, colors.green);
        return VMAction.CONTINUE; // 继续执行
    });

    const callbackId = vm.addCodeCB(InstPosition.PREINST, callback); // 添加指令回调(在指令执行前)

    const memAccCB = vm.newInstCallback( // 内存访问的回调
        (vm, gpr, fpr, data) => {
            let memoryAccessArr = vm.getInstMemoryAccess();
            memoryAccessArr.forEach(memoryAccess => {
                if (memoryAccess.accessAddress === 0) { // 如果访问到0就输出
                    console.log(JSON.stringify(memoryAccess))
                }
            })
            return VMAction.CONTINUE;
        }
    )
    vm.addMemAccessCB(MemoryAccessType.MEMORY_READ_WRITE, memAccCB)

    vm.call(funcPtr, args); // 在虚拟机里开始执行
    let retVal = state.getRegister(REG_RETURN); // 获取返回值,不同架构的REG_RETURN是不一样的,如x86是eax,这里用"宏定义"安全点
    if (postSync) {
        state.synchronizeContext(ctx, SyncDirection.QBDI_TO_FRIDA);
    }
    return retVal;
}

但是在逆向中更常见的例子是配合frida一起使用,让它拦截追踪真实环境的调用,关键是下面两个函数2

function hookDoCommandNative() {
    let base_address = Process.getModuleByName(config.targetLibrary).base;
    const targetAddress = base_address.add(DO_COMMAND_OFFSET);
    function processJniOnLoad(funcPtr) {
        // 这里用replace防止原始函数也被执行
        doCommandReplacement = Interceptor.replace(funcPtr, new NativeCallback(function (env, thiz, command, params) {
            Interceptor.revert(funcPtr); // 恢复原始函数的指令,vm里执行才不会出错
            Interceptor.flush(); // 确保修改已提交
            let retVal = traceExec(this.context, funcPtr, [env, thiz, command, params], true); // 这里是trace的逻辑
            processJniOnLoad(funcPtr); // 执行完后,重新替换掉,保证下次调用还是会被trace
            return retVal;
        }, "pointer", ["pointer", "pointer", "int32", "pointer"]));
    }

    processJniOnLoad(targetAddress);
}

// 保证不会遗漏
hookSo(config.targetLibrary, () => {
    let JNIOnload = Process.getModuleByName(config.targetLibrary).getExportByName("JNI_OnLoad"); // 开始追踪do command
    Interceptor.attach(JNIOnload, {
        onLeave: function (_) { // 在它刚被加载,还没有被外部主动调用前马上hook
            hookDoCommandNative();
        }
    })
});

其它的用法见文档,它的用法很简单,文档有非常详细的描述!

底层细节

添加指令回调

虚拟机事件是虚拟机管理代码里的,这里先看指令回调,回忆下Frida Stalker用了自己的(其实是Capstone/KeyStone)反汇编器/汇编器/重定位器,而QBDI使用了LLVM的MC来做汇编和反汇编等,不过它不是直接用MC写补丁,因为这非常复杂,相反他们定义了自己的补丁DSL,在里面他们用了Temp和Shadow来存放辅助数据,前者相当于虚拟寄存器,后则是更强大的虚拟空间,用于存放每条指令的分析/访存信息等:

_images/patchdsl_concepts.svg

咱先从InstRule,即插桩规则开始,它由两部分组成:条件和动作,它用于告知QBDI遇到什么指令时做什么事,以vm.addMemAccessCB(MemoryAccessType.MEMORY_READ_WRITE, memAccCB)为例,它表示当发生内存读时调用memAccCB,选用这个例子能包含内存事件与指令追踪两个部分,咱直接看源码:

uint32_t VM::addMemAccessCB(MemoryAccessType type, InstCallback cbk, void *data,
                            int priority) {
  QBDI_REQUIRE_ACTION(cbk != nullptr, return VMError::INVALID_EVENTID);
  recordMemoryAccess(type); // 这里开启内存访问记录
  switch (type) {
    case MEMORY_READ_WRITE:
      return engine->addInstrRule(InstrRuleBasicCBK::unique(     // 这里是InstrRuleBasicCBK,先注意下面会介绍
          Or::unique(conv_unique<PatchCondition>(DoesReadAccess::unique(), // OR 条件,在读写时都触发
                                                 DoesWriteAccess::unique())),
          cbk, data, InstPosition::POSTINST, true, priority,    // 在指令后触发用户的回调,breakToHost=true表示会转移到host去执行代码
          RelocTagPostInstStdCBK)); // 重定位标签为PostInstStdCBK
    // ...
  }
}

这里先看recordMemoryAccess,它做的事就是根据内存访问类型,对读/写指令插桩,记录读写指令的访问的内存及值:

bool VM::recordMemoryAccess(MemoryAccessType type) {
  if (type & MEMORY_READ && !(memoryLoggingLevel & MEMORY_READ)) {  // 这里判断标志位防止重复插桩
    memoryLoggingLevel |= MEMORY_READ;
    for (auto &r : getInstrRuleMemAccessRead()) {   // 获取内存读的插桩规则
      engine->addInstrRule(std::move(r));   // 添加规则到引擎中
    }
  }
  // ... 省略写内存的处理代码
}

std::vector<std::unique_ptr<InstrRule>> getInstrRuleMemAccessRead() {
  // 这里返回了两条规则
  return conv_unique<InstrRule>(
      // 第一条规则
      InstrRuleDynamic::unique(And::unique(conv_unique<PatchCondition>( // 这里有两个条件,封装到And条件里
                                   DoesReadAccess::unique(),    // 当前指令是内存读
                                   Not::unique(IsMOPSReadPrologue::unique()))), // 且不是MOPS,MOPS指令是ARM架构的一类特殊指令,它和普通的访存指令有区别,这里要排除掉,下面单独处理
                               generateReadInstrumentPatch, PREINST, false, // 在指令执行前插入读内存的插桩代码
                               PRIORITY_MEMACCESS_LIMIT + 1,
                               RelocTagPreInstMemAccess),
      // ... 省略了第二条规则:MOPS读序言指令的规则
  );
}

bool DoesReadAccess::test(const Patch &patch, const LLVMCPU &llvmcpu) const {
  return getReadSize(patch.metadata.inst, llvmcpu) > 0; // Patch结构含原始指令的各种信息(metadata)和插桩后的指令信息
}

const PatchGenerator::UniquePtrVec &generateReadInstrumentPatch(Patch &patch, const LLVMCPU &llvmcpu) {
  if (llvmcpu.hasOptions(Options::OPT_DISABLE_MEMORYACCESS_VALUE)) {    // 如果禁用了内存访问值的记录,就不会加载实际的内存值
    static const PatchGenerator::UniquePtrVec r = conv_unique<PatchGenerator>(
        GetReadAddress::unique(Temp(0)),
        WriteTemp::unique(Temp(0), Shadow(MEM_READ_ADDRESS_TAG)));  // 只记录地址信息
    return r;
  }
  switch (getReadSize(patch.metadata.inst, llvmcpu)) {  // 根据读的大小
    case 1:    case 2:    case 3:    case 4:    case 6:    case 8: {
      static const PatchGenerator::UniquePtrVec r = conv_unique<PatchGenerator>(
          GetReadAddress::unique(Temp(0)),  // 获取读地址,存到临时寄存器Temp(0)
          WriteTemp::unique(Temp(0), Shadow(MEM_READ_ADDRESS_TAG)), // 把读地址写到影子变量(MEM_READ_ADDRESS_TAG标签)里
          GetReadValue::unique(Temp(0), Temp(0), 0),    // 根据地址Temp(0)获取读值,存到Temp(0)
          WriteTemp::unique(Temp(0), Shadow(MEM_READ_VALUE_TAG))); // 把读值写到影子变量MEM_READ_VALUE_TAG
      return r;
    }
    // ....省略其它大小的处理代码
  }
}

再回到addMemAccessCB里,它用InstrRuleBasicCBK封装了用户的回调,它就是生成汇编将回调相关的数据写到hostState相关域里,在下一节会看到对它的使用,其代码如下:

InstrRuleBasicCBK::InstrRuleBasicCBK(PatchConditionUniquePtr &&condition, InstCallback cbk, void *data, InstPosition position, bool breakToHost, int priority, RelocatableInstTag tag)
    : AutoUnique<InstrRule, InstrRuleBasicCBK>(priority),
      condition(std::forward<PatchConditionUniquePtr>(condition)),
      patchGen(getCallbackGenerator(cbk, data)), position(position), // 这里有个封装
      breakToHost(breakToHost), tag(tag), cbk(cbk), data(data) {}

PatchGenerator::UniquePtrVec getCallbackGenerator(InstCallback cbk, void *data) {
  PatchGenerator::UniquePtrVec callbackGenerator;

  callbackGenerator.push_back(GetConstant::unique(Temp(0), Constant((rword)cbk))); // 将回调放入Temp(0)
  callbackGenerator.push_back(WriteTemp::unique( Temp(0), Offset(offsetof(Context, hostState.callback)))); // 写入hostState.callback = Temp(0) = cbk 

  callbackGenerator.push_back(GetConstant::unique(Temp(0), Constant((rword)data))); // 将data放入Temp(0)
  callbackGenerator.push_back(WriteTemp::unique(Temp(0), Offset(offsetof(Context, hostState.data))));  // 写入hostState.data = Temp(0) = data

  callbackGenerator.push_back(GetInstId::unique(Temp(0)));  // 获取当前指令ID到Temp(0)
  callbackGenerator.push_back(WriteTemp::unique(Temp(0), Offset(offsetof(Context, hostState.origin)))); // 写入hostState.origin = Temp(0) = InstId

  return callbackGenerator;
}

现在可知内存访问的回调插入了两部分规则,一部分是记录每条访存指令的详细信息,另一部分是当出现访存操作或匹配访存范围时调用用户回调,普通的指令回调和它同理!

插桩流程

现在看看它的插桩架构图:

api_architecture.svg

这里面有些重要数据结构先过一下:

1.VM:这是我们最常使用的对象,它作为公共接口暴露了大量API供使用

2.Engine:是VM最重要的内部成员,它负责执行插桩流程,包括底层的代码分析、JIT编译和执行管理等

3.ExecBlock:这是一个连续两页的结构,一个代码页(CodeBlock)放序言、插桩代码序列(基本块数组)、尾声等,一个数据页(DataBlock)存放Guest和Host的上下文信息、运行时元数据、Temp变量和Shadow信息等,这种安排也有玄妙

4.ExecBroker:管理原生代码和插桩代码之间的执行切换,包括插桩范围管理、执行控制转移和检测返回地址等

5.ExecBlockManager:管理ExecBlock,如所有执行块、原始地址与执行块序列间映射等

VM::call

OK,现在开始从vm.call开始吧,它其实就是稍微封装了下的vm.run

bool VM::call(rword *retval, rword function, const std::vector<rword> &args) { // 定参的call和变参的callV
  return this->callA(retval, function, args.size(), args.data());   // 内部都是调用callA
}

bool VM::callA(rword *retval, rword function, uint32_t argNum, const rword *args) {
  GPRState *state = getGPRState();
  if (QBDI_GPR_GET(state, REG_SP) == 0) { //确保设置了栈
    return false;
  }
  // 模拟函数调用的参数传递和返回值设置:优先存放在x0~x7,其次放在栈上,返回地址设置为FAKE_RET_ADDR
  simulateCallA(state, FAKE_RET_ADDR, argNum, args);
  bool res = run(function, FAKE_RET_ADDR); // 调用函数
  if (retval != nullptr) {
    *retval = QBDI_GPR_GET(state, REG_RETURN); // 获取调用返回值
  }
  return res;
}

bool VM::run(rword start, rword stop) {
  uint32_t stopCB = addCodeAddrCB(strip_ptrauth(stop), InstPosition::PREINST, stopCallback, nullptr);  // 在stop地址执行前添加回调,这个回调不做任何事,只返回VMAction::STOP来告知引擎停止执行
  bool ret = engine->run(strip_ptrauth(start), strip_ptrauth(stop)); // 执行引擎
  deleteInstrumentation(stopCB); // 删除之前添加的回调
  return ret;
}

看到核心还得跟入Engine,需要说明虚拟机有小部分代码是架构/平台相关的,我们只看Arm64+Android的代码:

bool Engine::run(rword start, rword stop) {

  rword currentPC = start;  // Current PC 设置为 start 地址
  bool hasRan = false;  // 标记是否至少执行过一个基本块
  bool warnAuthPC = true;       // 警告有指针认证(一次)
  curGPRState = gprState.get(); // 获取当前的通用寄存器和浮点寄存器状态为当前的状态
  curFPRState = fprState.get();

  rword basicBlockBeginAddr = 0;
  rword basicBlockEndAddr = 0;

  if (!execBroker->isInstrumented(start)) { // 检查起始地址是否在插桩范围内 (addInstrumented[Range|Module]和instrumentAllExecutableMaps设置的区域才会被插桩)
    return false;
  }

  running = true;

  do {  // 每次循环执行一个基本块
    VMAction action = CONTINUE;

    // 在进入一个新的基本块前,检查是否需要插桩,如果不在插桩范围内,则再判断是否可以直接转移执行,在arm64下lr或栈顶前两个里有指向插桩区域的地址才可以转移
    // 这可以允许安全的跳过未插桩区域的代码执行,避免崩溃病保留对上下文控制
    if (execBroker->isInstrumented(currentPC) == false and execBroker->canTransferExecution(curGPRState)) { 

      curExecBlock = nullptr;
      basicBlockBeginAddr = 0;
      basicBlockEndAddr = 0;

      action = signalEvent(EXEC_TRANSFER_CALL, currentPC, nullptr, 0, curGPRState, curFPRState);    // 发送转移事件,虽然叫事件,但实际上是同步调用,它会遍历所有注册的回调函数,并执行它们,当然每个回调会返回一个VMAction,最终返回最大的那个VMAction
      if (action == CONTINUE) {
        execBroker->transferExecution(currentPC, curGPRState, curFPRState);
        action = signalEvent(EXEC_TRANSFER_RETURN, currentPC, nullptr, 0, curGPRState, curFPRState); // 发送转移返回事件
      }
    }
    else { // 正常插桩执行路径
      VMEvent event = VMEvent::SEQUENCE_ENTRY;
      if (blockManager->isFlushPending()) { // 检查是否有待处理的缓存刷新请求,刷新就是清空缓存的区域,比如插桩规则变了,空间不够了等就会需要刷新
        *gprState = *curGPRState;
        *fprState = *curFPRState;
        curGPRState = gprState.get();
        curFPRState = fprState.get();
        blockManager->flushCommit(); // 在这个点统一刷新,确保安全的同时使在执行新的基本块前缓存是最新的
      }

      SeqLoc currentSequence;
      curExecBlock = blockManager->getProgrammedExecBlock(currentPC, curCPUMode, &currentSequence); // 先从缓存中获取当前PC对应的ExecBlock,它会返回执行块和在序列中的位置
      if (curExecBlock == nullptr) {
        handleNewBasicBlock(currentPC); // 创建新的基本块
        event |= BASIC_BLOCK_NEW;   // 标记为新基本块事件,注意这里不会立即执行,因为它可能会和下面的BASIC_BLOCK_ENTRY事件同时发生
        curExecBlock = blockManager->getProgrammedExecBlock( currentPC, curCPUMode, &currentSequence); // 重试
      }

      if (basicBlockEndAddr == 0) {
        event |= BASIC_BLOCK_ENTRY;
        basicBlockEndAddr = currentSequence.bbEnd;  // 记录当前基本块的起始与结束位置
        basicBlockBeginAddr = currentPC;
      }

      // 同步上下文寄存器 把当前寄存器状态注入到当前执行上下文块中
      if (&(curExecBlock->getContext()->gprState) != curGPRState || &(curExecBlock->getContext()->fprState) != curFPRState) {
        curExecBlock->getContext()->gprState = *curGPRState;
        curExecBlock->getContext()->fprState = *curFPRState;
      }
      curGPRState = &(curExecBlock->getContext()->gprState);
      curFPRState = &(curExecBlock->getContext()->fprState);

      action = signalEvent(event, currentPC, &currentSequence, basicBlockBeginAddr, curGPRState, curFPRState); // 发送基本块/序列入口事件,这里当两个事件同时发生也只会执行一次回调

      if (action == CONTINUE) {
        hasRan = true;
        action = curExecBlock->execute();   // 执行当前基本块
        if (action == CONTINUE) {   // 正常执行完毕
          if (basicBlockEndAddr == currentSequence.seqEnd) {    // 如果当前基本块结束位置是序列的末尾,则发送序列和基本块退出事件
            action = signalEvent(SEQUENCE_EXIT | BASIC_BLOCK_EXIT, currentPC, &currentSequence, basicBlockBeginAddr, curGPRState, curFPRState);
            basicBlockBeginAddr = 0;
            basicBlockEndAddr = 0;
          } else {
            action = signalEvent(SEQUENCE_EXIT, currentPC, &currentSequence, basicBlockBeginAddr, curGPRState, curFPRState);
          }
        }
      }
    }
    if (action == STOP) {   // 停止执行,退出虚拟机
      break;
    }
    if (action != CONTINUE) { // 如 BREAK_TO_VM,重置状态
      basicBlockBeginAddr = 0;
      basicBlockEndAddr = 0;
      curExecBlock = nullptr;
    }
    currentPC = QBDI_GPR_GET(curGPRState, REG_PC);  // 下一个基本块的首地址 ...如果有指针认证需要被处理
  } while (currentPC != stop);

// ...
}

这里面有三个重要的函数,ExecBroker::transferExecutionEngine::handleNewBasicBlockExecBlock::execute,下面分别介绍:

ExecBroker::transferExecution

它负责将执行控制权从插桩模式安全地转移到原生代码执行,并在返回时重新进入插桩模式,

bool ExecBroker::transferExecution(rword addr, GPRState *gprState, FPRState *fprState) {

  // Search all return address
  rword *ptr = getReturnPoint(gprState);    // 搜索安全的返回地址,一样在LR和栈上找两格
  rword *ptr2 = nullptr;    // 这是对linux而言的,当使用懒解析时PLT会先查找地址并执行,并修补GOT,第一次返回地址会在LR和栈上都有,这里两个都要修补否则会有问题... 省略这部分代码
  rword returnAddress = *ptr;

  rword hook;

  if (ptr == &(gprState->lr)) {
    hook = archData.transfertLR.hook;  //这是在ExecBroker.initExecBrokerSequences时生成的一段跳板,很简单就不说了
    transferBlock->selectSeq(archData.transfertLR.seqID);
  } else { //有些调用约定会用X28做LR
    hook = archData.transfertX28.hook;  
    transferBlock->selectSeq(archData.transfertX28.seqID);
  }

  // 设置fake返回地址为hook
  *ptr = hook;
  if (ptr2 != nullptr) {
    *ptr2 = hook;
  }

  transferBlock->getContext()->gprState = *gprState;
  transferBlock->getContext()->fprState = *fprState;
  transferBlock->getContext()->hostState.brokerAddr = addr; // 传递目标地址给transferBlock,在transfertX28.hook中会调用brokerAddr

  transferBlock->run(); // 执行transferBlock,

  *gprState = transferBlock->getContext()->gprState;  // 更新寄存器状态
  *fprState = transferBlock->getContext()->fprState;

  QBDI_GPR_SET(gprState, REG_PC, returnAddress);    // 恢复原始返回地址
  if (QBDI_GPR_GET(gprState, REG_LR) == hook) {
    QBDI_GPR_SET(gprState, REG_LR, returnAddress);
  }
  if constexpr (is_linux) { // plt/got修补
    if (ptr2 != nullptr and *ptr2 == hook) {
      *ptr2 = returnAddress;
    }
  }

  return true;
}

这里的run就是执行代码直到遇到回调返回或函数结束(当前情况),会在ExecBlock::execute中分析。

Engine::handleNewBasicBlock
void Engine::handleNewBasicBlock(rword pc) {
  Patch::Vec basicBlock = patch(pc);    // 反汇编指令,生成基本块的patch对象序列
  size_t patchEnd = blockManager->preWriteBasicBlock(basicBlock); // 在 ExecBlockManager 中预留缓存空间,并返回需要插桩的指令数量(跳过已缓存的指令)
  instrument(basicBlock, patchEnd); // 对未缓存的指令进行插桩处理
  blockManager->writeBasicBlock(std::move(basicBlock), patchEnd);  // 将插桩后的代码写入 ExecBlock 的代码缓存,建立地址到代码的映射
}

patch是将原始指令转换为易于操作的Patch:

std::vector<Patch> Engine::patch(rword start) {

  std::vector<Patch> basicBlock;
  const LLVMCPU &llvmcpu = llvmCPUs->getCPU(curCPUMode);

  size_t sizeCode = (size_t)-1; // 获取插桩范围
  const Range<rword> *curRange = execBroker->getInstrumentedRange().getElementRange(start);
  if (curRange != nullptr) {
    sizeCode = curRange->end() - start;
  }

  const llvm::ArrayRef<uint8_t> code((uint8_t *)start, sizeCode);
  rword address = start;
  bool endLoop = false;
  do {  // 迭代每条指令,应用patchDSL规则
    bool dstatus = llvmcpu.getInstruction(inst, instSize, code.slice(address - start), address);  // 从code中解析出address处的指令inst,instSize为指令长度
    endLoop = not patchRuleAssembly->generate(inst, address, instSize, llvmcpu, basicBlock);  // 生成插桩后的代码
    address += instSize;
  } while (endLoop);
  return basicBlock;
}

bool PatchRuleAssembly::generate(const llvm::MCInst &inst, rword address, uint32_t instSize, const LLVMCPU &llvmcpu, std::vector<Patch> &patchList) {

  Patch instPatch{inst, address, instSize, llvmcpu};

  for (uint32_t j = 0; j < patchRules.size(); j++) {    // 遍历PatchRule,找到第一个能应用的规则并应用,系统内部规则是架构相关的,处理指令转换,确保代码在 DBI 环境下正确执行
    if (patchRules[j].canBeApplied(instPatch, llvmcpu)) {
      patchRules[j].apply(instPatch, llvmcpu);  // 
      patchList.push_back(std::move(instPatch));
      Patch &patch = patchList.back();

      return patch.metadata.modifyPC;
    }
  }
}

而插桩就是对每个Patch调用前面插入的转换规则:

void Engine::instrument(std::vector<Patch> &basicBlock, size_t patchEnd) {
  const LLVMCPU &llvmcpu = llvmCPUs->getCPU(curCPUMode);

  for (size_t i = 0; i < patchEnd; i++) {
    Patch &patch = basicBlock[i];
    for (const auto &item : instrRules) {   // 遍历InstRule,这是用户添加的,用于实现用户自定义的监控和分析功能
      const InstrRule *rule = item.second.get();
      if (rule->tryInstrument(patch, llvmcpu)) { // 尝试插桩(test and instrument)
      }
    }
    patch.finalizeInstsPatch();
  }
}
ExecBlock::execute

它是插桩基本块的执行流程:

VMAction ExecBlock::execute() {
  do {
    context->hostState.callback = static_cast<rword>(0);    // 清空callback的信息
    context->hostState.data = static_cast<rword>(0);

    run();  // 执行被插桩的基本块,它不是完整执行,而是执行到下一个回调点

    if (context->hostState.callback != 0) { // 检查是否有回调点需要处理
      currentInst = context->hostState.origin;
      rword currentPC = QBDI_GPR_GET(&context->gprState, REG_PC);

      // 执行回调
      VMAction r = (reinterpret_cast<InstCallback>(context->hostState.callback))( vminstance, &context->gprState, &context->fprState, (void *)context->hostState.data);

      switch (r) {
        case CONTINUE:  // 继续执行 会忽略回调中对PC的修改
          break;
        case SKIP_INST: // 跳过当前指令 会忽略回调中对PC的修改 只在执行前回调中生效
          if (currentPC == instMetadata[currentInst].address) {
            context->hostState.selector =
                reinterpret_cast<rword>(codeBlock.base()) +
                static_cast<rword>(instRegistry[currentInst].offsetSkip);
          }
          break;
        case SKIP_PATCH:  // 跳过当前patch,继续执行下一条原始指令 忽略回调中对PC的修改
          if (not instMetadata[currentInst].modifyPC and QBDI_GPR_GET(&context->gprState, REG_PC) != currentPC) {
          }
          if (instMetadata[currentInst].modifyPC) {
            return BREAK_TO_VM;
          } else if (currentInst == seqRegistry[currentSeq].endInstID) {    // 已经是最后一条指令,直接设置下一条指令地址
            rword next_address = instMetadata[currentInst].address +
                                 instMetadata[currentInst].instSize;

            QBDI_GPR_SET(&context->gprState, REG_PC, next_address);
            return BREAK_TO_VM;
          } else {      // 到下一条指令
            currentInst += 1;
            context->hostState.selector = reinterpret_cast<rword>(codeBlock.base()) + static_cast<rword>(instRegistry[currentInst].offset);
          }
          break;
        case BREAK_TO_VM: // 立即退出当前 ExecBlock,返回到 Engine::run() 主循环,用于重新评估执行环境,如间接跳转或用户想交出控制权给VM
          return BREAK_TO_VM;
        case STOP: // 停止整个插桩执行过程
          return STOP;
      }
    }
  } while (context->hostState.callback != 0);
  currentInst = seqRegistry[currentSeq].endInstID;

  return CONTINUE;
}

看看run:

void ExecBlock::run() {
    // ... 确保codeBlock已正确设置为可执行内存
  // 保存临时寄存器,然后把DataBlock的基址存进去
  context->hostState.scratchRegisterValue = QBDI_GPR_GET(&context->gprState, context->hostState.currentSROffset);
  QBDI_GPR_SET(&context->gprState, context->hostState.currentSROffset, getDataBlockBase());

  if (not llvmCPUs.hasOptions(Options::OPT_DISABLE_ERRNO_BACKUP)) { // 如果设置了备份错误号,则在执行前后会备份恢复
    errno = vminstance->getErrno();
    qbdi_runCodeBlock(codeBlock.base(), context->hostState.executeFlags);
    vminstance->setErrno(errno);
  } else {
    qbdi_runCodeBlock(codeBlock.base(), context->hostState.executeFlags);
  }

  // 恢复临时寄存器
  QBDI_GPR_SET(&context->gprState, context->hostState.currentSROffset, context->hostState.scratchRegisterValue);
}

extern void qbdi_runCodeBlock(void *codeBlock,
                              QBDI::rword execflags) asm("__qbdi_runCodeBlock")
    __attribute__((target("arm")));

qbdi_runCodeBlock是汇编开发的,实现很简单,就是保存上下文后调用x0,之后恢复上下文:

__qbdi_runCodeBlock:

  bti c; # 表明这是一个合法的简介调用,保证开了BTI(CFI)的不会崩溃

  # 根据aapcs64调用约定 x0~x17是调用者保存的寄存器,这里就不保存了 
  # ... 这里只保存该由被调用者保存的寄存器 x18~x30 x0
  # 如stp x18, x19, [sp, #-16]!;


  # 同理 v8~v15只需要保存底64位,这里开辟空间并保存
  sub sp, sp, #64;
  mov x8, sp;
  st1 {v8.1d-v11.1d},  [x8], #32; 
  st1 {v12.1d-v15.1d}, [x8], #32;

  blr x0;   # x0是codeBlock指针

  # 恢复SIMD/FPU
  ld1     {v8.1d-v11.1d},  [sp], #32;
  ld1     {v12.1d-v15.1d}, [sp], #32;


 # ... 恢复 x18~x30 和 x0

  ret;

至此,整个虚拟机的插桩执行过程就结束了~可以再看看一些相关文章~ 4 5 6 7

Unidbg

unidbg是mobile逆向的常用工具,如果已经用它补完了环境,再额外用它做trace会很方便,当然了性能会又些折扣。它支持多种后端,不同后端对调试/追踪的能力支持不同,支持最完善的是unicorn后端,此时的追踪原理和上面的又有细微差异,它是在TCG做JIT时插入钩子代码实现的,下面先看用法:

用法

它的Emulator就提供了指令内存相关追踪:

// 内存读写 基于 TraceMemoryHook
TraceHook traceRead();
TraceHook traceRead(long begin, long end);
TraceHook traceRead(long begin, long end, TraceReadListener listener);
TraceHook traceWrite();
TraceHook traceWrite(long begin, long end);
TraceHook traceWrite(long begin, long end, TraceWriteListener listener);

void setTraceSystemMemoryWrite(long begin, long end, TraceSystemMemoryWriteListener listener);

// 指令追踪 基于 AssemblyCodeDumper
TraceHook traceCode();
TraceHook traceCode(long begin, long end);
TraceHook traceCode(long begin, long end, TraceCodeListener listener);

用法极其方便,直接调用即可,它默认输出标准错误输出,可调用setRedirect去指定其他位置。 它的Deubbger还支持函数级追踪,如:

Debugger debugger = emulator.attach(); // 附着 获取调试器
module = emulator.loadLibrary(executable, true);
// 追踪函数
debugger.traceFunctionCall(module, new FunctionCallListener() {
    @Override
    public void onCall(Emulator<?> emulator, long callerAddress, long functionAddress) {
        // 函数调用前
    }
    @Override
    public void postCall(Emulator<?> emulator, long callerAddress, long functionAddress, Number[] args) {
        // 调用后
        System.out.println("onCallFinish caller=" + UnidbgPointer.pointer(emulator, callerAddress) + ", function=" + UnidbgPointer.pointer(emulator, functionAddress));
    }
});

实际上traceFunctionCall用的也是指令级追踪,它会在每条指令执行前判断是否是函数调用指令(如bl),如果是则处理。 除此外它还支持块级追踪,但没有实现类似traceCode这种高级接口,所以我们需要直接使用更低级的接口以及做格式化输出,来到底层,无论是指令级hook还是内存读写hook都是用了hook_add_new重载:

// 指令级hook
backend.hook_add_new(CodeHook hook, long begin, long end, Object user);
// 块级hook
backend.hook_add_new(BlockHook hook, long begin, long end, Object user);
// 内存读写hook
backend.hook_add_new(ReadHook hook, long begin, long end, Object user);
backend.hook_add_new(WriteHook hook, long begin, long end, Object user);

所以我们把第一个参数改掉即可hook,至于输出可参考AssemblyCodeDumper去开发块级的打印方式。

附:SVC监视,我粗略的看了下(不保真),没在代码中看到它直接支持这个功能,不过实现起来也很简单,tracecode就能做(类似函数trace)但性能开销太大,unicorn的特殊性是它可以继承SyscallHandler来做,从底层看模拟器初始化时就添加了syscall的钩子,由ARM32/64SyscallHandler处理:

this.syscallHandler = createSyscallHandler(svcMemory);
backend.hook_add_new(syscallHandler, this);

它其实就是注册了UnicornConst.UC_HOOK_INTR类型的钩子,当中断为EXCP_SWI时就是系统调用,它会去直接执行定义的handler或查svcMap获取注册的handler去执行,我们可以在它执行前后添加代码就能高效的获取参数和返回值。

底层细节

没有太多细节要讲,了解过QEMU的应该对TCG(Tiny Code Generator)不陌生,它是QEMU的默认后端,支持常见的几乎所有架构的“模拟执行”,更准确的说它是一个动态二进制翻译器(Dynamic Binary Translator, DBT),它以翻译块(Translation Block, TB)为单位将原始指令转换TCG-Ops(IR),在IR上做些操作后再转换为目标指令,如将x86转换为arm64再执行,这个过程中就可以插入helper代码去调用用户添加的钩子。

先看unicorn底层提供的能力,它可使用uc_err uc_hook_add(uc_engine *uc, uc_hook *hh, int type, void *callback, void *user_data, uint64_t begin, uint64_t end, ...)uc_err uc_hook_del(uc_engine *uc, uc_hook hh)来增删钩子,它支持的钩子类型有:

Hook Type Callback Signature Description
UC_HOOK_CODE void (*)(uc_engine*, uint64_t address, uint32_t size, void* user_data) 代码执行 Hook,每条指令执行时触发
UC_HOOK_BLOCK void (*)(uc_engine*, uint64_t address, uint32_t size, void* user_data) 基本块执行 Hook,每个基本块执行时触发
UC_HOOK_MEM_* void (*)(uc_engine*, uc_mem_type type, uint64_t address, int size, int64_t value, void* user_data) 包括READ(读)/WRITE(写)/FETCH(取指)/READ_AFTER(读取后)触发
UC_HOOK_MEM[_*]_UNMAPPED/PROT/INVALID bool (*)(uc_engine*, uc_mem_type type, uint64_t address, int size, int64_t value, void* user_data) 包括READ(读)/WRITE(写)/FETCH(取指)/''(所有)未映射(UNMAPPED)内存和受保护(PROT)/非法(INVALID)内存触发
UC_HOOK_INTR void (*)(uc_engine*, uint32_t intno, void* user_data) 中断 Hook,中断发生时触发
UC_HOOK_INSN 可变操作,根据指令类型确定签名 特殊指令Hook,用于hook系统的特殊指令(如 x86 的 in、out、syscall、cpuid 等)
UC_HOOK_EDGE_GENERATED void (*)(uc_engine*, uc_tb* cur, uc_tb* prev, void* user_data) 在 TCG 生成新的执行边缘时触发,即当一个翻译块执行完成后,准备跳转到另一个翻译块时,可用于跟踪程序的控制流变化,观察基本块之间的跳转关系,比如动态构建CFG
UC_HOOK_TLB_FILL bool (*)(uc_engine*, uint64_t vaddr, uc_mem_type type, uc_tlb_entry* result, void* user_data) TLB 填充 Hook
UC_HOOK_INSN_INVALID bool (*)(uc_engine *uc, void *user_data); 在 CPU 遇到无法识别或无效的指令时触发,允许捕获和处理这些异常情况
UC_HOOK_INSN_INVALID void (*)(uc_engine *uc, uint64_t address, uint64_t arg1,uint64_t arg2, uint32_t size, void *user_data); 在代码翻译阶段触发,可hook翻译时的减法/比较等操作

unidbg使用了它的部分能力,现在以指令hook为例从上往下看,unidbg中它是封装了unicorn接口:

    @Override
    public void hook_add_new(final CodeHook callback, long begin, long end, Object user_data) throws BackendException {
        try {
            final Unicorn.UnHook unHook = unicorn.hook_add_new(new com.github.unidbg.arm.backend.unicorn.CodeHook() {
                @Override
                public void hook(Unicorn u, long address, int size, Object user) {
                    callback.hook(Unicorn2Backend.this, address, size, user);
                }
            }, begin, end, user_data);
            callback.onAttach(new UnHook() {
                @Override
                public void unhook() {
                    unHook.unhook();
                }
            });
        } catch (UnicornException e) {
            throw new BackendException(e);
        }
    }

继续向下:

    public UnHook hook_add_new(CodeHook callback, long begin, long end, Object user_data) throws UnicornException {
        NewHook hook = new NewHook(callback, user_data);
        long handle = registerHook(nativeHandle, UnicornConst.UC_HOOK_CODE, begin, end, hook);
        return new UnHook(handle);
    }

registerHook就是简单的native的unicorn:

JNIEXPORT jlong JNICALL Java_com_github_unidbg_arm_backend_unicorn_Unicorn_registerHook__JIJJLcom_github_unidbg_arm_backend_unicorn_Unicorn_NewHook_2
  (JNIEnv *env, jclass cls, jlong handle, jint type, jlong arg1, jlong arg2, jobject hook) {
  t_unicorn unicorn = (t_unicorn) handle;
  uc_engine *eng = unicorn->uc;

    uc_hook hh = 0;
    uc_err err = UC_ERR_OK;
    uint64_t begin = (uint64_t) arg1;
    uint64_t end = (uint64_t) arg2;

    struct new_hook *nh = malloc(sizeof(struct new_hook));
    nh->hook = (*env)->NewGlobalRef(env, hook);
    nh->unicorn = unicorn;

    switch (type) {
       case UC_HOOK_CODE:           // Hook a range of code
          err = uc_hook_add((uc_engine*)eng, &hh, (uc_hook_type)type, cb_hookcode_new, nh, begin, end);
          break;
       case UC_HOOK_BLOCK:          // Hook basic blocks
          err = uc_hook_add((uc_engine*)eng, &hh, (uc_hook_type)type, cb_hookblock_new, nh, begin, end);
          break;
       case UC_HOOK_MEM_READ:       // Hook all memory read events.
          err = uc_hook_add((uc_engine*)eng, &hh, (uc_hook_type)type, cb_hookmem_new, nh, begin, end);
          break;
       case UC_HOOK_MEM_WRITE:      // Hook all memory write events.
          err = uc_hook_add((uc_engine*)eng, &hh, (uc_hook_type)type, cb_hookmem_new, nh, begin, end);
          break;
    }
        // ...
}

unicorn源码里能看到更细一步,添加钩子不多说,就是将其放在uc_engine的hooks相关属性里,咱直接到TCG的指令翻译部分,它对每个指令会执行如下代码:

static inline void gen_uc_tracecode(TCGContext *tcg_ctx, int32_t size, int32_t type, void *uc, uint64_t pc)
{
        // qemu/include/tcg/tcg-op.h 该函数含大量删减 ...
    const int hook_type = type & UC_HOOK_IDX_MASK;
    // 如果只有一个钩子且允许停止时,会启用内联
    if (puc->hooks_count[hook_type] == 1 && !(type & UC_HOOK_FLAG_NO_STOP)) {
        cur = puc->hook[hook_type].head;
        while (cur) {
            hk = cur->data;
            if (!hk->to_delete) {
                puc->add_inline_hook(uc, hk, (void**)args, 4); // 直接将钩子调用内联进去,避免跳助手函数的调用开销
            }
            cur = cur->next;
        }
    } else {
        gen_helper_uc_tracecode(tcg_ctx, tsize, ttype, tuc, tpc);   // 否则会通过助手函数调用钩子
    }
}

再看助手函数做的事:

void helper_uc_tracecode(int32_t size, uc_hook_idx index, void *handle,
                         int64_t address)
{
    if (uc->stop_request && !not_allow_stop) { // 允许且请求停止则立即返回
        return;
    } else if (not_allow_stop && uc->stop_request) { // 不允许停止但又请求停止
        revert_uc_emu_stop(uc); // 撤销停止
    }

    for (cur = uc->hook[index].head; cur != NULL && (hook = (struct hook *)cur->data); cur = cur->next) { // 遍历钩子
        if (hook->to_delete) {  // 删除的就不执行了
            continue;
        }
        if (size == 0) {
            if (index == UC_HOOK_CODE_IDX && uc->count_hook) {
                JIT_CALLBACK_GUARD(((uc_cb_hookcode_t)hook->callback)(
                    uc, address, size, hook->user_data));
            }

            return;
        }

        if (HOOK_BOUND_CHECK(hook, (uint64_t)address)) { // 当前地址是在注册钩子时的地址范围再调用
            JIT_CALLBACK_GUARD(((uc_cb_hookcode_t)hook->callback)(
                uc, address, size, hook->user_data)); // 调用钩子
        }

                // 每次迭代都检测停止...
    }
}

ok,非常的简单~

参考

social