☰
深入Frida Gum引擎:动态插桩核心原理与常见坑排查
2026/10/21 9:33:49 网站建设 项目流程

1. 先建立全局观:一条命令背后的完整链路

干移动端安全评估、App行为分析,或者单纯想给自家应用做运行时诊断的同学,应该都绕不开 Frida 这个名字。它最让人上瘾的一点是:不需要重新编译App,也不用动一行Java或C代码,就能在运行时把函数调用、参数、返回值、内存数据全摸清楚。而这一切真正干体力活的,是藏在它内部的 Gum 引擎。很多人在脚本里用惯了Interceptor.attach和Memory.scan,但一旦遇到“hook不生效”“莫名崩溃”“性能卡成PPT”,就不知道从哪入手排查,这往往是因为没有真正理解 Gum 的工作原理。这篇文章我打算从架构层面把 Frida 的核心链路剥开,重点讲 Gum 引擎怎么在进程里做代码改写、怎么把回调桥接到脚本层,以及我在实际项目里踩过的那些坑。

1.1 三件套:客户端、服务端、注入Agent,各干各的活

很多人以为 Frida 就是一个命令行工具,往目标进程里一怼就完事。实际上完整的链路至少有三段:运行在你电脑上的客户端工具(CLI 或 Python 绑定)、跑在目标设备/系统上的frida-server,以及被注入到目标进程里的agent。

客户端这层最直观。你敲的每一条命令、写的每一行 JS 脚本,都先到达这里。它负责把脚本内容、操作指令序列化后发出去,然后接收目标进程传回来的日志和返回值,格式化展示在终端上。

frida-server是设备端的“总调度”。它以守护进程方式运行在目标系统上,监听来自客户端的请求。收到一条“spawn 某个App并注入脚本”的指令后,它会在目标进程里创建运行环境、注入动态库,把 agent 拉起来,然后在客户端和 agent 之间维持一条双向通信通道。spawn、attach、枚举进程列表、读取脚本内容,这一系列动作都由它响应。

真正在目标进程内部干活的,是 agent。它是一个被注入进去的动态库,其中不仅包含 Gum 引擎,还内嵌了一个 JavaScript 运行时。当客户端把脚本发过来,agent 里的运行时就会解释执行这段脚本,脚本里的每一条 API 调用,最后都会落到 Gum 引擎上,由 Gum 去操作目标进程的代码段、内存和寄存器。

可以这么理解:客户端是遥控器,frida-server是信号塔,agent 是进到屋子里操作的机器人,而 Gum 就是机器人手上那套精密工具。脚本只是指挥工具的动作指令,真正改变进程状态的是 Gum。

1.2 从命令行到底层调用,一次 spawn 的完整旅程

拿最常用的frida -U -f com.example.demo -l hook.js来说,这条命令背后发生的事,比大多数人想象的要复杂。

客户端先通过 USB 或网络找到设备上的frida-server,发出一条请求:启动com.example.demo进程,但先暂停在某个早期状态。frida-server收到后创建该进程,并用注入方式把 agent 动态库加载进去,同时初始化 runtime。这一步完成之前,目标进程是处于挂起状态的——这一点很关键,因为很多需要在进程启动早期就插桩的场景,必须靠 spawn 模式才能抓到,普通 attach 模式做不到。

随后客户端把hook.js内容发送给 agent,agent 在 runtime 里执行脚本。脚本里的Interceptor.attach、Memory.scan这些调用被转换为与 Gum 交互的底层操作,由 Gum 完成真正的代码改写、内存扫描。最后客户端发出恢复运行指令,目标进程继续跑起来,但此时已经被各种探针包围了。

整个过程依赖一个跨进程消息协议在客户端、server、agent 之间传递数据。这个协议把各种类型的消息做了编解码,日志、错误、回调事件都通过这条链路回传。所以脚本里console.log("hello")能直接出现在电脑终端上,靠的也是这条链路。

1.3 为什么这种设计比传统注入框架更适合日常分析

