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 Architecture

  • 图片来源: frida.re*
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
完整数据流:

  PC (Host)                        Android (Target)
 +------------------+             +-------------------------+
 | Python/Node.js   |   USB/TCP   |  frida-server (root)    |
 | 客户端脚本        |<==========>|  端口 27042             |
 | frida CLI        |             |     | ptrace/dlopen     |
 +------------------+             |     v                   |
                              |  +--------------------+ |
                              |  | 目标进程            | |
                              |  |  +--------------+  | |
                              |  |  | agent.so     |  | |
                              |  |  | V8 + Gum     |  | |
                              |  |  | user script  |  | |
                              |  |  +--------------+  | |
                              |  +--------------------+ |
                              +-------------------------+

核心组件详解

Frida-Server: 设备端的守护进程

frida-server 是一个在目标设备上运行的二进制文件。它的主要职责是:

  1. 监听连接: 监听来自你 PC 上 Frida 客户端的 TCP 连接。
  2. 进程管理: 枚举目标设备上正在运行的进程,获取应用信息。
  3. 注入 Agent: 当客户端指定要附加 (attach) 或启动 (spawn) 一个应用时,frida-server 负责将 frida-agent.so 注入到目标进程中。在 Android 上,它通常通过 ptrace 来实现这一点。

Frida-Core: 注入目标进程的核心引擎

frida-core 是 Frida 的核心,它被编译成 frida-agent.so 并注入到目标进程。它是一个用 C 语言编写的多平台库,主要负责:

  1. 进程内通信: 建立一个与 frida-server 的通信渠道,从而间接地与你的 PC 客户端通信。
  2. 加载 JavaScript 引擎: 它内部嵌入了一个 Google V8 JavaScript 引擎。
  3. 暴露原生 API: 将底层的 frida-gum 功能通过 JavaScript API (如 Interceptor, Memory, NativePointer) 暴露给用户脚本。

Frida-Gum: 实现 Hook 的魔法棒

frida-gumfrida-core 中最具魔力的部分,它是一个跨平台的代码插桩工具包。所有 Hook 和代码跟踪功能都由它提供。

Interceptor: 函数拦截器

Interceptor 是你最常使用的功能,用于 Hook/Trace/替换任意函数。

深入理解:Hook 的本质是什么?

很多人把 Hook 当成"黑魔法",但其实原理很朴素:

  • Hook = 劫持程序的执行流

想象你在高速公路上设置了一个收费站:

  1. 原始道路:函数的正常执行流程
  2. 收费站(Trampoline):你插入的代码
  3. 改道标志(JMP):修改函数入口的跳转指令
  4. 恢复通行:执行原始指令后继续

理解了这个本质,你就能:

  • 判断哪些 Hook 会相互冲突(都修改同一个函数入口)
  • 理解为什么有些反 Hook 检测能发现你(检查函数头的修改)
  • 知道如何写更隐蔽的 Hook(inline hook vs. PLT/GOT hook)
  • 实现原理:
  1. 动态代码生成: 当你 Interceptor.attach 一个函数时,Frida-Gum 会在内存中动态地生成一小段汇编代码,我们称之为蹦床 (Trampoline)
  2. 函数头重写 (Prologue Rewriting): Frida-Gum 会修改目标函数入口点(函数头)的几条指令,将其替换为一个无条件跳转 (JMP) 指令,该指令指向刚刚创建的蹦床。Frida 会非常小心地保存被它覆盖掉的原始指令。
  3. 执行流程:
  • 当应用调用目标函数时,它会首先跳转到蹦床。

  • 蹦床代码会保存当前的 CPU 上下文(寄存器状态),然后调用你在 JavaScript 中定义的 onEnter 回调。

  • onEnter 执行完毕后,蹦床会执行被它覆盖掉的原始函数指令,然后跳转回原始函数的剩余部分继续执行。

  • 当原始函数执行完毕后,控制权返回给蹦床,蹦床再调用你的 onLeave 回调。

  • 最后,蹦床恢复之前保存的 CPU 上下文,并将返回值传递给原始的调用者。

Stalker: 指令级跟踪器

