发布于 

Frida 综合情景案例分析

前面几篇教程我们搭建了 Frida 开发环境、掌握了构造数组和对象参数的技巧。本文将进入实战环节,通过 5 个典型案例 覆盖 Frida 逆向中最常见的场景:登录拦截、SSL Pinning 绕过、返回值篡改、密钥替换和 JNI Hook。每个案例都会给出可复用的完整脚本。

案例1:Hook 登录接口获取用户名密码

分析目标 APP 的登录流程

在逆向一个 APP 时,登录接口往往是第一个需要关注的点。用户输入的账号密码在提交到服务器之前,通常会经过本地加密或编码处理。我们的目标是在加密之前拦截明文参数。

大多数 Android APP 的登录流程如下:

用户输入 → UI 层收集 → 加密/签名处理 → 网络请求发送

定位关键类和方法

假设目标 APP 使用了 Retrofit + OkHttp 进行网络请求,登录接口位于 com.example.app.api.UserService 类的 login 方法中:

public class UserService {
    public Response login(String username, String password) {
        // 内部会对 password 做 MD5 加密后发送
        String encryptedPwd = MD5Util.encrypt(password);
        return apiService.login(username, encryptedPwd);
    }
}

编写 Frida 脚本拦截参数和返回值

// hook_login.js - Hook 登录接口获取明文用户名密码
Java.perform(function () {
    var UserService = Java.use("com.example.app.api.UserService");

    // Hook login 方法
    UserService.login.implementation = function (username, password) {
        console.log("[*] ========== 登录接口拦截 ==========");
        console.log("[+] 用户名: " + username);
        console.log("[+] 密码:   " + password);
        console.log("[+] 调用栈:");
        console.log(Java.use("android.util.Log").getStackTraceString(
            Java.use("java.lang.Exception").$new()
        ));

        // 调用原方法并拦截返回值
        var result = this.login(username, password);
        console.log("[+] 返回值: " + result.toString());

        // 如果想修改密码,可以替换参数
        // var result = this.login(username, "my_modified_password");

        return result;
    };
});

进阶技巧:如果登录方法使用了重载,可以通过 overload 指定具体签名:

UserService.login.overload('java.lang.String', 'java.lang.String')
    .implementation = function (username, password) {
    // ...
};

如果类名不确定,可以先枚举已加载的类进行模糊搜索:

Java.enumerateLoadedClasses({
    onMatch: function (className) {
        if (className.indexOf("Login") !== -1 ||
            className.indexOf("login") !== -1) {
            console.log("[*] 发现可疑类: " + className);
        }
    },
    onComplete: function () {
        console.log("[*] 枚举完成");
    }
});

案例2:绕过 SSL Pinning 实现抓包

SSL Pinning 原理简介

SSL Pinning(证书绑定)是一种安全机制,APP 在建立 HTTPS 连接时会校验服务器证书是否与本地内置的证书指纹匹配。即使你安装了 Charles/mitmproxy 的 CA 证书,APP 也会因为指纹不匹配而拒绝连接,导致抓包失败。

常见实现方式有两种:

  1. TrustManager 校验:自定义 X509TrustManager,在 checkServerTrusted 中比对证书
  2. OkHttp CertificatePinner:通过 OkHttp 的 certificatePinner 设置 hostname 与证书指纹的映射

Hook TrustManager 和 OkHttp 的 certificatePinner

