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

《安卓逆向实战:基于 Frida 与 JADX 定位并绕过常见 App 签名校验逻辑》

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

安卓逆向实战:基于 Frida 与 JADX 定位并绕过常见 App 签名校验逻辑

很多 Android App 会做签名校验:
有的是为了防二次打包,有的是为了限制调试环境,还有的是把签名结果当作某些敏感逻辑的“开关”。

站在开发者角度,这很正常;站在逆向分析角度,签名校验往往就是第一道门。本文不讨论恶意用途,而是聚焦合法授权测试、加固分析、攻防学习场景,带你用 JADX 做静态定位,再用 Frida 做动态验证与绕过

我会按“先定位、再验证、最后最小修改绕过”的思路来讲。这个流程比上来就全局 Hook 更稳,也更容易知道自己改动了什么。


背景与问题

App 为什么做签名校验

常见目的有几类:

  1. 防二次打包

    • 重新签名后,应用检测到证书不一致,直接退出或限制功能。
  2. 环境检测

    • 配合 root、debuggable、模拟器检测一起做,签名校验只是其中一环。
  3. 接口保护

    • 某些老项目会把签名摘要参与请求参数计算,服务端也会校验。
  4. 动态加载保护

    • 插件、SO、Dex 动态加载前会先检查宿主签名。

常见表现

你在分析时,通常会遇到这些现象:

  • App 一启动就闪退
  • 某个核心页面打不开
  • 登录、支付、会员等功能不可用
  • 日志里出现 signature invalidtamperedverify failed
  • Frida 附加后行为异常,但 UI 不明显报错

很多中级学习者卡在这一步,不是不会写 Hook,而是找不到真正的校验点
所以本文重点不是“给你一个万能脚本”,而是教你怎么从 APK 里定位逻辑,再用 Frida 精准切入


前置知识与环境准备

你需要准备的工具

  • JADX
    • 用来反编译 APK,查看 Java/Kotlin 层逻辑
  • Frida
    • 动态 Hook Java 层 / Native 层
  • adb
    • 安装 APK、看日志、转发端口
  • 一台测试机或模拟器
    • 推荐 Android 8~13 均可
  • frida-server
    • 设备端服务,与 Frida 版本保持一致

基本命令确认

先确认设备连通:

adb devices

确认 Frida 能识别进程:

frida-ps -U

如果要在启动时附加目标 App:

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

合法边界

请只在以下范围内操作:

  • 自有 App 的安全测试
  • 获得授权的渗透/加固评估
  • 教学与实验环境

不要把本文方法用于未授权目标。


核心原理

Android 的签名校验,本质上是:App 在运行时取到自身 APK 或安装包签名,再与内置的期望值进行比对

常见实现路径

  1. PackageManager 获取签名
  2. 计算证书摘要
    • MD5 / SHA1 / SHA256
  3. 和硬编码值比较
  4. 触发限制逻辑
    • return false
    • finish()
    • throw RuntimeException
    • native 校验失败后退出

一张总览图

flowchart TD
    A[启动 App] --> B[获取当前包签名]
    B --> C[证书转字节/字符串]
    C --> D[计算摘要 MD5/SHA1/SHA256]
    D --> E{是否与预置值一致}
    E -- 是 --> F[继续执行核心功能]
    E -- 否 --> G[退出/限制功能/报错]

常见 Java 层校验写法

1)老接口:GET_SIGNATURES

旧项目里很常见:

PackageInfo pi = pm.getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES);
Signature[] signs = pi.signatures;
String md5 = md5(signs[0].toByteArray());
return "预置摘要".equalsIgnoreCase(md5);

2)新接口:GET_SIGNING_CERTIFICATES

Android 9 以后更常见:

PackageInfo pi = pm.getPackageInfo(getPackageName(), PackageManager.GET_SIGNING_CERTIFICATES);
SigningInfo signingInfo = pi.signingInfo;
Signature[] signs = signingInfo.getApkContentsSigners();

3)直接比较签名字符串

