跳转到内容
123xiao | 无名键客

《安卓逆向实战:中级开发者如何用 Frida 定位并绕过常见 APK 签名校验逻辑》

字数: 0 阅读时长: 1 分钟

安卓逆向实战:中级开发者如何用 Frida 定位并绕过常见 APK 签名校验逻辑

做 Android 逆向时,最常见的一道门槛就是 APK 签名校验。很多同学第一次重打包一个 APK,安装倒是成功了,一运行就闪退,或者关键功能直接“装死”。这时候八成不是你改坏了代码,而是应用自己做了签名检查。

这篇文章我会从中级开发者可落地的角度,带你用 Frida 把常见签名校验逻辑“抓出来”,再一步步验证、定位、绕过。不会只讲原理,也不会只扔一段神秘脚本,而是尽量像我带你在真机旁边做一遍。

说明:本文内容仅用于安全研究、加固评估、兼容性测试和授权场景分析,请勿用于未授权目标。


背景与问题

很多应用会在运行时校验自己的签名,目的通常有几个:

  • 防止 APK 被二次打包
  • 防止调试版、测试版被伪装成正式版
  • 做渠道校验或许可证绑定
  • 联合反调试、反 Hook 形成更完整的保护链

对逆向分析来说,典型现象包括:

  • 重签名后启动即闪退
  • 某个核心页面打不开
  • 登录、支付、会员等功能提示“环境异常”
  • Frida 一附加就崩,或者校验结果直接失败

如果你只会“搜字符串”,往往会卡在两个地方:

  1. 校验点不止一个
  2. Java 层和 Native 层混合校验

所以更靠谱的思路不是先“盲改”,而是先建立一条定位路径。


前置知识与环境准备

建议你已经具备这些基础:

  • 会基本使用 adb
  • 知道 APK 重签名的大致流程
  • 能安装和启动 Frida Server
  • 了解 Java Hook 和 Native Hook 的区别

测试环境

下面这套环境比较常见:

  • Android 8 ~ 13 真机或模拟器
  • Frida / frida-tools
  • adb
  • jadx(静态查看 Java 代码)
  • apktool(反编译资源与 smali)
  • 可选:objection、Ghidra、IDA

Frida 连接确认

先确认目标进程可见:

frida-ps -U

若能看到目标包名,再测试简单注入:

frida -U -f com.example.target -l test.js --no-pause

test.js

Java.perform(function () {
    console.log("[*] Frida attached");
});

如果这里都过不去,别急着看签名校验,先处理反调试或设备环境问题。


核心原理

在 Android 里,签名校验常见落点主要有三类:

  1. 系统 API 获取签名后比对
  2. 应用内置证书摘要(MD5/SHA1/SHA256)进行比对
  3. Native 层通过 JNI 调 Java API,或直接做更底层校验

常见 Java 层调用链

  • PackageManager.getPackageInfo()
  • PackageInfo.signatures(老接口)
  • PackageInfo.signingInfo(新接口)
  • Signature.toByteArray()
  • MessageDigest.digest()
  • 自定义工具类:例如 SignUtils.getSign()

为什么 Frida 很适合做这件事

因为它能让我们在运行时观察真实参数和返回值

  • 调了哪个 API
  • 传了哪个包名
  • 最终算出的摘要是什么
  • 返回结果是否被业务逻辑使用

比起直接改 smali,Frida 的优势是:

  • 可快速验证,不改包
  • 可以逐层缩小范围
  • 能在 Java / Native 两边联动观察

先建立定位思路

我一般会按这个顺序排查:

flowchart TD
    A[应用启动或触发关键功能] --> B[Hook PackageManager相关API]
    B --> C[观察是否读取签名信息]
    C --> D[Hook MessageDigest.digest]
    D --> E[观察是否计算证书摘要]
    E --> F[Hook自定义校验函数]
    F --> G{是否仍失败}
    G -- 是 --> H[检查Native层/JNI调用]
    G -- 否 --> I[确定绕过点]

这个顺序的好处是:先看“取签名”,再看“算摘要”,最后看“判结果”
你不一定一开始就能找到最终判断函数,但你总能先找到上游流量。


常见签名校验模式拆解

模式一:直接读取签名并比对字符串

伪代码大概像这样:

PackageInfo pi = pm.getPackageInfo(pkg, PackageManager.GET_SIGNATURES);
Signature[] signs = pi.signatures;
String sha1 = sha1(signs[0].toByteArray());
if (!"AB:CD:EF:...".equals(sha1)) {
    System.exit(0);
}