// hook_ssl_pinning.js - 通用 SSL Pinning 绕过脚本
Java.perform(function () {

    // ====== 方案1:Hook TrustManager ======
    console.log("[*] 开始 Hook TrustManager...");

    var TrustManagerBuilder = Java.use("javax.net.ssl.TrustManagerBuilder");
    try {
        TrustManagerBuilder.checkServerTrusted.overload(
            '[Ljava.security.cert.X509Certificate;', 'java.lang.String'
        ).implementation = function (chain, authType) {
            console.log("[+] TrustManager.checkServerTrusted 被调用,已绕过");
        };
    } catch (e) {
        console.log("[-] TrustManagerBuilder 不存在,尝试其他方式");
    }

    // Hook X509TrustManager 的所有实现
    var X509TrustManager = Java.use("javax.net.ssl.X509TrustManager");
    var SSLContext = Java.use("javax.net.ssl.SSLContext");

    // 创建一个"信任一切"的 TrustManager
    var TrustManager = Java.registerClass({
        name: "com.frida.TrustAllManager",
        implements: [X509TrustManager],
        methods: {
            checkClientTrusted: function (chain, authType) { },
            checkServerTrusted: function (chain, authType) {
                console.log("[+] checkServerTrusted 已绕过, authType=" + authType);
            },
            getAcceptedIssuers: function () {
                return [];
            }
        }
    });

    // 替换所有 SSLContext 中的 TrustManager
    var trustManagers = [TrustManager.$new()];
    SSLContext.getInstance("TLS").init(null, trustManagers, null);
    console.log("[+] SSLContext TrustManager 替换完成");

    // ====== 方案2:Hook OkHttp CertificatePinner ======
    console.log("[*] 开始 Hook OkHttp CertificatePinner...");

    try {
        var CertificatePinner = Java.use(
            "okhttp3.CertificatePinner"
        );

        CertificatePinner.check.overload(
            'java.lang.String', 'java.util.List'
        ).implementation = function (hostname, peerCertificates) {
            console.log("[+] CertificatePinner.check 被调用");
            console.log("    hostname: " + hostname);
            console.log("[+] 已绕过 SSL Pinning 校验");
            // 不调用原方法,直接返回,跳过校验
        };

        // Hook 较新版本的 OkHttp (4.x) 可能使用的方法签名
        try {
            CertificatePinner.check.overload(
                'java.lang.String', 'kotlin.jvm.functions.Function0'
            ).implementation = function (hostname, peek) {
                console.log("[+] CertificatePinner.check(4.x) 已绕过, hostname=" + hostname);
            };
        } catch (e) {
            // 不是 4.x 版本,忽略
        }

    } catch (e) {
        console.log("[-] OkHttp CertificatePinner 不存在: " + e);
    }

    // ====== 方案3:Hook WebViewClient 的 SSL 错误处理 ======
    console.log("[*] 开始 Hook WebViewClient...");

    try {
        var WebViewClient = Java.use("android.webkit.WebViewClient");
        WebViewClient.onReceivedSslError.implementation = function (
            view, handler, error
        ) {
            console.log("[+] WebView SSL 错误被忽略: " + error.toString());
            handler.proceed(); // 继续加载
        };
    } catch (e) {
        console.log("[-] WebViewClient hook 失败: " + e);
    }

    console.log("[*] SSL Pinning 绕过脚本加载完成");
});

提示:Frida 官方仓库提供了一个更完善的通用脚本 ssl-pinning-bypass,覆盖了更多框架的 Pinning 实现,建议在实际项目中优先使用。

案例3:修改函数返回值绕过验证

Hook isVip()、isRooted() 等检测函数

很多 APP 内部通过检查方法返回值来控制功能权限,例如 VIP 功能、Root 检测、设备绑定等。通过 Hook 修改返回值,可以快速绕过这些限制。

