做逆向这几年,如果说只留一个工具在电脑里,我大概率会选 frida。不是因为它最新,而是它解决“改函数行为”这个需求时最直接——不用改包重打包、不用重启应用,手机端跑一个服务,电脑端写几十行 JS,就能把目标应用运行时的参数、返回值、调用时机全部看穿,还能现场改逻辑。这套思路行业里叫动态插桩,frida 把复杂度压到了入门级别,玩明白了确实称得上“万能”。这篇文章就是把我自己的 frida hook 套路完整梳理一遍,适合刚接触动态分析的开发者,也适合那些已经会写简单脚本、但遇到重载、构造方法、native 层仍一头雾水的人。前提是只拿它分析自己开发的 App、做授权的安全测试,合法合规地研究,别拿去做违规的事。
1. 为什么说 frida 是逆向 hook 的“万能钥匙”
1.1 传统改行为方式,为什么又慢又容易翻车
在没有 frida 之前,想改一个 App 的函数行为,比较常见的路子是反编译、改 smali、重打包、签名、重新安装。这一套流程下来,少说也要十几分钟,而且一旦目标加壳、加固、签名校验,整个过程就可能直接报废。重打包还会改变应用指纹,很多分析场景根本不合适。
还有一类思路是在模拟器里配合 Xposed 框架,运行时 hook 特定函数。Xposed 能力很强,但太依赖框架版本和系统版本,加上现在不少 App 对模拟器和 Xposed 都有检测,应用场景被压缩得越来越小。相比之下 frida 的动态插桩是在进程已经跑起来之后才注入代码,不碰安装包本体,不走重签名流程,目标函数被调用时直接被拦截或改写,天然适合做运行时分析和安全审计。
1.2 frida 的核心架构:本地 agent 加远端控制
frida 的思路其实很朴素。PC 端跑一个命令行工具,手机端跑一个同版本的 frida-server,两端通过 USB 或网络建立通信。手机端的 frida-server 注入到目标进程后,会创建一套 JavaScript 运行时,我们在 PC 端写的 hook 脚本通过它执行。换句话说,你在电脑上写的每一行 JS,实际是在目标进程内部执行的,自然能拿到完整的上下文、调用栈、对象实例。
这种“远端控制本地 agent”的架构有个很大的好处:分析过程是高度动态的。App 想签名校验、加固解密、动态加载某个类,只要类跑到内存里,frida 就有机会找到它、hook 它。这也是“万能”二字的底气来源——它不依赖安装包静态形态,而是跟着运行状态走。
1.3 “万能”到底体现在哪些场景
从我实际遇到的需求看,frida 能覆盖的面很广,随手列几类:
- 看函数入参与返回值:登录接口、加密函数、校验函数的行为一目了然。
- 改函数返回值:比如把某个判断条件强制改成期望值,快速验证后续逻辑。
- 枚举当前已加载的类与对象实例:适合动态注册的类、壳加载完才出现的类。
- hook native 层导出函数:直接管到 so 文件里的 JNI 函数。
- 主动调用目标函数:不依赖原业务触发,脚本里直接调。
- 自定义 RPC 接口:让 PC 端脚本远程调用目标进程里的方法。
这些能力组合起来,基本可以应对绝大多数“运行时行为分析”需求。
2. 环境准备:版本、连接和初始化配置
2.1 PC 端安装与版本锁定
PC 端用 pip 安装 frida-tools 就能获得命令行工具,但这里有个最容易踩的坑:frida 的 PC 版本和手机端 frida-server 版本必须严格对应,小版本不一致都可能连不上。
pip install frida-tools frida --version看到版本号后,去下载对应版本的 frida-server,不同架构选对应包。像比较新的 arm64 设备,基本用frida-server-*-android-arm64。版本对不上时最典型的报错是“unable to connect to remote frida-server”或者进程已运行但注入失败,排查时先对版本号,这个点绝对值得养成肌肉记忆。
2.2 手机端启动 frida-server
把 frida-server 推送到设备后,常规启动命令长这样:
adb push frida-server /data/local/tmp/ adb shell cd /data/local/tmp chmod +x frida-server ./frida-server &启动后可以用frida-ps -U验证设备状态,能看到进程列表说明通信正常。需要盯一个细节:frida-server 默认监听 27042 端口,如果设备上跑着其他安全监测、或目标 App 专门做反 frida 检测,服务名还有端口可能被探查。正常调试自己设备的 App 时问题不大,但如果目标进程有检测,就得考虑改名启动、随机端口、甚至隐藏线程名这些对抗手段,那就是另一个更深的课题。
2.3 attach 和 spawn 的差别
连接目标进程有两种方式:
frida -U -f com.example.app -l hook.js上面是 spawn 模式,先由 frida 冷启动目标 App 并暂停进程,再加载脚本;适合目标函数在启动早期就会执行、早到你的脚本来不及挂载的情况。
frida -U -n 应用名 -l hook.js上面是 attach 模式,直接附加到已经运行着的进程。优点是快,缺点是附加的晚,早期逻辑可能已经跑完了。
我第一次用 frida 时图省事全用 attach,结果发现有些目标函数根本没被 hook 到,后来才想明白是时机问题。如果分析的是登录、加解密这类启动后才触发的逻辑,attach 完全够用;一旦发现 hook 落空,优先切 spawn 模式。
2.4 常见连接初始化问题速查
| 现象 | 原因 | 处理方式 |
|---|---|---|
| frida 无法连接 | server 版本不匹配 | 两边版本号保持一致 |
| 进程列表为空 | adb 权限或 frida-server 未运行 | 核对 root 权限、重启 server |
| script 加载后无输出 | 函数未被调用或 class 未加载 | 换 spawn 模式、延迟挂载 |
| 手机端 segfault | frida-server 架构选错 | 检查设备架构,选对应包 |
| 端口冲突 | 27042 被占用 | 换端口或杀掉残留进程 |
3. 万能 frida hook 脚本模板拆解
3.1 从枚举类开始:先看清目标长什么样
拿到一个陌生的 App,最怕的就是两眼一抹黑不知道 hook 什么。frida 第一个实用技能是枚举当前进程里已经加载的所有类,筛出目标包名的关联类。
Java.perform(function () { Java.enumerateLoadedClasses({ onMatch: function (className) { if (className.indexOf("这里填包名关键字") !== -1) { console.log(className); } }, onComplete: function () { console.log("枚举完成"); } }); });这段脚本的运行逻辑特别适合前期探路。App 跑起来之后,内存里哪些类真实存在、有没有被壳隐藏、包名下有哪些模块,都会直接打印出来。筛出来的类名再配合反编译工具看一下相关逻辑,很快就能锁定要 hook 的目标。
找类方法也常用,拿到Java.use()后,可以直接列出所有声明的方法:
var TargetClass = Java.use("com.example.target.DemoClass"); var methods = TargetClass.class.getDeclaredMethods(); methods.forEach(function (m) { console.log(m.toString()); });这里注意com.example.target.DemoClass需要换成真实存在的类路径,否则Java.use会直接抛异常。
3.2 通用 hook 模板:实例方法、静态方法、打印调用栈一把梭
一旦确认类和方法,最常用的 hook 模板其实不长。以下面的模板为例,它能干三件事:打印入参、打印返回值、打印调用栈。
Java.perform(function () { var TargetClass = Java.use("com.example.target.LoginHelper"); TargetClass.login.implementation = function (username, password) { console.log("[+] login called"); console.log("username = " + username); console.log("password = " + password); var stackTrace = Java.use("android.util.Log") .getStackTraceString(Java.use("java.lang.Throwable").$new()); console.log(stackTrace); var result = this.login(username, password); console.log("[+] login result = " + result); return result; }; });关键技术点在于this.login(username, password):这是调用原始方法,而不是再次触发 hook 形成死循环。如果你在 implementation 里直接写this.login(),又用同一个函数签名,会无限递归,直到栈溢出。这个坑我踩过一次,后面总结出的习惯是:hook 内部调用原函数时,一眼扫过去确认参数跟 intercept 的参数一致,逻辑没毛病再跑。
如果方法有多个重载,TargetClass.login.implementation = ...可能报错。更稳的写法是先用overload精确定位要 hook 的签名:
TargetClass.login.overload('java.lang.String', 'java.lang.String') .implementation = function (username, password) { var result = this.login(username, password); return result; };遇到签名不确定的情况,可以先不指定 implementation,打印所有重载签名再逐个 hook。Java API 的方法是TargetClass.login.overloads,遍历一下能看见全部对应参数类型。
3.3 构造函数的 hook:目标类一创建就插一脚
实例方法好理解,但有时候你连目标对象是从哪儿来的都不知道,更别说主动调用。这时候 hook 构造函数是最省事的路径。目标对象只要一被 new 出来,脚本就能拿到它的字段和引用。
Java.perform(function () { var TargetClass = Java.use("com.example.target.NetworkClient"); TargetClass.$init.implementation = function (baseUrl, token) { console.log("[+] NetworkClient constructor called"); console.log("baseUrl = " + baseUrl); console.log("token = " + token); var result = this.$init(baseUrl, token); console.log("[+] constructor done, token=" + this.mToken); return result; }; });$init是 frida 里构造函数对应的特殊命名。多个构造函数同样用overload区分。构造 hook 之后最大的好处是你可以拿到 this 指针,后续所有字段读写都基于这个对象。一些分析工具的场景里,人们甚至会在构造函数里直接改字段值,让对象一出生就处于异常状态,用来测试后续逻辑分支。
3.4 获取对象实例与主动调用方法
光能 hook 还不够,有时候想要主动触发某个逻辑,不依赖用户去点 App 界面。frida 的Java.choose可以做堆上对象枚举,找到所有符合类型的实例,然后直接调方法:
Java.perform(function () { Java.choose("com.example.target.CacheManager", { onMatch: function (instance) { console.log("[+] found CacheManager instance"); instance.clearAllCache(); console.log("[+] cache cleared, size=" + instance.getCacheSize()); }, onComplete: function () {} }); });主动调用和 hook 配合,是日常分析中特别实用的组合拳:先 hook 构造函数确定对象有没有被创建,再 hook 关键函数打印行为,最后主动调用某个方法验证返回值分支。很多看似“绕过某个校验逻辑”的分析,就是这么一步步完成的。强调一下,这些操作务必只用于你自己拥有或明确授权的目标,不要当作通用破解手法在外面乱用。
3.5 RPC 玩法:把 PC 端脚本变成远程接口
frida 不仅能让你从 PC 控制过程,还能把脚本里执行的结果回传给 PC。通过rpc.exports暴露一个函数,PC 端可以调用,这就打开了自动化测试和批量验证的大门。
rpc.exports = { invokeLogin: function (username, password) { return new Promise(function (resolve) { Java.perform(function () { var TargetClass = Java.use("com.example.target.LoginHelper"); var result = TargetClass.login(username, password); resolve(result); }); }); } };保存脚本后用frida -U -f ... -l hook.js启动,Python 端可以用 frida 模块直接调这个接口,传参进去拿返回值回来。这相当于把目标进程变成了一个可编程的服务端点,非常适合批量验证“不同参数下函数有什么变化”这类测试需求。
4. 实战进阶:native 层 hook 与常见场景拆解
4.1 native 导出函数的 hook:从 Java 层下沉到 so
不少 App 的关键逻辑并不在 Java 层,而是把加解密、核心算法放到 so 文件里。Java 层的 hook 无能为力时,就需要直接用 frida 的 Interceptor 去 hook native 函数。
var libPath = "libnative-lib.so"; var baseAddr = Module.findBaseAddress(libPath); console.log("base address = " + baseAddr); var funcAddr = Module.findExportByName(libPath, "Java_com_example_target_NativeBridge_encode"); console.log("export function address = " + funcAddr); Interceptor.attach(funcAddr, { onEnter: function (args) { console.log("[+] native encode called"); console.log("arg0=" + args[0]); console.log("arg1=" + args[1]); }, onLeave: function (retval) { console.log("[+] native encode returned " + retval); } });native 层 args 恰好对应寄存器上的前几个参数,直接打印出来就是原始十六进制值。如果需要读字符串,要用Memory.readUtf8String(args[x])。这种能力在分析 JNI 包装函数时特别有用,Java 层传下来一个 byte 数组,到 native 层往往对应jbyteArray的指针,理解 JNI 参数布局才能顺利读完内容。
4.2 通用网络层校验逻辑的定位习惯
做安全测试时,常遇到需要绕过 SSL 证书校验来抓自己测试环境的包。这类场景不一定非要 hook 每一个网络库,先定位实际使用的校验类往往更高效。观察调用栈、结合日志输出找到TrustManagerImpl或X509TrustManager实现类,再精确 hook 对应方法。这里我给个通用思路,不要直接照抄类名,因为 Android 版本和厂商定制会影响具体路径。
Java.perform(function () { Java.enumerateLoadedClasses({ onMatch: function (className) { if (className.indexOf("TrustManager") !== -1) { console.log(className); } }, onComplete: function () {} }); });查到目标类后,再去 hook 它的核心校验方法,打印参数与返回值,就能确认哪些连接走了校验、校验结果是什么。典型的方法是verifyChain或checkServerTrusted。真实设备环境千差万别,这个流程价值最大的部分是“先枚举后 Hook”的思路——先弄清真实运行的类,再动手改,避免盯着反编译源码里的过时类名空转。
4.3 anti-frida 应对与检测思路
不少商业 App 会做反调试和反 frida,常见检测点包括:检查进程里有没有 frida-server 的默认特征、检查默认端口 27042、检查内存里有没有 frida 的库名。常规规避思路无非改名、换端口、用临时文件路径、隐藏线程,但很多检测手段跟进很快,每次对抗都可能要研发新方案。
对普通开发者而言,最值得先掌握的不是研究怎么绕过别人,反而是理解 frida 自身的检测思路。做自己的安全加固时,可以用这套路径检验加固方案是否到位:先判断默认 frida-server 能不被不检测,再检查注入后会不会触发日志异常、崩溃或行为感知。这种“以攻促防”的思路在 App 安全测试里非常普遍。
5. 常见问题排查与避坑实录
5.1 顺畅度问题:hook 脚本加载到一半就崩
最常见的第一坑是Java.perform里的类不存在。很多类是在特定时机动态加载的,脚本启动时还没有。解决办法也简单,延迟挂载或定时重试:
function tryHook() { Java.perform(function () { var TargetClass = Java.use("com.example.target.DemoClass"); TargetClass.hello.implementation = function () { return "hooked"; }; }); } setTimeout(tryHook, 500);反复尝试直到类出现,是处理动态加载类最直观的办法。我曾经遇到过目标类在一个服务进程里,而不是主进程,那时必须找到正确的 pid 或进程名再挂载,否则脚本想的写得再漂亮也白搭。
5.2 打印优化:不要直接打印巨型对象
hook 到函数后,把整个对象的toString()直接打出来,如果对象比较大,控制台会刷屏甚至卡死。成熟的处理方式是只挑关键字段打。尤其在 Java 层,有些对象的toString()内部还会触发别的逻辑,可能与你的 hook 产生递归,极其搞人心态。我的习惯是优先打印基本类型参数和字符串参数,对象类型先取一小段关键信息,确认有需要再扩展。
5.3 修改返回值无效,怎么办
有时候你明明已经把函数的 implementation 改了,也 return 了期望值,但后续逻辑还是不按预期走。常见原因有三个:
- 目标函数只是众多调用点之一,还有其他路径也会执行同样逻辑。
- 改的是 Java 层,但实际对行为起决定作用的逻辑在 native 层,Java 层返回值只是表面。
- hook 时机太晚,前面已经缓存了旧状态。
遇到这种情况,先不要急着加大力度去额外 hook,而是把调用栈完整打出来,顺着栈去看还有谁在调用、调用链里有什么隐藏路径。定位这一步往往比直接强行修改返回值更快。
5.4 进程崩溃排查
frida 注入后目标进程崩溃的诱因很多,最常见的其实是 hook 代码本身写崩了。比如回调里大量分配内存、打印超长日志、或者循环回调中误触发了原函数导致栈溢出。注意观察崩溃时机:是每次必崩还是偶发,是函数一进就崩还是调用原方法后崩,能大大缩小排查范围。调试阶段尽量把 frida 脚本拆成小块,每小段完成一个明确目标,崩溃时能快速注释定位。
6. 个人实操后的几点心法
frida 用多了,我最大的感触是:它确实万能,但“万能”不是说不用动脑,而是说排查路径大幅缩短。过去分析一个函数要反复改包、重启、打日志,现在可能只需要脚本来回调几遍,目标行为就清晰了。想要用好它,光背 API 不够,更重要的是养成“先看清运行趋势、再定位细节、最后精准 hook”的习惯。
最后分享一个微小但有用的技巧:写脚本时给 console.log 加上统一前缀,比如都带[+],这样在大量输出里能一眼分出自己的日志和 app 自身的日志;同时在每个 hook 点记录 hook 建立成功的回执信息,避免你以为挂上了、实际没生效的情况。坚持这套小习惯,调试效率能提升不少。
frida 这套动态插桩思路,已经不只是逆向分析者的专用工具,很多开发者在日常调试、自动化测试、配置验证里也在用。掌握它不亏,只要用对地方,它就是你把黑盒变透明的一把钥匙。