Lazy loaded image
Frida学习笔记(四):Hook 进阶 · 构造、内部类、字段、对象搜索
Words 10158Read Time 26 min
2026-5-20
2026-5-20
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 的关键管理类(如 UserManagerConfigManager)采用单例模式,整个 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 语句。
$init vs $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。这就引出了下一个话题——内部类的处理。
notion image
构造函数 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 混淆为 abc,你在 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)中搜索指定类型的所有存活实例,让你可以直接操作它们。
notion image
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 中所有类的清单。通过观察类名中的关键字(LoginEncryptApiSignTokenConfig),你可以快速定位到与你分析目标相关的类。
同步版本Java.enumerateLoadedClassesSync() 返回一个包含所有类名的数组,适合需要后处理的场景:

5.2 枚举指定类的所有方法和字段

当你确定了目标类后,下一步是了解这个类有哪些方法和字段。这相当于用 Frida 做了一次「运行时的 jadx」——而且这里看到的是混淆后的真实名字,比 jadx 有时显示的还原名字更可靠:

5.3 关键字搜索:在所有类中定位目标

当你只知道一个模糊的关键字(比如「这个 App 用了某种加密,但不知道在哪个类里」),可以用关键字在所有已加载的类名和方法名中搜索:
优化搜索性能:在大型 App 中(上万个类),上面的脚本可能需要几十秒。优化方法:
  1. 限制搜索范围——只搜索 App 包名前缀下的类:if (!className.startsWith("com.example")) return;
  1. 只搜类名不搜方法名——去掉方法反射部分,通常类名匹配就足够缩小范围
  1. 使用 enumerateLoadedClassesSync() 获取数组后在 JS 中过滤,减少回调开销

六、追踪一个类的所有方法调用

有时候你知道关键逻辑在某个类里,但不确定是哪个方法。这时可以一次性 Hook 这个类的所有方法,看看哪些方法在你执行特定操作时被调用了。

6.1 自动化追踪脚本

启动这个脚本后,在 App 中执行你关心的操作(比如登录),控制台就会打印出 EncryptUtils 中被调用的每一个方法、传入的参数和返回值。这比一个一个方法去 Hook 效率高得多。

6.2 性能警告与最佳实践

追踪一个类的所有方法是可以的(通常一个类只有几十个方法),但不要尝试追踪一个包下的所有类的所有方法。比如 Java.enumerateLoadedClasses 加上对每个类的所有方法做 Hook,在一个有几百个类、几千个方法的 App 上,不仅 Hook 过程需要几分钟,而且 Hook 生效后每次方法调用的额外开销叠加起来会让 App 变得极其卡顿,甚至 ANR(Application Not Responding)。
最佳实践——渐进式缩小范围
  1. 先用关键字搜索(5.3 节)找到几个可疑的类
  1. 对其中一个最可疑的类启用全方法追踪
  1. 在 App 中执行目标操作,观察哪些方法被触发
  1. 根据方法名和参数进一步缩小到具体方法
  1. 对确定的方法写精确的 Hook 脚本进行深入分析
这个「从面到点」的过程通常只需要 2-3 轮迭代就能定位到目标。

七、应对代码混淆的实战策略

真实的 App 几乎都会使用 ProGuard 或 R8 做代码混淆。混淆会把类名、方法名、字段名重命名为 abc 之类的短名字。面对一片 a.b.c.d.e() 的代码,如何定位目标?

7.1 混淆不会改变的东西

理解混淆的边界是制定策略的基础。混淆工具会改变类名、方法名、字段名,但以下内容不会被改变
不变的内容
原因
利用方式
字符串常量
运行时需要使用原始字符串值
搜索 "AES/CBC/...", URL, Key 等
Framework API
外部依赖不能重命名
Hook CipherSharedPreferences 等
参数/返回类型
跨模块调用需要类型兼容
按签名特征搜索候选方法
接口方法名
接口契约不能破坏
onClickonResponse 等保持原名
JNI 方法名
Native 层通过名称查找
native 方法通常保持原名
额外的不变量:Android 四大组件(Activity、Service、BroadcastReceiver、ContentProvider)的类名也不会被混淆,因为它们需要在 AndroidManifest.xml 中声明,Manifest 中的引用和代码中的类名必须一致。
notion image
应对代码混淆:三种定位策略

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 / 签名
在混淆代码中定位目标
三种策略组合使用效果最佳
上一篇
爬虫数据采集加密算法全景指南
下一篇
爬虫HTTP请求不用愁?揭秘爬虫框架的异步请求装饰器模块

Comments
Loading...