模式二:封装在工具类里

String current = SignUtils.getSha256(context);
return BuildConfig.RELEASE_SIGN.equals(current);

这类最适合直接 Hook 工具类返回值。

模式三:Java 拿数据,Native 做最终判断

sequenceDiagram
    participant App as App逻辑
    participant Java as Java工具类
    participant PM as PackageManager
    participant JNI as Native/JNI
    App->>Java: checkEnv()
    Java->>PM: getPackageInfo()
    PM-->>Java: Signature/SigningInfo
    Java->>JNI: nativeVerify(certBytes)
    JNI-->>Java: true/false
    Java-->>App: 校验结果

这种情况如果你只盯 Java 层,可能会误以为“都正常”,但最终仍然失败。


实战:用 Frida 逐步定位签名校验

下面这部分是本文的重点。我会给出一套可运行的 Frida 脚本,你可以按模块逐步开启。


第一步:Hook getPackageInfo,看应用有没有主动读取签名

Android 不同版本签名 API 有差异,所以建议把常见重载都 Hook 掉。

Java.perform(function () {
    var PackageManager = Java.use("android.app.ApplicationPackageManager");
    var Exception = Java.use("java.lang.Exception");
    var Log = Java.use("android.util.Log");

    function printStack(tag) {
        console.log("\n==== " + tag + " ====");
        console.log(Log.getStackTraceString(Exception.$new()));
        console.log("==== end ====\n");
    }

    PackageManager.getPackageInfo.overloads.forEach(function (ovl) {
        ovl.implementation = function () {
            var args = [];
            for (var i = 0; i < arguments.length; i++) {
                args.push(arguments[i]);
            }

            var pkgName = args.length > 0 ? args[0] : "unknown";
            var flags = args.length > 1 ? args[1] : "unknown";

            console.log("[*] getPackageInfo called, pkg=" + pkgName + ", flags=" + flags);
            var ret = ovl.apply(this, arguments);

            if (String(pkgName).indexOf(this.getPackageName()) >= 0) {
                printStack("getPackageInfo self");
            }
            return ret;
        };
    });
});

你应该关注什么

  • 调用的包名是不是它自己
  • flags 是否包含与签名相关的值
  • 调用栈里是否出现可疑工具类或业务类

如果这里能看到明显的自检调用,后面就好办很多。


第二步:Hook Signature.toByteArray() 和摘要计算

很多应用会把签名字节转成摘要,比如 SHA1、SHA256。
这一步是为了确认“签名是否进入摘要流程”。

Java.perform(function () {
    var Signature = Java.use("android.content.pm.Signature");
    var MessageDigest = Java.use("java.security.MessageDigest");

    Signature.toByteArray.implementation = function () {
        var ret = this.toByteArray();
        console.log("[*] Signature.toByteArray called, len=" + ret.length);
        return ret;
    };

    MessageDigest.digest.overloads.forEach(function (ovl) {
        ovl.implementation = function () {
            var algo = this.getAlgorithm();
            var ret = ovl.apply(this, arguments);
            console.log("[*] MessageDigest.digest called, algo=" + algo + ", outLen=" + ret.length);
            return ret;
        };
    });
});

实战经验

这一层日志通常很多。我的建议是:

  • 先在应用冷启动阶段观察
  • 再只触发某个受保护页面
  • 比较两次差异,筛出真正相关的调用

否则你会被正常业务里的各种哈希日志淹没。


第三步:直接枚举并 Hook 自定义签名工具类

如果你已经用 jadx 静态看过代码,知道有类似:

  • SignUtils
  • AppSecurity
  • CheckSignature
  • SecurityManager

这类类名,最省事的方式是直接 Hook 最终判断函数

假设目标函数是:

boolean com.example.security.SignUtils.isSignatureValid(Context ctx)

Frida 脚本如下:

Java.perform(function () {
    var SignUtils = Java.use("com.example.security.SignUtils");

    SignUtils.isSignatureValid.implementation = function (ctx) {
        console.log("[*] SignUtils.isSignatureValid called -> force true");
        return true;
    };
});

如果函数返回的是字符串摘要,也可以这样改:

Java.perform(function () {
    var SignUtils = Java.use("com.example.security.SignUtils");

    SignUtils.getSha256.implementation = function (ctx) {
        var fake = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
        console.log("[*] SignUtils.getSha256 called -> " + fake);
        return fake;
    };
});

