type
Post
status
Published
date
May 20, 2026
slug
summary
掌握 Java Hook 的「全套武器」。第03篇教会了你 Hook 普通方法——这只是起点。实际逆向中你会不断遇到新需求:想看一个对象是怎么创建的(构造函数 Hook)、想 Hook 一个回调接口的实现(内部类/匿名类)、想直接读取或修改某个运行中对象的状态(字段操作 + Java.choose)、想在一片被混淆成
a.b.c 的代码中定位目标(枚举与搜索)。tags
frida
Android
逆向工程
category
公众号
icon
password
上次编辑时间
May 20, 2026 06:44 AM
comment
AI 总结
一、Hook 构造函数:观察对象的诞生
1.1 为什么要 Hook 构造函数
在第03篇中,我们 Hook 的都是普通的实例方法或静态方法——在方法被调用时拦截。但有些时候,你关心的不是「某个方法被怎么调用了」,而是「某个对象是怎么被创建的」。
举一个典型场景:你在 jadx 中看到一个
Request 类,App 的所有网络请求都是先创建一个 Request 对象,然后把它发送出去。如果你能 Hook Request 的构造函数,就能看到每个请求在创建时传入了哪些参数(URL、请求头、请求体等),而不需要去 Hook 发送请求的方法。另一个场景是追踪单例模式(Singleton)。很多 App 的关键管理类(如
UserManager、ConfigManager)采用单例模式,整个 App 生命周期中只创建一个实例。Hook 它的构造函数可以精确地知道这个单例是在什么时候、被谁创建的,以及创建时传入了什么初始配置。更多应用场景:
- Hook
SecretKeySpec.$init可以拦截加密密钥的创建,直接获取 AES/DES 密钥的字节数组
- Hook
URL.$init可以监控走java.net.URL/HttpURLConnection路径的请求;OkHttp 等用自有HttpUrl抽象的栈不经过这里,需要另选 Hook 点(如Request$Builder.url)
- Hook
Intent.$init可以追踪 Activity/Service 之间的通信数据
1.2 语法:$init
在 Frida 中,构造函数用特殊的名字
$init 来表示。这个命名源于 Java 字节码的惯例——在 JVM 内部,构造函数的方法名是保留的特殊名字 <init>(类初始化块对应的是 <clinit>),这种名字在 Java 源码层根本没法直接调用。Frida 的 Java bridge 在生成 wrapper 时,把所有以 < 开头的特殊方法重写成了 $xxx 形式(<init> → $init、<clinit> → $clinit),让你能用属性语法 Class.$init 自然访问。第一,构造函数同样可以有多个重载版本。处理方式和普通方法完全一样——用
$init.overload("type1", "type2") 指定参数类型,或者用 $init.overloads 遍历所有版本。第二,你必须在回调中调用
this.$init(...) 来执行原始构造函数。如果不调用,这个对象就不会被正确初始化——它的字段可能都是 null 或零值,后续代码使用这个对象时大概率会崩溃。这比普通方法的 Hook 更严格——普通方法你可以选择不调用原始方法(虽然不推荐),但构造函数几乎没有不调用的合理理由。第三,构造函数没有返回值。在 Java 层面,
new MyClass(args) 表达式的值是新创建的对象,但这个「返回对象」的逻辑是由 JVM 的 new 字节码指令管理的,不在构造函数内部。所以你的 implementation 回调不需要写 return 语句。$initvs$new:你可能在 Frida 文档中还见过$new。两者的区别是:$init用于 Hook 已有对象的构造过程(拦截 App 自己创建对象时的行为),而$new()用于你主动创建一个新的 Java 对象(相当于在 JS 中执行new MyClass(args))。例如Java.use("java.lang.Exception").$new()会创建一个新的 Exception 实例——这在第03篇打印调用堆栈时就用到了。
1.3 实战示例:追踪 OkHttp Request 的创建
很多 Android App 使用 OkHttp 作为网络库。OkHttp 的请求对象通过 Builder 模式(Builder Pattern)构建——先创建一个
Request.Builder,调用一系列 setter 方法设置 URL、Header、Body 等,最后调用 build() 生成 Request 对象。Hook build() 方法可以一次性拿到完整的请求信息:注意这里用的是
okhttp3.Request$Builder 而不是 okhttp3.Request.Builder。这就引出了下一个话题——内部类的处理。
构造函数 Hook 与内部类命名规则
二、内部类与匿名类:Java 的隐藏世界
2.1 内部类在字节码中的命名规则
Java 中一个类可以包含另一个类,这叫做内部类(Inner Class)。在源代码中,它们的关系用
. 连接(如 Request.Builder),看起来很自然。但在编译成字节码之后,JVM 实际上把它们当作独立的顶层类处理,命名规则是用 $ 代替 .。Java 源代码写法 | 字节码中的实际类名 | Frida 中的写法 |
Request.Builder | okhttp3.Request$Builder | "okhttp3.Request$Builder" |
Config.Entry | com.app.Config$Entry | "com.app.Config$Entry" |
View.OnClickListener | android.view.View$OnClickListener | "android.view.View$OnClickListener" |
Map.Entry | java.util.Map$Entry | "java.util.Map$Entry" |
在 Frida 中使用
Java.use 时,你必须使用字节码中的实际类名(用 $),而不是源代码中的写法(用 .)。这是初学者经常犯的错误——在 jadx 中看到 Config.Entry,就在 Frida 中写 Java.use("com.app.Config.Entry"),结果 ClassNotFoundException。为什么用$? 这是 JVM 规范(JVMS)中的约定。JVM 内部不区分「外部类」和「内部类」的概念——所有类都是扁平的,存储在同一个命名空间中。编译器用$作为分隔符将外部类名和内部类名拼接成一个唯一的全限定名。这个$在 Java 语言层面是一个合法的标识符字符,但约定俗成只在编译器生成的类名中使用。
2.2 匿名类的命名:编号规则
匿名类(Anonymous Class)是 Java 中没有名字的内部类,通常出现在回调和事件处理中。编译器会给它们分配一个数字编号,从 1 开始递增:
编译后,这两个匿名类的类名分别是
LoginActivity$1 和 LoginActivity$2——编号取决于它们在源代码中出现的顺序。在 Frida 中 Hook 匿名类的方法:
2.3 不确定匿名类编号?枚举来找
匿名类的编号并不稳定——如果源代码中添加或删除了一个匿名类,后面所有匿名类的编号都会发生变化。而且在混淆后,你甚至可能不确定外部类的名字。这时候就需要通过枚举来定位。
思路是:枚举所有以目标外部类名开头的已加载类,然后检查每个内部类/匿名类实现了哪些接口、有哪些方法,以此判断哪个是你要找的。
假设输出是:
现在你可以清楚地看到:
$1 是点击事件的 Listener,$2 是网络请求的 Callback,$LoginResult 是一个命名内部类(数据类)。想 Hook 网络回调的 onResponse,就对 $2 下手。三、字段(Field)操作:读取和修改对象的状态
3.1 访问字段的两种方式
在 Frida 中,读取和修改 Java 对象的字段有两种方式,适用于不同的场景。
方式一:在 Hook 回调中通过
this 访问实例字段。 当你正在 Hook 某个对象的方法时,this 就是该对象的引用,你可以直接通过它访问该对象的任何字段:方式二:通过
Java.use 访问静态字段。 静态字段(static 修饰)属于类本身而不是某个实例,不需要有一个活跃的对象就能访问:何时用哪种方式? 简单的判断标准:如果你在 jadx 中看到字段声明有static关键字,用方式二;否则用方式一。方式一需要在 Hook 回调内(有this)才能使用,方式二可以在Java.perform的任何位置使用。
3.2 .value 的含义
你一定注意到了,访问字段时用的是
field.value 而不是直接 field。这是 Frida 的一个设计——当你写 this.apiUrl 时,得到的不是字段的值本身,而是一个「字段描述符」(Field Accessor)对象。这个描述符记录了字段的类型、修饰符等元信息。你需要通过 .value 属性来获取或设置字段的实际值。这个设计乍看有点多余,但它解决了一个关键的名字冲突问题:如果一个类有一个字段叫
name,同时也有一个方法叫 name(),Frida 需要一种方式来区分你访问的是字段还是方法。通过让字段返回描述符对象(需要 .value 取值)、方法返回可调用对象(可以直接 () 调用),两者就不会混淆了。字段名被混淆时:如果字段被 ProGuard 混淆为a、b、c,你在 Frida 中同样用混淆后的名字访问:this.a.value。如果不确定字段名,参见 3.4 节的反射方法来动态发现。
3.3 修改字段值的注意事项
修改字段值时要注意三点:
第一,修改
final 字段。 Java 的 final 关键字表示字段在初始化后不能被修改。但 Frida 通过 JNI 直接操作内存,可以绕过这个限制——final 字段在 Frida 中也可以被修改。不过要注意一个陷阱:Java 编译器可能会对 final 基本类型做编译期内联优化(Compile-time Constant Inlining)。比如:这种情况下,即使你用 Frida 把
MAX_RETRY 的字段值改成了 100,已经编译的代码中仍然是硬编码的 3——修改不会生效。这个限制只影响 static final 的基本类型和 String 常量,对象类型的 final 字段不受影响。第二,修改字段的时机。 如果你在 App 启动后才修改了某个配置字段,而 App 在启动时已经读取了这个字段的值并缓存了起来(比如存入了一个局部变量或另一个对象的字段),那么你的修改可能不会生效。你需要在 App 首次读取该字段之前就完成修改——这通常意味着使用 Spawn 模式并在脚本最前面执行修改。
第三,类型安全。 设置字段值时,你提供的值必须与字段的声明类型兼容。给一个
int 类型的字段设置一个字符串会导致崩溃。Frida 会在 JS 和 Java 之间做基本的类型转换(比如 JS number → Java int),但不会做跨类型的隐式转换。3.4 通过 Java 反射访问 private 字段
有时候,通过
this.fieldName.value 访问字段可能失败——特别是对于被混淆的字段名(你不确定名字是什么),或者字段名与方法名冲突导致 Frida 解析错误。这时可以降级到 Java 反射 API(Reflection API),它可以访问任何字段,不管可见性修饰符:反射遍历所有字段:当你完全不知道目标类有哪些字段时,可以用getDeclaredFields()获取全部字段,然后逐一读取:这在面对混淆代码时特别有用——你不需要知道字段名,直接把所有字段值打印出来看。
3.5 读取父类的字段
Java 的
getDeclaredFields() 只返回当前类自身声明的字段,不包括从父类继承的字段。如果你要读取的字段定义在父类中,需要沿着继承链向上查找:四、Java.choose():在堆中搜索活跃的对象实例
4.1 问题场景
到目前为止,我们访问 Java 对象的方式都是「被动」的——在 Hook 回调中通过
this 拿到被调用方法所属的对象。但有些时候,你想「主动」去找到一个对象。比如,App 中有一个
UserManager 单例,它保存了当前登录用户的 token、用户名等信息。你想在不触发任何操作的情况下,直接读取这些信息。这时候没有任何方法被调用,你没有 this,怎么办?Java.choose 就是为这个场景设计的。它在 Java 堆(Heap)中搜索指定类型的所有存活实例,让你可以直接操作它们。
Java.choose(): 在堆中搜索活跃对象
4.2 底层原理
Java 虚拟机使用堆内存来存储所有通过
new 创建的对象。ART 的垃圾回收器(GC,Garbage Collector)维护着堆中所有对象的信息,以便判断哪些对象还在使用(可达性分析)、哪些可以回收。Java.choose 利用了 ART 内部的堆遍历 API(art::gc::Heap::VisitObjects,函数名以 AOSP 主干为准,不同 Android 版本签名可能略有差异)——它请求 GC 子系统遍历堆中的每一个对象,对于每个对象检查其类型(klass_ 指针)是否与你指定的类名匹配。如果匹配,就调用你的 onMatch 回调,把该对象的引用包装成 JS Wrapper 传给你。性能特征:因为需要遍历整个堆,Java.choose有一定性能开销——堆越大(活跃对象越多),扫描时间越长。在实际使用中,对一个普通 App 调用一次Java.choose通常在几十到几百毫秒内完成,日常使用完全可以接受。但不要在高频循环(如setInterval每 100ms 调用一次)中使用它。
4.3 基本用法
onMatch 可能被调用零次(堆中没有该类型的实例——对象尚未创建或已被 GC 回收)、一次(单例模式)或多次(多个实例并存)。每次调用时传入的 instance 都是一个不同的存活对象。4.4 修改运行中对象的状态
Java.choose 不仅可以读取,还可以修改对象的状态。这在安全测试场景中非常有用——比如验证客户端校验是否容易被绕过:安全测试场景:通过Java.choose修改isVip和balance可以验证 App 是否仅在客户端做了权限/余额校验。如果修改后 App 直接放行了 VIP 功能,说明存在客户端校验绕过的安全风险——服务端应该独立校验。
4.5 Java.choose 的局限性
理解局限性和理解能力同样重要。
第一,只能找到存活对象。
Java.choose 只能找到堆中仍然存活(被 GC Root 引用链可达)的对象。如果目标对象已经被 GC 回收了(没有任何代码持有它的引用),你就找不到它。对于单例模式的对象(始终被静态字段引用,不会被 GC),这不是问题;但对于临时对象(方法局部变量,用完就丢弃),Java.choose 很可能找不到。第二,返回的是搜索时刻的快照。 在你处理
onMatch 回调的过程中,如果其他线程修改了该对象的字段值,你看到的可能是修改前的值。不过在实际使用中,这个竞态条件(Race Condition)很少造成问题——逆向分析场景下的精度要求没有多线程编程那么严格。第三,精确类型匹配。
Java.choose 按对象的 class pointer 精确比对,不走 instanceof——所以即使你写父类或接口名,也只会匹配该类型自身的实例,不会返回子类。比如搜 java.lang.Object 也不会把整个堆都倒出来。如果你想搜索实现了某个接口的所有对象,需要先枚举实现该接口的类名,再逐一 Java.choose。4.6 当 Java.choose 找不到对象时的替代方案
如果
Java.choose 的 onMatch 从来没有被触发,说明目标类型的实例当前不存在。先看决策表,再看实现:场景 | 推荐做法 | 关键 API |
对象是单例且已存在 | 直接搜堆 | Java.choose |
对象尚未创建 | 在创建瞬间拦截 | $init Hook |
对象是临时的,但有必经方法 | 顺着方法 Hook 抓 this | method.implementation |
不确定 | 三路并行,先触发哪个用哪个 | 全部尝试 |
下面给出后两种策略的实现。
策略一:Hook 构造函数,在对象诞生时拦截。 这样你不需要等对象出现在堆中,而是在它被创建的那一刻就捕获它:
策略二:Hook 一个必然被调用的方法。 如果你知道
UserManager.checkLogin() 会在 App 启动后被调用,就可以 Hook 它来获取实例引用:五、类和方法枚举:建立代码地图
在面对一个陌生的 App 时,你往往不知道关键逻辑在哪个类、哪个方法里。枚举是建立「代码地图」的第一步——把 App 加载的所有类列出来,搜索包含特定关键字的类名和方法名,快速缩小分析范围。
5.1 枚举已加载的类
这个脚本的输出会给你一个 App 中所有类的清单。通过观察类名中的关键字(
Login、Encrypt、Api、Sign、Token、Config),你可以快速定位到与你分析目标相关的类。同步版本:Java.enumerateLoadedClassesSync()返回一个包含所有类名的数组,适合需要后处理的场景:
5.2 枚举指定类的所有方法和字段
当你确定了目标类后,下一步是了解这个类有哪些方法和字段。这相当于用 Frida 做了一次「运行时的 jadx」——而且这里看到的是混淆后的真实名字,比 jadx 有时显示的还原名字更可靠:
5.3 关键字搜索:在所有类中定位目标
当你只知道一个模糊的关键字(比如「这个 App 用了某种加密,但不知道在哪个类里」),可以用关键字在所有已加载的类名和方法名中搜索:
优化搜索性能:在大型 App 中(上万个类),上面的脚本可能需要几十秒。优化方法:
- 限制搜索范围——只搜索 App 包名前缀下的类:
if (!className.startsWith("com.example")) return;
- 只搜类名不搜方法名——去掉方法反射部分,通常类名匹配就足够缩小范围
- 使用
enumerateLoadedClassesSync()获取数组后在 JS 中过滤,减少回调开销
六、追踪一个类的所有方法调用
有时候你知道关键逻辑在某个类里,但不确定是哪个方法。这时可以一次性 Hook 这个类的所有方法,看看哪些方法在你执行特定操作时被调用了。
6.1 自动化追踪脚本
启动这个脚本后,在 App 中执行你关心的操作(比如登录),控制台就会打印出
EncryptUtils 中被调用的每一个方法、传入的参数和返回值。这比一个一个方法去 Hook 效率高得多。6.2 性能警告与最佳实践
追踪一个类的所有方法是可以的(通常一个类只有几十个方法),但不要尝试追踪一个包下的所有类的所有方法。比如
Java.enumerateLoadedClasses 加上对每个类的所有方法做 Hook,在一个有几百个类、几千个方法的 App 上,不仅 Hook 过程需要几分钟,而且 Hook 生效后每次方法调用的额外开销叠加起来会让 App 变得极其卡顿,甚至 ANR(Application Not Responding)。最佳实践——渐进式缩小范围:
- 先用关键字搜索(5.3 节)找到几个可疑的类
- 对其中一个最可疑的类启用全方法追踪
- 在 App 中执行目标操作,观察哪些方法被触发
- 根据方法名和参数进一步缩小到具体方法
- 对确定的方法写精确的 Hook 脚本进行深入分析
这个「从面到点」的过程通常只需要 2-3 轮迭代就能定位到目标。
七、应对代码混淆的实战策略
真实的 App 几乎都会使用 ProGuard 或 R8 做代码混淆。混淆会把类名、方法名、字段名重命名为
a、b、c 之类的短名字。面对一片 a.b.c.d.e() 的代码,如何定位目标?7.1 混淆不会改变的东西
理解混淆的边界是制定策略的基础。混淆工具会改变类名、方法名、字段名,但以下内容不会被改变:
不变的内容 | 原因 | 利用方式 |
字符串常量 | 运行时需要使用原始字符串值 | 搜索 "AES/CBC/...", URL, Key 等 |
Framework API | 外部依赖不能重命名 | Hook Cipher, SharedPreferences 等 |
参数/返回类型 | 跨模块调用需要类型兼容 | 按签名特征搜索候选方法 |
接口方法名 | 接口契约不能破坏 | onClick, onResponse 等保持原名 |
JNI 方法名 | Native 层通过名称查找 | native 方法通常保持原名 |
额外的不变量:Android 四大组件(Activity、Service、BroadcastReceiver、ContentProvider)的类名也不会被混淆,因为它们需要在AndroidManifest.xml中声明,Manifest 中的引用和代码中的类名必须一致。

