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

《安卓逆向实战:基于 Frida 与 JADX 联动定位并绕过应用签名校验逻辑》

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

安卓逆向实战:基于 Frida 与 JADX 联动定位并绕过应用签名校验逻辑

很多 Android 应用会做“自签名校验”:启动时读取自身 APK 的签名信息,再和代码里预置的指纹、哈希或证书内容比较。一旦不一致,就闪退、禁用功能,或者直接提示“环境异常”。

如果你在做安全研究、兼容性测试,或者只是想搞清楚某个 App 到底把校验写在哪儿,JADX + Frida 这套组合非常高效:

  • JADX 负责静态看代码:找到校验入口、比对逻辑、关键类名
  • Frida 负责动态验证:在运行时打印参数、改返回值、直接绕过判断

这篇文章我会按“先定位,再验证,最后绕过”的思路带你走一遍。重点不是堆概念,而是让你看完能自己上手。

说明:本文内容仅用于授权测试、安全研究与教学演示,请勿用于未授权场景。


背景与问题

为什么签名校验总是绕不过去?

因为它不一定只写在一个地方。常见情况包括:

  1. Java 层直接校验

    • PackageManager
    • getPackageInfo(...).signaturessigningInfo
    • 算 MD5 / SHA1 / SHA256 后比对
  2. Native 层二次校验

    • Java 只是壳,真正校验在 .so
    • Java 返回正常,但 Native 再次拦截
  3. 多点校验

    • Application 启动校验一次
    • 核心页面进来再校验一次
    • 网络请求前再校验一次
  4. 混淆与反调试

    • 类名、方法名全混淆
    • 动态加载 Dex
    • 检测 Frida、Root、调试器

所以实际做的时候,最怕的不是“不会 Hook”,而是没找准点。这也是为什么我通常会把 JADX 静态分析Frida 动态验证 连起来用。


前置知识与环境准备

你需要准备什么

  • 一台已安装 adb 的电脑
  • 一台测试 Android 设备或模拟器
  • jadx-gui
  • frida-tools
  • 目标 APK
  • 对 Java/Kotlin 基本语法有概念

工具安装示例

pip install frida-tools
frida --version

确认设备连通:

adb devices

确认 Frida Server 已在设备上运行后,再测试进程枚举:

frida-ps -U

核心原理

签名校验本质上是一条“取值 -> 变换 -> 比较 -> 分支”的链路。

一个典型流程

  1. 获取包签名
  2. 计算证书摘要
  3. 与预置值比对
  4. 不一致则返回 false / 抛异常 / 结束进程
flowchart TD
    A[应用启动] --> B[读取自身包信息]
    B --> C[提取签名/证书]
    C --> D[计算 MD5/SHA1/SHA256]
    D --> E{与内置指纹比较}
    E -- 一致 --> F[正常执行]
    E -- 不一致 --> G[提示异常/退出/功能禁用]

逆向时我们关注哪些点?

通常有三类可下手的位置:

  • 源头:Hook PackageManager / Signature / MessageDigest
  • 中间:Hook 自定义摘要函数、证书转换函数
  • 结果:Hook 最终布尔判断,强制返回 true

为什么要联动使用 JADX 和 Frida?

因为两者解决的问题不同:

sequenceDiagram
    participant A as JADX 静态分析
    participant B as 逆向人员
    participant C as Frida 动态验证
    participant D as 目标应用

    A->>B: 提供类名、方法名、字符串常量
    B->>C: 编写 Hook 脚本
    C->>D: 注入并拦截调用
    D-->>C: 返回运行时参数/返回值
    C-->>B: 输出日志、修改结果
    B->>A: 回看代码确认真实分支
  • JADX 告诉你“可能在哪里”
  • Frida 告诉你“运行时到底是不是这里”

这比盲 Hook 高效太多。


用 JADX 定位签名校验逻辑

第一步:全局搜关键词

把 APK 丢进 jadx-gui 后,先全局搜索这些词:

  • signature
  • signatures
  • signingInfo
  • PackageManager
  • getPackageInfo
  • MessageDigest
  • SHA1
  • SHA-256
  • MD5
  • X509Certificate
  • CertificateFactory

如果目标 App 混淆比较重,还可以搜:

  • 异常提示文本,比如“签名错误”“环境异常”“非法安装包”
  • 固定长度的十六进制字符串
  • Base64 字符串

第二步:关注这些代码形态

例如下面这种就很典型:

PackageInfo pi = context.getPackageManager().getPackageInfo(
    context.getPackageName(), 64
);
Signature[] signatures = pi.signatures;
byte[] cert = signatures[0].toByteArray();
String sha1 = digest(cert);
return "A1:B2:C3:...".equalsIgnoreCase(sha1);