String sign = signatures[0].toCharsString();
if (!EXPECTED.equals(sign)) {
    System.exit(0);
}

4)校验封装在工具类或 Application 里

例如:

  • AppSignUtil.checkSign(context)
  • SecurityManager.verifySignature()
  • BaseApplication.attachBaseContext()
  • SplashActivity.onCreate()

动态分析时的思路

我一般按这个顺序:

  1. JADX 搜关键字

    • getPackageInfo
    • signatures
    • SigningInfo
    • SHA1
    • SHA-256
    • toCharsString
    • MessageDigest
    • PackageManager
  2. 找比较点

    • equals
    • equalsIgnoreCase
    • contains
    • 布尔返回值
  3. 找失败后的动作

    • finish()
    • exitProcess
    • System.exit
    • 抛异常
    • 调 native 方法
  4. Frida 动态验证

    • 打印方法参数与返回值
    • 确认是不是核心校验
    • 最后只改必要位置

用 JADX 定位签名校验逻辑

这一段很关键。很多时候你真正需要 Hook 的不是系统 API,而是App 自己封装的校验方法

第一步:全局搜索关键字

在 JADX 中全局搜索这些词:

  • getPackageInfo
  • GET_SIGNATURES
  • GET_SIGNING_CERTIFICATES
  • signatures
  • signingInfo
  • MessageDigest
  • SHA1
  • SHA-256
  • X509Certificate

如果搜索结果很多,优先看:

  • Application
  • SplashActivity
  • LoginActivity
  • SecurityUtil
  • CheckSignUtil
  • NativeBridge

第二步:识别“签名获取 + 摘要计算 + 比较”三段式

典型伪代码如下:

public static boolean check(Context context) {
    try {
        PackageManager pm = context.getPackageManager();
        PackageInfo pi = pm.getPackageInfo(context.getPackageName(), 64);
        Signature signature = pi.signatures[0];
        byte[] cert = signature.toByteArray();
        String sha1 = digest(cert, "SHA-1");
        return "12AB34CD...".equalsIgnoreCase(sha1);
    } catch (Exception e) {
        return false;
    }
}

只要你看到类似结构,基本就是目标点。

第三步:顺藤摸瓜找到调用链

不要停在工具函数本身,继续看谁调用了它
因为你真正要绕过的,可能是下面这种业务逻辑:

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

或者:

boolean ok = SecurityManager.verify(this);
nativeInit(ok);

这会决定你的 Hook 策略是:

  • 直接改 check() 返回值
  • 还是改获取签名的系统 API
  • 还是拦截失败分支

一张定位路径图

sequenceDiagram
    participant A as 分析者
    participant J as JADX
    participant C as 校验工具类
    participant B as 业务入口
    A->>J: 搜索 signatures / signingInfo / MessageDigest
    J-->>A: 返回工具类与调用点
    A->>C: 查看签名获取与摘要计算
    A->>B: 查看失败分支
    A->>A: 决定 Hook 粒度

实战:Frida 绕过常见签名校验

下面给出一个完整的实践流程。
为了更贴近实际,我按三层来做:

  1. 观察
  2. 验证
  3. 最后绕过

实战代码(可运行)

方案一:Hook App 自己的校验函数(首选)

如果你已经通过 JADX 找到了类似 com.example.app.SecurityUtil.checkSign(Context) 的方法,最稳的办法就是直接改它的返回值。

示例脚本:强制返回 true

Java.perform(function () {
    var SecurityUtil = Java.use('com.example.app.SecurityUtil');

    SecurityUtil.checkSign.overload('android.content.Context').implementation = function (ctx) {
        console.log('[+] SecurityUtil.checkSign() called, force return true');
        return true;
    };
});

运行:

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

这种方法优点很明显:

  • 改动小
  • 命中准
  • 不容易影响其他逻辑

如果有多个重载,先枚举一下:

Java.perform(function () {
    var SecurityUtil = Java.use('com.example.app.SecurityUtil');
    SecurityUtil.checkSign.overloads.forEach(function (o) {
        console.log(o);
    });
});