应对代码混淆:三种定位策略
7.2 策略一:通过字符串常量反向定位
在 jadx 中搜索你知道的字符串(比如 API 的 URL、加密算法名称、SharedPreferences 的 key),找到使用这个字符串的代码位置。即使代码被混淆了,字符串引用会指引你到正确的类和方法:
字符串搜索技巧:除了直接搜索,还可以搜索字符串的片段。比如搜索"Padding"会找到所有使用了 padding 模式的加密代码,搜索"/api/"会找到所有 API 端点的定义。
7.3 策略二:Hook 系统 API 并通过堆栈反向追踪
即使 App 代码被混淆得面目全非,它最终还是要调用 Android Framework 的 API 来完成实际的加密、网络请求、文件操作等。这些 API 不会被混淆。
所以你可以直接 Hook 这些 Framework API(比如
javax.crypto.Cipher.doFinal),通过调用堆栈反向追踪到 App 自身的代码。堆栈中虽然类名和方法名是混淆后的短名字,但你可以看到调用链——从混淆的调用者到未混淆的框架方法之间的路径就是你的分析目标。常用的 Framework Hook 点(反混淆利器):
Hook 目标 用途Cipher.doFinal/Cipher.init 追踪对称/非对称加密MessageDigest.digest 追踪 MD5/SHA 哈希SecretKeySpec.$init 获取加密密钥Mac.doFinal 追踪 HMAC 签名SharedPreferences.getString/putString 追踪配置/token 存取URL.$init/HttpURLConnection.connect 追踪网络请求Log.d/i/w/e 查看 App 自己的调试日志
7.4 策略三:通过方法签名特征搜索
如果你从 jadx 的分析中知道目标方法的参数类型和返回类型(这些不会被混淆),可以在所有类中搜索匹配这个签名的方法:
这可能返回多个候选方法,但数量通常可控(几个到十几个)。然后你可以逐一 Hook 这些候选方法,看哪个在你执行目标操作时被触发——被触发的那个就是你要找的。
byte[] 版本的对照模板:实战中加密入口更常见的形态是byte[](byte[], byte[])(密文 + 密钥)或byte[](byte[], byte[], byte[])(外加 IV)。把上面的匹配条件改成:常见数组描述符:[B=byte[]、[C=char[]、[I=int[]、[Ljava.lang.String;=String[]。
组合策略:实际分析中,这三种策略往往需要组合使用。比如:先用策略二(Hook Cipher)确认 App 确实有加密操作,从堆栈中拿到混淆后的类名com.example.a.b;然后用策略三确认该类中的哪个方法是加密入口;最后用策略一在 jadx 中搜索该类使用的字符串常量来理解加密的具体参数。
总结
本篇覆盖的能力把你从「Hook 一个方法」推到「分析一个 App」。
技巧 | API / 语法 | 核心用途 | 注意事项 |
构造函数 Hook | $init | 观察对象创建时机和初始化参数 | 必须调用 this.$init(),无需 return |
内部类引用 | OuterClass$InnerClass | Hook Builder、回调等内部类 | 用 $ 不用 .,匿名类编号不稳定 |
字段读写 | .value | 读取/修改对象运行时状态 | final 基本类型可能被内联,注意修改时机 |
堆对象搜索 | Java.choose() | 主动查找和操作存活对象 | 精确类型匹配,有性能开销 |
类/方法枚举 | enumerateLoadedClasses + 反射 | 建立代码地图,缩小分析范围 | 限制范围避免性能问题 |
全方法追踪 | 遍历 methods + 批量 Hook | 快速发现哪些方法被调用 | 只追踪单个类,勿大范围 |
反混淆 | 字符串 / 系统API / 签名 | 在混淆代码中定位目标 | 三种策略组合使用效果最佳 |
- Author:24th
- URL:https://24th.top/article/366e5b08-46db-80b4-86ec-c8aa207731a8
- Copyright:All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!