或者 Android 9+ 常见写法:

PackageInfo pi = context.getPackageManager().getPackageInfo(
    context.getPackageName(), PackageManager.GET_SIGNING_CERTIFICATES
);
SigningInfo info = pi.signingInfo;
Signature[] signatures = info.getApkContentsSigners();

第三步:顺着调用链往上找

你找到的摘要比较方法,通常不是入口。真正关键的是:

  • 谁调用了它?
  • 调用结果被谁消费?
  • 是不是进入 if (!check()) { finish(); } 这种分支?

这一步很重要。因为你最终想 Hook 的,不一定是摘要函数本身,而是控制流程的那个布尔函数


实战示例:从静态定位到动态绕过

下面用一个可运行的教学示例来演示。假设目标应用中,我们在 JADX 里找到了如下代码:

public class SecurityUtil {
    public static boolean checkSign(Context context) {
        try {
            PackageInfo pi = context.getPackageManager().getPackageInfo(
                context.getPackageName(), 64
            );
            Signature sig = pi.signatures[0];
            String sha1 = sha1(sig.toByteArray());
            return "12AB34CD56EF".equalsIgnoreCase(sha1);
        } catch (Exception e) {
            return false;
        }
    }
}

上层调用:

if (!SecurityUtil.checkSign(this)) {
    Toast.makeText(this, "环境异常", 0).show();
    finish();
}

这时最稳的做法通常是:

  1. 先 Hook checkSign,确认命中
  2. 再强制返回 true
  3. 如果没命中,再退回去 Hook PackageManager 或摘要函数

实战代码(可运行)

方案一:直接 Hook 目标校验方法

这是我最推荐的第一手方案,简单、稳定、对业务副作用小。

Java.perform(function () {
    var SecurityUtil = Java.use("com.demo.app.SecurityUtil");

    SecurityUtil.checkSign.overload("android.content.Context").implementation = function (ctx) {
        console.log("[*] SecurityUtil.checkSign() called");
        var original = this.checkSign(ctx);
        console.log("[*] original result = " + original);
        console.log("[*] force return true");
        return true;
    };
});

启动方式:

frida -U -f com.demo.app -l bypass_sign.js --no-pause

适用场景

  • 已经在 JADX 明确找到校验方法
  • 方法签名清晰
  • 没有太多重载干扰

方案二:Hook 摘要计算,观察真实比较值

如果你还不确定目标比对的究竟是什么,可以先 Hook MessageDigest 或自定义摘要函数。

下面示例 Hook Java 层的 MessageDigest.digest()

Java.perform(function () {
    var MessageDigest = Java.use("java.security.MessageDigest");
    var Arrays = Java.use("java.util.Arrays");

    MessageDigest.digest.overload("[B").implementation = function (input) {
        var result = this.digest(input);
        console.log("[*] MessageDigest.digest([B) called");
        console.log("    input length = " + input.length);
        console.log("    output = " + Arrays.toString(result));
        return result;
    };
});

不过实际排查中,我更建议直接 Hook 业务自己的摘要方法,例如:

Java.perform(function () {
    var SecurityUtil = Java.use("com.demo.app.SecurityUtil");

    SecurityUtil.sha1.overload("[B").implementation = function (data) {
        console.log("[*] sha1 called, data length = " + data.length);
        var ret = this.sha1(data);
        console.log("[*] sha1 result = " + ret);
        return ret;
    };
});

为什么先打印不先改?

因为很多时候你以为是 SHA1,结果它是:

  • 证书转字符串后再摘要
  • 摘要后再做大写/加冒号
  • 只取前 16 位比较
  • 多个签名拼接后再算

先看清楚,再决定绕过点,成功率高很多。


方案三:通用 Hook PackageManager.getPackageInfo

当代码混淆很严重、业务方法找不到时,可以从系统 API 入手。

Java.perform(function () {
    var ApplicationPackageManager = Java.use("android.app.ApplicationPackageManager");

    ApplicationPackageManager.getPackageInfo.overload("java.lang.String", "int").implementation = function (pkg, flags) {
        console.log("[*] getPackageInfo called");
        console.log("    pkg = " + pkg + ", flags = " + flags);
        return this.getPackageInfo(pkg, flags);
    };
});

如果你发现目标在启动阶段频繁取自己包信息,那么可以继续顺着这个调用栈找。

加上堆栈打印会更实用:

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

    ApplicationPackageManager.getPackageInfo.overload("java.lang.String", "int").implementation = function (pkg, flags) {
        console.log("[*] getPackageInfo(" + pkg + ", " + flags + ")");
        console.log(Log.getStackTraceString(Exception.$new()));
        return this.getPackageInfo(pkg, flags);
    };
});