第四步:更通用的绕过方式——伪造 PackageInfo 返回

当你不知道最终工具类在哪,但确定校验依赖系统签名信息时,可以从源头下手:伪造返回的签名对象

这个方式适合做验证,但不一定适合所有 APP,因为新旧 API、字段类型、校验链长度都不同。

针对老接口 signatures

Java.perform(function () {
    var PackageManager = Java.use("android.app.ApplicationPackageManager");
    var Signature = Java.use("android.content.pm.Signature");

    var fakeHex = "308203..." // 这里应替换为真实证书DER十六进制,示意用途

    function hexToBytes(hex) {
        var result = [];
        for (var i = 0; i < hex.length; i += 2) {
            result.push(parseInt(hex.substr(i, 2), 16));
        }
        return result;
    }

    PackageManager.getPackageInfo.overloads.forEach(function (ovl) {
        ovl.implementation = function () {
            var ret = ovl.apply(this, arguments);

            try {
                var pkgName = arguments[0];
                if (String(pkgName) === this.getPackageName() && ret.signatures.value) {
                    console.log("[*] forging PackageInfo.signatures for " + pkgName);
                    var fakeBytes = hexToBytes(fakeHex);
                    var sig = Signature.$new(Java.array('byte', fakeBytes));
                    ret.signatures.value = Java.array("android.content.pm.Signature", [sig]);
                }
            } catch (e) {
                console.log("[!] forge signatures failed: " + e);
            }

            return ret;
        };
    });
});

注意

真实环境中,fakeHex 最好来自:

  • 原始 APK 证书
  • 可信版本提取出的签名证书

否则你只是“造了个签名对象”,后续摘要值未必能匹配内置白名单。


第五步:绕过最终布尔判断,比绕过上游更稳

如果你已经通过日志找到了类似 check() / verify() / validate() 的最终判断点,优先改它。

原因很现实:

  • 上游链条可能有多处冗余校验
  • 直接伪造签名可能引出更多兼容问题
  • 最终布尔值通常最稳定

例如:

Java.perform(function () {
    var target = Java.use("com.example.security.AppSecurity");

    target.verify.overloads.forEach(function (ovl) {
        ovl.implementation = function () {
            console.log("[*] AppSecurity.verify called -> force true");
            return true;
        };
    });
});

一份相对完整的 Frida 实战脚本

下面给一份整合版脚本,适合先跑起来做观察,再逐步精简。

Java.perform(function () {
    console.log("[*] Frida signature tracing start");

    var Log = Java.use("android.util.Log");
    var Exception = Java.use("java.lang.Exception");
    var PackageManager = Java.use("android.app.ApplicationPackageManager");
    var Signature = Java.use("android.content.pm.Signature");
    var MessageDigest = Java.use("java.security.MessageDigest");

    function stack(tag) {
        console.log("\n==== " + tag + " ====");
        console.log(Log.getStackTraceString(Exception.$new()));
        console.log("==== end ====\n");
    }

    // 1. Hook getPackageInfo
    PackageManager.getPackageInfo.overloads.forEach(function (ovl) {
        ovl.implementation = function () {
            var pkg = arguments.length > 0 ? arguments[0] : "unknown";
            var flags = arguments.length > 1 ? arguments[1] : "unknown";
            var ret = ovl.apply(this, arguments);

            console.log("[*] getPackageInfo -> pkg=" + pkg + ", flags=" + flags);

            try {
                if (String(pkg) === this.getPackageName()) {
                    stack("self getPackageInfo");
                }
            } catch (e) {}

            return ret;
        };
    });

    // 2. Hook Signature.toByteArray
    Signature.toByteArray.implementation = function () {
        var ret = this.toByteArray();
        console.log("[*] Signature.toByteArray len=" + ret.length);
        stack("Signature.toByteArray");
        return ret;
    };

    // 3. Hook digest
    MessageDigest.digest.overloads.forEach(function (ovl) {
        ovl.implementation = function () {
            var algo = "unknown";
            try {
                algo = this.getAlgorithm();
            } catch (e) {}

            var ret = ovl.apply(this, arguments);
            console.log("[*] MessageDigest.digest algo=" + algo + ", outLen=" + ret.length);
            return ret;
        };
    });

    // 4. Optional: hook your known custom method
    try {
        var SignUtils = Java.use("com.example.security.SignUtils");
        if (SignUtils.isSignatureValid) {
            SignUtils.isSignatureValid.implementation = function (ctx) {
                console.log("[*] isSignatureValid -> force true");
                return true;
            };
        }
    } catch (e) {
        console.log("[*] custom SignUtils not found, skip");
    }

    console.log("[*] Frida signature tracing ready");
});