传统注入式 hook 方案通常要把整个 hook 逻辑预先编译成动态库,运行逻辑全部用原生代码写在里面,改动一处就要重新编译、重新注入。Frida 把大部分逻辑搬到 JS 层之后,迭代效率完全不在一个量级。你改一行脚本,重新跑一次命令就行,agent 本身不用重新构建。

而且 Gum 把平台差异吸收得很好。同样是 inline hook,在 ARM64、x86_64、Thumb 模式下要做的事完全不一样,但 Gum 对上层的 API 几乎是一致的。这让我在分析 Android、PC 桌面程序、甚至某些嵌入式系统时,可以共用一套脚本逻辑,最多调整一下模块名和偏移。

不过这种“一切都被封装好”的便利也有副作用:一旦出现问题,很多人会在网上一顿搜索也定位不了,因为完全不知道底层发生了什么。所以下一部分我会重点把 Gum 最核心的部分拆开,看看它在 attach 一个函数时到底做了哪些工作。

2. Gum 引擎的第一张王牌:Interceptor 的工作机制

Interceptor.attach是日常用的最多的 API,但它不是简单地“在函数开头改一个跳转地址”就完事。Gum 的 inline hook 涉及指令备份、跳板构建、现场保护、返回地址捕获、指令重定位等一系列操作,任何一个环节出错,目标进程都会毫不留情地崩溃给你看。

2.1 inline hook 不是“改一行指令”这么简单

所谓 inline hook,通俗说就是把目标函数的开头若干字节替换成一条跳转指令,让 CPU 执行到这的时候直接跳到 Gum 预先准备的一段代码里。但这只是表面。

被覆盖掉的那些原始指令不能丢。它们原本是函数功能的一部分,Gum 需要把这段指令完整搬运到另一块内存区域,在后面再接一条跳转指令,让程序执行完这些备份指令后还能回到原函数剩余位置继续执行。这块搬运后的代码区域,行业内通常叫 trampoline 或者 backup slice。

还有一个容易被忽略的问题:代码段通常是只读的。Gum 在写入跳转指令之前,必须先通过系统调用修改对应内存页的权限,比如从 r-x 改成 rwx,写完后再根据 CPU 架构决定是否需要刷新指令缓存,否则可能出现“代码已经改了,但 CPU 还在执行旧指令”的诡异情况。

所以一次看似简单的Interceptor.attach,背后至少包括改页权限、指令备份、构建跳板、刷新缓存这几个步骤。Gum 把这些封装得干净利落,但使用者心里得清楚。

2.2 onEnter 和 onLeave 之间,Gum 替你做了什么

写过 Frida 脚本的人都熟悉onEnter和onLeave这两个回调。但很少有人细想:为什么一定要分成两个阶段?

其实这是由 inline hook 的实现方式决定的。当目标函数被调用时,CPU 首先被跳转到 Gum 的 handler,handler 会保存当前线程的寄存器现场,准备好本次调用的上下文对象,然后去执行 JS 层的onEnter。onEnter结束后,handler 恢复现场,让程序跳转到 backup 区域执行那些被搬走的原始指令,再回到原函数覆盖位置之后继续执行原逻辑。

那onLeave是怎么触发的?函数执行到末尾时通常会返回,返回地址保存在 LR 寄存器里。Gum 会在 handler 阶段悄悄替换返回地址,让它先跳回到 Gum 的另一个 handler,由这个 handler 传好返回值,调用 JS 层的onLeave,然后才真正返回到上一级调用者。整个过程对目标进程的绝大部分代码是透明的,但如果你在onLeave里修改了返回地址或者返回值,处理不当就会让调用链混乱。

正因如此,onEnter和onLeave之间共享同一个this对象。你可以在onEnter里把参数、调用地址暂存进去,到onLeave时再读取,这是 Frida 官方推荐的做法,也是很多复杂脚本的基础。比如记录某个参数在函数执行过程中有没有被修改,就可以把原始值存到this.originalArg0,然后在onLeave里对比。

