Frida 核心模块与实现原理
Frida 核心模块与实现原理
Frida 是一个功能强大的动态插桩框架,但要充分利用它,理解其内部工作原理至关重要。本指南将深入探讨构成 Frida 的几个核心模块、它们的作用以及它们是如何协同工作的。
Frida 的 architecture 概览
Frida 采用的是一种客户端-服务器 (Client-Server) 架构。
Frida 为什么不设计成一个简单的工具,而要分成客户端、服务器、Agent 三层?
- 跨平台的必然选择:
- 隔离性:你的分析脚本(Python)运行在 PC,不会影响目标设备的性能
- 安全性:Server 只负责进程管理和注入,真正的"危险操作"在隔离的进程内
- 灵活性:同一个 Server 可以同时为多个客户端服务,支持团队协作
- 跨语言:PC 端用 Python/Node.js 编写自动化脚本,目标进程内用 JavaScript 操作内存,各取所长
这种架构的本质是:把"控制"和"执行"分离,就像遥控无人机——遥控器在你手上,但飞行逻辑在机上。
客户端 (Client): 运行在你 PC 上的部分。这包括你编写的 Python 或 Node.js 脚本,以及你使用的 Frida 命令行工具 (
frida,frida-trace等)。服务器 (Server): 在目标设备(如 Android 手机)上以后台守护进程模式运行的
frida-server。Agent: 当你附加到一个目标进程时,Frida 会将一个动态库 (
frida-agent.so) 注入到该进程的内存空间中。这个 Agent 负责执行你在客户端脚本中定义的逻辑。

