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是{地址:次数}数据
},
});
这里onReceive和onCallSummary只能指定一个,它会在每个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_next和XX_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直接提供了更强大的功能,先看一眼它的架构图:
它将我们外部的代码环境叫做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来存放辅助数据,前者相当于虚拟寄存器,后则是更强大的虚拟空间,用于存放每条指令的分析/访存信息等:
咱先从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;
}
现在可知内存访问的回调插入了两部分规则,一部分是记录每条访存指令的详细信息,另一部分是当出现访存操作或匹配访存范围时调用用户回调,普通的指令回调和它同理!
插桩流程
现在看看它的插桩架构图:
这里面有些重要数据结构先过一下:
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, ¤tSequence); // 先从缓存中获取当前PC对应的ExecBlock,它会返回执行块和在序列中的位置
if (curExecBlock == nullptr) {
handleNewBasicBlock(currentPC); // 创建新的基本块
event |= BASIC_BLOCK_NEW; // 标记为新基本块事件,注意这里不会立即执行,因为它可能会和下面的BASIC_BLOCK_ENTRY事件同时发生
curExecBlock = blockManager->getProgrammedExecBlock( currentPC, curCPUMode, ¤tSequence); // 重试
}
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, ¤tSequence, 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, ¤tSequence, basicBlockBeginAddr, curGPRState, curFPRState);
basicBlockBeginAddr = 0;
basicBlockEndAddr = 0;
} else {
action = signalEvent(SEQUENCE_EXIT, currentPC, ¤tSequence, 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::transferExecution、Engine::handleNewBasicBlock和ExecBlock::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,非常的简单~