方案二:Hook PackageManager.getPackageInfo,替换或观测签名获取

如果找不到上层封装,或者校验点很多,可以先从系统 API 入手观察。

观察版脚本:打印包名与 flags

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 => pkg=' + pkg + ', flags=' + flags);
        return this.getPackageInfo(pkg, flags);
    };
});

如果目标使用的是新版签名接口,还要看更高版本的重载:

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

    ApplicationPackageManager.getPackageInfo.overloads.forEach(function (ov) {
        console.log('[*] overload => ' + ov);
    });
});

直接绕过思路

如果 App 最终比较的是 PackageInfo.signaturesSigningInfo 的内容,你可以进一步 Hook 签名对象的字符串转换或摘要计算函数。


方案三:Hook 摘要比较函数,直接让比较通过

有些 App 会把签名处理得比较绕,但最后一定有一个“比较”的动作。
这时可以 Hook java.lang.String.equals 或业务类里的比较函数做验证,不过我不建议长期用全局 Hook,因为副作用较大。

验证版:仅打印可疑比较

Java.perform(function () {
    var StringCls = Java.use('java.lang.String');

    StringCls.equals.overload('java.lang.Object').implementation = function (obj) {
        var left = this.toString();
        var right = obj ? obj.toString() : 'null';

        if (left.length > 20 || right.length > 20) {
            if (left.indexOf(':') !== -1 || right.indexOf(':') !== -1 || /^[0-9A-Fa-f]+$/.test(left) || /^[0-9A-Fa-f]+$/.test(right)) {
                console.log('[*] String.equals => left=' + left + ', right=' + right);
            }
        }

        return this.equals(obj);
    };
});

这个脚本主要用于观察,看是不是在比较证书摘要。
确认后,应该回去找更精准的业务函数下手。


方案四:Hook MessageDigest,定位签名摘要算法

当你在 JADX 里只看到了很多加密逻辑,不确定哪个是签名摘要时,可以 Hook MessageDigest

Java.perform(function () {
    var MessageDigest = Java.use('java.security.MessageDigest');

    MessageDigest.getInstance.overload('java.lang.String').implementation = function (alg) {
        console.log('[*] MessageDigest.getInstance => ' + alg);
        return this.getInstance(alg);
    };
});

如果想进一步打印输入输出,可以继续 Hook digest()。不过要注意输出可能很多。

Java.perform(function () {
    var MessageDigest = Java.use('java.security.MessageDigest');

    MessageDigest.digest.overload('[B').implementation = function (data) {
        var ret = this.digest(data);
        console.log('[*] MessageDigest.digest called, inputLen=' + data.length + ', outputLen=' + ret.length);
        return ret;
    };
});

方案五:直接拦截失败动作

这是我在一些“逻辑分散、校验点不好找”的样本里常用的兜底法。
比如 App 校验失败后直接 finish(),那就先阻止它退出,保住现场。

Hook Activity.finish

Java.perform(function () {
    var Activity = Java.use('android.app.Activity');

    Activity.finish.implementation = function () {
        var name = this.getClass().getName();
        console.log('[!] Activity.finish intercepted => ' + name);
        // 不调用原方法,阻止关闭
    };
});

Hook System.exit

Java.perform(function () {
    var SystemCls = Java.use('java.lang.System');

    SystemCls.exit.implementation = function (code) {
        console.log('[!] System.exit intercepted => code=' + code);
        // 阻止退出
    };
});

这个办法不一定能真正“通过校验”,但它可以让你继续观察后续逻辑,快速缩小排查范围。


一套更完整的组合脚本

下面给一个更实用的组合脚本:
它会同时做三件事:

  1. 观察签名相关 API
  2. 打印摘要算法
  3. 如果命中特定业务类,强制返回成功

你只需要把类名改成自己在 JADX 里看到的即可。

