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就是intbyte[]就是[Bint[]就是[I。记不住?没关系,Frida有个小技巧:

小技巧: 如果你不确定某个重载的签名,可以先故意写一个错误的overload,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);
    };
});
注意: 在构造函数hook里,this.$init()必须调用,否则对象构造不完整,后面调用任何方法都会崩溃。我曾经有一次忘了调,排查了半天才发现是构造函数没走完。

还有一个细节:如果你想在构造函数里修改成员变量,必须在this.$init()之后操作,因为之前对象还没初始化完成。

8.3 动态类加载Hook:App偷偷加载的类,一个也别放过

很多加固方案会使用动态类加载。什么意思?就是App不把核心类写在APK的dex里,而是运行时从网络下载或者解密出一个dex,然后用DexClassLoader或者PathClassLoader加载。你如果只hook静态的类,根本找不到它们。

我遇到过最夸张的一个案例,App把核心逻辑分成了7个dex文件,每个dex只在特定场景下加载。你如果不知道动态加载的时机,连类名都看不到。

解决方案有两个:

  1. Hook ClassLoader的loadClass方法:拦截所有类加载请求
  2. 枚举已加载的类:用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.scanMemory.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把所有字符串都加密了,但解密后的明文会短暂出现在堆上。我搜了一个已知的字符串特征,顺藤摸瓜找到了解密逻辑。

搜索技巧: 如果搜字符串,建议用部分匹配。比如搜"password"可能被混淆成"p@ssw0rd",你可以搜"p"+"a"+"s"的连续字节,或者直接用通配符。Frida的模式支持?表示任意字节,比如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