2.3 被挪走的指令为什么要重定位,这是我踩过最深的坑

前面说 Gum 会把原始指令搬到 trampoline 里,但搬过去之后直接跑就能了吗?不行。

CPU 里有一大类指令是“PC 相对寻址”,也就是指令本身携带的偏移量,是以当前指令地址为基准计算的。比如 ARM64 下非常常见的ADRP X0, #imm,它计算出的目标地址是“当前 PC 对齐后的基址 + 立即数”。指令被搬到 trampoline 后,它的地址变了,如果还照原样执行,计算出来的目标地址就会偏移,轻则拿到错误数据,重则直接访问非法地址崩溃。

Gum 必须逐条识别这些被搬运的指令,把原来的相对偏移修正成新的、在 trampoline 中依然成立的偏移。这个过程本质上就是重定位(relocation),和链接器在把目标文件链接成可执行文件时做的事情思路一致,只不过 Gum 是在运行时对一小段代码做动态修正。

这类问题在写底层 hook 工具、或者自己实现类似 inline hook 时很容易踩。只搬指令不修正偏移,十个案例九个崩。理解了这一点,你在遇到“hook 某函数后进程立刻 SIGSEGV”时,至少能有一个排查方向:是不是入口处的指令里包含了 PC 相对寻址,并且备份逻辑没有处理好。

2.4 指令集差异:ARM、Thumb、x86 下的不同表现

Gum 能跨架构工作,但不同架构下面临的问题差异很大。

ARM64 指令固定 4 字节,指令对齐整齐,hook 机制相对干净,但 PC 相对寻址指令种类多、出现频率极高,所以重定位负担很重。Thumb 模式则要额外处理 16/32 位混合指令,以及 ARM/Thumb 状态切换的问题——目标函数地址的最低位有时候就被用来标记 Thumb 模式,自己计算偏移时必须保留这一位,否则调转模式会出错。

x86 这边是变长指令,最短 1 字节、最长可能十几个字节。Gum 需要借助反汇编引擎逐条解析指令边界,在入口处尽量找到一个合理的覆盖窗口,既不能截断某条指令,又要保证跳转序列足够放下。边界不齐的话,程序跑到下一条指令中间就会解析出完全错误的东西。

这些差异平时藏在抽象层下面,但当你分析一个 32 位的老程序,或者碰到某项检测时,就会意识到“同一个脚本在不同架构上表现不同”并不是玄学,而是底层处理逻辑本身就不同。

3. 更底层的追踪能力:Stalker 与内存操作

如果说 Interceptor 是定点爆破,那 Stalker 就是全链路视频监控。后者实现思路完全不同,理解它的定位,能帮你在正确场景下选对工具。

3.1 Stalker 的指令重写思想:基于基本块的代码缓存

Stalker 的核心思路不是改某个函数入口,而是把目标线程执行的每一段“基本块”都翻译到一块独立的代码缓存中执行。基本块是编译原理里的概念,简单说就是一段“只有入口、没有中途跳进来的代码”的连续指令段。

程序正常执行时,会沿着原始基本块逐个执行。Stalker 介入后,每发现一个新基本块,就把它翻译到 code cache 里的一个地址,并在翻译过程中插入探针代码。程序后续的执行路径变成:原始地址 -> 跳入代码缓存 -> 执行翻译后的代码 -> 跳回原始地址的下一个基本块。

因为翻译是按块来的,Stalker 可以对每一块都做类似 Interceptor 的重定位处理,而且能跟踪到非常细粒度的执行流。它天然比 Interceptor 重得多,因为每个基本块都要经过翻译、缓存管理、块间切换,涉及大量的上下文保存和恢复。

实际项目中我一般只在需要还原复杂调用链、或者分析某个被混淆的函数内部执行顺序时才会短暂开 Stalker。它适合“看一段过程”,不适合长时间挂在生产环境里。

3.2 Interceptor 和 Stalker 怎么配合,什么时候用哪个