Stalker 是 Frida 的代码跟踪引擎,功能极其强大但使用也更复杂。它可以用来记录一个线程执行过的每一条汇编指令。

  • 实现原理 (基于动态重新编译):
  1. 基本块 (Basic Block): Stalker 将代码分解为"基本块"。一个基本块是一系列连续的指令,只有一个入口点和一个出口点(通常是跳转或返回指令)。
  2. 代码拷贝与插桩: 当一个线程将要执行某个基本块时,Stalker 会: a. 将这个基本块的所有指令拷贝到一块新的内存区域。 b. 在这份拷贝中插入你的分析代码(例如,记录指令地址、寄存器值的代码)。 c. 执行这份被插桩后的代码副本。
  3. 代码缓存 (Code Cache): Stalker 会缓存这些被修改过的基本块。下次再执行到同一个基本块时,可以直接使用缓存中的版本,极大地提高了性能。
  4. 链接 (Chaining): Stalker 会修改每个插桩后基本块的末尾,使其跳转到下一个即将执行的原始基本块对应的"插桩版本",从而形成一个完整的跟踪链。

简而言之,Stalker 通过创建和执行原始代码的"带监控的副本"来实现无死角的指令级跟踪。

JavaScript (V8) 运行时: 脚本的执行环境

为什么我们用 JavaScript 写 Hook 逻辑?因为 frida-agent.so 在注入目标进程后,会初始化一个 V8 引擎实例。你的 JS 脚本被完整地加载到这个 V8 引擎中执行。

这带来了巨大的优势:

  • 高级语言的便利性: 你可以在目标进程的地址空间内,用 JavaScript 的便利性来操作内存、调用函数。

  • JIT 编译: V8 的即时编译 (JIT) 特性使得你的 JS 脚本能以接近原生的速度运行,性能远超解释执行。

  • 强大的生态: 可以利用现有的 JS 库。

Frida 实际上提供了两个 JS 运行时:

运行时引擎特点适用场景
V8Google V8JIT 编译,性能最优默认选择,大多数场景
QuickJSFabrice 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 时,发生了什么?

  1. [PC] frida (Python 客户端) 解析命令。
  2. [PC -> Phone] 客户端通过 USB 连接到手机上的 frida-server
  3. [PC -> Phone] 客户端向 frida-server 发送指令:“请以 spawn 模式启动 com.example.app"。
  4. [Phone] frida-server 找到 com.example.app 并启动它,但使其处于暂停状态。
  5. [Phone] frida-serverfrida-agent.so 注入到这个新创建的应用进程中。
  6. [Phone] frida-agent.so 在进程内初始化,启动 V8 引擎,并建立与 frida-server 的内部通信。
  7. [PC -> Phone] 客户端读取 script.js 的内容,并通过 frida-server 将其发送给 frida-agent.so
  8. [Phone] frida-agent.so 中的 V8 引擎执行 script.js 的代码(例如,Interceptor.attach(...))。
  9. [PC -> Phone] 客户端发送"恢复进程"的指令。
  10. [Phone] 应用进程从暂停状态中恢复,开始正常执行。当它调用被 Hook 的函数时,你在 script.js 中定义的逻辑就会被触发。
  11. [双向] 脚本中的 send() 消息会通过 agent -> server -> client 的路径回到你的 PC 终端上显示。

注入机制深入

理解 Frida 如何将 frida-agent.so 注入到目标进程,是掌握整个框架的基础。

ptrace 注入流程

ptrace 是 Linux 内核提供的进程跟踪系统调用。Frida 利用它完成注入:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
frida-server                   目标进程
    |                              |
    | 1. ptrace(PTRACE_ATTACH)     |  进程暂停 (SIGSTOP)
    | 2. PTRACE_GETREGS            |  保存寄存器状态
    | 3. 远程调用 mmap()            |  分配 RWX 内存
    | 4. PTRACE_POKEDATA           |  写入 shellcode
    | 5. PTRACE_SETREGS (PC=shellcode) |
    | 6. PTRACE_CONT               |  执行 shellcode:
    |                              |    dlopen("frida-agent.so")
    |                              |    frida_agent_main()
    | 7. 恢复原始寄存器状态          |
    | 8. ptrace(PTRACE_DETACH)     |  进程恢复正常

远程 mmap 调用是最巧妙的部分:frida-server 通过修改目标进程的寄存器来设置 mmap 系统调用的参数,将 PC 指向一条 SVC #0(ARM syscall 指令),执行一条指令后捕获返回值,从而在目标进程中分配出可执行内存。

写入目标进程的 shellcode 核心逻辑如下:

1
2
3
4
5
shellcode 伪代码:
  1. dlopen("frida-agent.so", RTLD_LAZY)   // 加载 Agent
  2. dlsym(handle, "frida_agent_main")     // 获取入口函数
  3. frida_agent_main(agent_parameters)    // 启动 Agent
  4. 触发 SIGTRAP                          // 通知 server 完成