这样你就能从日志里反推出调用方类名,再回到 JADX 继续看。


方案四:直接修改最终分支结果

如果上层是一个集中校验器,比如:

public boolean isEnvSafe() {
    return checkSign() && checkRoot() && checkDebugger();
}

那么你可以直接 Hook 最终方法:

Java.perform(function () {
    var EnvChecker = Java.use("com.demo.app.EnvChecker");

    EnvChecker.isEnvSafe.implementation = function () {
        console.log("[*] EnvChecker.isEnvSafe() called");
        return true;
    };
});

这个方案的优缺点

优点:

  • 成本最低
  • 成功率高
  • 对细节不敏感

缺点:

  • 可能漏掉其他流程
  • 可能掩盖真正的校验点,不利于分析全貌

如果你做的是研究而不是“先跑起来”,还是建议回头把真实链路补齐。


一套我常用的定位流程

下面这张图可以作为你实战时的“小抄”。

flowchart LR
    A[JADX 搜索关键词] --> B[找到签名/摘要相关方法]
    B --> C[分析调用链与最终分支]
    C --> D[Frida 打日志验证是否命中]
    D --> E{命中?}
    E -- 是 --> F[强制返回 true 或改参数]
    E -- 否 --> G[Hook 系统 API / 打印堆栈]
    G --> H[回到 JADX 继续定位]

逐步验证清单

我建议按这个顺序做,别一上来就写一大坨脚本。

1. 先确认进程能正常注入

frida -U -f com.demo.app -l test.js --no-pause

test.js

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

2. 验证目标类是否存在

Java.perform(function () {
    var SecurityUtil = Java.use("com.demo.app.SecurityUtil");
    console.log(SecurityUtil);
});

3. 验证方法重载是否正确

Java.perform(function () {
    var SecurityUtil = Java.use("com.demo.app.SecurityUtil");
    console.log(SecurityUtil.checkSign.overloads);
});

4. 先打印参数和返回值

不要急着改返回值,先确认它真的被调用:

Java.perform(function () {
    var SecurityUtil = Java.use("com.demo.app.SecurityUtil");

    SecurityUtil.checkSign.overload("android.content.Context").implementation = function (ctx) {
        console.log("[*] checkSign hit");
        var ret = this.checkSign(ctx);
        console.log("[*] ret = " + ret);
        return ret;
    };
});

5. 最后再强制绕过

确认命中后,再改成 return true;


常见坑与排查

1. Hook 了但完全不生效

常见原因:

  • 类名写错
  • 方法重载选错
  • 目标代码执行得太早
  • 实际校验在其他进程
  • 代码在 Native 层,不在 Java 层

排查建议

先枚举类名,看是不是混淆/多 Dex 导致:

Java.perform(function () {
    Java.enumerateLoadedClasses({
        onMatch: function (name) {
            if (name.indexOf("Security") !== -1 || name.indexOf("sign") !== -1) {
                console.log(name);
            }
        },
        onComplete: function () {}
    });
});

如果怀疑启动太早,可以改为 spawn 模式注入:

frida -U -f com.demo.app -l bypass_sign.js --no-pause

2. 调用原方法时死递归

这是 Frida 新手最容易踩的坑之一。

例如:

implementation = function (ctx) {
    return this.checkSign(ctx); // 这里如果写法不当,可能递归
}

通常要确保你 Hook 的是特定重载,并在该重载上调用原始实现。最稳妥的写法是先保存重载对象:

Java.perform(function () {
    var SecurityUtil = Java.use("com.demo.app.SecurityUtil");
    var target = SecurityUtil.checkSign.overload("android.content.Context");

    target.implementation = function (ctx) {
        console.log("[*] hit checkSign");
        var ret = target.call(this, ctx);
        console.log("[*] original ret = " + ret);
        return true;
    };
});

这个写法我自己也更常用,出问题少。


3. Android 版本差异导致签名 API 不同

老版本常见:

  • PackageInfo.signatures

新版本常见:

  • PackageInfo.signingInfo
  • getApkContentsSigners()
  • getSigningCertificateHistory()

如果你只盯着 signatures,可能会漏掉新版实现。


4. 目标 App 有多进程

有些应用把安全校验放在独立进程。你 Hook 主进程没反应,不代表脚本错了。

先看进程列表:

frida-ps -Uai

再确认包名对应的所有进程。


5. Java 层绕过了,App 还是退出

这通常说明还有别的检测:

  • Native 签名校验
  • Root 检测
  • 调试检测
  • 完整性校验
  • 服务器二次校验

这时别死磕一个点,回到调用链看“失败后还有谁参与决策”。


6. 混淆后方法名看不懂,怎么找?

