Frida Stalker指令级动态二进制插桩实战:逆向工程与漏洞挖掘的底层追踪利器

发布时间:2026/7/1 22:11:39

Frida Stalker指令级动态二进制插桩实战:逆向工程与漏洞挖掘的底层追踪利器 1. 项目概述为什么我们需要指令级跟踪在逆向工程、漏洞挖掘或者安全分析的工作中我们常常会遇到一个令人头疼的瓶颈传统的Hook和函数调用跟踪只能告诉我们“函数A调用了函数B并返回了结果C”。这就像你只看到了一个黑盒子的输入和输出却完全不知道里面那些精密的齿轮是如何咬合、转动的。当我们需要分析一段高度优化的汇编代码、一个复杂的混淆算法或者一个只在特定指令序列下触发的漏洞时这种粗粒度的信息就远远不够了。我们需要的是能够深入到CPU执行指令的层面像慢镜头回放一样看清楚每一条指令的执行、每一个寄存器的变化、每一次内存的读写。这就是Frida Stalker Trace的价值所在。它不是一个简单的函数调用跟踪器而是一个指令级的动态二进制插桩DBI跟踪引擎。Stalker这个词本身就很有意思意为“潜行者”或“跟踪者”非常形象地描述了它的能力——它能悄无声息地附着在目标进程的代码流上对每一条即将执行的指令进行实时监控、修改和记录。通过它我们不仅能知道程序“做了什么”更能精确地知道它是“怎么一步一步做到的”。这对于理解复杂逻辑、定位隐蔽漏洞、分析恶意代码行为模式来说是无可替代的利器。我最初接触Stalker是为了分析一个手游的加密协议。常规的API Hook只能抓到加密函数的输入输出但密钥生成和变换过程完全在几个高度内联和优化的汇编块里完成。没有指令级跟踪根本无从下手。而Stalker让我能够“钻进”CPU亲眼看着每一个XMM寄存器里的数据是如何被AES指令集一步步处理的最终成功还原了整个流程。这次经历让我深刻认识到掌握Stalker Trace是从“脚本小子”迈向深度分析师的必经之路。它适合所有不满足于表面现象渴望理解二进制世界最底层运行机制的安全研究员、逆向工程师和漏洞猎人。2. Stalker Trace核心原理与架构拆解要玩转Stalker不能只停留在调用API的层面必须对其工作原理有一个清晰的认知。这能帮助你在遇到复杂场景时做出正确的设计和排错。2.1 Stalker的运作模式编译与执行Stalker的核心思想是“即时编译JIT跟踪”。它不会像调试器那样通过断点中断CPU那太慢了。相反它的工作流程可以概括为以下几步选定跟踪范围你告诉Stalker一个内存地址比如某个函数的开头或者干脆让它跟踪整个线程。指令翻译与插桩当目标线程执行到被跟踪的地址时Stalker会拦截执行流。它把原生的机器指令比如ARM或x86指令翻译成等价的、但嵌入了监控代码的中间表示。这个过程是在内存中动态完成的。执行编译后的代码块翻译并插桩好的代码块被放置在一块专门分配的内存通常称为“代码缓存”或“Stalker缓存”中。随后CPU的执行流被导向这块缓存执行我们“加工过”的指令。收集与回调嵌入的监控代码负责收集我们关心的信息比如当前执行的指令地址、寄存器状态、内存访问地址等。这些信息会被打包并通过回调函数实时传递给我们写的JavaScript或Python脚本。块链接与优化为了提升性能Stalker会记住翻译过的代码块。如果程序执行流跳回一个已经被翻译的地址Stalker可以直接跳转到缓存中对应的代码块无需重新翻译这称为“块链接”。你可以把Stalker想象成一个极其高效的“代码重写器”。它把原始程序“掰开”在每一条指令的缝隙里塞进我们自己的监听器然后再“粘合”起来让CPU执行。整个过程对原程序是透明的性能损耗相对直接调试要小得多。2.2 三种跟踪粒度解析Stalker提供了不同粒度的跟踪选项对应不同的开销和详细程度基本块跟踪Basic Block这是最常用的模式。一个基本块是指一段顺序执行、只有一个入口和一个出口通常是跳转指令的指令序列。在此模式下Stalker会在每个基本块执行前后触发回调。它告诉你“程序执行了从地址A到地址B的这一串指令”并给出执行前后的寄存器快照。这已经能提供非常丰富的控制流信息。指令级跟踪Instruction这是最详细的模式。Stalker会在每一条指令执行后都触发回调。你可以拿到每条指令的地址、操作码、以及执行后的完整寄存器上下文。开销巨大但信息也最完整适合分析短小精悍的关键算法循环。调用跟踪Call此模式主要关注函数调用CALL/BL等和返回RET指令。它可以帮助你快速理清程序的调用树而忽略函数内部的细节。开销最小。选择哪种模式取决于你的具体目标。如果只是分析函数调用关系用Call模式如果要理解一个复杂循环用Basic Block模式通常就够了如果你在死磕一段高度优化的加密汇编或者怀疑某条特定指令的行为那就必须上Instruction模式。2.3 寄存器上下文与内存访问捕获Stalker的强大之处在于它能提供完整的CPU上下文。在回调函数中你可以访问一个CpuContext对象它包含了通用寄存器x86下的rax,rbx,rcx,rdx,rsi,rdi,rbp,rsp,r8-r15ARM下的x0-x30,sp,pc等。标志寄存器EFLAGS(x86) 或NZCV(ARM) 等用于判断上一条指令的结果是否为零、是否进位等。浮点与向量寄存器xmm0-xmm15(SSE),ymm0-ymm15(AVX), ARM的v0-v31(NEON/ASIMD)。这对于分析多媒体、加密算法至关重要。程序计数器pc(ARM) 或rip(x86)即当前执行指令的地址。除了寄存器Stalker还能捕获内存访问。通过配置你可以让它在每次内存读/写操作时触发回调并告诉你访问的地址和数据大小。这对于分析缓冲区操作、数据结构遍历非常有用但请注意这会带来极高的性能开销必须谨慎使用。注意性能与开销的平衡开启Stalker尤其是Instruction粒度或内存访问跟踪会让目标程序的运行速度下降几十甚至上百倍。它可能导致超时、程序行为异常比如因为运行太慢触发了看门狗或者直接卡死。永远不要在生产环境或对时间敏感的程序上长时间全量开启Stalker Trace。最佳实践是先通过其他手段如函数Hook定位到关键的小范围代码段几十到几百条指令然后只对这个范围开启高粒度跟踪。3. 实战搭建从环境配置到第一个Trace脚本理论说再多不如动手跑一遍。我们从一个最简单的实战例子开始目标是跟踪一个Android原生库.so中的一个小函数观察其指令执行和寄存器变化。3.1 环境准备与工具选型你需要准备以下环境Frida环境主机端在分析机通常是你的电脑上安装Frida和Frida-tools。pip install frida-tools是最简单的方式。同时建议安装frida的Node.js绑定有时一些高级示例是JS写的。目标设备你需要将对应架构的Frida Server推送到Android手机或模拟器上并运行。这是Frida在目标系统上的“大脑”。务必确保Frida Server的版本与主机端fridaPython库的版本一致否则会出现连接错误。目标程序一个包含你感兴趣代码的Android APK或iOS应用。为了演示我们可以自己写一个简单的NDK程序里面包含一个用C/汇编写的计算函数。分析工具IDA Pro / Ghidra / Binary Ninja用于静态分析目标二进制文件找到你要跟踪的函数的准确地址。这是使用Stalker的前提因为你必须告诉它从哪里开始跟踪。编辑器编写Frida JavaScript脚本。VS Code、Sublime Text等均可。3.2 定位目标函数与地址假设我们有一个目标应用其原生库libtarget.so中有一个函数int calculate(int a, int b)我们想跟踪它。使用adb pull将APK中的libtarget.so拉取到本地。用IDA Pro加载这个so文件。在函数窗口找到calculate函数或者通过导出表、字符串引用等方式定位它。记下这个函数的内存偏移地址File Offset。例如IDA显示calculate在文件中的偏移是0x1234。关键步骤计算运行时地址。so文件在内存中加载的基址Base Address每次运行都可能不同由于ASLR。我们需要在脚本中动态获取。在Frida脚本中可以通过Module.findBaseAddress(libtarget.so)获取该so本次运行时的基址。运行时地址 模块基址 函数文件偏移。即realAddress baseAddr.add(0x1234)。实操心得地址对齐与Thumb模式ARM在ARM架构特别是32位ARM上需要特别注意指令集模式。ARM指令是4字节对齐的而Thumb指令是2字节对齐的。如果一个函数的地址最低位是1例如0x1235表明它处于Thumb模式。Frida的NativePointer能够很好地处理这个。通常从IDA获取的地址已经是考虑了模式的。在调用Stalker时直接使用这个NativePointer对象即可Stalker会自动识别模式。一个常见的坑是如果你手动计算地址忘记处理Thumb标志位可能会导致跟踪从错误的指令开始甚至崩溃。3.3 编写第一个Stalker Trace脚本下面是一个完整的Frida JavaScript脚本示例它跟踪calculate函数并以基本块粒度打印信息。// stalker_demo.js Java.perform(function () { console.log([*] Script loaded.); // 1. 获取目标模块和函数地址 var libName libtarget.so; var funcOffset 0x1234; // 从IDA中获取的calculate函数偏移 var baseAddr Module.findBaseAddress(libName); if (baseAddr) { var targetAddr baseAddr.add(funcOffset); console.log([*] Found ${libName} at ${baseAddr}); console.log([*] calculate function at ${targetAddr}); // 2. 定义Stalker转换器回调这里我们使用最简单的 // 我们暂时不自定义转换只使用Stalker内置的收集功能 Stalker.follow(Process.getCurrentThreadId(), { // 指定开始跟踪的地址 events: { // 收集所有事件编译、基本块、调用等 compile: true, }, // 接收二进制代码块编译信息可选用于高级用途 onReceive: function (events) { // 这里可以处理压缩的事件流但为了简单我们先不用 }, // 每个基本块执行后的回调 transform: function (iterator) { // 这是一个简化示例实际transform函数用于指令级修改 // 我们这里只是让Stalker按默认方式工作并收集信息 iterator.putCallout(function (context) { // 这个callout会在每个基本块执行后被调用 var pc context.pc; var x0 context.x0; // ARM64 第一个参数 var x1 context.x1; // ARM64 第二个参数 console.log([BB] PC: ${pc}, x0: ${x0.toString(16)}, x1: ${x1.toString(16)}); }); iterator.keep(); // 继续处理下一条指令 } }); // 3. 使用Interceptor在函数入口触发Stalker跟踪 Interceptor.attach(targetAddr, { onEnter: function (args) { console.log(\n[*] calculate() called! a${args[0]}, b${args[1]}); // 开始跟踪当前线程从当前PC即函数入口开始 // 这里我们设定只跟踪这个函数内部粒度设为基本块 Stalker.follow(Process.getCurrentThreadId(), { events: { compile: false, // 不接收编译事件 block: true, // 接收基本块事件 }, // 接收基本块事件 onReceive: function (rawEvents) { // 将原始事件数据解析为可读的格式 var events Stalker.parse(rawEvents, { stringify: false, // 返回对象而非字符串 annotate: true // 尝试反汇编指令 }); events.forEach(function (event) { if (event[0] block) { var start event[1]; var end event[2]; console.log( - Block: ${start} - ${end}); } }); } }); }, onLeave: function (retval) { console.log([*] calculate() returning: ${retval}); // 函数结束停止跟踪当前线程 Stalker.unfollow(Process.getCurrentThreadId()); // 也可以停止所有跟踪Stalker.flush(); Stalker.garbageCollect(); } }); } else { console.log([!] Could not find module: ${libName}); } });脚本解析与要点双重跟踪策略这个脚本展示了一种常见模式。先用Interceptor.attach钩住目标函数在onEnter中启动针对当前线程的Stalker跟踪在onLeave中停止。这样可以确保我们只跟踪目标函数内部的执行避免跟踪到无关的库代码极大减少开销。Stalker.follow参数events对象指定了我们想收集的事件类型。block: true表示收集基本块事件。onReceive回调会收到一个原始的二进制事件流我们需要用Stalker.parse来解析它。transform与callouttransform函数是一个更底层的接口它允许你对每条指令进行自定义修改或插入代码。我们在里面通过iterator.putCallout注册了一个回调这个回调会在每个基本块执行后被调用并传入当前的CPU上下文(context)。这是一种在指令流中主动插入探针的方式功能强大但更复杂。上面的示例中我们只是简单打印了PC和寄存器。运行脚本将脚本保存为stalker_demo.js在命令行执行frida -U -f com.example.targetapp -l stalker_demo.js --no-pause。运行后当你触发calculate函数时控制台会输出函数参数、执行过的基本块地址范围以及寄存器值。这就是你第一次“看见”指令级执行流。4. 高级技巧寄存器变化监控与指令流还原基本的跟踪只能告诉我们执行了哪些块。要深入分析我们需要更精细的数据每条指令是什么执行后寄存器如何变化4.1 实现指令级单步与寄存器Diff为了实现“指令级单步”效果我们需要在transform函数中做更多工作。Frida的Instruction迭代器提供了访问每条指令细节的能力。// 在 Stalker.follow 的 transform 选项中 transform: function (iterator) { var start iterator.next(); // 获取当前指令 while (start ! null) { // 1. 记录指令执行前的寄存器状态 (需要在上一条指令的callout中保存这里简化) // 2. 插入一个callout在*这条指令执行后*被调用 iterator.putCallout(function (context) { // 此时context是这条指令执行后的状态 var pc context.pc; var instruction Instruction.parse(context.pc); // 反汇编当前指令 console.log([INS] ${pc}: ${instruction}); // 打印关键寄存器例如ARM64的x0-x7, sp, pc console.log( x0:${context.x0.toString(16)} x1:${context.x1.toString(16)} ... sp:${context.sp}); }); // 3. 继续处理下一条指令 iterator.keep(); start iterator.next(); } }要计算寄存器变化Diff我们需要在指令执行前保存一份寄存器快照在执行后的callout中进行对比。这需要更精巧的设计通常需要维护一个线程局部的状态对象。var threadLocal require(thread-local); // Frida 15 支持 var registerSnapshot threadLocal.create(); Stalker.follow(threadId, { transform: function (iterator) { var instruction iterator.next(); if (instruction) { // 在当前指令前插入callout保存执行前状态 iterator.putCallout(function (context) { registerSnapshot.set(pre, { x0: context.x0, x1: context.x1, /* ... 保存所有关心的寄存器 */ pc: context.pc }); }); // 在当前指令后插入callout计算差异 iterator.putCallout(function (context) { var pre registerSnapshot.get(pre); if (pre) { var diff {}; if (context.x0.compare(pre.x0) ! 0) diff.x0 {from: pre.x0, to: context.x0}; if (context.x1.compare(pre.x1) ! 0) diff.x1 {from: pre.x1, to: context.x1}; // ... 比较其他寄存器 if (Object.keys(diff).length 0) { var ins Instruction.parse(pre.pc); console.log([DIFF] ${pre.pc} (${ins}):, JSON.stringify(diff)); } } // 为下一条指令准备保存当前状态作为它的“前状态” registerSnapshot.set(pre, { x0: context.x0, x1: context.x1, pc: context.pc // 注意这里已经是下一条指令的地址了不还是当前指令后的上下文。 }); }); iterator.keep(); } } });4.2 生成可读的指令轨迹与流程图控制台输出对于短轨迹还行长了就难以分析。我们可以将轨迹输出到文件并格式化为更易读的形式甚至生成控制流图CFG。var fs require(fs); var traceLog []; Stalker.follow(threadId, { events: { block: true, call: true, // 也收集调用/返回事件 }, onReceive: function (events) { var parsed Stalker.parse(events, { annotate: true, stringify: false }); parsed.forEach(function (event) { traceLog.push({ type: event[0], from: event[1], to: event[2], meta: event[3] // 可能包含调用目标等信息 }); }); // 定期写入文件避免内存占用过大 if (traceLog.length 10000) { fs.writeFileSync(/sdcard/trace.json, JSON.stringify(traceLog, null, 2)); traceLog []; } } }); // 在脚本退出或跟踪结束时保存最终日志 process.on(exit, function() { if (traceLog.length 0) { fs.writeFileSync(/sdcard/trace_final.json, JSON.stringify(traceLog, null, 2)); } });得到的JSON文件包含了基本块和调用事件。你可以用Python脚本处理这个文件结合IDA的API或capstone反汇编引擎将地址解析为函数名和指令最终生成一个文本报告或使用Graphviz等工具绘制出执行流程图。4.3 条件跟踪与过滤策略全量跟踪开销太大。我们常常只关心特定条件下的执行例如当寄存器x0等于某个特定值或者当程序执行到某个地址范围时。Frida Stalker本身没有内置的高级过滤条件但我们可以通过transform和callout的组合来实现。var targetValue ptr(0xdeadbeef); Stalker.follow(threadId, { transform: function (iterator) { var ins iterator.next(); while (ins ! null) { // 检查当前指令地址是否在我们感兴趣的范围内 var pc ins.address; if (pc.compare(targetModuleBase.add(0x1000)) 0 pc.compare(targetModuleBase.add(0x2000)) 0) { // 在范围内插入详细日志callout iterator.putCallout(function (ctx) { if (ctx.x0.compare(targetValue) 0) { // 仅当x0等于特定值时记录 logDetailed(ctx); } }); iterator.keep(); } else { // 不在范围内不插入任何callout直接继续执行原指令 iterator.keep(); // 或者用 iterator.next(); continue; 但keep是必须的 } ins iterator.next(); } } });更复杂的过滤逻辑比如“监控对某一特定内存地址的写操作”可能需要结合内存访问监控通过MemoryAccessMonitor或者在对内存操作指令如STRMOV [MEM], REG进行插桩时判断目标地址。5. 实战疑难杂症与性能优化指南在实际使用中你会遇到各种各样的问题。下面是我踩过的一些坑和总结的解决方案。5.1 常见崩溃与稳定性问题目标进程崩溃SIGSEGV原因A转换错误。Stalker在翻译某些特殊或罕见的指令序列时可能出错生成错误的代码导致崩溃。特别是涉及浮点、向量或系统寄存器如PC,SP的指令。对策尽量缩小跟踪范围避免跟踪系统库或高度优化的编译器生成代码如libc.so,libart.so。如果崩溃尝试在transform函数中对可疑指令可以通过Instruction.toString()判断直接调用iterator.keep()而不做任何插桩让其原样执行。原因B递归跟踪或循环。如果你的callout回调函数内部又触发了被跟踪的代码例如在回调中调用了一个也会被Stalker翻译的函数会导致无限递归和栈溢出。对策在callout或transform中避免调用任何可能触及跟踪内存的代码。如果必须调用使用Stalker.unfollow临时解除跟踪调用完再follow回去。Frida脚本超时或无响应原因onReceive回调或callout中处理逻辑太复杂或者日志输出太多速度跟不上Stalker产生事件的速度导致事件队列堆积最终脚本被卡死。对策精简回调逻辑在回调中只做最简单的数据收集如推入数组将复杂的处理如反汇编、字符串格式化、文件写入放到另一个由setImmediate或setTimeout驱动的异步任务中。采样不要记录每一条指令。可以在callout中通过随机数或计数器来决定是否记录当前信息。使用二进制事件流onReceive收到的原始事件是高度压缩的二进制数据。直接处理这个二进制流比如只计算大小比用Stalker.parse解析成对象要快得多。你可以先存下来离线解析。5.2 性能开销分析与优化策略Stalker的性能开销主要来自JIT编译开销首次翻译一个代码块的成本。块链接管理开销维护缓存和查找块的成本。回调函数开销执行你插入的JavaScript代码的成本。这是最大的开销来源优化黄金法则范围最小化用Interceptor精确控制跟踪的起点和终点。绝对不要长时间跟踪整个线程或进程。粒度最粗化能用Call模式就不用Block模式能用Block模式就不用Instruction模式。回调轻量化避免在callout中进行console.log。它的性能极差。改为向一个全局数组push数据。避免在callout中进行复杂的JavaScript运算或对象创建。使用thread-local存储来避免跨线程锁竞争如果跟踪多线程。利用Stalker.flush()和Stalker.garbageCollect()跟踪一段时间后可以调用flush()确保所有事件被传递然后调用garbageCollect()释放已编译但可能不再使用的代码缓存防止内存无限增长。考虑离线分析如果可能将Stalker收集到的原始二进制事件流onReceive中的rawEvents直接写入文件。然后编写一个独立的桌面分析工具来解析和展示这些数据。这能将运行时的开销降到最低。5.3 与其他Frida模块的协同作战Stalker很少单独使用它通常是“最后一公里”的精细工具。典型的协同工作流是Interceptor 进行粗定位先用Interceptor.attach或Interceptor.replace钩住高层API或关键函数打印参数、返回值缩小可疑代码的范围。Memory API 进行数据监视使用Memory.scan或MemoryAccessMonitor来监视特定内存区域的变化找到数据流的关键节点。Stalker 进行精细跟踪在定位到一小段几十条指令关键汇编代码后用Stalker的Instruction或Block模式深入跟踪理清每一处的数据流向和控制逻辑。结合静态分析将Stalker跟踪得到的地址、跳转关系与IDA Pro等静态分析工具中的反汇编结果进行对照和注释能极大提升分析效率。可以写脚本将动态跟踪结果导入IDA生成执行热图或注释。例如你可以先用Interceptor找到解密函数然后在这个函数的入口和出口用MemoryAccessMonitor监视输出缓冲区的变化。最后对解密循环的内部用Stalker进行指令级跟踪看密钥是如何与数据混合的。一个真实案例的排查技巧有一次跟踪一个游戏的渲染循环开启Stalker后游戏直接卡成幻灯片onReceive事件堆积导致脚本崩溃。我的解决方法是首先用Process.enumerateThreads()找到渲染线程只跟踪它。其次在transform中我判断指令地址是否在游戏自己的图形库模块内不在则直接iterator.keep()跳过。最后我在callout中不再打印而是将指令地址和关键寄存器值压缩成一个Uint8Array批量推送到一个NativeFunction创建的共享内存队列中由另一个线程异步写入文件。这样终于得到了流畅的跟踪数据。这个案例说明面对高性能要求的场景你必须把Stalker当作一个需要精心调校的低级工具来用任何不必要的JavaScript交互都是性能杀手。

相关新闻