ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Frida进阶:Java运行时Hook实战,攻克动态代理与反射调用

2026/8/2 15:52:21 拓冰建站 浏览量
Frida进阶:Java运行时Hook实战,攻克动态代理与反射调用 1. 项目概述从基础到精通的桥梁搞Android逆向和安全测试的朋友对Frida这个“瑞士军刀”肯定不陌生。基础的Java.use、Java.perform大家都会用能hook个方法、改个返回值解决一些简单问题。但当你真正面对一个加固严密、混淆严重、或者大量使用动态代理、反射、匿名内部类的商业级App时是不是常常感觉力不从心明明逻辑就在那里却像隔着一层毛玻璃看得见摸不着改不了。这就是基础用法到实际复杂战场之间的鸿沟。“Frida Hook Android App 进阶用法之 Java 运行时”这个主题正是要填平这道鸿沟。它不再满足于简单的函数替换而是深入到Java虚拟机JVM/Dalvik/ART的运行时刻去观察、干预、甚至重塑应用的行为逻辑。这就像你从一个只能在外围拍照的游客变成了拥有后台通行证可以随时调整舞台灯光和演员台词的后台导演。核心价值在于它能让你处理那些常规hook束手无策的场景比如hook通过Class.forName动态加载的类、拦截通过Proxy.newProxyInstance创建的代理对象、枚举并操作匿名内部类、甚至是动态修改已经加载到内存中的类结构。这篇文章适合已经熟悉Frida基本语法能写简单脚本但在实战中遇到复杂目标时感到困惑的中级开发者或安全研究员。我会结合我过去几年在分析各类App时踩过的坑和总结的技巧带你系统性地掌握Java运行时的hook精髓让你手中的Frida真正成为一把无坚不摧的利器。2. 核心思路理解Java运行时的Hook层次在深入代码之前我们必须建立一个清晰的认知模型Frida对Java的Hook操作发生在哪个层面这决定了你能做什么以及你会遇到什么限制。2.1 静态Hook与动态Hook的区分很多人把Frida的Hook都混为一谈其实从时机上可以粗略分为两类静态Hook类加载时 这是最常见的Java.use(‘com.example.Class’).method.implementation ...。它的生效时机是在目标类被Java虚拟机加载的时候。如果这个类在脚本注入前就已经加载了Frida会通过内部机制重新触发一次类加载过程在独立的ClassLoader中以便让你能Hook到。这种方式对于App启动时就存在的类很有效但对于运行时动态生成的类就无能为力了。动态Hook运行时 这才是“进阶用法”的核心。它关注的是对象实例、方法调用栈、反射调用、动态代理等运行时才产生的行为。我们的Hook点不再是“某个类的某个方法”而是“某次方法调用的上下文”、“某个反射调用的目标”或“某个接口的所有实现”。2.2 Java运行时的关键访问点要在运行时进行精细操控我们需要以下几个关键的“把手”Java VM接口JNIEnv Frida通过JNI与Java虚拟机交互。理解JNI函数如FindClass,GetMethodID,CallObjectMethod等能帮助我们在Native层直接操作Java对象这是实现一些底层Hook的基础。Java反射APIjava.lang.Class,java.lang.reflect.Method,java.lang.reflect.Field。Frida本身大量使用反射而我们也可以Hook这些反射API本身来监控或篡改App自身的反射行为。类加载器ClassLoader 尤其是dalvik.system.DexClassLoader和PathClassLoader。HookloadClass方法可以拦截所有类的加载过程这是实现“静态Hook”动态化的关键。动态代理机制java.lang.reflect.Proxy和java.lang.reflect.InvocationHandler。很多网络库如Retrofit、插件化框架、RPC框架大量使用动态代理。不搞定它很多核心逻辑根本看不见。注意 进阶Hook往往需要组合使用多种技术。例如先通过Hook ClassLoader发现动态加载的类再通过反射API获取其方法最后用Frida进行Hook。它是一个系统工程而不是单一API调用。3. 实战进阶技巧一Hook动态加载的类与匿名内部类这是实战中最常遇到的第一个拦路虎。很多App为了安全或灵活会把关键逻辑放在assets、网络服务器或者通过字节码加密的方式在运行时通过自定义ClassLoader加载。3.1 监控与Hook自定义ClassLoader思路是Hookdalvik.system.BaseDexClassLoader或其子类的loadClass或findClass方法。这样每当App尝试加载一个类时我们都能第一时间知道并有机会对这个新加载的类进行即时Hook。Java.perform(function () { // 获取BaseDexClassLoader类 var BaseDexClassLoader Java.use(dalvik.system.BaseDexClassLoader); // Hook loadClass方法 BaseDexClassLoader.loadClass.overload(java.lang.String).implementation function (name) { console.log([ClassLoader] 尝试加载类: ${name}); // 打印调用栈看看是谁在加载这个类 // console.log(Java.use(android.util.Log).getStackTraceString(Java.use(java.lang.Exception).$new())); // 如果是我们关心的类比如包含‘secret’、‘core’、‘plugin’等关键字 if (name.indexOf(com.example.secret) ! -1) { console.log([!] 发现目标类: ${name}, 开始动态Hook...); // 这里不能直接Java.use因为类可能还没被定义。 // 需要先让原始方法执行加载这个类。 var result this.loadClass(name); // 调用原方法 // 加载后再安排一个异步任务去Hook这个类 setImmediate(function () { try { var TargetClass Java.use(name); if (TargetClass) { // 现在可以像平常一样Hook它的方法了 TargetClass.encrypt.implementation function (data) { console.log([动态Hook] 调用加密方法数据: ${data}); return this.encrypt(data); // 调用原方法 }; console.log([] 成功动态Hook类: ${name}); } } catch (e) { console.log([-] 动态Hook失败: ${e}); } }); return result; } // 对于其他类正常加载 return this.loadClass(name); }; });实操心得时机问题 在loadClass的implementation里直接Java.use一个尚未加载的类名会抛出ClassNotFoundException。正确的做法是先调用原方法this.loadClass(name)让类加载完成然后在setImmediate或下一个事件循环Tick中再去Java.use和Hook。setImmediate确保了Hook代码在类加载完成后执行。性能考虑 打印所有类的加载日志会极度拖慢App速度只应在分析阶段使用。在生产脚本中一定要通过类名关键词如secret,decrypt,api或包名进行过滤。多ClassLoader环境 一个App可能有多个ClassLoader主加载器、插件加载器、热修复加载器。Java.use默认使用当前线程的ClassLoader通常是系统主加载器。对于插件类可能需要先获取到插件对应的ClassLoader实例然后用Java.ClassFactory.get(pluginClassLoader).use(className)的方式来访问。3.2 处理匿名内部类匿名内部类在编译后会生成类似OuterClass$1,OuterClass$2这样的名字。直接Java.use(‘com.example.OuterClass$1’)的难点在于你根本不知道它有多少个匿名内部类分别叫什么。策略一枚举所有已加载的类Java.perform(function () { // 遍历所有已加载的类 Java.enumerateLoadedClasses({ onMatch: function (className) { // 如果类名包含外部类名和$符号 if (className.indexOf(com.example.OuterClass$) ! -1) { console.log(发现匿名/内部类: ${className}); // 可以进一步检查这个类的方法判断它是不是我们要找的 try { var clazz Java.use(className); var methods clazz.class.getDeclaredMethods(); // ... 分析方法列表找到目标方法进行Hook } catch (e) {} } }, onComplete: function () { console.log(枚举完成); } }); });策略二Hook外部类的构造函数或方法很多时候匿名内部类是在外部类的某个方法内部new出来的。我们可以先Hook外部类的这个方法在它执行时打印出new出来的对象所属的类。Java.perform(function () { var OuterClass Java.use(com.example.OuterClass); OuterClass.someMethod.implementation function () { console.log(OuterClass.someMethod 被调用); // 调用原方法前我们可以做点手脚但这里更关键的是观察内部类的创建 var result this.someMethod(); // 调用原方法后我们或许可以通过某个字段获取到内部类实例 // 假设原方法创建了一个监听器并赋值给了 this.listener if (this.listener) { console.log(监听器对象类名: ${this.listener.getClass().getName()}); // 现在知道了具体类名就可以动态Hook它了 var listenerClassName this.listener.getClass().getName(); setImmediate(function () { try { var ListenerClass Java.use(listenerClassName); ListenerClass.onEvent.implementation function (event) { console.log(拦截到事件: ${event}); return this.onEvent(event); }; } catch (e) {} }); } return result; }; });4. 实战进阶技巧二深入对象实例与反射调用Hook一个方法我们通常满足于修改输入输出。但在运行时我们常常需要操作具体的对象实例或者拦截通过反射发起的调用。4.1 操作与替换对象实例Java.use获取的是类Class的引用而Java.choose用于在堆内存中查找已有的对象实例。但有时我们需要替换某个字段引用的对象。Java.perform(function () { var TargetClass Java.use(com.example.SessionManager); var EvilClass Java.use(com.example.EvilSession); // 我们伪造的类 // 找到所有的SessionManager实例 Java.choose(com.example.SessionManager, { onMatch: function (instance) { console.log(找到SessionManager实例: ${instance}); // 假设SessionManager有一个mCurrentSession字段 // 我们先获取原session对象 var originalSession instance.mCurrentSession.value; console.log(原Session: ${originalSession}); // 创建一个我们控制的伪造session对象 // 注意EvilSession必须与原Session类型兼容相同类或子类 var evilSession EvilClass.$new(); // 关键步骤替换 // 方法1直接修改字段值需要字段可访问 instance.mCurrentSession.value evilSession; // 方法2如果字段是private可以通过反射API设置 // var field instance.getClass().getDeclaredField(mCurrentSession); // field.setAccessible(true); // field.set(instance, evilSession); console.log([] 已将会话实例替换为伪造对象); }, onComplete: function () { console.log(实例搜索完成); } }); });注意事项对象生命周期Java.choose是用于扫描当前已存在的对象。如果对象在脚本运行后才创建可能需要轮询或Hook创建它的工厂方法。类型兼容性替换进去的对象evilSession必须能被赋值给目标字段。通常是相同类型或是其子类。否则会抛出ClassCastException。线程安全这种全局替换在多线程环境下非常危险可能引发难以预料的并发问题。务必清楚替换的时机和影响范围。4.2 拦截与篡改反射调用App自身也大量使用反射来调用方法或访问字段特别是那些被混淆或隐藏的API。Hook反射入口是洞察这类行为的绝佳方式。Java.perform(function () { // Hook java.lang.Class 的 getMethod 和 getDeclaredMethod var Class Java.use(java.lang.Class); Class.getMethod.overload(java.lang.String, [Ljava.lang.Class;).implementation function (name, parameterTypes) { console.log([反射监控] Class.getMethod: 类${this.getName()}, 方法名${name}); var method this.getMethod(name, parameterTypes); // 调用原方法 // 我们可以返回一个被我们包装过的Method对象 return method; }; // Hook java.lang.reflect.Method 的 invoke 方法 var Method Java.use(java.lang.reflect.Method); Method.invoke.overload(java.lang.Object, [Ljava.lang.Object;).implementation function (obj, args) { console.log([反射监控] Method.invoke: 方法${this.getName()}, 所属类${this.getDeclaringClass().getName()}, 调用对象${obj}, 参数${args}); // 如果这是我们关心的方法可以篡改参数或返回值 if (this.getName().indexOf(secretOperation) ! -1) { console.log([!] 拦截到关键反射调用); // 例如修改传入的参数 if (args args.length 0) { args[0] Hacked_ args[0]; } // 调用原方法并获取结果 var originalResult this.invoke(obj, args); // 篡改结果 var modifiedResult originalResult _Tampered; console.log(原始结果: ${originalResult}, 篡改后: ${modifiedResult}); return modifiedResult; } // 对于其他方法正常调用 return this.invoke(obj, args); }; });排查技巧 如果直接HookMethod.invoke发现不生效可能是因为App使用的是更底层的native方法调用或者使用了MethodHandle。此时可以尝试Hookjava.lang.reflect.Constructor的newInstance或者java.lang.reflect.Field的get/set方法。反射监控的输出量巨大务必结合Logcat的标签过滤和脚本内的条件判断来聚焦关键信息。5. 实战进阶技巧三攻克动态代理与接口Hook动态代理是Java中实现AOP、RPC客户端等的核心手段。它会在运行时生成一个实现指定接口的代理类。我们无法直接Java.use这个生成的代理类因为它的类名是随机的如$Proxy0。5.1 定位与Hook动态代理策略是Hook创建代理的地方java.lang.reflect.Proxy.newProxyInstance。Java.perform(function () { var Proxy Java.use(java.lang.reflect.Proxy); var InvocationHandler Java.use(java.lang.reflect.InvocationHandler); Proxy.newProxyInstance.overload(java.lang.ClassLoader, [Ljava.lang.Class;, java.lang.reflect.InvocationHandler).implementation function (loader, interfaces, handler) { console.log([动态代理监控] 创建代理实例); console.log( 类加载器: ${loader}); console.log( 实现的接口:); for (var i 0; i interfaces.length; i) { console.log( - ${interfaces[i].getName()}); } console.log( 调用处理器: ${handler}); // 关键我们可以替换掉原始的InvocationHandler换成我们自己的 var originalHandler handler; // 创建一个我们自定义的InvocationHandler var myHandler Java.registerClass({ name: com.example.MyEvilInvocationHandler, implements: [InvocationHandler], fields: { originalHandler: java.lang.reflect.InvocationHandler }, methods: { $init: function (originalHandler) { this.originalHandler.value originalHandler; }, invoke: function (proxy, method, args) { console.log([我的Handler] 代理调用: 方法${method.getName()}, 接口${method.getDeclaringClass().getName()}); // 可以在这里拦截、修改参数、记录日志、甚至改变调用目标 // 例如拦截所有‘query’方法 if (method.getName().indexOf(query) ! -1) { console.log([!] 拦截到query操作参数: ${args}); // 修改参数 if (args args.length 0) { args[0] injected_condition; } } // 最终调用原始的InvocationHandler return this.originalHandler.value.invoke(proxy, method, args); } } }); var wrappedHandler myHandler.$new(originalHandler); // 用我们包装后的handler去创建代理 var proxyInstance this.newProxyInstance(loader, interfaces, wrappedHandler); console.log([] 已用自定义Handler包装代理); return proxyInstance; }; });实操心得Java.registerClass 这个API允许我们在运行时动态创建一个新的Java类。这里我们创建了一个新的InvocationHandler来包装原始的Handler。这是Frida非常强大的一个功能常用于实现复杂的中间人逻辑。接口方法过滤 动态代理可能实现多个接口每个接口有很多方法。在自定义invoke方法里需要根据method.getName()和method.getDeclaringClass().getName()来精确过滤我们关心的操作避免日志泛滥和性能问题。原始调用链 一定要记得最后调用this.originalHandler.value.invoke(...)将调用传递下去否则会破坏App的正常功能导致崩溃或逻辑错误。我们的目的是观察和干预而不是阻断。5.2 直接Hook接口方法如果我们知道代理最终实现的接口是什么比如是一个网络请求接口ApiService我们可以直接Hook这个接口的所有实现类。虽然代理类本身是生成的但它实现的方法体逻辑最终会走到我们包装的InvocationHandler.invoke或者如果我们能拿到具体的实现类实例也可以直接Hook。Java.perform(function () { // 假设我们知道接口叫 com.example.ApiService // 首先尝试找到所有实现了此接口的对象可能包括代理对象和真实对象 Java.choose(com.example.ApiService, { onMatch: function (instance) { console.log(找到ApiService实例: ${instance.getClass().getName()}); // 由于是接口我们不能直接Hook。但可以获取其Class然后看它有哪些声明的方法来自接口 var clazz instance.getClass(); var methods clazz.getDeclaredMethods(); // 对于代理类这里返回的是接口方法 // 更直接的办法Hook这个实例的具体调用。但这需要更精细的控制通常结合动态代理监控更可行。 }, onComplete: function () {} }); // 另一种思路如果App有创建ApiService的工厂方法如Retrofit.create直接Hook那个工厂方法拿到返回的实例动态代理进行包装。 var Retrofit Java.use(retrofit2.Retrofit); Retrofit.create.overload(java.lang.Class).implementation function (service) { console.log(Retrofit.create 被调用创建接口: ${service.getName()}); var originalProxy this.create(service); // 原代理对象 // 这里可以像上一节一样用自定义InvocationHandler包装原代理的调用处理器 // 但需要先通过反射获取原代理内部的InvocationHandler步骤稍复杂。 // 更简单的方式直接返回一个我们完全控制的代理对象需要实现所有接口方法但这工作量很大。 return originalProxy; // 暂时返回原对象需要更深入hack }; });6. 高级话题内存漫游与主动调用当所有常规Hook手段都失效时例如关键逻辑在Native层或者被VM加固深度混淆我们可能需要更底层的手段。6.1 枚举与调用堆栈上的对象Java.choose是堆扫描而Java.choose的scope参数可以指定搜索范围如‘heap’。但有时对象只在某个线程的局部变量或方法参数中存在。我们可以通过Hook上下文方法结合Java.cast和Java.use来操作。Java.perform(function () { var TargetClass Java.use(com.example.Processor); TargetClass.process.implementation function (inputData, callback) { console.log(Processor.process 被调用输入: ${inputData}); // 此时this 是Processor实例callback 是回调接口实例 // 我们可以直接操作它们 // 1. 修改输入参数 var hackedInput inputData _tampered; // 2. 替换回调对象假设Callback是一个接口 var OriginalCallback Java.use(com.example.Callback); var evilCallback Java.registerClass({ name: com.example.EvilCallback, implements: [OriginalCallback], methods: { onSuccess: function (result) { console.log([邪恶回调] 收到结果: ${result}); // 篡改结果或者什么都不做阻止原回调执行 // 如果想调用原回调需要保存原callback引用这里略 return; }, onFailure: function (error) { console.log([邪恶回调] 调用失败: ${error}); return; } } }).$new(); // 将原回调替换为我们自己的 // 注意这里只是示例实际参数可能是final的无法修改。通常需要更早地介入。 // 3. 主动调用其他方法 var secret this.getSecretKey(); // 假设存在这个方法 console.log(主动调用获取密钥: ${secret}); // 继续执行原流程使用可能被修改的参数 return this.process(hackedInput, evilCallback); }; });6.2 使用Java.vm对象进行底层操作Java.vm对象提供了访问JNI环境JNIEnv的能力虽然用起来更复杂但功能也更强大。Java.perform(function () { // 获取JNI环境 var env Java.vm.getEnv(); // 示例通过JNI直接查找并调用一个静态方法不推荐但展示可能性 // 这需要知道方法的签名 // var jclass env.findClass(com/example/Utils); // var jmethodId env.getStaticMethodID(jclass, staticMethod, (Ljava/lang/String;)I); // var result env.callStaticIntMethod(jclass, jmethodId, env.newStringUtf8(test)); // 更实用的处理JNI函数HookNative层与Java层的交互 // 这需要使用Interceptor.attach去Hook so库中的JNI函数如FindClass, GetMethodID, CallVoidMethod等。 // 这属于Frida Native Hook的范畴是另一个深水区。 });警告 直接操作Java.vm和JNI环境风险极高极易导致虚拟机崩溃。除非你对JNI和Android Runtime有深刻理解并且常规Java层Hook完全无效否则不建议轻易尝试。7. 问题排查与脚本调试技巧实录写复杂的运行时Hook脚本翻车是家常便饭。下面是我总结的一些常见问题和排查手段。7.1 常见崩溃与异常处理异常现象可能原因排查与解决思路TypeError: cannot read property ‘implementation’ of undefined1. 类名拼写错误。2. 类尚未被加载。3. 当前ClassLoader上下文不对。1. 使用Java.enumerateLoadedClasses确认类名。2. 在setImmediate或HookClassLoader.loadClass后尝试。3. 使用Java.ClassFactory.get(classLoader).use(className)。Java.lang.ClassNotFoundException在错误的时机尝试Java.use一个未加载的类。确保在类加载完成后再调用Java.use。使用loadClassHook或等待特定初始化方法被调用。Invalid number of argumentsoverload签名指定错误与实际方法不匹配。使用Java.use(‘ClassName’).method.overloads查看所有重载版本选择正确的签名。对于[Ljava.lang.String;这样的数组类型要格外小心。脚本注入后App无反应或立即闪退1. Hook了关键系统方法导致死锁或初始化失败。2. 脚本存在无限循环或内存泄漏。3. 与App自身的反调试/反Hook机制冲突。1. 注释掉部分Hook代码采用二分法定位问题Hook点。2. 检查Java.choose的回调函数是否过于耗时避免阻塞主线程。3. 尝试在非关键路径上Hook或者先绕过反调试。使用frida -D连接并观察设备Logcat输出。Error: access violation accessing(Native层错误)通常发生在错误的JNI句柄操作或Native内存访问中。检查所有通过Java.vm或NativePointer进行的操作确保指针有效内存访问权限正确。7.2 调试与日志输出优化结构化日志 给你的日志加上前缀、标签、线程ID和时间戳方便过滤。function log(tag, message) { var timestamp new Date().toISOString(); var thread Java.use(java.lang.Thread).currentThread().getName(); console.log([${timestamp}][${thread}][${tag}] ${message}); } log(HOOK, Method called);条件式Hook与日志 不要无脑打印所有信息。通过判断类名、方法名、参数值、调用栈等来决定是否输出日志或执行Hook逻辑大幅提升脚本性能和日志可读性。implementation: function (arg1) { if (arg1.indexOf(password) -1) { return this.method(arg1); // 快速返回不处理不关心的调用 } log(SECRET, 捕获密码相关操作: arg1); // ... 详细处理逻辑 }使用console.error和try-catch 将错误信息与普通日志区分开。用try-catch包裹可能出错的代码块如Java.use、Java.choose在catch中打印详细错误信息避免脚本因一处异常而整体停止。try { var trickyClass Java.use(some.obfuscated.Class$123); // ... hook logic } catch (e) { console.error(Failed to hook obfuscated class: ${e.message}\n${e.stack}); }利用Frida RPC 对于需要与外部交互或分步调试的情况将关键功能暴露为RPC方法通过Python脚本或其他客户端动态调用而不是把所有逻辑写死在Java.perform里。这让你可以动态启用/禁用特定Hook或查询运行时状态。7.3 性能与稳定性考量避免在Hook实现中执行耗时操作 特别是同步的、耗时的网络请求或复杂计算。这会阻塞被Hook线程导致ANR。如需执行耗时任务使用setImmediate或setTimeout将其转移到其他线程但要注意线程上下文。谨慎使用Java.choose 全堆扫描非常慢且可能触发GC。尽量在目标对象已创建后且有明确标识如特定字段值时使用。或者通过Hook对象创建的方法来直接捕获新实例。及时清理资源 虽然Frida脚本在会话结束时会自动清理但对于长期运行的脚本注意避免在回调函数中累积闭包引用导致内存泄漏。脚本模块化 将不同功能的Hook写成独立的函数或模块通过注释可以方便地启用或禁用便于管理和调试。掌握Java运行时的Hook是一个从“使用者”到“掌控者”的蜕变过程。它没有固定的套路更多是面对具体问题时对Java运行时模型、Frida API以及目标App架构理解的综合考验。最好的学习方式就是找一个复杂的App比如一些金融类或游戏类App带着明确的目标例如找到某个加密算法、绕过某个验证不断地尝试、失败、排查、再尝试。每一次成功的Hook都会让你对这座冰山之下的运行机理有更深一层的认识。