辨析frida中.class/$className/getClass()- JADX容易误解之处-Via修改资源嗅探白名单
缘起
前几天搜索flutter逆向的时候无意之中发现了https://www.52pojie.cn/thread-2047991-1-1.html,
想着flutter这个硬骨头啃不动,找个软柿子捏捏也好,然后就开始分析,一开始非常顺利,打印了一下调用堆栈直接定位到了判断位置,之后就见鬼了,hook了一定被调用的方法但是没有任何输出,吓死了🤯。。。弄了好久,绕了一大个圈子才明白。。。
文章AI味道有点浓,因为我在这方面的知识实在是少得可怜,只能求助于AI,各位见谅。
字符串定位
我们对目标网站哔哩哔哩进行资源嗅探的时候提示”该站点不支持资源嗅探”
一、 什么是 resources.arsc?
resources.arsc 是 Android 应用程序包(APK)在编译过程中,由 AAPT/AAPT2(Android Asset Packaging Tool) 生成的编译型二进制资源索引表文件(Compiled Resource Table)。
在 Android 开发中,非代码资源(如字符串、颜色、尺寸、以及图片和布局的路径)在编译期会被赋予一个唯一的 int 类型资源 ID(形如 0x7fxxxxxx)。
resources.arsc 本质上就是一个多维的关系型查找表,建立了“资源 ID”到“具体资源内容或资源路径”之间的映射关系。
二、 resources.arsc 的核心作用与机制
1. 资源高效索引与内存优化
在应用运行时,若直接解析原始的 XML 文本文件会消耗大量的 CPU 算力和内存空间。AAPT2 通过将这些资源预编译并结构化地存储在 resources.arsc 中,使得 Android 系统的 AssetManager 可以通过二进制偏移量(Offset)直接定位资源。这种“常量池”的设计极大提升了资源的读取性能,并降低了内存开销。
2. 运行时配置适配(Runtime Configuration Resolution)
Android 支持多语言、多屏幕密度(dpi)、多横竖屏状态的动态适配。resources.arsc 内部根据不同的配置维度对资源进行了分组(如 values-zh-rCN、values-en)。当应用运行时,系统会根据当前的 Configuration(如系统语言、屏幕密度),在 resources.arsc 中利用最佳匹配算法,动态筛选并加载最符合当前设备环境的资源。
3. 非 Value 类资源的路径映射
对于图片(Drawable/Mipmap)和布局(Layout)等复杂资源,resources.arsc 并不存储其实际的二进制数据,而是存储这些文件在 APK 内部的相对路径指针(res/layout/activity_main.xml)。代码调用findViewById(R.id.view),代码传入R.layout.activity_main 的 ID ⇒ 在 resources.arsc 中查到路径 res/layout/activity_main.xml ⇒通过 AssetManager 读取对应的二进制 XML 并进行 Inflate(解析膨胀)。
我们看到的字符串大概率会出现在resources中,我们在JADX中进行搜索
确实存在,我们进行跳转
发现被这个字符串的唯一标识符是”yx”,一般开发者会通过R.string.yx 来引用这个字符串资源。我们直接在资源中搜索这个唯一标识符
我们没有发现直接的引用,但是我们发现了系统的引用方式,在android编译阶段,为了减小系统的开销,有些时候未必会使用name来进行查找,而是直接在编译阶段就将偏移作为数字直接写入程序
我们在代码里写R.string.yx,编译后在 class 字节码里其实就变成了0x7f0f03b8。当程序运行到这里时,系统拿着0x7f0f03b8去我们之前学过的resources.arsc(资源索引表)里面一查,发现这个 ID 对应的真实文本是"该站点不支持资源嗅探",于是啪地一下显示在屏幕上!
我们直接搜索这个偏移
发现代码中存在这个定义,但是对yx查询引用发现0处引用
这时候我们猜测应该是动态拼接而来,比如说是Resources**.***getString() /`Resources.getIdentifier()`*
当然了,我们比较懒,不想一探究竟了,反正最后都是要经过android系统提供的Resource.getxxxx来获取字符串的,我们直接hook这俩方法即可,如果传入的id或者name是我们的目标,那么直接打印出调用堆栈即可
java.lang.Exception
at android.content.res.Resources.getString(Native Method)
at android.content.Context.getString(Context.java:784)
at e8.ra.t1(r8-map-id-4409be8d4681378c329ef0157d46a3e130c9d2663eafa6818f26cb4a4d7b24ac:29)
at e8.r6.x9(r8-map-id-4409be8d4681378c329ef0157d46a3e130c9d2663eafa6818f26cb4a4d7b24ac:337)
at e8.r6.O5(r8-map-id-4409be8d4681378c329ef0157d46a3e130c9d2663eafa6818f26cb4a4d7b24ac:31)
at e8.f4.a(r8-map-id-4409be8d4681378c329ef0157d46a3e130c9d2663eafa6818f26cb4a4d7b24ac:3)
at androidx.fragment.app.FragmentManager$l.a(r8-map-id-4409be8d4681378c329ef0157d46a3e130c9d2663eafa6818f26cb4a4d7b24ac:3)
at androidx.fragment.app.FragmentManager.x1(r8-map-id-4409be8d4681378c329ef0157d46a3e130c9d2663eafa6818f26cb4a4d7b24ac:19)
at k8.j.y3(r8-map-id-4409be8d4681378c329ef0157d46a3e130c9d2663eafa6818f26cb4a4d7b24ac:26)
at k8.j.D1(r8-map-id-4409be8d4681378c329ef0157d46a3e130c9d2663eafa6818f26cb4a4d7b24ac:1)
at androidx.fragment.app.Fragment.g2(r8-map-id-4409be8d4681378c329ef0157d46a3e130c9d2663eafa6818f26cb4a4d7b24ac:20)
at androidx.fragment.app.j0.g(r8-map-id-4409be8d4681378c329ef0157d46a3e130c9d2663eafa6818f26cb4a4d7b24ac:170)
at androidx.fragment.app.j0.m(r8-map-id-4409be8d4681378c329ef0157d46a3e130c9d2663eafa6818f26cb4a4d7b24ac:288)
at androidx.fragment.app.FragmentManager.i0(r8-map-id-4409be8d4681378c329ef0157d46a3e130c9d2663eafa6818f26cb4a4d7b24ac:350)
at androidx.fragment.app.FragmentManager.p1(r8-map-id-4409be8d4681378c329ef0157d46a3e130c9d2663eafa6818f26cb4a4d7b24ac:92)
at androidx.fragment.app.FragmentManager.f0(r8-map-id-4409be8d4681378c329ef0157d46a3e130c9d2663eafa6818f26cb4a4d7b24ac:22)
at androidx.fragment.app.FragmentManager$f.run(r8-map-id-4409be8d4681378c329ef0157d46a3e130c9d2663eafa6818f26cb4a4d7b24ac:4)
at android.os.Handler.handleCallback(Handler.java:942)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loopOnce(Looper.java:211)
at android.os.Looper.loop(Looper.java:300)
at com.tuyafeng.support.crash.a$a.run(r8-map-id-4409be8d4681378c329ef0157d46a3e130c9d2663eafa6818f26cb4a4d7b24ac:1)
at android.os.Handler.handleCallback(Handler.java:942)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loopOnce(Looper.java:211)
at android.os.Looper.loop(Looper.java:300)
at android.app.ActivityThread.main(ActivityThread.java:8503)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:561)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:954)
嗯,确实是输出了,排除开头俩调用,我们观察e8.ra.t1
唉,代码都被混淆了,看着是真难受
看着还是挺像那么回事儿的
哦对了,这里插一嘴,我们在调用堆栈中看到的这俩突然出现的android调用,底部弹出的窗口的函数,我们可以以这个为分界线,分界线下的代码我们其实不怎么需要关注
说回来,我们应该分析一下这里的getUrl函数,看看是不是获取了我们当前页面的网址
要是是的话,那么大概率就是在这里进行判定了
java层定位
smali代码分析
太他妈神奇了,我们使用frida hook居然也会翻车,smali代码太有用了!!!
我们直接分析smali代码
.method public t1()V
.registers 7
.line 1
invoke-virtual {p0}, Le8/ra;->K0()Lr4/a;
.line 2
.line 3
.line 4
move-result-object v0
.line 5
if-eqz v0, :cond_24
.line 6
.line 7
invoke-static {}, Lt9/g;->a()Lt9/e;
.line 8
.line 9
.line 10
move-result-object v1
.line 11
invoke-interface {v0}, Lr4/a;->getUrl()Ljava/lang/String;
我们先分析简单一点的
首先开头的.method public t1()V
这里是声明一个方法,访问修饰符是public,公开方法,然后t1是方法名,()表示无入参,V表示返回值类型是void
.registers 7
声明本方法一共使用了7个寄存器
这里插一嘴,寄存器分两类:
- 参数寄存器 pxx:方法入参占用
非静态方法 public t1() 隐含参数 this → p0
所以这里 p0 占 1 个寄存器
- 本地临时寄存器 vxx:v0、v1、v2… 供代码临时存数据
.registers 7 = 总寄存器数量 = 参数寄存器 + 本地寄存器 p0 (1) + v0~v5 (6) = 7 个
.line x
调试信息标记,对应 Java 源码第 x 行,不影响程序执行,只给调试器映射源码行号。但是实际不可信,对我们分析来说用处不大。
invoke-virtual {p0}, Le8/ra;->K0()Lr4/a;
invoke-virtual 调用普通实例虚方法,所谓虚方法简而言之就是非静态、非接口、非私有,那么为啥要叫做虚方法呢?因为java支持多态,要调用一个实例的方法在静态编译的时候永远无法确定,只有等到代码开始运行才能确定,故而称之为虚方法。一言以蔽之中,虚方法=运行时动态绑定。
invoke-virtual:普通类实例虚方法(你这条)
invoke-direct:构造方法、private 私有方法
invoke-static:static 静态方法
invoke-interface:接口方法
invoke-super:调用父类同名方法
JVM 每个类存在方法表:
- 虚方法会在运行时根据实例找到对应类的方法实现(动态绑定)
- final、static 编译期直接确定调用哪个方法(静态绑定,性能略高)
{p0}代表本次方法调用,只传 p0 这一个寄存器作为入参。对于实例方法,第一个参数永远是当前对象 this。
invoke-xxx {寄存器1,寄存器2,...}, 方法签名
大括号里所有寄存器,按从左到右顺序依次对应方法形参。
但是这里我们要注意,因为调用的是实例方法,默认存在一个this对象存放在p0中,所以这里虽然显示入参{p0},但是实际上在java中就是this.K0() 。
Le8/ra;->K0()Lr4/a;
这一整串就是ART标准方法签名,最开头的Le8中的L表示这个不是java的基础类型,而是一个引用类型,这种标记方法在底层都是通用的,我们之前在unidbg也见到过。
e8/ra 类的完整路径,Smali 里用/代替 Java 的.包分隔符:/= Java 中的.
e8/ra翻译成 Java 全限定类名:e8.ra
;结束标识符,代表这个类描述到此截止,不能省略。字节码解析器靠分号识别一个完整类类型。
K0表示调用的方法名,()表示无入参,Lr4/a;表示返回值类型是r4.a
但是这样又引出了一个更深层次的问题
其次这里我们还能够窥探到if判断在底层真正的执行过程
我们看到这里move-result-object v0这句话,说明此时已经完成了r4.a aVarK0 = K0();
然后接下来判断是否等于null,这里直接执行的是短路逻辑,如果是null,那么直接就跳转到cond_24,这里的cond就是condition的缩写,完全等同于C中的goto&label
我们看smali代码中显示和0在进行比较,但是java中显示的却是和null在进行比较,这是因为Dalvik/ART 虚拟机底层统一用 0 代表空引用。在 Android 字节码里:对象引用本质就是一个内存地址(数字),而空对象 null = 地址值 0。
所以我们再次观察代码,我们hook getUrl这个方法没有任何输出说明没有被调用,按照常理来说,因为这里是短路执行那么getUrl没被调用一定是因为前面的对象值为null ,但是我们使用frida hook K0,又是存在返回值且正常输出的,那么这是因为什么呢?是啊,是因为什么呢???
我们现在有一个大胆的猜测,K0确实是执行了,也正常返回了,那么≠null的结果是true,短路并没有被执行,而是正常调用了getUrl这个方法,但是!但是!但是,K0返回的对象并不存在一个getUrl方法,代码在这里报错了,所以frida压根儿就无法hook到getUrl,而java在这个时候抛出异常,被上层捕获并处理,App正常运行。
我们可以hook Java.use("java.lang.Throwable");来验证一下
。。。验证了一下,发现没有任何输出,那么我们的推论有问题,并没有出触发异常退出
但是我们发现一个很神奇的现象,我们在jadx中看到的
但是我们通过frida直接hook得到的类的名称确是
这就有点问题了。。。
frida返回类型javascript wrapper辨析
此时我们询问AI,为什么给出的代码中出现了
var result = K0();
var obj = Java.cast(result, Java.use("java.lang.Object")).getClass().getName();
Q:我本来hook的就是java层的对象,为什么这里firda还需要进行一次强制转换???直接拿来使用不就行了吗???
A:你这里混淆了frida QuickJS脚本引擎和ART虚拟机之间的关系,当JS引擎开始执行hook代码的时候,frida眼中并不存在java对象的概念,frida能够看到的就是一个裸指针,这个指针指向一个???在内存中的起始地址而JS原生并不存在.getClass()、getName()这些方法。注意,这里并不是说result就是一个裸指针,这里的result返回的就是一个Js wrapper对象。这里重点是强调frida中并不存在java对象以及java中的方法,需要包装成JS wrapper对象才行
Q:那么Java.use拿到的是什么???
let ObjectCls = Java.use("java.lang.Object");
A:答案是类的包装器,warpper
ObjectCls不是 Java 里的 Object.class,而是Frida 自制的 JS 代理壳
壳内部预加载了这个类全部:实例方法、静态方法、字段、方法重载签名。只有拿到这个壳,Frida 才知道:getClass()方法对应的JNI MethodID是多少,才能完成跨层调用。
也就是说frida也是需要通过JNI来操作,可能有人会问,JNI不是java层用于和native层沟通的工具吗?怎么能够和frida进行沟通呢?这里就存在两个误区,第一个误区,JNI不是单向的,native层可以通过JNI调用java层并获取到需要的数据而java层也可以通过JNI调用native层完成某些计算。
第二则是frida并不是独立于java和native层的,Frida的调试能力本质上就是依靠frida-agent.so这个so库得到的,所以frida其实位于native层。而Java.cast唯一的作用就是将裸句柄套上JS代理壳,将一个地址包装成执行的代理对象。
那么这时候又有人要问了,你说你拿到的是内存中的裸句柄,那你this.K0不是用的挺顺溜吗???上手直接调用K0方法,也没见你报错啊???
这是因为this对象比较特殊,已经被frida包装成了Java.Wrapper类型的对象,所以具备这个实例该有的所有方法。
这里其实很好理解,在某些情况下frida只能拿到一个内存地址,也就是裸句柄。就是一个地址,这个地址代表的是什么也不知道,有效地址的范围结尾也不知道,什么都不知道,这时候我们要是想使用就需要通过Java.cast对其进行包装,将其指定为某个类,如果我们不知道具体对应的哪个类,我们可以直接指定为java.lang.Object类,如果我们知道,我们可以精确指定。同时如果一个原本是r4.a类的实例在frida中被我通过cast指定为Object类实例,但是接下来我调用r4.a类的getUrl方法,那么并不会报错,因为Frida通过JNI调用Java虚拟机的时候,java虚拟机能够正确识别这个指针指向的内存的数据结构,能够动态查找到对应的方法。
我们可以来看一下Java.cast的注解,第一个参数接受的类型确实是可以是Native Pointer或者一个JS wrapper。
在 Java 的执行机制里,除了static、private和构造方法之外,所有普通的方法,默认都是虚方法(Virtual Method)。虚方法的调用遵循动态绑定原则。当 Frida 通过 JNI 的CallObjectMethod传进这个指针时,虚拟机底层不会看你在 JS 里声明它是啥,而是直接去执行。根据对象真实的类,去查它的虚方法表(vtable)这一套标准流程。只要r4.a的实现类确实在表里登记了getUrl的函数指针,调用就会一路绿灯
Q:为什么调用this.K0返回的不是裸句柄/NativePointer,而是一个JS wrapper?
A:在 RA.K0.implementation 内部的 this:
- 是
e8.ra 类绑定好的实例包装器;
- 内部自带该类完整方法元数据、JNI MethodID;
- 调用
this.K0() 是 Frida 封装的跨 JNI 调用逻辑。
而调用 Java 实例方法的返回值规则
Frida-Java-Bridge 有固定转换逻辑:
- ART 返回一个
jobject 本地引用(纯数字句柄,裸指针);
- Frida 自动根据返回值真实类,生成对应 JS Wrapper 包装对象;
- 把
jobject 句柄藏在内部 $handle / $h 属性,再挂载内置 $className、$class 等 $ 系列属性;
- 最终赋值给
result。
但是有一点我们要注意,有代码
import Java from "frida-java-bridge";
export function dumpObj(obj: null | Java.Wrapper) {
if (!obj) {
console.log("[-] null object");
return;
}
try {
// var cls = Java.cast(obj, Java.use("java.lang.Object")).getClass();
console.log("Class => " + obj.$className /*obj.getClass().getName()*/);
console.log(obj.class);
var fields = obj.class.getDeclaredFields(); // 需要注意这里的getDeclaredFields方法是类方法,不能对单个实例使用
for (var i = 0; i < fields.length; i++) {
try {
var f = fields[i];
f.setAccessible(true); // 核心:放开访问权限 private/protected 默认禁止读写,这行强制允许反射取值
var type = f.getType().getName();
var name = f.getName();
var value = f.get(obj);
console.log(type + " " + name + " = " + value);
} catch (e) {
console.log("[field error] " + e);
}
}
} catch (e) {
console.log("[dumpObj error] " + e);
}
}
export function dump(obj: any) {
if (!obj) {
console.log("[-] null object");
return;
}
try {
var cls = Java.cast(obj, Java.use("java.lang.Object")).getClass();
console.log("Class => " + cls.getName());
var fields = cls.getDeclaredFields();
for (var i = 0; i < fields.length; i++) {
try {
var f = fields[i];
f.setAccessible(true);
var name = f.getName();
var type = f.getType().getName();
var value = f.get(obj);
console.log(type + " " + name + " = " + value);
} catch (e) {
console.log("[field error] " + e);
}
}
} catch (e) {
console.log("[dump error] " + e);
}
}
乍看这两份代码的功能别无二致,但是如果碰到特殊情况,那么相差会非常的大
如果我们的传入的参数obj在java层是一个用接口定义的变量呢?
例如
我们发现返回来的实际上是一个实现了某个接口的类,但是此时如果我们直接使用frida包装好的.class语法,我们获取到的类名就是
raw result = [object Object]
Class => r4.d
interface r4.a
但是我们调用getClass().getName(),获取到的就是
raw result = [object Object]
Class => r4.d
int a = 1
android.content.Context b = mark.via.Shell@69c0250
java.util.List c = [t4.c@157fb59, t4.c@e1531e]
p4.a d = p4.b@e77b7ff
int e = 1
boolean f = true
android.os.Bundle g = null
java.lang.String h = null
java.lang.String i = null
long j = 1781617018775
这又是为什么呢???
在 Java 代码里,r4.a 实际上是一个接口(Interface),而真正返回并活在内存里的,是一个叫 r4.d 的具体实现类(Implementation Class)!
为什么后者能够正常打印?
因为 getClass() 强行向 Android 虚拟机(ART)申请去调取它的真身。虚拟机去堆内存里一看,这个指针指向的真实结构体是 r4.d 这个具体的类。所以它返回的 cls 代表的是 r4.d(实现类)的实体原型。既然是实体类,它肚子里长年累月存着的 int a、Context b、List c 等 11 个私有成员变量,自然就全部被暴力反射打印出来了!
为什么新函数 dumpObj 只憋出了一句 interface r4.a?!!!重点,看这里看这里
在 Frida 的设计中,当一个对象是通过具体的方法(比如你的 K0())隐式包装返回时,Frida 认为它的声明类型(也就是它的衣服)是接口 r4.a。也就是我们代码中声明的。当你在 JS 里直接点出 obj.class 时,Frida 默认返回的是这个包装体当前所捆绑的静态声明类型(也就是接口 r4.a 本身),而不是去内存里实时调取它的真实运行期类 r4.d!
所以你的 console.log(obj.class) 在控制台直接打印出了 interface r4.a。既然它拿到的只是一个干净的“接口原型”,而 Java 的接口(Interface)里面是绝对不可能定义任何成员变量的(只能定义常量和方法),那么你再去调用 getDeclaredFields(),拿到的列表长度自然就是 0。
这就是为什么 dumpObj 里面的 for 循环完全没有走,因为它错把“接口”当成了扫描对象,根本没有去扫描真正干活的 r4.d 实现类!
注意,使用$className的效果和getClass().getName()的效果是完全等价的,而.class只会输出静态声明时的数据类型。注意,.class返回的是一个JS Wrapper,而使用getClass得到的也是一个包装器
Q:$className等价于.getClass().getName()吗
A:从返回的【类名字符串】在含义上完全一致”这个角度来看,它们俩确实是等价的
但是从JS引擎中的【物理调用机制】和【报错】来看,它们俩完全不等价
一、 为什么说它们“含义等价”?(因为结果是一样的)
- 只要
obj.$className 跑成功了,它吐出来的字符串是 "r4.d"。
- 只要
obj.getClass().getName() 跑成功了,它吐出来的字符串也是 "r4.d"。
它们俩在揭穿混淆真身的业务目标上,最终达到的效果是 100% 相同的。它们都是铁面无私的真理,谁也没被接口的静态外壳(r4.a)给欺骗!
二、 为什么说它们“物理不等价”?
既然最终吐出的字条一模一样,那为什么面对同一个 obj,一个能顺利拿到,另一个直接报 TypeError: not a function 呢?(前情提要,这里调用$className能够正常输出类名,但是使用getClass()直接是报错的)
因为它们在 Frida 里面的物理存在形式,有着本质的不同:
1. obj.$className 是一张“死字条”
Frida 的 C++ 底层非常疼你,它在刚拿到这个对象、还没把它交给你的时候,就已经在后台悄悄去虚拟机里打听好了它的真名叫 "r4.d"。
然后,Frida 把这个名字直接当成一个死字符串(String),缝合在了 JS 对象的 $className 属性上。
// Frida 在底层直接给你写死了:
obj.$className = "r4.d";
当你在脚本里写 obj.$className 时,JS 引擎只是去内存里读取了一个普通的、已经存在的死属性。它不需要去找函数,不需要匹配签名。所以,它永远不可能报错,永远等在那里让你读!
2. obj.getClass() 是一条“需要当场现挖的地下通道”
而 getClass() 在 Java 里是一个活的方法。Frida 为了让你能调用它,必须在 JS 对象的表面动态生成一个同名的 JS 函数来当代理:
// Frida 必须在表面挂载这个函数:
obj.getClass = function(){ /* 跨界调用 JNI... */ };
翻车点来了! 现在 Hook 的这个对象,表面穿的衣服是接口 r4.a。
在 Frida 的设计中,当它给接口(Interface)生成 JS 包装手套时,由于接口本身没有声明 getClass(),Frida 在映射方法时偷懒了、产生缺陷了,导致它根本就没有成功在 obj 表面挂载上这个 getClass 函数!
此时,obj.getClass 的值在 JS 里其实是 undefined(未定义)。
你直接在后面加小括号去强行执行一个根本不存在的函数:
JavaScript
obj.getClass(); // ❌ 引擎大喊:“它根本不是一个函数,你加什么括号调用它?!”
于是,控制台就啪地一下,砸出了你看到的那个标准的 TypeError: not a function 崩溃报错!
所以我们把逻辑理顺:
- 数据含义上:
$className ≡ getClass().getName() → 拿到的都是真实类名文本 "r4.d"。
- 物理调用上:
$className(死属性,永不报错) = getClass()(活函数,因接口包装缺陷而挤丢了,导致报错)。
这就是为什么我们在写工业级高稳定性脚本时,为了拿到真实的类原型,高手们更喜欢绕过这个会翻车的 getClass() 函数,直接用那招反向大法:
let realCls = Java.use(obj.$className).class;
Q:为什么之前用 obj.class 拿到了接口,而现在用 Java.use(obj.$className).class 就能拿到真实的类?
A:
路线 A:之前的 obj.class(从变量开始查询 → 被接口拦截)
当写 obj.class 时,Frida 顺着当前这个活对象的 “方法返回值标签” 去找。因为方法签名上写着“这里会返回一个 r4.a 接口”,Frida 犯懒了,直接就把接口 r4.a 塞给了你。
路线 B:现在的 Java.use(obj.$className).class(拿真名直接查询)
- 第一步:你先调用
obj.$className。我们刚刚一再证实过,它是一个铁面无私的探长,吐出来的是纯粹的、死板的运行期真实类名字符串 —— "r4.d"。
- 第二步:你执行了
Java.use("r4.d")。这时候,你等于直接贴着 Android 虚拟机的耳朵大喊:“喂!把那个名字叫 r4.d 的类,在内存里的类布局给我加载出来!”
- 第三步:Frida 乖乖听令,在后台把
r4.d 实体类 载入了进来。接着你在这个基础上面点了 .class。
你想想看,此时 Java.use 抓过来的本体本来就是 r4.d 这个实体类了,那它吐出来的 .class 衣服,自然就是 r4.d 这条铁汉自己身上穿的实体类衣服(r4.d 原型),怎么可能凭空退化成接口 r4.a 呢?!
let realCls = Java.use(obj.$className).class;
这行代码之所以被称为工业级的终极外挂,就是因为它完成了最美妙的强强联合:
- 利用
$className 的诚实本能,绕过方法映射的冲突,强行拿到了最真实的实体类名字符串("r4.d")。
- 利用
Java.use().class 的纯净缓存路径,绕过了会报错的 getClass() 活函数,百分之百稳固地在 JS 层拿到了属于这个实体类的真实 Class 原型!
所以现在我们来总结一下
| 魔法符号 |
属于谁的魔法? |
底层获取的是什么? |
返回值类型 |
$className |
Frida 的专属属性 |
对象的类名字符串 |
String (JavaScript 字符串) |
.class |
Java 语法特性 / Frida 属性 |
JVM 堆中的类全名或类镜像 |
java.lang.Class 实例 |
.getClass() |
Java 原生对象方法 |
对象头(Object Header)里的类型指针 |
java.lang.Class<?> 实例 |
.getClass().getName() |
Java 原生方法链 |
类元数据中的名称字符串 |
java.lang.String |
为了彻底弄懂它们,我们要把目光投向 JVM(Java虚拟机)的内存世界。
当你在 Android 里 new 了一个对象,比如 MyWebViewClient obj = new MyWebViewClient();,在内存里其实发生了三件事:
- 方法区(Method Area):存放了
MyWebViewClient 类的结构信息(图纸)。
- 堆内存(Heap):存放了你创建的这个
obj 实体(真车)。而在 obj 的最前端,有一块区域叫对象头(Object Header),里面有一个类型指针(Class Pointer),死死地指向方法区里的图纸。
- Frida 进程:它是个局外人,通过注入技术在旁边看着这一切。
有了这个画面,我们再来看这四个家伙:
1️⃣ obj.$className
- 底层原理:这是 Frida 特意为 JavaScript 包装对象(Wrapper)量身定制的一个快捷属性。当 Frida 在内存中 Hook 到一个 Java 对象时,它内部其实已经缓存了这个对象的类名。当你调用
$className 时,Frida 直接从它自己的管理结构里把这个类名字符串吐给你,完全没有去惊动 Java 虚拟机的反射系统。
- 返回值:JavaScript 的
String。
- 特点:速度极快,专门在 Frida 脚本里用来做条件判断(比如
if (obj.$className === 'xxx'))。
2️⃣ Java.use("xxx").class 或 obj.class
- 底层原理:
- 在纯 Java 中:
MyWebViewClient.class 是一种编译期常量的语法糖。JVM 在加载类时,会在堆内存中专门创建一个 java.lang.Class 类型的对象,用来作为暴露给开发者的“图纸入口”。
- 在 Frida 中:当你对一个 Frida 包装类(比如
let Cls = Java.use('...'))使用 .class 时,Frida 会通过 JNI 接口去调用 Java 底层,拿到这个类在 JVM 堆里对应的那个 java.lang.Class 实例的句柄(也就是纯 Java 身份证)。
- 返回值:
java.lang.Class 的原生对象。
- 特点:它是静态的。哪怕你没有实例化“真车”,只要知道“型号”,就能拿到“图纸”。
3️⃣ obj.getClass()
- 底层原理:这是 Java 之中所有对象的始祖
java.lang.Object 自带的原生方法。当你在 Frida 里调用 obj.getClass() 时,实际上是触发了 JNI,让 JVM 去顺着这个 obj 对象头(Object Header)里的类型指针,一路摸到方法区,找到它诞生时的真正类信息,并返回对应的 Class 对象。
- 返回值:
java.lang.Class<?> 的原生对象。
- 特点:它是动态的,认死理。哪怕你把一个多态对象转型成了父类(比如
Object obj = new String();),obj.getClass() 拿到的依然是代表 String 的 Class 对象。
4️⃣ obj.getClass().getName()
- 底层原理:这是一个经典的 Java 方法链调用。
- 第一步:通过
getClass() 摸到对象头,拿到 JVM 里的 java.lang.Class 对象(图纸)。
- 第二步:调用
java.lang.Class 类的原生方法 getName(),让 JVM 从图纸的常量池(Constant Pool)里把该类的 全限定类名(如 java.lang.String) 以 Java 字符串的形式提取出来。
- 返回值:Java 原生的
java.lang.String(在 Frida 传回 JS 时会自动转换成 JS 字符串)。
- 特点:这是最纯正的 Java 反射写法。
相同点
- 终极目标一致:它们四个围绕的核心,都是为了让你知道或者利用“这个对象到底是什么类出来的”。
- 在普通类上结果相似:如果你面对的是一个普通的、没被魔改的类(比如一个普通的
java.lang.String),obj.$className 和 obj.getClass().getName() 打印出来的字符串内容是完全一模一样的。
不同点:返回值本质不同(Java 对象 vs 字符串)
$className 和 .getClass().getName() 返回的是 字符串。你不能对它们进行 Java 反射操作(比如你不能 $className.getMethods(),因为字符串没有这个方法)。
.class 和 .getClass() 返回的是 Java Class 对象。它们是尊贵的“图纸”,你可以继续调用 getMethods(), getInterfaces() 等 Java 反射 API。
这个返回的对象被声明为r4.a类的实例
但是实际上r4.a是一个接口,所以实际上K0返回的对象的类型是实现了r4.a这个接口的某个类的实例
我们通过.getClass().getName()获取到在运行时的对象的类,发现是r4.d
也就是说实际上这个对象的类型是r4.d实例,那么我们如果要调用getUrl(),对于ART来说,会根据实现接口的类动态派发,最后还是会回转到对应的类的getUrl方法。同理,使用frida主动调用这个方法,frida交给ART也会自动动态派发。但是当我们使用jadx中自动生成的功能的时候,我们就会被徐晃一枪,为什么?因为jadx中是静态分析,自动生成的代码是根据声明来的,所以生的的hook代码是
var a = Java.use("r4.a");
a["getUrl"].implementation = function () {
console.log(`a.getUrl is called`);
let result = this["getUrl"]();
console.log(`a.getUrl result=${result}`);
return result;
};
我们发现jadx生成的居然是对接口的hook,在代码执行的时候这个是肯定不会被调用的,所以也就没有任何输出,这就是为啥我们特么跟见鬼了一样,明明应该被执行但是hook却显示没有被调用。。。而当我们将r4.a修改为r4.d的时候,也就是hook这个实例对应的类中方法,就能够正常输出结果了
那么之后就很简单了,我们直接修改getUrl的返回值就完事儿了