Java.perform(function () {
    console.log('[+] script loaded');

    // 1. 观察 PackageInfo 获取
    try {
        var APM = Java.use('android.app.ApplicationPackageManager');
        APM.getPackageInfo.overload('java.lang.String', 'int').implementation = function (pkg, flags) {
            console.log('[*] getPackageInfo(pkg=' + pkg + ', flags=' + flags + ')');
            return this.getPackageInfo(pkg, flags);
        };
    } catch (e) {
        console.log('[-] hook getPackageInfo failed: ' + e);
    }

    // 2. 观察摘要算法
    try {
        var MessageDigest = Java.use('java.security.MessageDigest');
        MessageDigest.getInstance.overload('java.lang.String').implementation = function (alg) {
            console.log('[*] MessageDigest algorithm => ' + alg);
            return this.getInstance(alg);
        };
    } catch (e) {
        console.log('[-] hook MessageDigest failed: ' + e);
    }

    // 3. 精准绕过业务函数
    try {
        var SecurityUtil = Java.use('com.example.app.SecurityUtil');
        SecurityUtil.checkSign.overload('android.content.Context').implementation = function (ctx) {
            console.log('[+] bypass SecurityUtil.checkSign');
            return true;
        };
    } catch (e) {
        console.log('[-] hook SecurityUtil.checkSign failed: ' + e);
    }

    // 4. 兜底:拦截退出
    try {
        var SystemCls = Java.use('java.lang.System');
        SystemCls.exit.implementation = function (code) {
            console.log('[!] blocked System.exit(' + code + ')');
        };
    } catch (e) {
        console.log('[-] hook System.exit failed: ' + e);
    }
});

逐步验证清单

我建议你不要一次写太多 Hook,而是按下面的顺序逐步验证:

第 1 步:确认静态定位没偏

  • 在 JADX 中找到疑似签名校验函数
  • 确认它被启动流程或核心功能调用
  • 确认失败分支存在明显限制逻辑

第 2 步:只打印,不修改

先运行观察脚本,确认:

  • getPackageInfo 是否真的被调用
  • 摘要算法是否命中
  • 业务校验函数是否在关键时机被执行

第 3 步:只改一个点

优先改:

  • checkSign() 返回值
  • verify() 返回值
  • 单一业务判断分支

不要一开始就全局改 String.equals()

第 4 步:观察功能是否恢复

检查:

  • App 是否不再闪退
  • 目标页面是否可以进入
  • 网络请求是否恢复
  • 是否仍有 native 层报错

第 5 步:精简脚本

当你已经确认绕过成功后,把临时观测 Hook 删除,只保留:

  • 最少的业务 Hook
  • 必要的退出拦截
  • 少量日志

这样稳定性最好。


常见坑与排查

这一部分基本都是实战里最容易翻车的点。

1. Hook 了,但没有生效

常见原因:

  • 类名写错
  • 方法重载签名不对
  • 目标类还没加载
  • 你附加太晚,校验已经执行完了

排查方法:

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

如果校验在启动早期执行,优先使用:

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

而不是 attach 到已经启动的进程。


2. Android 版本不同,签名 API 不一样

老版本常见:

  • PackageInfo.signatures

新版本常见:

  • PackageInfo.signingInfo
  • SigningInfo.getApkContentsSigners()

所以在 JADX 里看到的实现可能与网上示例不同。
不要死记 API 名字,要抓住本质:一定有“取签名 -> 处理 -> 比较”


3. 校验在 Native 层

表现通常是:

  • Java 层看起来只做了很少的事情
  • 校验函数很快进入 native 方法
  • Java 层 Hook 返回 true 后仍然退出

例如:

public native boolean verifyNative(Context context);

这时需要进一步:

  • System.loadLibrary
  • 看 native 方法注册
  • 用 Frida 的 Interceptor.attach 去 Hook so 导出或 JNI 注册函数

如果你还在学习阶段,建议先确认 Java 层是否只是“前置筛选”,不要一上来就把问题全归到 native。


4. App 开了混淆,函数名全乱了

混淆不是大问题,关键还是看行为特征。
我通常这样找:

  • getPackageInfo
  • MessageDigest
  • 搜硬编码摘要字符串
  • 看返回 boolean 的短函数
  • ApplicationattachBaseContext、启动 Activity