两者不是竞争关系,而是互补关系。Interceptor 适合对已知函数做精准拦截,开销小、语义清晰,能在 onEnter/onLeave 里直接读写参数和返回值。Stalker 适合你不知道代码会走哪条路,或者目标函数内部逻辑一团乱麻时,先把完整执行轨迹拉出来看看。

一个比较实用的组合是:先用 Stalker 快速定位到关键跳转点,找到真正需要拦截的函数,然后关掉 Stalker,改用 Interceptor 做长期挂载。这样既避免了 Stalker 的持续性能开销,又能把精力聚焦在关键点上。

另一个经验是,Stalker 的窗口设置很重要。把跟踪范围限定在某个函数或某个模块内,能大幅减少翻译量;如果一上来就对整个进程开 Stalker,日志量能在几秒内把内存耗尽,进程直接卡死。这算是 Stalker 最经典的翻车方式。

3.3 Memory.scan 和页操作,Gum 怎么读写进程内存

除了代码插桩,Gum 还封装了一整套内存操作能力。Memory.scan用于在指定地址区间内搜索字节模式,它的实现会把目标区间的内存按页读取出来,然后用模式匹配算法逐一扫描。这个 API 在定位全局变量、查找对象实例、确认某段数据是否被修改时非常有用。

但直接对整个进程地址空间做全局扫描是不明智的。更合理的做法是先通过Process.enumerateModules()拿到目标模块基址和大小,把扫描范围限定在模块的某个段里。比如想定位某个关键字符串,可以先扫.rodata段,而不是全进程扫。

Memory.protect则是直接修改页权限。有些分析场景需要临时写入只读内存,或者想验证某个内存页是不是被多次写入,都会用到这个 API。它和 Gum 在 hook 时内部做的改页操作是同一套底层能力,只是开放给了脚本层。

3.4 别把 Native 和 Java 的 hook 混为一谈

Frida 脚本里还有一类高频 API:Java.perform、Java.use,看起来和Interceptor.attach差不多,但底层完全不是一条路线。

Java.perform是走 ART 虚拟机内部的 hook 机制,跟 Gum 引擎没有直接关系。它操作的其实是 Java 层方法,运行时把 Java 方法替换成 native 桩。而Interceptor.attach是纯 Native 层操作,直接改机器码。

很多初学者在分析 Android App 时,一会儿Java.use一会儿Interceptor.attach,分不清两者边界。遇到“hook Java 类方法没反应”时,我一般会先确认目标方法是不是被 JNI 转发到了 native 层,或者是不是有加固层做了方法抽取。如果逻辑在 native 层,Java.use自然是无效的,得回到 Gum 这条路线上来。

理解这两套机制的分工,能帮你快速定位“为什么 hook 不到”的问题,而不是漫无目的地换 API 试。

4. 从架构到实操:在目标进程里跑起第一段 Gum 代码

纸上谈兵到这里,该上手了。下面这套流程是我自己常用的“模板化步骤”,适合拿一个新的测试目标做实验。

4.1 环境体检清单,先确认这四件事

第一,确认设备端frida-server的架构和系统匹配。Android 常见的是 arm64-v8a,但老机型可能有 armeabi-v7a,模拟器又可能是 x86_64。拿错架构,server 起不来,后面全白搭。

第二,版本一定要对齐。客户端工具和frida-server的大版本不一致时,会出现连接失败、协议不兼容之类的报错。具体操作时,先在电脑上执行frida --version确认客户端版本,再去下载同版本的frida-server放到设备上。

第三,确认注入权限。Android 上frida-server一般需要 root 权限,而且启动目录、守护进程用户、SELinux 策略都可能影响成败。我习惯把它放到/data/local/tmp/下,chmod 755后以 root 启动,这样能绕过绝大多数权限坑。

第四,确认目标进程是用 spawn 还是 attach。attach 是“目标已经在跑,我再贴上去”,简单但没法干预早期启动逻辑;spawn 是“我让目标重新冷启动,从一开始就挂上探针”。对于分析 App 的某个初始化流程,spawn 几乎是必须的。