排错提示:如果注入时遇到 "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 的应对策略:

  1. frida-server 以 root (u:r:su:s0) 运行,天然拥有大多数权限
  2. 使用 memfd_create() 替代文件系统上的 .so,通过 dlopen("/proc/self/fd/N") 加载,避免文件 SELinux 标签检查
  3. 通过 /proc/<pid>/mem 直接写入,在某些内核版本上可绕过 ptrace 限制
  4. 作为最后手段setenforce 0 临时关闭 SELinux(不推荐用于生产环境)

frida-gadget (无 root 方案)

当设备无 root 时,将 frida-gadget.so 打包进 APK:

1
2
3
4
5
1. 解压 APK
2. 拷贝 frida-gadget.so 到 lib/<abi>/
3. 在入口 Activity 的 smali 中添加 System.loadLibrary("frida-gadget")
   或用 patchelf --add-needed frida-gadget.so 添加为已有 .so 的依赖
4. 重新打包、签名、安装

Gadget 支持多种运行模式,通过 frida-gadget.config 配置:

模式说明
listen等待客户端连接(类似 frida-server)
script自动加载指定 JS 脚本,无需客户端
script-directory监视目录中的脚本变化并自动加载

Interceptor 实现细节

ARM64 Trampoline 结构

ARM64 上 Frida 需要覆盖函数头部 16 字节来放置跳转:

1
2
3
4
5
6
原始函数 (Hook )           Hook 后:

0x7000: STP X29,X30,[SP,#-16]!  0x7000: LDR X16, #8     (4 bytes)
0x7004: MOV X29, SP             0x7004: BR  X16          (4 bytes)
0x7008: SUB SP, SP, #0x40       0x7008: <trampoline_addr>(8 bytes)
0x700C: STR X0, [SP, #0x38]     0x7010: STR X0,[SP,#0x38] <-- 正常继续

Trampoline 执行流:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
调用者 -> [函数入口 JMP] -> Enter Trampoline
                            | 保存 X0-X30, SP, NZCV
                            | 调用 onEnter(context)
                            v
                          原始指令 Trampoline (被覆盖指令的重定位副本)
                            | JMP -> 原始函数 offset+16
                            v
                          原始函数剩余代码 ... RET
                            v
                          Leave Trampoline
                            | 捕获返回值, 调用 onLeave
                            | 恢复寄存器, 返回调用者

Inline Hook vs PLT/GOT Hook

Inline Hook (默认)PLT/GOT Hook
修改位置函数代码段的指令GOT 表中的函数指针
拦截范围所有调用(包括直接调用)仅通过 PLT 的调用(外部库调用)
隐蔽性低(代码段被修改)高(代码段不变)
实现Interceptor.attach()手动修改 GOT 条目

PLT/GOT Hook 示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
var mod = Process.findModuleByName("libnative.so");
var imp = mod.enumerateImports().find(i => i.name === "strcmp");
if (imp) {
    var orig = Memory.readPointer(imp.slot);
    var hook = new NativeCallback(function(s1, s2) {
        console.log("strcmp:", s1.readUtf8String(), s2.readUtf8String());
        return new NativeFunction(orig, 'int', ['pointer','pointer'])(s1, s2);
    }, 'int', ['pointer','pointer']);
    Memory.protect(imp.slot, Process.pointerSize, 'rw-');
    Memory.writePointer(imp.slot, hook);
}

指令重定位 (Code Relocation)

被覆盖的原始指令搬到 trampoline 后,位置相关指令必须修正:

指令类型问题修正方法
ADRP X0, #immPC 相对页地址计算替换为 MOV+MOVK 绝对地址加载
B #offset / BL #offsetPC 相对跳转若超出范围,替换为间接跳转
TBZ/TBNZ/CBZ/CBNZ条件跳转偏移展开为多条指令
LDR Xt, [PC, #imm]PC 相对数据加载计算绝对地址后用寄存器间接加载

Frida-Gum 的 GumArm64Relocator 组件自动处理这些重定位。重定位示例:

1
2
3
4
5
6
7
原始位置 0x7000:
  ADRP X0, #0x2000     -> X0 = 0x9000 (基于 PC 的页地址)
  LDR  X0, [X0, #0x10] -> X0 = *(0x9010)

搬迁到 trampoline 位置 0xA000 :
  MOV  X0, #0x9010     -> X0 = 0x9010 (绝对地址,不依赖 PC)
  LDR  X0, [X0]        -> X0 = *(0x9010)

ARM32/Thumb 的额外复杂性

ARM32 下的 Hook 更复杂,因为 Thumb 模式指令长度为 2 或 4 字节(而非 ARM64 统一的 4 字节)。关键问题包括:

  • 指令边界对齐:覆盖时可能跨越 Thumb 指令边界
  • ARM/Thumb 模式切换:跳转 shellcode 可能需要先切换到 ARM 模式
  • IT (If-Then) 块:不能部分覆盖 IT 块中的指令,否则处理器会产生未定义行为

Interceptor 的线程安全

多线程同时执行被 Hook 函数时,Frida 通过以下设计保证安全:

  1. Trampoline 代码本身无状态(可重入)
  2. 每次进入 trampoline 时在栈上分配 GumInvocationContext(每个线程有独立栈)
  3. 使用线程局部存储 (TLS) 维护调用深度计数

因此不同线程的 Hook 上下文互不干扰。但需要注意:你的 onEnter/onLeave JS 回调中访问共享 JS 变量时,由于 Frida 使用锁序列化 JS 执行,高并发时会产生锁竞争开销。


Stalker 深入分析

Stalker 本质上是一个动态二进制翻译器 (DBT),类似 QEMU 用户态模式或 Intel PIN。

翻译管线

1
2
3
原始基本块 -> [解码] -> [Transform 回调] -> [插桩] -> [写入代码缓存] -> [链接]
              ARM64Reader  用户可修改     插入跟踪    Slab 分配器    连接后续块
                           /删除/添加指令  回调代码

代码缓存使用 Slab 分配器:每个 Slab 约 2MB 的连续 RWX 内存,翻译后的基本块紧密排列以提高 CPU 缓存命中率。已翻译的块通过 Block Chaining 直接互相跳转,避免经过 Stalker 调度。

核心思想:永远不执行原始代码,只执行经过插桩的副本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
原始代码                      Stalker 代码缓存
+-------------------+         +---------------------------+
| BB1 (0x1000):     |  翻译   | BB1':                     |
|   MOV X0, #1      | ------> |   [记录执行到 0x1000]      |
|   CMP X0, #5      |         |   MOV X0, #1              |
|   B.EQ 0x1020     |         |   CMP X0, #5              |
+-------------------+         |   B.EQ -> BB3'(已翻译)     |
| BB2 (0x100C):     |         |   B   -> BB2'(延迟翻译)   |
|   MOV X2, #0      |         +---------------------------+
|   B   0x1030      |         | BB2':                     |
+-------------------+         |   [记录执行到 0x100C]      |
| BB3 (0x1020):     |         |   MOV X2, #0  ...         |
|   MOV X2, #1      |         +---------------------------+
|   RET             |         | BB3':                     |
+-------------------+         |   [记录执行到 0x1020]      |
                              |   MOV X2, #1  ...         |
                              +---------------------------+

性能特征

使用方式开销倍数说明
call/ret 事件2-5x插桩代码少
所有 exec 事件10-50x每条指令前都插入回调
transform + callout5-20x取决于 callout 复杂度
transform 仅修改指令2-8x无 JS 回调开销

实际应用:代码覆盖率收集

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
var target = Process.findModuleByName("libtarget.so");
var coverage = new Set();
Stalker.follow(Process.getCurrentThreadId(), {
    events: { compile: true },
    onReceive: function(events) {
        Stalker.parse(events, {stringify:false, annotate:false}).forEach(function(e) {
            var addr = e[0];
            if (addr >= target.base && addr < target.base.add(target.size))
                coverage.add(addr.sub(target.base).toInt32());
        });
    }
});

实际应用:加密算法指令追踪

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 跟踪加密函数内的算术/逻辑指令,用于分析白盒加密算法
var encryptFunc = Module.findExportByName("libcrypto.so", "encrypt_block");
Interceptor.attach(encryptFunc, {
    onEnter: function(args) {
        Stalker.follow(Process.getCurrentThreadId(), {
            transform: function(iterator) {
                var insn;
                while ((insn = iterator.next()) !== null) {
                    iterator.keep();
                    var m = insn.mnemonic;
                    if (["eor","and","orr","lsl","lsr","ror","add","sub","mul"]
                        .indexOf(m) !== -1) {
                        iterator.putCallout(function(ctx) {
                            console.log("PC=" + ctx.pc +
                                " X0=" + ctx.x0 + " X1=" + ctx.x1);
                        });
                    }
                }
            }
        });
    },
    onLeave: function() {
        Stalker.unfollow(); Stalker.flush();
    }
});

实际应用:动态指令修补

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Stalker.follow(Process.getCurrentThreadId(), {
    transform: function(iterator) {
        var insn;
        while ((insn = iterator.next()) !== null) {
            if (insn.address.equals(ptr("0x12345678"))) {
                iterator.putNop();  // 将条件跳转替换为 NOP
            } else {
                iterator.keep();
            }
        }
    }
});

内存管理

Frida 在目标进程中的内存占用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
注入后新增内存映射(典型值):

区域                    大小        权限   用途
frida-agent.so 代码段   ~15MB      r-x   Agent 代码
frida-agent.so 数据段   ~4MB       rw-   Agent 数据
V8 堆                  可变       rw-   JS 对象
V8 JIT 输出            可变       rwx   编译后的 JS 代码
Interceptor trampoline  可变       rwx   Hook 蹦床代码
Stalker 代码缓存        可变       rwx   翻译后的基本块

总占用:30-80MB(取决于脚本复杂度)

页面权限与缓存刷新

修改代码段需要 mprotect 更改权限,修改后必须刷新 I-Cache(ARM 使用分离的数据/指令缓存):

1
2
3
4
5
6
// 使用 Memory.patchCode 安全修改代码(自动处理权限和缓存刷新)
Memory.patchCode(targetAddr, 16, function(code) {
    var w = new Arm64Writer(code, { pc: targetAddr });
    w.putNop(); w.putNop(); w.putNop(); w.putNop();
    w.flush();
});

Trampoline 内存分配策略

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
策略优先级:

1. 近距离分配 (优先)
   在目标函数 +/-128MB 范围内寻找可用地址
   -> 可使用短跳转,减少覆盖字节数
   -> 扫描 /proc/self/maps 寻找空隙

2. mmap 新页面
   使用 MAP_FIXED 尝试在目标附近映射
   -> 回退到任意地址 + 长跳转 (LDR+BR)

3. 代码洞复用 (高级)
   扫描模块中的 padding/NOP 区域
   ->  "洞" 中写入跳转代码
   -> 不增加新映射,更隐蔽

进阶 Memory API

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 内存搜索 - 在所有可读区域搜索字节模式
var ranges = Process.enumerateRanges('r--');
ranges.forEach(function(range) {
    Memory.scan(range.base, range.size, "48 65 6C 6C 6F", {
        onMatch: function(addr, size) { console.log("Found at: " + addr); },
        onComplete: function() {}
    });
});

// 近距离内存分配
var nearby = Memory.alloc(Process.pageSize, {
    near: ptr("0x71000000"),
    maxDistance: 0x7FFFFFFF
});

通信机制

消息路径

1
2
3
4
5
6
7
send(msg, data)  ->  V8 序列化  ->  GumMessage  ->  DBus 编码
    [JS]              [Agent]        [frida-core]    [IPC]
                                          |
                                    Unix/TCP Socket
                                          |
                                    frida-server  ->  TCP/USB  ->  Client
                                                                on_message(msg, data)

RPC 调用流程

1
2
// Agent 端
rpc.exports = { add: function(a, b) { return a + b; } };
1
2
# Client 端
result = script.exports_sync.add(1, 2)  # => 3

内部实现: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 暂停。

性能优化建议

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 避免: 每次调用都 send
Interceptor.attach(target, {
    onEnter: function(args) { send({a: args[0].toInt32()}); }
});

// 推荐: 批量发送
var buf = [];
Interceptor.attach(target, {
    onEnter: function(args) {
        buf.push(args[0].toInt32());
        if (buf.length >= 100) { send({batch: buf}); buf = []; }
    }
});

// 大数据用二进制格式 (第二参数)
send({type: "dump"}, Memory.readByteArray(addr, 1024));

与其他框架对比

维度FridaXposed/LSPosedeBPF
Hook 层面Native 指令 + Java ARTJava 方法级内核函数 + syscall
注入方式ptrace + dlopen修改 Zygote (Zygisk)bpf() 系统调用
粒度指令级 (Stalker) / 函数级方法级函数入口/出口
运行时V8/QuickJSARTeBPF VM (受限)
持久性非持久持久 (Magisk 模块)非持久
性能开销中-高极低
隐蔽性较易检测较易检测几乎不可检测

核心差异:Xposed 修改的是 ART 虚拟机中 ArtMethodentry_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)使用替代跳转指令

常见绕过模板:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 绕过 /proc/self/maps 检测
var openPtr = Module.findExportByName(null, "open");
Interceptor.attach(openPtr, {
    onEnter: function(args) {
        var path = args[0].readUtf8String();
        if (path && path.indexOf("maps") !== -1)
            this.filterMaps = true;
    },
    onLeave: function(retval) {
        // 可进一步 Hook read() 过滤包含 "frida" 的行
    }
});

// 绕过端口扫描
Interceptor.attach(Module.findExportByName(null, "connect"), {
    onEnter: function(args) {
        var port = args[1].add(2).readU16();
        port = ((port & 0xFF) << 8) | ((port >> 8) & 0xFF); // ntohs
        if (port === 27042) args[1].add(2).writeU16(0);      // 改端口使连接失败
    }
});

常见崩溃与排查

信号常见原因排查
SIGSEGVHook 了已卸载的 .so检查模块是否仍在内存中
SIGBUS内存未对齐访问确认 readU32 地址 4 字节对齐
SIGILL指令重定位错误尝试更新 Frida 版本
死锁onEnter 调用了持有锁的函数避免阻塞操作
OOMStalker 缓存过大缩小跟踪范围,及时 unfollow

实践注意事项

  1. 不要在回调中做耗时操作 - 回调在目标线程同步执行,会拖慢应用
  2. Spawn 模式下先 Hook 再 resume - 确保 script.load() 完成后再 device.resume()
  3. 多进程应用 - Android 应用可能有多个进程(如 WebView),每个需单独注入,可用 child_gating 自动跟踪
  4. NativeFunction 与死锁 - 在 onEnter 中通过 NativeFunction 调用的函数如果内部持有锁,可能与当前被 Hook 函数产生死锁
  5. C++ 构造/析构函数风险 - Hook 后若 onEnter 抛出异常,对象可能处于未完全初始化状态,析构函数类似可能导致资源泄漏
  6. V8 JIT 与 W^X 策略 - 某些加固的 Android ROM 启用了严格的 W^X(可写与可执行互斥),可能导致 V8 JIT 和 Stalker 代码缓存分配失败,此时考虑使用 QuickJS 运行时

源码导读

仓库关键文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
frida-gum/gum/
  guminterceptor.c              Interceptor 核心逻辑
  guminterceptor-arm64.c        ARM64 trampoline 生成
  gumstalker-arm64.c            ARM64 Stalker (6000+ 行,最复杂)
  gummemory.c                   内存管理
  gumarm64writer.c              ARM64 代码生成器
  gumarm64relocator.c           ARM64 指令重定位器
  bindings/gumjs/
    gumv8interceptor.cpp        Interceptor 的 JS 绑定
    gumv8stalker.cpp            Stalker 的 JS 绑定

frida-core/src/
  linux/helpers/inject-context.c    ptrace 注入实现
  linux/linux-host-session.vala     Session 管理
  agent/agent.vala                  Agent 入口与生命周期

推荐阅读路径

路径 1 - 理解 Hookguminterceptor.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 - 理解 Stalkergumstalker.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 机制最直接的方式。

从源码构建

1
2
3
4
5
git clone --recurse-submodules https://github.com/frida/frida.git
cd frida
# 构建 Android ARM64 的 frida-server
make server-android-arm64
# 输出: build/frida-android-arm64/bin/frida-server

总结

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
Frida 知识体系:

                    [Frida 核心原理]
                          |
       +------------------+------------------+
       |                  |                  |
  [注入机制]          [Hook 引擎]         [通信架构]
  ptrace/dlopen      Interceptor         DBus IPC
  SELinux/Gadget     Stalker/DBT         RPC/send
       |                  |                  |
       +--------+---------+--------+--------+
                |                  |
          [底层细节]           [实际应用]
          ARM64 trampoline    反检测绕过
          指令重定位           代码覆盖率
          代码缓存             算法分析
          内存管理             动态修补

掌握这些底层原理后,Frida 不再是"黑盒"工具。当你遇到 Hook 失败、进程崩溃或性能问题时,这些知识将帮助你快速定位原因并找到解决方案。


相关内容

如果这篇文章对你有帮助,请我喝杯咖啡吧~
+5 支付宝支付宝
+5 微信微信