// hook_return_value.js - 修改函数返回值绕过验证
Java.perform(function () {

    // ====== 绕过 VIP 检测 ======
    try {
        var UserInfo = Java.use("com.example.app.model.UserInfo");
        UserInfo.isVip.implementation = function () {
            console.log("[+] isVip() 被调用,原始返回: " + this.isVip());
            console.log("[+] 修改返回值为 true");
            return true;
        };

        UserInfo.getVipLevel.implementation = function () {
            console.log("[+] getVipLevel() 被调用,修改为最高级 3");
            return 3;
        };
    } catch (e) {
        console.log("[-] VIP 检测类未找到: " + e);
    }

    // ====== 绕过 Root 检测 ======
    try {
        var RootCheck = Java.use("com.example.app.security.RootDetector");
        RootCheck.isRooted.implementation = function () {
            console.log("[+] isRooted() 返回 false,Root 检测已绕过");
            return false;
        };
    } catch (e) {
        console.log("[-] RootDetector 类未找到: " + e);
    }

    // ====== 绕过设备绑定检查 ======
    try {
        var DeviceBind = Java.use("com.example.app.security.DeviceBind");
        DeviceBind.isDeviceBound.implementation = function () {
            console.log("[+] isDeviceBound() 返回 true,设备绑定检查已绕过");
            return true;
        };

        // 如果校验的是设备 ID 字符串
        DeviceBind.getDeviceId.implementation = function () {
            console.log("[+] getDeviceId() 被调用");
            var original = this.getDeviceId();
            console.log("[+] 原始设备ID: " + original);
            var fakeId = "ANDROID_FAKE_DEVICE_001";
            console.log("[+] 替换为: " + fakeId);
            return fakeId;
        };
    } catch (e) {
        console.log("[-] DeviceBind 类未找到: " + e);
    }

    // ====== 绕过签名校验 ======
    try {
        var SignatureCheck = Java.use(
            "com.example.app.security.SignatureVerify"
        );
        SignatureCheck.verifySignature.implementation = function () {
            console.log("[+] 签名校验已绕过");
            return true;
        };
    } catch (e) {
        console.log("[-] SignatureVerify 类未找到: " + e);
    }

    console.log("[*] 返回值修改脚本加载完成");
});

注意事项:有些检测函数可能在多个类中都有实现,建议先用搜索定位所有相关调用点,逐一 Hook。

案例4:动态修改加密算法的密钥

Hook 密钥生成函数

在逆向分析中,经常遇到 APP 使用固定密钥对数据进行 AES/DES 加密。如果密钥是动态生成的,我们可以通过 Hook 密钥生成过程来获取或替换密钥。

// hook_crypto_key.js - 动态修改加密密钥
Java.perform(function () {

    // ====== Hook javax.crypto.Cipher ======
    var Cipher = Java.use("javax.crypto.Cipher");

    // Hook Cipher.init —— 捕获密钥
    Cipher.init.overload(
        'int', 'java.security.Key',
        'java.security.spec.AlgorithmParameterSpec'
    ).implementation = function (opmode, key, params) {
        var algo = this.getAlgorithm();
        var keyBytes = key.getEncoded();
        var keyHex = bytesToHex(keyBytes);

        console.log("[*] ========== Cipher.init ==========");
        console.log("[+] 算法: " + algo);
        console.log("[+] 模式: " + (opmode == 1 ? "加密" : "解密"));
        console.log("[+] 原始密钥(hex): " + keyHex);

        // 替换为我们自己的密钥(16字节 AES-128)
        if (keyBytes.length === 16) {
            var newKeyBytes = [0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48,
                              0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50];
            var SecretKeySpec = Java.use("javax.crypto.spec.SecretKeySpec");
            var newKey = SecretKeySpec.$new(
                arrayToByte(newKeyBytes), key.getAlgorithm()
            );
            console.log("[+] 替换密钥(hex): " + bytesToHex(newKey.getEncoded()));
            this.init(opmode, newKey, params);
        } else {
            this.init(opmode, key, params);
        }
    };

    // Hook Cipher.doFinal —— 捕获输入输出
    Cipher.doFinal.overload('[B').implementation = function (input) {
        var inputHex = bytesToHex(input);
        console.log("[+] doFinal 输入(hex): " + inputHex.substring(0, 64) + "...");

        var result = this.doFinal(input);
        var outputHex = bytesToHex(result);
        console.log("[+] doFinal 输出(hex): " + outputHex.substring(0, 64) + "...");

        return result;
    };

    // ====== Hook SecretKeySpec 构造函数 ======
    try {
        var SecretKeySpec = Java.use("javax.crypto.spec.SecretKeySpec");
        SecretKeySpec.$init.overload('[B', 'java.lang.String')
            .implementation = function (keyBytes, algo) {
            console.log("[*] ========== SecretKeySpec 创建 ==========");
            console.log("[+] 算法: " + algo);
            console.log("[+] 密钥(hex): " + bytesToHex(keyBytes));
            console.log("[+] 密钥长度: " + keyBytes.length + " bytes");
            this.$init(keyBytes, algo);
        };
    } catch (e) {
        console.log("[-] SecretKeySpec hook 失败: " + e);
    }

    console.log("[*] 加密密钥 Hook 脚本加载完成");

    // ====== 工具函数 ======
    function bytesToHex(byteArray) {
        var hex = "";
        for (var i = 0; i < byteArray.length; i++) {
            var b = (byteArray[i] & 0xFF).toString(16);
            hex += (b.length === 1 ? "0" + b : b);
        }
        return hex;
    }

    function arrayToByte(arr) {
        var ByteArray = Java.array('byte', arr);
        return ByteArray;
    }
});