4.2 拦截一个导出函数,并修改返回值

假设我们有一个测试库libtest_core.so,里面有个导出函数check_password,接受一个字符串参数,返回 0 表示失败、1 表示成功。我们要做的第一件事,是先确认这个函数能拿到地址。

const mod = Process.findModuleByName("libtest_core.so"); const target = Module.findExportByName("libtest_core.so", "check_password"); if (!mod || !target) { console.log("check module / symbol first"); } else { console.log("module base: " + mod.base); console.log("target func: " + target); Interceptor.attach(target, { onEnter(args) { this.input = args[0].readCString(); console.log("check_password called, input = " + this.input); }, onLeave(retval) { console.log("original retval = " + retval.toInt32()); retval.replace(ptr(1)); } }); }

这段脚本里有两个细节值得展开。第一,args[0].readCString()是直接读取指针指向的内存字符串,但如果这个指针是非法的,会抛异常导致脚本中断。实际分析中,宁可先用args[0].isNull()判断一下,或者把读字符串包在try/catch里,也不要裸读。

第二,onEnter里把字符串存到this.input,onLeave里再想读取就没问题。如果你直接在onEnter做const input = args[0],到onLeave时那个变量还在闭包作用域里,其实也能访问。但更规范的做法是用this挂载,因为 Gum 保证同一个调用周期里onEnter和onLeave共享同一个上下文对象。

修改返回值是Interceptor.attach最实用的能力之一。retval.replace(ptr(1))会把原函数返回给调用者的值替换成 1。需要注意,替换值必须是以NativePointer形式构造的,直接替换整数在某些版本下不生效。

4.3 让脚本从“能用”变成“可维护”

脚本一旦超过几十行,全塞在一个 JS 文件里就会变得很难受。我的做法是引入frida-compile,用 TypeScript 写逻辑,然后编译成单文件 JS,再交给 Frida 加载。这样既能用上类型检查,也能把不同 hook 模块按文件拆分。

// 简单示例:编译后的脚本入口 import { Interceptor, Module } from "frida-gum/Interceptor"; const target = Module.findExportByName("libtest_core.so", "check_password"); Interceptor.attach(target, { onEnter(args) { console.log("enter"); }, onLeave(retval) { console.log("leave: " + retval); } });

还有一点,如果你对性能敏感,JS 回调在目标进程里跑得并不便宜。每个函数调用都要经历 native -> JS 边界切换。对于高频函数,我通常会在onEnter里先做一个快速条件判断,只打印满足条件的数据,而不是把每次调用都打出来。控制日志输出频率,往往比事后加日志过滤更有效。

4.4 内存扫描定位数据,实战演示

函数拦截能拿到调用信息,但有些时候我们更关心“某段特征数据到底在哪个地址”。这就轮到Memory.scan出场。

const base = Module.findBaseAddress("libtest_core.so"); if (base) { Memory.scan(base, 0x100000, "de ad be ef 01", { onMatch(addr, size) { console.log("pattern found at " + addr + ", size=" + size); }, onComplete() { console.log("scan finished"); } }); }

这个例子在模块起始地址往后 1MB 范围内搜索de ad be ef 01这个 5 字节模式。onMatch每命中一次回调一次,onComplete在扫描结束时调用。实际项目中,我一般先用Memory.scan定位到目标数据地址,再通过Memory.readByteArray、Memory.writeByteArray去读改写,组合使用非常顺。

5. 常见崩溃与排查实录

Frida 脚本写多了,最深的感受是:80% 的问题都出在环境匹配和 hook 点选择上。下面这几类问题,几乎每个常写 Frida 脚本的人都遇过。

5.1 版本不匹配,一个最不起眼的坑

每次换电脑、换设备,我都可能栽在版本匹配上。客户端是 16.x,设备上跑的是 12.x 的 server,连上去要么直接超时,要么报unable to connect to remote frida-server。这种问题看起来像是网络不通,改设备、改权限折腾一圈,最后发现只是版本不同。

