Frida 这个系列的第一篇讲的是自动化批量 hook:遍历类、遍历方法、统一打点,能快速知道目标进程里有哪些方法在跑、参数长什么样。这种思路在做初步信息收集时效率很高,但真到了实战阶段,你会发现自动化脚本往往在几个关键点处失灵——最典型的就是Java.choose扫描不到实例、同一方法名存在多个重载导致脚本崩溃、或者类加载器不对导致Java.use都过不去。这些问题的共性在于:自动化少了一步“手动决策”。所以我专门把“手动 hook”拿出来写第二篇,把这套流程彻底拆开,从 Java 层到 Native 层,逐个环节说明白。
手动 hook 并不神秘,搞明白它的核心流程就够了:选定目标类、处理加载器、确定方法签名、构造 hook 逻辑、根据实例或静态上下文注入代码。每一步都有它的原理和坑。这篇会结合我在实际调试中遇到的场景,讲清楚每个动作背后的“为什么”,而不是只丢给你现成脚本。
1. 手动 hook 的基本盘:为什么自动化脚本在某些场景下会失效
1.1 自动枚举 hook 的典型局限
先说一个常见场景:目标 App 里有核心加密逻辑,用了某条encrypt方法。自动枚举脚本能把所有带encrypt名字的方法全部 hook 一遍,然后打印参数和返回值。听起来很全,但实际跑起来会碰到几个问题。
第一个是重载。一个方法名对应多个签名,比如encrypt(byte[])、encrypt(String)、encrypt(byte[], String),自动脚本如果没做签名区分,调用implementation = function(...)的时候十有八九会报TypeError: not a function,因为 Frida 不知道该替换哪一个。
第二个是实例状态。Java 方法很多是非静态方法,你拿到方法以后直接 hook,假设类本身没被加载,或者目标实例还没创建,那么即使 hook 成功,也不会有调用触发。自动脚本为了能跑起来,通常是懒扫描,找得到就 hook,找不到就跳过。问题在于,跳过的时候没有任何提示,你可能盯着日志看半天,以为目标逻辑没被触发,其实只是没 hook 对实例。
第三个是类加载器。Android 上插件化、热修复框架非常多,同一个类名可能存在于多个 ClassLoader 里。Java.use默认用的加载器可能在目标类加载之前就已经把类给初始化了,这时候你再 hook,hook 的是另一个副本,代码跑的是业务里那个副本,两边根本对不上。
1.2 手动 hook 解决问题的思路
手动 hook 的本质就是把上面这些决策点显式化。不再依赖脚本帮你猜,而是你自己根据反编译结果、调用栈、日志去确定一个精确的“hook 点”。比如你用 JADX 看完反编译代码,确定了目标方法是某个类的某个重载签名,再写脚本直接去匹配。
手动 hook 的优势在排查这三类难题时体现得很明显:
- 精确定位重载。通过
overload指定签名,只 hook 你关心的那个版本。 - 控制实例获取方式。要么直接 hook 构造函数,要么用
Java.choose在堆上扫描符合条件的目标实例。 - 主动指定 ClassLoader。在
Java.enumerateClassLoaders之后找到目标 loader,再传给Java.use,避免 hook 到“假类”。
所以手动 hook 不是让你丢掉自动化,而是在自动化的基础上多一层主动权。熟练之后你会形成一种习惯:先自动枚举看大概,再手动定点做精准 hook。
2. 手动 hook 的三块地基:Java.perform、Java.use、Java.choose
2.1 Java.perform:为什么所有 Java 操作都要包在它里
写 Frida 脚本时,你基本会见到这样的结构:
Java.perform(function () { // 所有 Java 相关操作都在这里 });新手往往不理解这一层到底是什么作用,甚至有人直接不写Java.perform也能跑通。真实原因在于:Frida 的 JS 引擎(V8 或 QuickJS)与 ART 虚拟机(Android 运行时)是两个独立的运行时。需要在使用 Java 层 API 之前,先挂载 ART 运行时,把 JS 线程的状态切换到已附加到 Java 环境的状态。Java.perform做的是发生在 ART 运行时启动以后、并且稳定可用的前提下,注入并执行回调。
我的习惯是只要脚本里出现任何一个以Java.开头的 API 调用,就包一层Java.perform。不包的隐患是偶发性的java.lang.ClassNotFoundException或调用时机过早导致引用无效,这类问题排查成本很高,因为没有固定崩溃点,有时跑一次正常、跑两次报错。
2.2 Java.use:获取类引用的正确姿势
Java.use的基本写法是:
var TargetClass = Java.use('com.example.securechat.core.CryptoManager');这一步会在当前默认的 ClassLoader 下查找目标类,并返回一个可用于后续操作的“类包装对象”。Java.use返回的包装对象有类方法可以直接调用,也有$new用来构造实例。
但默认加载器覆盖不了动态加载类的情况。之前线上遇到一个插件模块,核心逻辑打在一个dex文件里,由某个插件框架在运行时加载。默认情况下Java.use拉不到这个类。这时候要先用枚举函数把加载器找出来:
Java.perform(function () { Java.enumerateClassLoaders({ onMatch: function (loader) { try { var TargetClass = Java.use('com.example.securechat.core.CryptoManager'); if (TargetClass && loader.loadClass('com.example.securechat.core.CryptoManager')) { // 找到目标加载器 console.log('找到目标类加载器'); } } catch (e) {} }, onComplete: function () {} }); });还有一种更粗暴但有效的方法:在Java.perform里调用Java.classFactory.loader = loader,然后把后续的Java.use都挂在这个 loader 下面。很多插件化场景里,这种方式比逐个枚举加载器更直接。
2.3 Java.choose:从堆上捞活动实例
如果是非静态方法,光有类不够,还要有实例。最常用的方式有两种:第一是 hook 构造函数,每次创建实例都自动处理;第二是Java.choose,在当前进程堆里扫描符合条件的实例。
Java.choose的语义是“遍历堆上所有该类型的对象”,每找到一个就回调一次:
Java.perform(function () { Java.choose('com.example.securechat.core.SessionManager', { onMatch: function (instance) { console.log('找到一个 SessionManager 实例: ' + instance); }, onComplete: function () { console.log('扫描结束'); } }); });这里有几个关键点。onMatch的执行时机可能比较长,因为堆扫描本身开销不小,不要在回调里做太重的事情,否则卡顿明显。回调里拿到的instance是 Frida 包装后的对象,你可以直接调用它的方法,也可以对方法进行赋值 hook。
有一个容易踩的坑是:一次Java.choose可能匹配到多个实例,但你只需要其中某一个。区分实例需要有明确的标识字段,比如包名、会话 ID、某个内部状态值。在onMatch里先做条件判断,确认是目标实例后再动手。
3. 手把手搭一个手动 hook 脚本:从方法到实例
3.1 最简单的静态方法 hook
静态方法不需要实例,直接用Java.use拿类,给方法赋值implementation即可。假设目标逻辑是一个内置的校验:
Java.perform(function () { var Validator = Java.use('com.example.securechat.core.AuthValidator'); Validator.checkSign.implementation = function (data) { console.log('[AuthValidator] checkSign 被调用'); var result = this.checkSign(data); console.log('[AuthValidator] 原始返回值: ' + result); return result; }; });注意this.checkSign(data)的写法。在替换implementation之后,this指向的是当前类实例或类的静态上下文,所以调用this.方法名才会进入原方法本体。有人会把原方法名和替换方法名混淆,或者想当然地直接调用全局变量,结果造成递归调用或不生效。
3.2 非静态方法:没有实例也要找到实例
假设你的目标方法是SessionManager.createSession(String userId),但这个类的实例是在某个登录流程里才建立的。如果你只是Java.use然后给方法赋implementation,实例没创建时,永远不会被调用。
此时有两个方案。
第一个方案是 hook 构造函数:
Java.perform(function () { var SessionManager = Java.use('com.example.securechat.core.SessionManager'); SessionManager.$init.overload('java.lang.String').implementation = function (userId) { console.log('[SessionManager] 构造函数被调用,userId = ' + userId); this.$init(userId); console.log('[SessionManager] 构造完成'); }; });第二个方案是用Java.choose找出已有的实例:
Java.perform(function () { Java.choose('com.example.securechat.core.SessionManager', { onMatch: function (instance) { if (instance.__hooked) return; instance.__hooked = true; instance.createSession.implementation = function (userId) { console.log('[SessionManager] createSession 被调用,userId = ' + userId); var result = this.createSession(userId); return result; }; }, onComplete: function () {} }); });这里我用了instance.__hooked作为标记,防止同一个实例被多次扫描重复 hook。__hooked是 JS 对象的自定义属性,不会写进 Java 实例本身,也不会影响业务逻辑,只是一个防重标记。
3.3 重载方法如何精准命中
重载是手动 hook 中最容易翻车的一环。在给方法赋值implementation之前,先输出该方法的全部重载签名:
var CryptoManager = Java.use('com.example.securechat.core.CryptoManager'); var overloads = CryptoManager.encrypt.overloads; overloads.forEach(function (ov) { console.log('签名: ' + ov.signature); });然后再针对你要的那个签名写:
CryptoManager.encrypt.overload('[B', 'java.lang.String').implementation = function (data, key) { // 在这里做 hook 逻辑 var result = this.encrypt(data, key); return result; };签名的写法要严格遵守 JNI 类型描述符:byte[]写作[B,String写作java.lang.String,int写作int,对象类型写作com.example.xxx.ClassName。如果签名写错,匹配不到方法,会直接报错。
有些工具会自动生成签名,比如 JADX 的方法签名可以直接复制后缀部分。我自己的习惯是:先用overloads打印一遍,再手动对比,避免把参数类型搞错。
3.4 返回值篡改与类型问题
hook 的终极目的是修改返回结果。常见的场景是让某个判断恒为真或直接改写加密结果。
CryptoManager.encrypt.overload('[B', 'java.lang.String').implementation = function (data, key) { var result = this.encrypt(data, key); console.log('[CryptoManager] 原始密文长度 = ' + result.length); // 返回一个固定假的密文 return Java.array('byte', [1, 2, 3, 4]); };这里有个细节:如果原方法返回byte[],Frida JS 侧拿到的返回值是一个 Java 的字节数组包装对象,你直接return [1,2,3,4]返回一个纯 JS 数组是不行的,类型不匹配。需要借助Java.array('byte', [...])创建 Java 字节数组。如果是 String 或 int 类型,直接返回 JS 字符串和数字即可。
返回值类型处理错了,可能在运行时报TypeError: cannot read property,也可能静默失败——原方法返回 null,业务层直接崩溃。
4. Native 层手动 hook:JNI 函数与 so 符号级别的精确控制
4.1 用 Interceptor.attach 处理导出函数
Java 层的 hook 依赖 ART 的运行时机制,Native 层则要直接操作 ELF 符号。最常见的需求是 hook so 里的 JNI 函数,因为 JNI 函数名和 Java 类的包名、类名、方法名是一一对应的。
例如 Java 层调用:
public class CryptoManager { public static native byte[] encrypt(byte[] data, String key); }对应的 JNI 符号通常是:
Java_com_example_securechat_core_CryptoManager_encrypt手动 hook 的写法是:
var encryptPtr = Module.findExportByName('libsecurechat.so', 'Java_com_example_securechat_core_CryptoManager_encrypt'); if (encryptPtr) { Interceptor.attach(encryptPtr, { onEnter: function (args) { console.log('[Native] encrypt onEnter'); // args[0] 是 JNIEnv* // args[1] 是 jobject 或 jclass // args[2] 是 jbyteArray // args[3] 是 jstring }, onLeave: function (retval) { console.log('[Native] encrypt onLeave,返回值地址: ' + retval); } }); }这里初学者最困惑的是args的下标。JNI 函数的前两个参数永远是JNIEnv*和jobject/jclass,从第三个参数开始才是 Java 层方法传入的参数。所以上面例子里 Java 层的data对应args[2],key对应args[3]。
4.2 强符号与弱符号:同一个函数名可能有多份实现
Module.findExportByName找的是 so 的动态导出符号表。如果 so 里同一个函数名有两个实现(比如因为链接方式或者 UPX 壳),这个方法只能拿到其中一个。遇到这种情况,需要用Process.enumerateModules()遍历所有模块,或者在Module.enumerateExports里打印导出表自己对比。
实战中还有一类情况是函数没有导出。so 内部函数做了static处理,符号表里看不到。此时需要先结合 IDA 的分析,拿到函数偏移量,然后通过基址加偏移的方式计算运行时地址:
var base = Module.findBaseAddress('libsecurechat.so'); var target = base.add(0x1234A0); // 假设 IDA 分析出的偏移是 0x1234A0 Interceptor.attach(target, { onEnter: function (args) { console.log('命中未导出函数,argument[0] = ' + args[0]); }, onLeave: function (retval) {} });有一点要提醒:base.add(0x1234A0)里的 0x1234A0 要换算成模块加载后的相对偏移,不是文件偏移,也不是虚拟地址。通常 IDA 里看到的地址是基址 + 偏移,要减去 IDA 加载基址才能得到相对偏移。做的时候先确认 so 是否被加固、是否有反调试逻辑。
4.3 读取并修改 Native 层的内存参数
Native 层 hook 最大的优势是可以直接操作指针。比如 JNI 函数args[2]是 jbyteArray,要读取它的内容,需要用Java.vm.getEnv()拿到 JNIEnv 指针,然后调用 JNI 函数族的接口去读取数组。
onEnter: function (args) { var env = Java.vm.getEnv(); var jarray = args[2]; var data = env.getByteArrayRegion(jarray, 0, env.getArrayLength(jarray)); // data 是字节数组,可以直接打印 console.log(hexdump(data)); }同样地,修改返回值也可以直接构造一个新的 jbyteArray 或者把原数组内容改掉。我记得有一次调试某签名算法,代码里把签名结果写进了一个全局 buffer,Java 层再取出来。直接返回一个新数组不生效,因为调用方拿的还是同一个 buffer 的引用,必须原地改写 buffer 内容。这个坑让我明白:hook 之前一定要先搞清数据流的方向,是“返回值传递”还是“引用原地修改”。
5. 实际场景实战:一个手动 hook 的完整排查过程
5.1 场景描述:某社交 App 的本地加密逻辑
模拟项目 X 是一个社交 App,它在本地做消息加密,加密函数位于com.example.securechat.core.MessageCipher。我需要 hook 这个类里的encrypt(byte[])方法,确认两个东西:第一,加密使用的密钥是否固定;第二,返回结果是否被上层二次处理。
第一步,先写一个最简单的 hook 脚本验证方法是否被调用:
Java.perform(function () { var MessageCipher = Java.use('com.example.securechat.core.MessageCipher'); MessageCipher.encrypt.overload('[B').implementation = function (data) { console.log('encrypt 被调用,输入长度: ' + data.length); var result = this.encrypt(data); console.log('encrypt 返回,长度: ' + result.length); return result; }; });跑起来以后发现没有日志输出。这不是方法没被调用,而是类可能还没被加载。于是改脚本,先用Java.choose扫描实例:
Java.perform(function () { Java.choose('com.example.securechat.core.MessageCipher', { onMatch: function (instance) { console.log('找到实例: ' + instance); instance.encrypt.implementation = function (data) { console.log('实例 encrypt 被调用,输入长度: ' + data.length); return this.encrypt(data); }; }, onComplete: function () {} }); });这一版开始有日志了,但是只输出了一条,说明实例是在某个特定时刻才会创建,我们对它 hook 得不够早。
第二步,动态监控构造时机。改用 hook 构造函数,保证实例一诞生就被标记:
Java.perform(function () { var MessageCipher = Java.use('com.example.securechat.core.MessageCipher'); MessageCipher.$init.overload('[B', '[B').implementation = function (key, iv) { console.log('构造函数被调用,key 字节: ' + key.length + ', iv 字节: ' + iv.length); this.$init(key, iv); }; });这里出现了overload('[B', '[B'),是因为我用overloads输出后发现构造函数有两个重载版本,一个是(byte[] key, byte[] iv),另一个是(String algorithm)。只 hook 带密钥的那个版本。
第三步,确认类加载器无问题。如果构造函数也迟迟不触发,就要检查Java.use到底用的是哪个 ClassLoader。插件化场景尤其容易在这步卡住,处理方式就是前面说的enumerateClassLoaders,找到承载该类的加载器后切换。
5.2 场景总结:手动 hook 的标准动作
上面的排查经历其实涵盖了一套标准动作,我整理出来给你参考:
- 反编译确认目标类、方法签名、构造方式。
Java.use获取类引用,先打印overloads看清楚重载。- 判断方法是否静态,静态方法直接 hook;非静态方法考虑 hook 构造或
Java.choose。 - 判断是否涉及动态加载类,涉及则枚举 ClassLoader。
- hook 时保留原始调用链,先打印后修改,避免一上来就改坏业务。
- 根据数据流方向决定是否需修改引用内容。
这套流程做熟了以后,手动 hook 的效率其实不比自动脚本慢,因为它跳过了大量无效 hook 点的干扰,有目标地输出日志,定位问题会非常快。
6. 手动 hook 常见问题与排查技巧实录
6.1 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| hook 后完全无日志 | 类未加载、实例不存在、方法未被调用 | 用Java.choose扫描实例;hook 构造函数 |
报TypeError: not a function | 方法有多个重载,未指定签名 | 打印overloads,补.overload() |
报ClassNotFoundException | ClassLoader 不对 | enumerateClassLoaders定位目标 loader |
| hook 方法后业务崩溃 | 返回值类型篡改错误 | 用Java.array包装数组;保持原返回值类型 |
Java.choose很慢或卡顿 | 堆上目标对象过多 | 缩短回调逻辑;仅做轻量判断 |
Module.findExportByName返回 null | 符号未导出或被 strip | 用 IDA 分析偏移、基址 + 偏移定位 |
| Native hook 命中但参数不对 | JNI 参数下标错位 | 记住 JNI 函数前两个固定参数:env、jobject |
| hook 后同一个函数被重复触发多次 | 重复 hook | 用自定义标记__hooked防重 |
6.2 几个值得单独强调的坑
第一个坑是Java.use之后立刻调用overload('byte[]')这样的写法。JNI 类型描述符里byte[]是[B,不是byte[],手写签名很容易写错。最稳的办法是先打印overloads,从输出里复制签名格式。
第二个坑是返回值是数组类型时,Java.array和普通 JS 数组混用。你直接返回[1,2,3]在某些 Frida 版本里能跑,但类型不是 Java 字节数组,后续调用方用result.length和读取元素时行为不稳定。这时候一定要显式Java.array('byte', ...)。
第三个坑是 Native 层 hook JNI 函数后,如果同时也在 Java 层 hook 了同一个法子函数,两层日志会互相干扰。建议排查时先做一层,确认没问题再做第二层,否则无法判断问题出在哪一层。
6.3 独家技巧:用调用栈定位准确类加载器
有些场景下,目标类本身被混淆了,类名混乱,一时分不清要 hook 哪个类。此时可以在报错点附近手动打一个Java.use,配合Thread.currentThread().getStackTrace()拿到 Java 层的调用栈,看看到底是从哪个类、哪个方法链进来的。
Java.perform(function () { var Throwable = Java.use('java.lang.Throwable'); Throwable.$init.implementation = function () { this.$init(); var stack = this.getStackTrace(); for (var i = 0; i < stack.length; i++) { console.log('[stack] ' + stack[i].toString()); } }; });这招在定位“看似调用却 hook 不到”的问题时特别管用。很多时候你以为目标是 A 类的方法,调用栈打出来以后发现实际走的是 B 类的同名方法,或者中间隔了一层转发逻辑。
6.4 收尾的一个经验
手动 hook 本身并不神秘,难点全在于精确。你要钩得准,首先得看清目标;要看清目标,就得理解类加载机制、JNI 参数布局和 Frida 的封装规则。写脚本的时候不要怕多打印几行日志,console.log是调试过程中最值得信任的工具,比任何单步调试都直观。
另一个容易被忽略的点是:hook 完记得确认是否会影响 App 的稳定性。尤其是修改返回值、篡改内存这种操作,容易导致业务逻辑异常。我一般会维护一个可开关的 hook 开关模块,用全局变量控制是否生效,方便在排查后快速恢复。
手动 hook 这块写到这也差不多了,能把这些环节理顺,你就已经具备独立处理大多数动态分析需求的能力了。下一步可以往 Frida 脚本模块化、RPC 调用、以及基于 hook 结果的自动化测试方向延伸,但这些都是熟能生巧的事,先把手动这一步练扎实最关键。