- 图片来源: frida.re*
| |
核心组件详解
Frida-Server: 设备端的守护进程
frida-server 是一个在目标设备上运行的二进制文件。它的主要职责是:
- 监听连接: 监听来自你 PC 上 Frida 客户端的 TCP 连接。
- 进程管理: 枚举目标设备上正在运行的进程,获取应用信息。
- 注入 Agent: 当客户端指定要附加 (attach) 或启动 (spawn) 一个应用时,
frida-server负责将frida-agent.so注入到目标进程中。在 Android 上,它通常通过ptrace来实现这一点。
Frida-Core: 注入目标进程的核心引擎
frida-core 是 Frida 的核心,它被编译成 frida-agent.so 并注入到目标进程。它是一个用 C 语言编写的多平台库,主要负责:
- 进程内通信: 建立一个与
frida-server的通信渠道,从而间接地与你的 PC 客户端通信。 - 加载 JavaScript 引擎: 它内部嵌入了一个 Google V8 JavaScript 引擎。
- 暴露原生 API: 将底层的
frida-gum功能通过 JavaScript API (如Interceptor,Memory,NativePointer) 暴露给用户脚本。
Frida-Gum: 实现 Hook 的魔法棒
frida-gum 是 frida-core 中最具魔力的部分,它是一个跨平台的代码插桩工具包。所有 Hook 和代码跟踪功能都由它提供。
Interceptor: 函数拦截器
Interceptor 是你最常使用的功能,用于 Hook/Trace/替换任意函数。
很多人把 Hook 当成"黑魔法",但其实原理很朴素:
- Hook = 劫持程序的执行流
想象你在高速公路上设置了一个收费站:
- 原始道路:函数的正常执行流程
- 收费站(Trampoline):你插入的代码
- 改道标志(JMP):修改函数入口的跳转指令
- 恢复通行:执行原始指令后继续
理解了这个本质,你就能:
- 判断哪些 Hook 会相互冲突(都修改同一个函数入口)
- 理解为什么有些反 Hook 检测能发现你(检查函数头的修改)
- 知道如何写更隐蔽的 Hook(inline hook vs. PLT/GOT hook)
- 实现原理:
- 动态代码生成: 当你
Interceptor.attach一个函数时,Frida-Gum 会在内存中动态地生成一小段汇编代码,我们称之为蹦床 (Trampoline)。 - 函数头重写 (Prologue Rewriting): Frida-Gum 会修改目标函数入口点(函数头)的几条指令,将其替换为一个无条件跳转 (
JMP) 指令,该指令指向刚刚创建的蹦床。Frida 会非常小心地保存被它覆盖掉的原始指令。 - 执行流程:
当应用调用目标函数时,它会首先跳转到蹦床。
蹦床代码会保存当前的 CPU 上下文(寄存器状态),然后调用你在 JavaScript 中定义的
onEnter回调。onEnter执行完毕后,蹦床会执行被它覆盖掉的原始函数指令,然后跳转回原始函数的剩余部分继续执行。当原始函数执行完毕后,控制权返回给蹦床,蹦床再调用你的
onLeave回调。最后,蹦床恢复之前保存的 CPU 上下文,并将返回值传递给原始的调用者。
Stalker: 指令级跟踪器
Stalker 是 Frida 的代码跟踪引擎,功能极其强大但使用也更复杂。它可以用来记录一个线程执行过的每一条汇编指令。
- 实现原理 (基于动态重新编译):
- 基本块 (Basic Block): Stalker 将代码分解为"基本块"。一个基本块是一系列连续的指令,只有一个入口点和一个出口点(通常是跳转或返回指令)。
- 代码拷贝与插桩: 当一个线程将要执行某个基本块时,Stalker 会: a. 将这个基本块的所有指令拷贝到一块新的内存区域。 b. 在这份拷贝中插入你的分析代码(例如,记录指令地址、寄存器值的代码)。 c. 执行这份被插桩后的代码副本。
- 代码缓存 (Code Cache): Stalker 会缓存这些被修改过的基本块。下次再执行到同一个基本块时,可以直接使用缓存中的版本,极大地提高了性能。
- 链接 (Chaining): Stalker 会修改每个插桩后基本块的末尾,使其跳转到下一个即将执行的原始基本块对应的"插桩版本",从而形成一个完整的跟踪链。
简而言之,Stalker 通过创建和执行原始代码的"带监控的副本"来实现无死角的指令级跟踪。
JavaScript (V8) 运行时: 脚本的执行环境
为什么我们用 JavaScript 写 Hook 逻辑?因为 frida-agent.so 在注入目标进程后,会初始化一个 V8 引擎实例。你的 JS 脚本被完整地加载到这个 V8 引擎中执行。
这带来了巨大的优势:
高级语言的便利性: 你可以在目标进程的地址空间内,用 JavaScript 的便利性来操作内存、调用函数。
JIT 编译: V8 的即时编译 (JIT) 特性使得你的 JS 脚本能以接近原生的速度运行,性能远超解释执行。
强大的生态: 可以利用现有的 JS 库。
Frida 实际上提供了两个 JS 运行时:
| 运行时 | 引擎 | 特点 | 适用场景 |
|---|---|---|---|
| V8 | Google V8 | JIT 编译,性能最优 | 默认选择,大多数场景 |
| QuickJS | Fabrice Bellard 的 QuickJS | 解释执行,体积小 | iOS 无 JIT 权限时 |
语言绑定 (Bindings): 你的控制台
frida-python, frida-node 等库是你的"控制端"。它们负责:
连接 Server: 与设备上的
frida-server建立通信。发送指令: 将你的指令(如"附加到 PID 1234")发送给
frida-server。加载脚本: 将你的
.js脚本文件内容发送给frida-agent.so里的 V8 引擎去执行。双向通信 (RPC): 建立一个双向的 RPC 通道。这使得你在 JS 中调用
send()的数据能被 Python 的on_message回调接收,反之亦然。
工作流程串讲
当你执行 frida -U -f com.example.app -l script.js 时,发生了什么?
- [PC]
frida(Python 客户端) 解析命令。 - [PC -> Phone] 客户端通过 USB 连接到手机上的
frida-server。 - [PC -> Phone] 客户端向
frida-server发送指令:“请以spawn模式启动com.example.app"。 - [Phone]
frida-server找到com.example.app并启动它,但使其处于暂停状态。 - [Phone]
frida-server将frida-agent.so注入到这个新创建的应用进程中。 - [Phone]
frida-agent.so在进程内初始化,启动 V8 引擎,并建立与frida-server的内部通信。 - [PC -> Phone] 客户端读取
script.js的内容,并通过frida-server将其发送给frida-agent.so。 - [Phone]
frida-agent.so中的 V8 引擎执行script.js的代码(例如,Interceptor.attach(...))。 - [PC -> Phone] 客户端发送"恢复进程"的指令。
- [Phone] 应用进程从暂停状态中恢复,开始正常执行。当它调用被 Hook 的函数时,你在
script.js中定义的逻辑就会被触发。 - [双向] 脚本中的
send()消息会通过agent -> server -> client的路径回到你的 PC 终端上显示。
注入机制深入
理解 Frida 如何将 frida-agent.so 注入到目标进程,是掌握整个框架的基础。
ptrace 注入流程
ptrace 是 Linux 内核提供的进程跟踪系统调用。Frida 利用它完成注入:
| |
远程 mmap 调用是最巧妙的部分:frida-server 通过修改目标进程的寄存器来设置 mmap 系统调用的参数,将 PC 指向一条 SVC #0(ARM syscall 指令),执行一条指令后捕获返回值,从而在目标进程中分配出可执行内存。
写入目标进程的 shellcode 核心逻辑如下:
| |
排错提示:如果注入时遇到
"Failed to attach"或"unable to access process",首先用getenforce检查 SELinux 状态,然后确认 frida-server 是否以 root 运行。
Spawn vs Attach 模式
Spawn 模式 (-f) | Attach 模式 | |
|---|---|---|
| 时机 | 应用启动前注入 | 应用运行中注入 |
| 实现 | 利用 zygote fork,exec 前 ptrace | 直接 ptrace 运行中进程 |
| 优势 | 可 Hook 初始化代码、构造函数 | 不干扰启动流程 |
| 风险 | 可能影响启动时序 | 错过早期调用 |
SELinux 的影响
SELinux 在 enforcing 模式下可能阻止 ptrace 附加、mmap 可执行内存、agent 的 socket 通信。Frida 的应对策略:
- frida-server 以 root (u:r:su:s0) 运行,天然拥有大多数权限
- 使用
memfd_create()替代文件系统上的 .so,通过dlopen("/proc/self/fd/N")加载,避免文件 SELinux 标签检查 - 通过
/proc/<pid>/mem直接写入,在某些内核版本上可绕过 ptrace 限制 - 作为最后手段,
setenforce 0临时关闭 SELinux(不推荐用于生产环境)
frida-gadget (无 root 方案)
当设备无 root 时,将 frida-gadget.so 打包进 APK:
| |
Gadget 支持多种运行模式,通过 frida-gadget.config 配置:
| 模式 | 说明 |
|---|---|
listen | 等待客户端连接(类似 frida-server) |
script | 自动加载指定 JS 脚本,无需客户端 |
script-directory | 监视目录中的脚本变化并自动加载 |
Interceptor 实现细节
ARM64 Trampoline 结构
ARM64 上 Frida 需要覆盖函数头部 16 字节来放置跳转:
| |
Trampoline 执行流:
| |
Inline Hook vs PLT/GOT Hook
| Inline Hook (默认) | PLT/GOT Hook | |
|---|---|---|
| 修改位置 | 函数代码段的指令 | GOT 表中的函数指针 |
| 拦截范围 | 所有调用(包括直接调用) | 仅通过 PLT 的调用(外部库调用) |
| 隐蔽性 | 低(代码段被修改) | 高(代码段不变) |
| 实现 | Interceptor.attach() | 手动修改 GOT 条目 |
PLT/GOT Hook 示例:
| |
指令重定位 (Code Relocation)
被覆盖的原始指令搬到 trampoline 后,位置相关指令必须修正:
| 指令类型 | 问题 | 修正方法 |
|---|---|---|
ADRP X0, #imm | PC 相对页地址计算 | 替换为 MOV+MOVK 绝对地址加载 |
B #offset / BL #offset | PC 相对跳转 | 若超出范围,替换为间接跳转 |
TBZ/TBNZ/CBZ/CBNZ | 条件跳转偏移 | 展开为多条指令 |
LDR Xt, [PC, #imm] | PC 相对数据加载 | 计算绝对地址后用寄存器间接加载 |
Frida-Gum 的 GumArm64Relocator 组件自动处理这些重定位。重定位示例:
| |
ARM32/Thumb 的额外复杂性
ARM32 下的 Hook 更复杂,因为 Thumb 模式指令长度为 2 或 4 字节(而非 ARM64 统一的 4 字节)。关键问题包括:
- 指令边界对齐:覆盖时可能跨越 Thumb 指令边界
- ARM/Thumb 模式切换:跳转 shellcode 可能需要先切换到 ARM 模式
- IT (If-Then) 块:不能部分覆盖 IT 块中的指令,否则处理器会产生未定义行为
Interceptor 的线程安全
多线程同时执行被 Hook 函数时,Frida 通过以下设计保证安全:
- Trampoline 代码本身无状态(可重入)
- 每次进入 trampoline 时在栈上分配
GumInvocationContext(每个线程有独立栈) - 使用线程局部存储 (TLS) 维护调用深度计数
因此不同线程的 Hook 上下文互不干扰。但需要注意:你的 onEnter/onLeave JS 回调中访问共享 JS 变量时,由于 Frida 使用锁序列化 JS 执行,高并发时会产生锁竞争开销。
Stalker 深入分析
Stalker 本质上是一个动态二进制翻译器 (DBT),类似 QEMU 用户态模式或 Intel PIN。
翻译管线
| |
代码缓存使用 Slab 分配器:每个 Slab 约 2MB 的连续 RWX 内存,翻译后的基本块紧密排列以提高 CPU 缓存命中率。已翻译的块通过 Block Chaining 直接互相跳转,避免经过 Stalker 调度。
核心思想:永远不执行原始代码,只执行经过插桩的副本。
| |
性能特征
| 使用方式 | 开销倍数 | 说明 |
|---|---|---|
仅 call/ret 事件 | 2-5x | 插桩代码少 |
所有 exec 事件 | 10-50x | 每条指令前都插入回调 |
| transform + callout | 5-20x | 取决于 callout 复杂度 |
| transform 仅修改指令 | 2-8x | 无 JS 回调开销 |
实际应用:代码覆盖率收集
| |
实际应用:加密算法指令追踪
| |
实际应用:动态指令修补
| |
内存管理
Frida 在目标进程中的内存占用
| |
页面权限与缓存刷新
修改代码段需要 mprotect 更改权限,修改后必须刷新 I-Cache(ARM 使用分离的数据/指令缓存):
| |
Trampoline 内存分配策略
| |
进阶 Memory API
| |
通信机制
消息路径
| |
RPC 调用流程
| |
| |
内部实现:Client 发送 ["frida:rpc", id, "call", "add", [1, 2]],Agent 执行后返回 ["frida:rpc", id, "ok", 3],通过 request_id 匹配。
性能参考数据
| 操作 | 典型延迟 | 吞吐量 |
|---|---|---|
| send() 小消息 (< 1KB) | ~0.1ms | ~10000 msg/s |
| send() 大消息 (1MB) | ~5ms | ~200 MB/s |
| RPC 调用 (简单返回) | ~1ms | ~1000 call/s |
| RPC + 二进制数据 (1MB) | ~10ms | ~100 MB/s |
瓶颈主要来自:JSON 序列化(CPU 密集)、USB 传输(USB 2.0 限制 ~480Mbps)、V8 GC 暂停。
性能优化建议
| |
与其他框架对比
| 维度 | Frida | Xposed/LSPosed | eBPF |
|---|---|---|---|
| Hook 层面 | Native 指令 + Java ART | Java 方法级 | 内核函数 + syscall |
| 注入方式 | ptrace + dlopen | 修改 Zygote (Zygisk) | bpf() 系统调用 |
| 粒度 | 指令级 (Stalker) / 函数级 | 方法级 | 函数入口/出口 |
| 运行时 | V8/QuickJS | ART | eBPF VM (受限) |
| 持久性 | 非持久 | 持久 (Magisk 模块) | 非持久 |
| 性能开销 | 中-高 | 低 | 极低 |
| 隐蔽性 | 较易检测 | 较易检测 | 几乎不可检测 |
核心差异:Xposed 修改的是 ART 虚拟机中 ArtMethod 的 entry_point 指针(Java 层面),而 Frida 直接修改机器码指令(Native 层面)。eBPF 则在内核中运行受限程序,通过 kprobe/uprobe 探针工作。
选择建议:Frida 的灵活性和交互性使它成为逆向分析的首选。需要永久性修改时用 Xposed/LSPosed。eBPF 适合内核行为分析和性能诊断。
已知限制与陷阱
竞态条件
Hook 安装时,如果某线程正好执行到被覆盖的函数头部中间位置,可能导致执行半条指令而崩溃。Frida 在 ARM64 上使用原子写入(STP 一次写 16 字节)来尽量避免,但不能保证 100% 无竞态。
反检测向量
| 检测方式 | 原理 | 绕过思路 |
|---|---|---|
| 端口扫描 | 检测 27042 端口 | frida-server -l 0.0.0.0:PORT 改端口 |
| 进程名 | 搜索 “frida-server” | 重命名二进制 |
/proc/self/maps | 搜索 “frida-agent” | Hook open/read 过滤 |
| 函数完整性 | 检查函数头是否被修改 | 用 PLT/GOT Hook 或 Hook 检测函数本身 |
| 线程名 | 搜索 “gmain”/“gdbus” 线程 | Hook 线程枚举相关函数 |
| D-Bus 探测 | 向 socket 发 D-Bus 握手 | 修改通信协议 |
| Inline Hook 检测 | 检查函数入口是否为 LDR X16,#8 (0x58000050) | 使用替代跳转指令 |
常见绕过模板:
| |
常见崩溃与排查
| 信号 | 常见原因 | 排查 |
|---|---|---|
| SIGSEGV | Hook 了已卸载的 .so | 检查模块是否仍在内存中 |
| SIGBUS | 内存未对齐访问 | 确认 readU32 地址 4 字节对齐 |
| SIGILL | 指令重定位错误 | 尝试更新 Frida 版本 |
| 死锁 | onEnter 调用了持有锁的函数 | 避免阻塞操作 |
| OOM | Stalker 缓存过大 | 缩小跟踪范围,及时 unfollow |
实践注意事项
- 不要在回调中做耗时操作 - 回调在目标线程同步执行,会拖慢应用
- Spawn 模式下先 Hook 再 resume - 确保
script.load()完成后再device.resume() - 多进程应用 - Android 应用可能有多个进程(如 WebView),每个需单独注入,可用
child_gating自动跟踪 - NativeFunction 与死锁 - 在 onEnter 中通过 NativeFunction 调用的函数如果内部持有锁,可能与当前被 Hook 函数产生死锁
- C++ 构造/析构函数风险 - Hook 后若 onEnter 抛出异常,对象可能处于未完全初始化状态,析构函数类似可能导致资源泄漏
- V8 JIT 与 W^X 策略 - 某些加固的 Android ROM 启用了严格的 W^X(可写与可执行互斥),可能导致 V8 JIT 和 Stalker 代码缓存分配失败,此时考虑使用 QuickJS 运行时
源码导读
仓库关键文件
| |
推荐阅读路径
路径 1 - 理解 Hook:guminterceptor.h (接口) -> guminterceptor.c (平台无关逻辑) -> guminterceptor-arm64.c (trampoline 生成) -> gumarm64writer.c + gumarm64relocator.c (代码生成与重定位)
路径 2 - 理解注入:linux-host-session.vala (Session 创建) -> inject-context.c (ptrace 注入) -> agent.vala (Agent 初始化)
路径 3 - 理解 Stalker:gumstalker.h (接口) -> gumstalker-arm64.c 分段阅读:gum_stalker_follow_me (入口) -> gum_exec_ctx_obtain_block_for (翻译) -> gum_exec_block_virtualize_*_insn (指令处理)
调试技巧:在源码中添加
g_print()后重新编译 frida-server 推送到设备,通过 stdout 或adb logcat观察内部行为,是理解 Frida 机制最直接的方式。
从源码构建
| |
总结
| |
掌握这些底层原理后,Frida 不再是"黑盒"工具。当你遇到 Hook 失败、进程崩溃或性能问题时,这些知识将帮助你快速定位原因并找到解决方案。
相关内容
支付宝
微信