排查建议很直接:设备上先执行frida-server --version,电脑上执行frida --version,两个数字的大版本必须一致。如果设备上不方便查,干脆直接重装一个和客户端完全一致的 server,省得后面出幺蛾子。

5.2 hook 不生效,先怀疑这三个方向

第一,是否存在同名但不同模块的导出函数?Module.findExportByName("libA.so", "foo")和Module.findExportByName("libB.so", "foo")拿到的是完全不同的地址,hook 错了模块当然没效果。

第二,符号是不是被 strip 了?有些发布版 App 的 so 并不保留导出符号,函数可能只是内部符号。这时候Module.findExportByName返回 null,需要用DebugSymbol.fromName或内存特征定位。

第三,函数的真实逻辑是不是被加固层抽走了?很多加固方案会把函数体抽到壳里,入口处只剩一个跳转桩,直接 hook 入口只会看到跳转逻辑,真正的业务代码在另一个地方执行。遇到这种情况,要先反汇编入口看指令,再决定 hook 点。

5.3 运行中忽然崩溃,大部分是访问了不该访问的内存

用readCString()读一个非法的指针,或者把一个越界地址传给Memory.read*,脚本会抛异常。如果放在onEnter里,异常会沿着 Gum 的 handler 向上抛,很可能直接带崩整个目标进程。

我的铁律是:所有脚本内对内存的读取都加保护。要么先用isNull()判断指针,要么整体包一层try/catch。宁可日志少打几行,也不要让目标进程一次崩溃。

detach 时机也一样。如果在一个函数正执行到一半的时候主动Interceptor.detach,Gum 的返回地址替换逻辑可能还没走完,这会导致返回路径错乱。最佳实践是尽量不频繁 detach,用“标志位”的方式控制回调是否执行,而不是反复挂起和解除 hook。

5.4 性能严重退化,别让 JS 回调变成瓶颈

拦截一个每秒被调用百万次的小函数时,每调用一次都做一次console.log,目标进程会慢到不可接受。就算不打印,JS 回调和 native 之间的边界切换也有不小的开销。

遇到这种场景,我一般会先确认这个函数的真实调用频率,再决定是否值得 hook。如果要保留 hook,就把回调里的逻辑缩到最短;如果只是为了确认“有没有被调用”,可以用一个计数变量累加,最后统一输出,而不是每秒打满屏日志。

Stalker 就更麻烦了。它会翻译基本块,哪怕是空函数,也会产生明显的性能损失。实测下来,某些高频路径上开 Stalker 能让进程慢一个量级以上,所以我通常只在定位问题时短时间开一下,定位完立刻关掉。

5.5 排障速查表

现象可能原因处理建议
server 启动失败架构不匹配、权限不足换成对应架构的 frida-server,以 root 启动并确保目录可执行
客户端连不上 server版本不一致、端口被占对齐客户端与 server 版本,检查设备网络和监听端口
attach 时报进程未找到包名/进程名拼写错误先用frida-ps -U枚举确认真实进程名
hook 无任何日志符号缺失、hook 点不对用enumerateSymbols和反汇编确认,不要盲 hook
hook 后进程崩溃弱内存访问、入口被抽取、detach 不当加 try/catch,确认入口指令,避免执行中途 detach
进程极慢JS 回调太频繁、Stalker 未关精简回调、加条件过滤、关闭全量跟踪

Frida 这套工具链看似简单,真正用好它的人,都绕不开对底层 Gum 引擎的理解。上面这些排查经验,很多是我在实际项目里一次次“崩出来”的。先去理解 Gum 在替你做什么,再遇到问题就不会两眼一抹黑。

我个人体会最深的一点是:Gum 其实完全可以独立于 Frida 的脚本生态来用。它是一个可嵌入的代码修改引擎,底层能力远不止 JS 脚本那几条 API。理解了它的 Interceptor 和 Stalker 设计,不仅能在 Frida 脚本里游刃有余,以后即使遇到别的类似工具,上手速度也会快很多。这篇文章如果能帮你少走几次弯路,就值了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询