8、Frida进阶技巧:方法重载Hook、构造函数Hook、动态类加载与内存搜索
好,我们继续往下走。上一章我们把Frida的基础API过了一遍,能hook普通方法了。但说实话,真实App里的情况要复杂得多。你想想看,一个方法可能有多个重载,对象还没创建你怎么hook?类被动态加载了怎么办?还有,有时候你连方法名都不知道,只想在内存里搜某个特征值——这些才是实战中真正要啃的硬骨头。
我个人习惯把这一章叫做“Frida的进阶四板斧”。这四招用熟了,大部分加固和混淆的App你都能找到突破口。咱们一个一个来拆。
8.1 方法重载Hook:同一个方法名,多个签名
Java里方法重载太常见了。同一个方法名,参数类型不同,返回值不同。Frida默认情况下,如果你只写一个hook,它只会hook最后一个实现。这显然不是我们想要的。
举个例子,假设有个类叫com.example.CipherBox,里面有个encrypt方法,有三个重载:
public String encrypt(String data);
public byte[] encrypt(byte[] data);
public String encrypt(String data, String key);
你想三个都hook住,怎么办?
Frida提供了overload方法,专门解决这个问题。看代码:
Java.perform(function() {
var CipherBox = Java.use('com.example.CipherBox');
// Hook 第一个重载:String encrypt(String)
CipherBox.encrypt.overload('java.lang.String').implementation = function(data) {
console.log('[+] encrypt(String) called, data: ' + data);
var result = this.encrypt(data);
console.log('[+] result: ' + result);
return result;
};
// Hook 第二个重载:byte[] encrypt(byte[])
CipherBox.encrypt.overload('[B').implementation = function(data) {
console.log('[+] encrypt(byte[]) called, length: ' + data.length);
var result = this.encrypt(data);
return result;
};
// Hook 第三个重载:String encrypt(String, String)
CipherBox.encrypt.overload('java.lang.String', 'java.lang.String').implementation = function(data, key) {
console.log('[+] encrypt(String, String) called, data: ' + data + ', key: ' + key);
var result = this.encrypt(data, key);
return result;
};
});
这里有个坑,我刚开始用的时候踩过。参数类型必须写全限定名,比如java.lang.String,不能只写String。基本类型也有对应写法:int就是int,byte[]就是[B,int[]就是[I。记不住?没关系,Frida有个小技巧:
CipherBox.encrypt.overload('int'),它会报错并列出所有合法的签名。
8.2 构造函数Hook:对象还没出生,我就盯上它了
构造函数跟普通方法不一样。对象还没创建,你怎么hook?其实Frida的思路很简单——构造函数在Java里叫$init。对,就是美元符号加init。
我记得有一次分析一个金融App,它的加密密钥是在构造函数里初始化的。如果不hook构造函数,等对象创建完了,密钥已经生成好了,你根本拿不到原始种子。这时候就必须在$init里动手。
看例子:
Java.perform(function() {
var MyClass = Java.use('com.example.MyClass');
// Hook 无参构造函数
MyClass.$init.overload().implementation = function() {
console.log('[+] MyClass() constructor called');
this.$init(); // 必须调用原构造函数
console.log('[+] Object created, hash: ' + this.hashCode());
};
// Hook 带参构造函数
MyClass.$init.overload('java.lang.String', 'int').implementation = function(str, num) {
console.log('[+] MyClass(String, int) called, str: ' + str + ', num: ' + num);
this.$init(str, num);
// 这里可以修改参数
// this.$init('hacked', 999);
};
});
this.$init()必须调用,否则对象构造不完整,后面调用任何方法都会崩溃。我曾经有一次忘了调,排查了半天才发现是构造函数没走完。
还有一个细节:如果你想在构造函数里修改成员变量,必须在this.$init()之后操作,因为之前对象还没初始化完成。
8.3 动态类加载Hook:App偷偷加载的类,一个也别放过
很多加固方案会使用动态类加载。什么意思?就是App不把核心类写在APK的dex里,而是运行时从网络下载或者解密出一个dex,然后用DexClassLoader或者PathClassLoader加载。你如果只hook静态的类,根本找不到它们。
我遇到过最夸张的一个案例,App把核心逻辑分成了7个dex文件,每个dex只在特定场景下加载。你如果不知道动态加载的时机,连类名都看不到。
解决方案有两个:
- Hook ClassLoader的loadClass方法:拦截所有类加载请求
- 枚举已加载的类:用
Java.enumerateLoadedClasses列出所有类
先看第一种,拦截类加载:
Java.perform(function() {
var ClassLoader = Java.use('java.lang.ClassLoader');
ClassLoader.loadClass.overload('java.lang.String').implementation = function(className) {
console.log('[+] Class loaded: ' + className);
var result = this.loadClass(className);
// 如果发现感兴趣的类,可以立即hook
if (className.indexOf('com.example.core') !== -1) {
console.log('[!] Found target class: ' + className);
// 这里可以延迟hook,等类加载完再操作
Java.scheduleOnMainThread(function() {
var targetClass = Java.use(className);
// 执行hook操作...
});
}
return result;
};
});
第二种,枚举已加载的类:
Java.perform(function() {
Java.enumerateLoadedClasses({
onMatch: function(className) {
if (className.indexOf('com.example') !== -1) {
console.log('[+] Found: ' + className);
}
},
onComplete: function() {
console.log('[+] Enumeration complete');
}
});
});
enumerateLoadedClasses看看当前有哪些类,再hookloadClass监控后续加载的类。双管齐下,动态加载的类一个都跑不掉。
8.4 内存搜索:大海捞针,但我知道针长什么样
有时候你连类名都不知道,只知道某个关键数据在内存里。比如一个加密后的字符串,或者一个特定的魔数。这时候就需要内存搜索了。
Frida提供了Memory.scan和Memory.scanSync两个API。前者是异步的,后者是同步的。我一般用同步的,简单直接。
看个例子,搜索内存中的特定字符串:
Java.perform(function() {
// 搜索整个进程空间
var pattern = '48 65 6C 6C 6F'; // "Hello"的十六进制
var matches = Memory.scanSync(Process.getRange('heap'), pattern);
matches.forEach(function(match) {
console.log('[+] Found at: ' + match.address);
console.log('[+] Size: ' + match.size);
// 可以读取附近的内存
var nearby = Memory.readByteArray(match.address.sub(16), 64);
console.log(hexdump(nearby, { offset: 0, length: 64, header: true, ansi: true }));
});
});
这里有个关键点——Process.getRange。你可以指定搜索范围:
Process.getRange('heap'):堆内存Process.getRange('code'):代码段Process.getRange('anon'):匿名映射- 也可以自己指定起始地址和大小
我曾经在分析一个混淆得很厉害的App时,就是用内存搜索找到了它的解密函数地址。那个App把所有字符串都加密了,但解密后的明文会短暂出现在堆上。我搜了一个已知的字符串特征,顺藤摸瓜找到了解密逻辑。
?表示任意字节,比如48 65 ?? 6C。
8.5 综合实战:一个完整的Hook脚本
把上面四招串起来,写一个完整的脚本。假设我们要分析一个使用了动态加载和重载的App:
Java.perform(function() {
console.log('[+] Frida advanced script loaded');
// 1. 枚举已加载的类
Java.enumerateLoadedClasses({
onMatch: function(className) {
if (className.indexOf('com.target') !== -1) {
console.log('[+] Found target class: ' + className);
hookTargetClass(className);
}
},
onComplete: function() {}
});
// 2. 监控动态类加载
var ClassLoader = Java.use('java.lang.ClassLoader');
ClassLoader.loadClass.overload('java.lang.String').implementation = function(className) {
var result = this.loadClass(className);
if (className.indexOf('com.target') !== -1) {
console.log('[!] Dynamic load detected: ' + className);
Java.scheduleOnMainThread(function() {
hookTargetClass(className);
});
}
return result;
};
// 3. 内存搜索关键数据
var pattern = 'DE AD BE EF'; // 假设的魔数
var matches = Memory.scanSync(Process.getRange('heap'), pattern);
if (matches.length > 0) {
console.log('[!] Found magic bytes in heap');
matches.forEach(function(m) {
console.log(' Address: ' + m.address);
});
}
});
function hookTargetClass(className) {
try {
var TargetClass = Java.use(className);
// Hook 构造函数
TargetClass.$init.overload().implementation = function() {
console.log('[+] ' + className + '() constructor');
this.$init();
};
// Hook 重载方法
TargetClass.process.overload('java.lang.String').implementation = function(data) {
console.log('[+] process(String): ' + data);
return this.process(data);
};
TargetClass.process.overload('[B').implementation = function(data) {
console.log('[+] process(byte[]), length: ' + data.length);
return this.process(data);
};
console.log('[+] Hooked: ' + className);
} catch (e) {
console.log('[-] Failed to hook ' + className + ': ' + e);
}
}
这个脚本基本覆盖了大部分实战场景。你拿它去分析一个加固App,至少能打开局面。
8.6 避坑指南
最后,分享几个我踩过的坑:
- 重载签名写错:参数类型必须用全限定名,基本类型用特殊标记。拿不准就故意写错,让Frida告诉你正确答案。
- 构造函数里忘了调$init:这会导致对象构造不完整,后续所有方法调用都崩溃。记住,
this.$init()必须在最前面调用。 - 动态加载的类hook时机不对:类刚加载完可能还没准备好,最好用
Java.scheduleOnMainThread延迟一下。 - 内存搜索范围太大:全进程搜索会很慢,尽量缩小范围。先搜堆,不行再搜匿名映射。
- 忘记处理异常:hook动态加载的类时,类可能还没完全初始化。用try-catch包一下,避免脚本崩溃。
嗯,这四板斧讲完了。你把这些练熟,大部分App的逆向分析你都能上手。下一章我们聊聊更高级的话题——如何对抗反调试和完整性校验。不过那是后话了,先把今天的内容消化掉。
公众号:蓝海资料掘金营,微信deep3321