核心原理:通过 Hook Cipher.initSecretKeySpec 的构造函数,我们可以在密钥生成/使用的瞬间截获原始密钥,并将其替换为我们控制的密钥。这样就能用已知密钥解密所有通信数据。

案例5:Hook JNI 函数获取 native 层数据

使用 Interceptor.attach hook SO 中的导出函数

当核心逻辑被放进了 Native 层(SO 库),Java 层的 Hook 就无能为力了。这时需要使用 Frida 的 Interceptor 模块直接 Hook SO 中的导出函数。

// hook_native.js - Hook JNI 函数获取 native 层数据

// ====== 基础示例:Hook SO 导出函数 ======
Interceptor.attach(Module.findExportByName("libnative-lib.so", "Java_com_example_app_NativeHelper_encrypt"), {
    onEnter: function (args) {
        // JNI 函数签名: JNIEnv*, jobject, jstring
        console.log("[*] ========== native encrypt 被调用 ==========");

        // args[0] = JNIEnv*
        // args[1] = jobject (this)
        // args[2] = jstring (输入字符串)

        // 读取 JNI 参数需要通过 JNIEnv 的函数表
        // 这里我们直接读取内存中的字符串数据
        var env = args[0];
        var jstr = args[2];

        // 通过 JNI 调用 GetStringUTFChars
        var GetStringUTFChars = new NativeFunction(
            env.readPointer().add(0x548).readPointer(),
            'pointer', ['pointer', 'pointer', 'pointer']
        );

        var cstr = GetStringUTFChars(env, jstr, ptr(0));
        console.log("[+] 输入字符串: " + cstr.readUtf8String());
    },
    onLeave: function (retval) {
        // retval 是 jstring 返回值
        console.log("[+] 返回值(jstring): " + retval);
    }
});

// ====== 进阶:Hook 非 JNI 的普通 C 函数 ======
Interceptor.attach(Module.findExportByName("libnative-lib.so", "aes_encrypt"), {
    onEnter: function (args) {
        console.log("[*] ========== aes_encrypt 被调用 ==========");

        // 假设函数签名: int aes_encrypt(uint8_t* input, int input_len,
        //                                   uint8_t* output, uint8_t* key)
        var input = args[0];
        var inputLen = args[1].toInt32();
        var output = args[2];
        var key = args[3];

        console.log("[+] 输入数据(hex): " + hexdump(input, { length: inputLen }));
        console.log("[+] 输入长度: " + inputLen);

        // 读取密钥(假设 AES-128 = 16 字节)
        if (!key.isNull()) {
            console.log("[+] 密钥(hex): " + hexdump(key, { length: 16 }));
        }
    },
    onLeave: function (retval) {
        // retval 是 int 返回值
        console.log("[+] 返回值: " + retval.toInt32());
    }
});