运行方式:

frida -U -f com.example.target -l sign_hook.js --no-pause

如果目标在 Native 层校验,怎么继续?

这时候要把思路切到 JNI。

常见现象是:

  • Java 层签名获取正常
  • 你 Hook 了 Java 校验方法但仍失败
  • 日志里出现 System.loadLibrary
  • jadx 中有 native boolean verify(...)

先观察 so 加载

Java.perform(function () {
    var System = Java.use("java.lang.System");
    System.loadLibrary.overload("java.lang.String").implementation = function (name) {
        console.log("[*] System.loadLibrary: " + name);
        return this.loadLibrary(name);
    };
});

再追 Native 导出

如果知道 so 名称,可配合 Module.enumerateExports() 看导出函数;
如果是 RegisterNatives 动态注册,就需要进一步跟 JNI 注册流程。

下面给一个简单的 Native 层观察例子:

setImmediate(function () {
    var dlopen = Module.findExportByName(null, "android_dlopen_ext");
    if (dlopen) {
        Interceptor.attach(dlopen, {
            onEnter: function (args) {
                this.path = args[0].readCString();
            },
            onLeave: function (retval) {
                if (this.path && this.path.indexOf(".so") !== -1) {
                    console.log("[*] loaded so: " + this.path);
                }
            }
        });
    }
});

Java 与 Native 联合定位图

flowchart LR
    A[启动应用] --> B[Hook Java层 getPackageInfo]
    B --> C[Hook Signature.toByteArray]
    C --> D[Hook MessageDigest.digest]
    D --> E{找到最终校验函数?}
    E -- 是 --> F[直接修改返回值]
    E -- 否 --> G[观察 System.loadLibrary / dlopen]
    G --> H[定位 JNI / Native verify]
    H --> I[Hook native函数或回溯Java调用点]

逐步验证清单

别一口气上所有 Hook。建议按这个清单验证:

阶段 1:确认签名读取行为

  • getPackageInfo 是否被调用
  • 是否读取自身包名
  • 是否能看到可疑调用栈

阶段 2:确认摘要行为

  • Signature.toByteArray() 是否被调用
  • MessageDigest.digest() 是否在关键页面触发
  • 算法是 SHA1 / SHA256 / MD5 哪种

阶段 3:确认最终判断点

  • 是否存在自定义 verify/check/validate 方法
  • 改返回值后是否生效
  • 是否仍有 Native 层补充校验

阶段 4:验证绕过稳定性

  • 冷启动是否稳定
  • 切后台重进是否稳定
  • 登录、支付、会员等关键场景是否仍触发异常

常见坑与排查

这一段很重要,我自己踩过不少。

1. Hook 太晚,校验已经执行完了

现象:

  • attach 后看不到关键信息
  • 但应用明显已经闪退

解决:

  • 使用 -f 启动注入,而不是先打开应用再 attach
  • 必要时加 --no-pause
frida -U -f com.example.target -l sign_hook.js --no-pause

2. Android 版本差异导致接口不一致

老版本常见:

  • PackageInfo.signatures

新版本更常见:

  • PackageInfo.signingInfo

如果你只盯老接口,可能会漏掉。

建议静态和动态结合,检查是否用到了:

  • getApkContentsSigners()
  • getSigningCertificateHistory()

3. 反 Frida / 反调试导致进程崩溃

现象:

  • 一注入就闪退
  • frida-server 可连,但目标进程不稳定

常见对抗点:

  • 检查 /proc/self/maps
  • 扫描 frida 相关字符串
  • 检查调试端口
  • ptrace 反调试

排查建议:

  • 先最小脚本注入,只打印一句日志
  • 再逐个 Hook 打开
  • 如有必要,先做基础反调试绕过,再处理签名

4. Hook 了错误重载

同名方法可能有多个重载,最常见的坑就是“看着 Hook 了,实际没进”。

排查方式:

Java.perform(function () {
    var Cls = Java.use("com.example.security.SignUtils");
    Cls.isSignatureValid.overloads.forEach(function (o, i) {
        console.log(i + ": " + o);
    });
});

确认参数签名后再精确 Hook。


5. Native 层返回值改了,但 Java 侧还有二次判断

比如 Native 返回的是中间值,Java 还会再比对一次。
这时候你会觉得“明明 Hook 成功了,为什么还失败”。

