深入Frida源码:从动态插桩原理到Hook执行全流程解析

发布时间:2026/6/24 17:11:16
深入Frida源码:从动态插桩原理到Hook执行全流程解析 1. 项目概述为什么我们要深入Frida源码如果你在移动安全、逆向工程或者应用动态分析这个圈子里混过一段时间Frida这个名字对你来说应该像吃饭喝水一样熟悉。它是个极其强大的动态代码插桩工具让你能在运行时去窥探、修改甚至控制目标应用的行为无论是Android、iOS、Windows还是macOS。我们用它来Hook函数、调用私有API、脱壳、分析协议几乎无所不能。但不知道你有没有过这样的时刻写好的脚本突然不生效了遇到一个诡异的崩溃却毫无头绪或者想实现一个高级功能却发现官方文档语焉不详。这时候仅仅停留在“会用”的层面就显得捉襟见肘了。“Frida源码逻辑梳理一时序图”这个项目就是一次从“使用者”到“理解者”的深度穿越。它不满足于知道frida-trace怎么用或者Interceptor.attach的API长什么样。它的目标是深入到Frida这座宏伟建筑的内部从地基开始搞清楚各个核心组件比如frida-core、frida-gum、frida-server是如何协同工作的一次完整的Hook请求从你的Python脚本发出到在目标进程中被执行这中间究竟经历了怎样的“奇幻漂流”。而时序图就是我们这次探险的“地图”和“行动日志”它能最直观地揭示对象之间的交互顺序和生命周期把复杂的异步调用、跨进程通信、事件驱动模型清晰地呈现出来。理解源码逻辑尤其是核心的交互时序能给你带来质的飞跃。当你的脚本报出一个模糊的错误时你能大概猜到是消息序列化出了问题还是目标进程的frida-agent没有正常加载当你想定制一个特殊功能比如在非标准环境下注入你知道应该从哪个模块的哪个接口入手更重要的是它能帮你建立一套调试复杂问题的系统性思维而不是盲目地四处console.log。接下来我们就从最宏观的架构视角开始一步步拆解并用时序图串联起整个故事。2. Frida整体架构与核心组件交互总览在画第一张时序图之前我们必须对Frida的“全家福”有一个清晰的认识。Frida不是一个单一的工具而是一个精巧的、模块化的生态系统。把它想象成一个特工网络有负责策划和指挥的“控制中心”你的脚本有负责潜入敌后的“特工”注入到目标进程的代码还有负责在两者之间传递情报的“通信链路”和“联络站”。2.1 核心四件套及其职责Frida CLI / frida-tools (控制台与工具集)这是我们最常打交道的部分。包括frida、frida-trace、frida-ps等命令行工具以及我们写Python脚本时导入的frida模块。它的角色是“客户端”或“控制端”。它提供用户接口把我们用JavaScript或Python写的逻辑打包成标准的指令。frida-core (核心库)这是Frida的“大脑”和“协议中心”。它实现了核心的抽象概念如Session会话、Device设备、Script脚本等。最重要的是它定义了客户端本地或远程与目标设备之间通信的私有协议。frida-python、frida-node等绑定库本质上都是对frida-core的封装。它负责将高级指令序列化成二进制消息也负责将接收到的二进制消息反序列化成事件。frida-server (守护进程 / 服务端)在Android或越狱后的iOS设备上你会运行一个frida-server。它是常驻在目标系统上的“联络站”和“调度中心”。它的核心职责是进程管理枚举进程、附加到进程、启动新进程。端口监听在TCP端口默认27042上监听来自控制端的连接。消息路由与代理在控制端frida-core和各个目标进程内的frida-agent之间转发消息。它本身不执行Hook逻辑只是一个高效的路由器。frida-gum / frida-agent (注入引擎与代理)这是真正执行“脏活累活”的“特工”。frida-gum (GNU-like Universal Machine)一个用C写的、跨平台的动态插桩框架。它提供了底层的内存操作、代码注入、函数HookInterceptor、内存扫描Memory.scan、Stalker指令级跟踪等核心能力。它非常轻量被编译进frida-agent。frida-agent一个动态库如Android的.so、iOS的.dylib其核心是frida-gum和一个JavaScript运行时通常是Duktape或V8。它被注入到目标进程后就成为了在该进程内执行我们JavaScript代码的“沙箱”或“虚拟机”。我们的Hook脚本最终就在这里被加载和执行。2.2 核心交互流程鸟瞰图一次典型的本地attach并执行脚本的流程可以概括为以下几步连接你的Python脚本通过frida-core连接到本机的frida-server对于USB连接的设备会通过adb forward建立隧道。附加脚本请求frida-server附加到目标进程如com.example.app。注入frida-server在目标进程中创建线程加载frida-agent。通信建立frida-agent初始化后会通过frida-server与控制端建立独立的、点对点的通信通道。脚本加载控制端将JavaScript代码发送给该frida-agent。执行与交互frida-agent内的JS引擎执行代码Hook目标函数。当Hook被触发时JS代码执行并可能通过send()函数将数据异步发送回控制端。控制端通过on(message, ...)接收。注意这里有一个关键点容易混淆frida-server在初始连接和进程附加阶段是核心枢纽但一旦frida-agent注入成功控制端与frida-agent之间会建立直接的通信通道通常是通过Unix Domain Socket或命名管道后续的脚本加载、消息传递send/on大部分是点对点的frida-server只做最低限度的路由或完全旁路。这是为了追求极致的性能和降低延迟。理解了这些角色和大致流程我们就可以开始用时序图来描绘更具体的场景了。时序图能清晰地回答“到底是谁在什么时候调用了谁的什么方法”3. 关键交互时序图解析从连接到脚本执行现在我们进入核心环节通过三张关键的时序图把静态的组件关系变成动态的交互序列。我会先给出图的核心描述然后解释每个步骤背后的“为什么”。3.1 时序图一建立连接与附加进程这张图描述从你的Python脚本运行到成功附加到目标进程的完整过程。参与者 控制端脚本 (Python) - frida-core (本地库) - frida-server (远程) - 目标系统内核 - 目标进程脚本初始化你执行import frida然后调用frida.get_usb_device()或frida.get_device_manager().enumerate_devices()。此时frida-core库被加载。发现设备frida-core会尝试通过多种方式发现设备。对于USB Android设备它内部会调用ADB命令如adb devices、adb forward tcp:27042 tcp:27042建立端口转发然后尝试连接127.0.0.1:27042。TCP握手frida-core与frida-server在设备端监听27042端口建立TCP连接。连接成功后双方会交换版本号等基础信息。枚举进程控制端调用device.enumerate_processes()。该请求被frida-core序列化为一个二进制消息通过TCP连接发送给frida-server。服务器处理frida-server接收消息反序列化理解这是“枚举进程”请求。它调用本地系统API如Android的/proc遍历或iOS的proc_listpids获取进程列表。返回结果frida-server将进程列表序列化通过TCP连接回传给控制端的frida-core。frida-core反序列化后将结果呈现为Process对象列表给你的Python脚本。附加请求你选择目标进程调用device.attach(pid)。frida-core再次序列化“附加”请求发送给frida-server。服务器执行附加这是最关键的一步。frida-server收到请求后它首先检查目标进程是否存在以及权限是否足够。然后它通过ptrace(PTRACE_ATTACH, ...)或类似的调试API附着到目标进程暂停目标进程的执行。接着它在目标进程的内存空间中分配一块区域将frida-agent的动态库文件如libfrida-agent.so写入。最后它通过创建远程线程或修改线程上下文如__libc_dlopen_mode的方式让目标进程执行dlopen()来加载这个注入的agent。Agent初始化frida-agent的入口函数被调用开始初始化设置自己的内存管理、初始化frida-gum、启动JavaScript运行时并最重要的一步——向frida-server“报到”告知“我已就绪这是我的通信端点如一个socket的文件描述符”。建立直接通道frida-server将agent的通信端点信息转发给控制端的frida-core。此后frida-core可能会与frida-agent建立一条新的、更高效的直接通信通道如Unix socket用于后续高频的脚本消息交互。原始的TCP连接可能仅用于传输控制命令。返回Session对象frida-core在收到附加成功的确认后会创建一个Session对象并将其返回给你的Python脚本。这个Session对象就代表了你与目标进程中那个frida-agent的会话连接。实操心得这一步最常见的失败点是frida-server版本与客户端frida-tools版本不匹配或者frida-server没有以root权限运行在Android上。务必使用frida --version和adb shell /data/local/tmp/frida-server --version检查版本一致性。另一个坑是如果目标进程有较强的反调试或ptrace检测ptrace附着可能会失败这时候可能需要考虑绕过方案比如使用Spawn方式启动进程。3.2 时序图二创建与加载脚本在成功获取Session对象后我们要加载并执行我们的JavaScript Hook代码。参与者 控制端脚本 - frida-core - frida-agent (在目标进程内)注意此时frida-server可能已不在主要通信路径上除非是路由模式创建脚本对象你调用session.create_script(js_code)。js_code是你写的包含Interceptor.attach等逻辑的JavaScript字符串。序列化与传输frida-core会将“创建脚本”的指令以及你的JS代码序列化成二进制消息。这条消息通过上一步建立好的直接通道或经由frida-server转发发送给目标进程内的frida-agent。Agent接收与编译frida-agent收到消息后其内部的JavaScript运行时如V8会接收这段JS代码。这里有一个关键细节Frida并不是简单地把你的代码eval。它通常会先创建一个独立的“脚本实例”这个实例被沙箱化拥有自己独立的作用域。然后JS引擎会编译这段代码。暴露API在编译和执行你的代码之前frida-agent会向这个脚本的全局作用域注入Frida提供的所有Native API比如Interceptor、Memory、Process、Module等。这些API实际上是frida-gum的C函数通过绑定Binding暴露给JavaScript的桥接层。设置消息回调你的JS代码中如果有send()函数或者你通过Script.on(message, ...)设置的回调frida-agent会建立相应的内部映射确保当JS代码调用send(data)时data能被正确捕获并准备发回控制端。加载完成事件脚本编译和初始化成功后frida-agent会发送一个“脚本已加载”的消息回控制端。控制端接收与绑定frida-core收到消息触发Script对象的created或loaded事件具体取决于API设计。你在Python端可以通过script.on(message, on_message)来绑定消息监听器。执行脚本你调用script.load()。这个调用会再次发送一个指令给frida-agent告诉它“现在开始执行那个编译好的脚本”。脚本执行与Hook安装frida-agent开始执行你的JS代码。当执行到Interceptor.attach(targetAddress, { onEnter, onLeave})时Interceptor对象是frida-gum暴露的API。调用attach会通过frida-gum的C层在targetAddress处安装一个“蹦床”Trampoline。这个蹦床是一小段汇编代码它的作用是把函数执行流劫持到frida-gum自定义的处理器。frida-gum会保存原始函数的开头几条指令并替换为跳转指令如jmp。同时它会设置好上下文使得当跳转发生时能够调用你提供的JavaScript回调函数onEnter和onLeave。返回控制Hook安装成功后执行流返回到你的JS代码末尾。脚本主体执行完毕但其中定义的Hook回调函数onEnter、onLeave已经被注册到frida-gum的内部事件系统中等待被触发。注意事项script.load()的调用时机很重要。如果在Hook安装前目标函数已经被执行那么你可能错过一些调用。对于启动时就执行的函数通常需要使用setImmediate或通过Process.enumerateModules()找到模块基址后再动态计算地址并Hook。另外JS代码中的语法错误会在编译阶段被frida-agent捕获并通过消息发送回控制端通常会导致script.load()抛出异常这是一个重要的调试信息源。3.3 时序图三Hook触发与消息传递这是最激动人心的部分当目标函数被调用时整个系统如何联动。参与者 目标进程原始线程 - frida-gum (Hook引擎) - frida-agent JS运行时 - 控制端脚本原始调用发生目标进程的某个线程执行到了被我们Hook的函数地址。跳转到蹦床由于函数开头已被替换为跳转指令CPU的执行流直接跳转到frida-gum设置的蹦床代码处。上下文保存与切换蹦床代码首先以极快的速度汇编级别保存当前的CPU寄存器状态即函数调用上下文然后准备切换到frida-gum的控制逻辑。这个过程必须非常快且稳定不能影响目标进程的稳定性。调用JS回调 (onEnter)frida-gum的C代码根据之前注册的信息构造一个代表此次函数调用的对象包含参数、线程ID等然后调用JavaScript运行时触发我们之前注册的onEnter(args)回调函数。JS代码执行你的onEnter函数开始执行。你可以在这里读取args[0]等参数修改它们或者调用this.context访问CPU寄存器。如果你在onEnter中调用了send()例如send({ type: enter, args: args[0].toInt32() })那么 a.send()是Frida注入到JS作用域的全局函数。 b. 它会把传入的JavaScript对象序列化成JSON或其他二进制格式。 c. 然后通过frida-agent与frida-core之间的直接通信通道异步发送出去。恢复执行原函数你的onEnter回调执行完毕后控制权返回给frida-gum。frida-gum可以选择恢复原始函数的执行。这里有两种模式替换模式如果你在onEnter里修改了参数或者想完全跳过原函数可以调用replace函数并提供返回值。继续模式默认情况frida-gum会恢复之前保存的寄存器状态并执行我们备份的原始函数开头的那几条指令然后跳转回原函数被Hook位置之后继续执行就像什么都没发生过一样除了执行了我们的onEnter回调。原函数执行原始函数以可能被修改过的参数继续运行直到它即将返回。再次捕获 (onLeave)frida-gum通过多种技术比如在函数返回地址上做手脚或者使用单步调试再次捕获到执行流此时原始函数已经执行完毕返回值已经确定可能在某个寄存器或栈上。调用JS回调 (onLeave)frida-gum再次调用JS运行时触发onLeave(retval)回调。你可以在这里检查或修改返回值。同样你也可以在这里调用send()。最终返回onLeave执行完后frida-gum恢复现场让原始调用线程带着可能被修改过的返回值继续它原本的执行路径。消息抵达控制端与此同时步骤5或9中通过send()发出的消息已经通过异步通道传回了控制端的frida-core。frida-core反序列化消息并触发你在Python端设置的script.on(message, callback)事件。你的callback函数被调用处理接收到的数据。避坑技巧在onEnter和onLeave回调中切忌执行耗时操作或阻塞操作。因为你正在目标进程的主线程或某个关键线程上执行代码长时间阻塞会导致应用卡顿甚至ANR。如果需要复杂处理应该将数据通过send()快速发回控制端在控制端的Python/Node.js线程中进行处理。另外send()的数据必须是可JSON序列化的传递Native指针等对象需要先转换如.toInt32()。4. 源码导读对照时序图看关键代码时序图给了我们骨架现在需要去源码里找到对应的肌肉和神经。我们不会逐行阅读而是聚焦于几个与时序图节点对应的关键文件和方法。假设你已经在本地克隆了Frida的源码仓库https://github.com/frida/frida。4.1 连接与附加流程 (frida-core/frida-server)入口点对于Python绑定我们从frida/python/frida/core.py的Device.attach()开始追踪。但真正的核心在frida-core的C库中。关键文件frida-core/device.vala(Vala语言类似C#)。查找attach()方法。它会调用session_provider.attach()。协议序列化在frida-core/session.vala或frida-core/agent.vala中你会看到大量的try_write_message()和on_message_received()方法。消息的序列化/反序列化逻辑通常在frida-core/tcp-peer.vala或相关的peer类中它们使用GLib的GBytes来处理二进制数据。服务器端处理frida-server的源码在frida-server/目录下。关键的附加逻辑在frida-server/session.vala的attach()方法里。你会看到它调用injector注入器的相关函数。注入器的实现在frida-core/inject/目录下根据不同平台linuxdarwinwindows有不同实现。例如Android/Linux的ptrace注入代码就在frida-core/inject/linux/linjector.c中。如何对照打开这些文件用你喜欢的编辑器搜索关键词 “attach”, “inject”, “agent”, “on_message”。结合时序图第一步到第八步你会看到代码是如何一步步从API调用走到系统调用ptrace,dlopen的。4.2 脚本加载与编译 (frida-gumjs)关键目录frida-gumjs/这个库是frida-gum的 JavaScript 绑定和运行时集成。这是理解脚本如何被执行的核心。脚本创建在frida-gumjs/script.c中查找frida_script_create()或script_create()函数。这里会初始化一个脚本实例。JS引擎集成frida-gumjs/runtime/目录下有不同JS引擎的实现如duktape和v8。以V8为例frida-gumjs/runtime/v8/runtime-v8.cc中的RuntimeV8::CreateScript()方法负责将传入的JS代码字符串编译成V8的Script对象。API暴露frida-gumjs/目录下的gumjs/子目录中有许多文件如interceptor.c,memory.c,process.c。这些文件实现了Interceptor、Memory等JS API它们通过gumjs的模块系统注册到JS运行时中。搜索gumjs_register_module相关的调用。如何对照对照时序图二的第3、4、5步。看源码是如何从接收二进制消息到调用JS引擎编译代码再到将C函数绑定为JS全局对象的。4.3 Hook安装与回调触发 (frida-gum)核心目录frida-gum/这是所有魔法的底层基础。Interceptor实现frida-gum/gum/interceptor.c是Interceptor.attach的C语言实现。gum_interceptor_attach()是这个功能的核心函数。蹦床生成Hook的关键在于生成蹦床代码。这部分是平台相关的汇编代码。对于ARM架构查看frida-gum/gum/arch-arm/armrelocator.c和.../armwriter.c对于x86/x64查看frida-gum/gum/arch-x86/下的对应文件。gum_arm_writer_put_ldr_reg_address()或gum_x86_writer_put_jmp_near()这样的函数正在写入跳转指令。回调机制当蹦床跳转到Gum的处理函数后比如gum_interceptor_invoke()它如何调用JS回调这需要结合frida-gumjs来看。在frida-gumjs/interceptor.c中有on_enter,on_leave这样的函数它们作为C回调被frida-gum注册当被触发时它们会调用gumjs的接口去执行对应的JS函数。如何对照这是时序图三的核心。建议的阅读路径是从frida-gumjs/interceptor.c的attach()方法开始看它如何调用gum_interceptor_attach()并传递C回调函数。然后跳到frida-gum/gum/interceptor.c看它如何生成蹦床、保存上下文。最后当回调触发时再看控制流如何从Gum的C函数流转回frida-gumjs并最终调用到你的JS函数。源码阅读心得不要试图一次性读懂所有代码。带着时序图中的具体问题去读比如“参数args是如何从CPU寄存器传递到我的JS回调的”。使用grep -r on_enter frida-gum/这样的命令进行全局搜索。多关注函数名和变量名Frida的代码命名相对清晰。理解数据结构的流转如GumInvocationContext比理解每一行汇编更重要。5. 常见问题排查与调试技巧理解了原理和流程当东西不工作时你的排查就有了方向。以下是一些典型问题及其对应的排查层次。5.1 连接与附加失败现象frida.get_usb_device()超时或device.attach()抛出异常。排查步骤基础检查adb devices能看到设备吗frida-server进程在设备上运行吗 (ps | grep frida)版本匹配吗端口检查adb forward --list查看端口转发是否建立。可以尝试adb shell netstat -tlnp | grep 27042查看frida-server是否在监听。权限问题Android上非root设备需要使用frida-server吗不通常用frida-gadget或objection(基于frida-server的非root方案)。如果是root设备确保frida-server以root身份运行。进程状态目标进程是否存在是否处于可调试状态ptrace可能被其他调试器占用或者进程设置了PR_SET_DUMPABLE等反调试日志输出在启动frida-server时加上-D或-l参数输出调试日志 (./frida-server -D -l 0.0.0.0)在客户端使用frida -D连接。观察日志中的错误信息。5.2 脚本加载失败或语法错误现象script.load()抛出异常提示SyntaxError或ReferenceError。排查步骤本地验证先将你的JS代码在Node.js或浏览器的开发者工具中运行一下排除基本的语法错误。分块加载将复杂的脚本拆分成小块逐一加载定位出错的具体语句。利用console.log在脚本开头和可能出错的地方加入console.log()信息会输出到Frida客户端的标准输出。检查API可用性确保你使用的Frida API与当前版本兼容。有些API可能在较新或较旧的版本中才有。5.3 Hook不生效现象脚本加载成功无错误但预期的函数调用没有被拦截到。排查步骤按照时序图反向排查地址是否正确这是最常见的原因。你Hook的地址是绝对地址还是相对地址如果是Module.getExportByName(libc.so, open)确保模块已加载。对于动态链接库在onEnter里打印Module.getBaseAddress(libc.so)验证基址。对于未导出的函数使用Module.findBaseAddress()配合Memory.scan()或模式匹配。时机问题脚本加载时目标函数是否已经被调用过了尝试在setImmediate里执行Hook或者监听Module.load事件在模块加载后再Hook其内部的函数。线程问题Hook是否只应用于特定线程Interceptor.attach默认拦截所有线程的调用。确认函数是在你预期的线程中被调用。Hook本身是否被干扰有些加固或反调试技术会检测函数头是否被修改。可以尝试使用Interceptor.replace()或者更高级的Stalker来跟踪执行流而不是直接修改代码。回调函数错误你的onEnter/onLeave回调函数本身有错误导致整个Hook失效在回调函数第一行加console.log(Hook triggered!)验证。查看Gum日志通过设置环境变量GUM_DEBUG1来运行frida-server或注入frida-gadget可以输出frida-gum的详细调试信息包括Hook的安装过程。5.4 消息 (send) 收不到或进程崩溃现象onEnter中的send()发送了数据但Python端的on(message)回调没触发或者目标进程突然崩溃。排查步骤序列化问题send()的数据是否包含不可JSON序列化的对象比如Native指针 (NativePointer)。必须将其转换为字符串或数字ptr.toString()或ptr.toInt32()。性能与阻塞你是否在onEnter中执行了繁重的同步操作或死循环这会导致目标进程线程卡死。确保onEnter/onLeave尽可能轻量复杂逻辑移到控制端。内存操作错误你是否在回调中错误地访问了内存如对无效指针调用.readByteArray()这会导致段错误SIGSEGV使进程崩溃。使用Memory.protect()或Memory.isReadable()进行检查。通信通道阻塞如果发送的消息量非常大、频率非常高可能会导致内部消息队列阻塞。考虑在JS端进行采样或聚合减少发送频率。控制端回调错误Python端的消息回调函数本身是否有异常用try...except包裹你的回调函数并打印错误。5.5 高级调试手段使用frida-tracefrida-trace -U -i open com.example.app它可以快速验证Frida的基本功能是否正常以及函数地址是否正确。Stalker 跟踪对于复杂的控制流或找不到入口点的情况可以使用Stalker.follow()进行指令级跟踪虽然慢但能揭示执行路径。查看内部状态在JS代码中可以访问Process.arch,Process.pageSize,Module.enumerateImports()等来了解进程环境。源码调试最彻底的方式。在关键函数如gum_interceptor_attach处打上断点使用GDB附加到frida-server或目标进程如果注入的是frida-gadget单步跟踪整个流程。这需要你对Frida源码和编译环境有较深的了解。梳理Frida源码的时序和逻辑就像拿到了一张精密仪器的电路图。它不会直接让你成为更好的脚本小子但当下次你的“仪器”出现故障时你不会再只是盲目地拍打它而是可以拿起万用表沿着电路图有条不紊地测量每一个关键节点的电压和信号最终定位到那个损坏的电阻或虚焊的焊点。这种从现象深入到本质从使用升级到理解的能力才是持续解决复杂问题的根本。希望这份基于时序图的梳理能成为你探索Frida内部世界的第一张有效地图。