我自己的经验是,不要执着于类名语义,改盯这三样:

  • 常量:固定哈希、错误文案、包名
  • API 组合getPackageInfo + MessageDigest
  • 调用时机:启动页、Application、登录前

有时一个叫 a.a.a.a() 的方法,照样能靠行为特征锁定。


安全/性能最佳实践

虽然这里讲的是“绕过”,但在研究和测试过程中,仍然要有边界感和工程意识。

1. 优先做最小化 Hook

如果已经定位到 checkSign(),就别上来全局 Hook MessageDigest

原因很简单:

  • 日志噪音大
  • 性能开销高
  • 容易影响其他业务逻辑
  • 排查结果反而变乱

建议:从最具体的业务方法开始,逐步往底层扩。


2. 先观测,后修改

实战里我一般分两轮:

  • 第一轮:只打印参数、返回值、调用栈
  • 第二轮:确认后再改返回值

这样能避免“虽然绕过去了,但根本不知道自己改的是哪一层”的尴尬。


3. 对启动关键路径少做重操作

Application.onCreate()、启动页首屏逻辑,本来时间窗口就短。这里如果:

  • 打太多日志
  • Hook 太多系统 API
  • 做复杂字符串处理

很容易引发卡顿甚至时序变化,导致现象不稳定。


4. 保留可回滚脚本版本

建议把脚本拆成几个阶段:

  • observe_sign.js
  • trace_pkginfo.js
  • bypass_sign.js

不要把所有逻辑塞进一个文件。这样你回退和比对更方便。


5. 注意合法授权边界

签名校验绕过属于典型安全敏感操作。用于:

  • 自有 App 测试
  • 授权渗透/加固评估
  • 教学研究

是合理的;用于未授权目标则可能触碰法律与合规问题。这条边界一定要明确。


一个更完整的可运行脚本示例

下面给一个我平时排查时会用的“观察 + 绕过”合并版脚本。你可以按实际类名修改。

Java.perform(function () {
    console.log("[*] script loaded");

    try {
        var Log = Java.use("android.util.Log");
        var Exception = Java.use("java.lang.Exception");
        var SecurityUtil = Java.use("com.demo.app.SecurityUtil");

        var target = SecurityUtil.checkSign.overload("android.content.Context");

        target.implementation = function (ctx) {
            console.log("[*] SecurityUtil.checkSign hit");
            console.log(Log.getStackTraceString(Exception.$new()));

            var ret = target.call(this, ctx);
            console.log("[*] original return = " + ret);

            if (ret !== true) {
                console.log("[*] bypass signature check");
            }
            return true;
        };

        console.log("[*] hook installed");
    } catch (e) {
        console.log("[!] hook failed: " + e);
    }
});

运行:

frida -U -f com.demo.app -l bypass_sign.js --no-pause

如果校验在 Native 层怎么办?

这篇重点放在 Java 层联动定位,但你要知道一个边界:如果 JADX 里只看到一个 JNI 包装方法,实际比较在 .so 里完成,那么只 Hook Java 入口可能不够。

这时可以继续走两条路:

  1. Hook JNI 导出函数 / RegisterNatives
  2. 在 Java 层直接 Hook 最终结果方法,绕过上层消费逻辑

也就是说,JADX 找不到完整逻辑,并不代表没有价值。它至少能帮你确认:

  • Native 是从哪个 Java 方法进的
  • 结果返回给谁
  • 最终在哪里触发异常流程

总结

这套 JADX + Frida 的联动方法,核心不是“某个神奇 Hook 点”,而是一种高效定位思路:

  1. 先用 JADX 找到签名相关代码形态
  2. 顺着调用链定位最终控制分支
  3. 用 Frida 动态验证是否真实命中
  4. 优先 Hook 具体业务方法,而不是一开始全局乱扫
  5. 如果业务点不明确,再退回系统 API 和调用栈排查

如果你只记住一句话,我会建议记这个:

静态分析负责缩小范围,动态 Hook 负责确认真相。

在签名校验这类场景里,这种联动往往比单独使用任一工具都高效。

最后给几个可执行建议:

  • 能 Hook 最终布尔判断,就先 Hook 最终布尔判断
  • 要研究完整链路,再去 Hook 摘要函数和系统 API
  • Java 层无果时,及时怀疑 Native 层与多进程
  • 先观察、后修改,别把“定位”和“绕过”混在第一步里

只要按这个节奏做,绝大多数 Java 层签名校验都能比较快地定位清楚。


分享到:

上一篇
《Java 中基于 CompletableFuture 的异步编排实战:从并行聚合到超时降级设计》
下一篇
《大模型应用中的 RAG 实战:从知识库构建到检索增强问答系统落地》