从Java到Native:Frida手动Hook的完整实战流程
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默认用的加载器可能在目标类加载之前就已经把类给初始化了这时候你再 hookhook 的是另一个副本代码跑的是业务里那个副本两边根本对不上。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.choose2.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[]写作[BString写作java.lang.Stringint写作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 层手动 hookJNI 函数与 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 或者把原数组内容改掉。我记得有一次调试某签名算法代码里把签名结果写进了一个全局 bufferJava 层再取出来。直接返回一个新数组不生效因为调用方拿的还是同一个 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()报ClassNotFoundExceptionClassLoader 不对enumerateClassLoaders定位目标 loaderhook 方法后业务崩溃返回值类型篡改错误用Java.array包装数组保持原返回值类型Java.choose很慢或卡顿堆上目标对象过多缩短回调逻辑仅做轻量判断Module.findExportByName返回 null符号未导出或被 strip用 IDA 分析偏移、基址 偏移定位Native hook 命中但参数不对JNI 参数下标错位记住 JNI 函数前两个固定参数env、jobjecthook 后同一个函数被重复触发多次重复 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 结果的自动化测试方向延伸但这些都是熟能生巧的事先把手动这一步练扎实最关键。