建议:

  • 一定要同时观察上游参数和最终业务分支
  • 优先找真正影响 UI 或流程的最终布尔值

6. 多进程应用只 Hook 了主进程

有些校验在独立进程里跑,比如:

  • :push
  • :guard
  • :plugin

你只进主进程,看起来“什么都没发生”。

先列进程:

frida-ps -Uai

必要时切换到具体进程注入。


安全/性能最佳实践

虽然是逆向调试,但脚本写法还是有讲究,尤其在复杂应用里。

1. 先观察,后修改

我很少一上来就强改返回值。先记录调用链,再动手,能少走很多弯路。

推荐顺序:

  1. 记录 API 调用
  2. 记录参数与返回值
  3. 确认校验链
  4. 最小化修改点

2. 只 Hook 必要方法,减少性能噪音

MessageDigest.digest() 这种全局热点方法,日志非常多。
建议在验证完成后关闭或加过滤条件,比如只打印特定线程、特定时机、特定调用栈。

示例:按包名关键词过滤调用栈。

Java.perform(function () {
    var Log = Java.use("android.util.Log");
    var Exception = Java.use("java.lang.Exception");
    var MessageDigest = Java.use("java.security.MessageDigest");

    MessageDigest.digest.overload().implementation = function () {
        var ret = this.digest();
        var stack = Log.getStackTraceString(Exception.$new());
        if (stack.indexOf("com.example") !== -1) {
            console.log("[*] digest from target stack");
            console.log(stack);
        }
        return ret;
    };
});

3. 优先改最终判断,不优先伪造整条链

在工程实践里,最终判断点往往是最稳的 Hook 位点。
伪造上游签名对象虽然“看起来更底层”,但兼容性不一定更好。

适用边界:

  • 如果应用有多条签名链并交叉验证,伪造源头更合适
  • 如果只是单点布尔判断,直接改最终值更省心

4. 注意 Hook 代码的可维护性

建议把脚本拆成几个模块:

  • trace_pm.js
  • trace_digest.js
  • bypass_verify.js
  • trace_native.js

这样你以后复盘时不会面对一个 500 行的大脚本发呆。


5. 不要忽略合法合规边界

本文讨论的是安全研究方法,不代表可以对未授权应用做任何修改或绕过。
在企业内网、安全测试、加固验证、兼容性分析中,这类技术是有价值的;脱离授权边界就不是技术问题了。


一个更贴近实战的定位策略

如果让我总结一套“中级开发者最省时间”的打法,我会这么做:

stateDiagram-v2
    [*] --> 冷启动注入
    冷启动注入 --> 观察签名读取
    观察签名读取 --> 观察摘要计算
    观察摘要计算 --> 搜索自定义校验类
    搜索自定义校验类 --> Hook最终返回值
    Hook最终返回值 --> 验证关键功能
    验证关键功能 --> Native排查: 仍失败
    验证关键功能 --> [*]: 成功绕过
    Native排查 --> Hook JNI/so
    Hook JNI/so --> 验证关键功能

这套流程的关键不是“Hook 得多炫”,而是每一步都可验证
一旦某一步没有证据,就不要急着跳下一步。


总结

APK 签名校验并不神秘,难点通常不在“原理”,而在“定位链条太长”。
对中级开发者来说,最实用的思路是:

  • 先从 getPackageInfo 入手,确认是否读取签名
  • 再看 Signature.toByteArray()MessageDigest.digest(),确认摘要链
  • 如果已有静态分析结果,优先 Hook 自定义校验函数
  • 真正要稳定绕过时,优先修改最终判断点
  • 若 Java 层看起来都对,但结果仍失败,马上怀疑 Native/JNI

最后给几个可执行建议:

  1. 不要一开始就改 smali,先用 Frida 验证校验路径
  2. 不要一上来全量 Hook 热点 API,日志会把你淹没
  3. 找到最终布尔判断后优先从那里下手
  4. 多进程、反调试、Native 校验 是最容易漏的三个点

如果你能把“观察上游数据流”和“锁定最终判断”这两件事练熟,绕过常见 APK 签名校验就会从“碰运气”变成一套可复用的方法论。


分享到:

上一篇
《从零搭建到生产落地:基于开源项目 Harbor 的企业级容器镜像仓库实战指南》
下一篇
《Spring Boot 中基于 Spring Cache 与 Redis 的多级缓存实战:提升接口性能与一致性控制》