读写寄存器和内存

// hook_registers_memory.js - 读写 ARM 寄存器和内存

// Hook 一个函数并修改寄存器值
Interceptor.attach(Module.findExportByName("libnative-lib.so", "check_license"), {
    onEnter: function (args) {
        console.log("[*] ========== check_license 进入 ==========");
        console.log("[+] x0 (JNIEnv*): " + this.context.x0);
        console.log("[+] x1 (jobject): " + this.context.x1);
        console.log("[+] PC: " + this.context.pc);

        // 修改参数寄存器的值(ARM64 下参数通过 x0-x7 传递)
        // this.context.x2 = ptr(0x1);  // 修改第三个参数为 1
    },
    onLeave: function (retval) {
        console.log("[*] ========== check_license 返回 ==========");
        console.log("[+] 返回值(x0): " + this.context.x0);

        // 修改返回值为 1(表示验证通过)
        this.context.x0 = ptr(1);
        console.log("[+] 已将返回值修改为 1");
    }
});

// ====== 内存读写操作 ======
// 读取指定地址的内存
function readMemory(address, size) {
    try {
        var buf = Memory.readByteArray(address, size);
        console.log(hexdump(buf, { header: false, ansi: false }));
    } catch (e) {
        console.log("[-] 内存读取失败: " + e);
    }
}

// 在内存中搜索特征字符串
function searchPattern(moduleName, pattern) {
    var ranges = Process.enumerateRangesSync('r--');
    ranges.forEach(function (range) {
        try {
            var results = Memory.scanSync(range.base, range.size, pattern);
            results.forEach(function (match) {
                console.log("[+] 找到匹配: " + match.address + " (" + match.size + " bytes)");
            });
        } catch (e) {
            // 忽略不可读区域
        }
    });
}

// 使用示例:搜索 SO 文件中的 AES S-Box 常量
console.log("[*] 搜索 AES S-Box...");
// AES S-Box 的前几个字节特征
searchPattern("libnative-lib.so", "63 7C 77 7B F2 6B 6F C5");

寄存器约定:在 ARM64 下,函数参数通过 x0-x7 传递,返回值放在 x0。在 ARM32(Thumb)下,参数通过 r0-r3 传递。Frida 通过 this.context 访问这些寄存器。

综合技巧总结

场景 核心 API 关键要点
Java 方法 Hook Java.use().method.implementation 注意重载使用 overload()
枚举类 Java.enumerateLoadedClasses() 结合字符串匹配模糊定位
SSL Pinning 绕过 Hook TrustManager + CertificatePinner 多方案组合确保成功率
返回值篡改 替换 implementation 返回值 注意多检测点需全部覆盖
加密密钥拦截 Hook Cipher.init / SecretKeySpec 修改密钥可实现自定义解密
Native 函数 Hook Interceptor.attach() 理解 JNI 调用约定和寄存器
内存操作 Memory.readByteArray() / Memory.scanSync() 搜索特征常量定位关键函数

调试建议

  1. 善用 hexdump():对二进制数据使用 hexdump() 输出,比直接打印可读性更好
  2. 调用栈追踪:Java 层用 Log.getStackTraceString(),Native 层用 Thread.backtrace() 配合 DebugSymbol.fromAddress() 解析符号名
  3. 定时器模式:对于启动时就执行的函数,可以使用 setTimeout 延迟 Hook,或通过 Java.scheduleOnMainThread() 确保在主线程执行
  4. Spawn 模式:使用 frida -U -f com.example.app -l script.js 以 Spawn 模式启动,可以在 APP 加载前注入,避免错过早期初始化逻辑

以上就是 Frida 在 Android 逆向中常见的五大综合案例。掌握这些模式后,大部分逆向场景都可以通过组合和变体来应对。下一阶段建议深入 Frida 的 RPC 机制和 Stalker 代码追踪,进一步提升逆向分析能力。