即使类名是 a.a.a.b,逻辑结构也骗不了人。


5. Hook 过于粗暴,App 其他功能异常

比如你全局改了:

  • String.equals
  • MessageDigest.digest
  • Activity.finish

会导致:

  • 登录逻辑异常
  • 页面无法正常关闭
  • 摘要算法结果紊乱

我的经验是:
全局 Hook 只用于定位,不用于最终方案。
最终脚本尽量只改业务函数返回值。


6. 多进程 App,Hook 到了错误进程

有些 App 会把校验放在:

  • :push
  • :guard
  • :remote
  • 独立壳进程

先看看进程列表:

frida-ps -Uai

必要时指定进程名附加。


安全/性能最佳实践

虽然我们这里讨论的是逆向分析,但做动态 Hook 也要讲“工程习惯”。

1. 优先精准 Hook,避免全局污染

推荐优先级:

  1. 业务工具类 checkSign()/verify()
  2. 单一调用点
  3. 系统签名 API
  4. 全局比较或退出函数

这样能最大限度减少副作用。

2. 日志适度,不要刷爆输出

String.equalsMessageDigest.digest 这类方法调用频率可能很高。
日志过多会导致:

  • Frida 卡顿
  • App 响应变慢
  • 关键日志被淹没

建议只打印满足条件的调用,或者阶段性开启。

3. 对异常做好兜底

Frida 脚本里尽量用 try/catch 包住每个 Hook 点:

try {
    // hook logic
} catch (e) {
    console.log(e);
}

这样即使某个类不存在,也不会让整个脚本失效。

4. 保持版本匹配

确保:

  • frida-tools 版本
  • frida Python 包版本
  • frida-server 版本

三者尽量一致。
很多“莫名其妙连不上”“注入后崩溃”的问题,本质就是版本不匹配。

5. 先验证边界,再做结论

即使绕过了本地签名校验,也不代表整个保护链都过了。
因为还有可能存在:

  • 服务端二次校验
  • native 层再次校验
  • Dex/so 完整性校验
  • 运行时反调试

所以当你看到“界面能打开了”,不要立刻下结论。
最好再验证:

  • 网络请求是否成功
  • 核心功能是否可用
  • 是否存在延迟触发的退出逻辑

一个实战思路模板

如果你以后拿到一个陌生 APK,可以直接按这个模板走:

flowchart LR
    A[导入 JADX] --> B[搜索签名相关 API]
    B --> C[锁定工具类和调用入口]
    C --> D[Frida 观察调用链]
    D --> E[确认真实校验点]
    E --> F[精准 Hook 返回值]
    F --> G[验证功能恢复]
    G --> H[删除多余 Hook 保留最小脚本]

总结

这类签名校验绕过,最怕两件事:

  • 只会“抄脚本”,不会定位逻辑
  • 一上来就全局 Hook,结果把现场弄乱

更稳的做法是:

  1. JADX 静态定位

    • 找“取签名 -> 算摘要 -> 做比较 -> 失败分支”
  2. Frida 动态验证

    • 先打印,再确认调用链
  3. 最小化绕过

    • 优先改业务函数返回值
    • 不行再拦截系统 API 或失败动作
  4. 验证完整链路

    • 不只看 UI,还要看网络和延迟触发逻辑

如果你是中级读者,我给你的可执行建议只有一句:
以后遇到签名校验,先别急着 Hook 系统层,先在 JADX 里把调用链走通。
你一旦能稳定找到“真正决定成败的那个 boolean”,后面的绕过其实只是技术细节。

如果目标样本已经把校验下沉到 native 或做了壳保护,那就是另一套打法了;但即便如此,本文这套“静态定位 + 动态验证 + 最小修改”的方法,仍然是非常可靠的起点。


分享到:

上一篇
《前端性能实战:基于 Core Web Vitals 的渲染优化与问题排查指南》
下一篇
《Spring Boot 中基于 JWT 与 Spring Security 的前后端分离鉴权实战与权限设计》