安卓逆向实战:基于 Frida 与 JADX 定位并绕过常见签名校验逻辑
很多 Android App 会做“签名校验”:确认自己是不是由官方证书签名、有没有被二次打包、是不是运行在被篡改的环境里。对开发者来说,这是保护手段;对逆向分析来说,它往往是第一道门槛。
这篇文章我不打算只讲概念,而是按一个能落地复现的实战路径来走:先用 JADX 找到签名校验代码,再用 Frida 动态 hook,把校验结果改掉,最终验证绕过是否生效。
说明一下:本文内容用于安全研究、加固评估与自测,请勿用于未授权目标。
背景与问题
在 Android 逆向里,签名校验常见于这些场景:
- App 启动时检查自身签名
- 登录、支付、会员功能前做二次校验
- Native 层配合 Java 层双重校验
- 检测是否被重打包、注入、替换壳
逆向时常见症状也很典型:
- App 一启动就闪退
- 某页面打不开,提示“环境异常”
- 某个按钮点击后无响应
- 改包名、重签名之后直接触发保护逻辑
如果你只是“知道有签名校验”,但不知道它在哪、怎么执行、怎么改结果,那就很难推进。实际工作里,我一般会把问题拆成三步:
- 静态定位:用 JADX 找到疑似签名校验入口
- 动态确认:用 Frida 验证是否真的走到了这里
- 最小绕过:尽量少改逻辑,只改关键返回值
前置知识与环境准备
建议你至少具备这些基础:
- 会用
adb - 知道 APK 安装、启动、查看日志
- 对 Java/Android API 有基本认识
- 用过 Frida 的基础 hook 语法
环境准备
- Android 真机或模拟器
adbjadx-guifrida-tools- 与设备架构匹配的
frida-server
安装 Frida 工具:
pip install frida-tools
查看设备架构:
adb shell getprop ro.product.cpu.abi
推送并启动 frida-server:
adb push frida-server /data/local/tmp/
adb shell "chmod +x /data/local/tmp/frida-server"
adb shell "/data/local/tmp/frida-server &"
验证设备连接:
frida-ps -U
核心原理
Android 签名校验常见实现方式,核心无非就几类:
- 直接读取自身签名并比对
- 取签名摘要(如 SHA-1 / SHA-256 / MD5)后比对
- 通过
PackageManager查询包信息 - Native 层通过 JNI 调 Java API 或直接校验
- 服务端参与校验,本地只做前置判断
Java 层常见签名校验路径
老版本常见写法:
PackageInfo packageInfo = pm.getPackageInfo(pkg, PackageManager.GET_SIGNATURES);
Signature[] signatures = packageInfo.signatures;
新版本更常见:
PackageInfo packageInfo = pm.getPackageInfo(pkg, PackageManager.GET_SIGNING_CERTIFICATES);
SigningInfo signingInfo = packageInfo.signingInfo;
之后通常会:
- 调
toByteArray() - 喂给
MessageDigest - 转 hex 字符串
- 与硬编码值比对
我们绕过的基本思路
绕过不是只有一种姿势,常见有三类:
- 改入口参数:让它查不到真实状态
- 改中间过程:比如改摘要、改签名数组
- 改最终结果:直接把校验函数返回
true
实战里我更推荐先从最终结果下手,因为:
- 改动最少
- 风险小
- 更容易验证
- 不容易引发连锁副作用
用 JADX 做静态定位
拿到 APK 后,先用 JADX 打开。不要急着全文乱搜,我一般按下面的顺序查。
1. 先搜高频关键词
优先搜索这些词:
getPackageInfoGET_SIGNATURESGET_SIGNING_CERTIFICATESsignaturessigningInfoMessageDigestSHA1SHA-1SHA256md5Signaturecertificatedebuggabletamper安全签名
2. 关注这些可疑代码形态
例如:
public static boolean checkSignature(Context context) {
try {
PackageManager pm = context.getPackageManager();
PackageInfo pi = pm.getPackageInfo(context.getPackageName(), 64);
Signature[] signatures = pi.signatures;
MessageDigest md = MessageDigest.getInstance("SHA1");
md.update(signatures[0].toByteArray());
String hex = bytesToHex(md.digest());
return "12AB34CD56EF7890".equalsIgnoreCase(hex);
} catch (Exception e) {
return false;
}
}
这个就很典型:
getPackageInfo(..., 64)中64就是旧版GET_SIGNATURES- 取签名字节
- 做摘要
- 与常量比对
- 返回
boolean
这类函数几乎就是理想 hook 点。
3. 反查调用链
找到校验函数后,继续看:
- 谁调用了它?
- 是在
Application.attach()里调用? - 还是
MainActivity.onCreate()? - 有没有在按钮点击、网络请求前调用?
如果函数名混淆严重,不要怕,重点看参数类型和调用链位置。
典型校验流程图
flowchart TD
A[App 启动/进入功能页] --> B[调用签名校验函数]
B --> C[PackageManager 获取签名信息]
C --> D[MessageDigest 计算摘要]
D --> E[与内置摘要比对]
E -->|匹配| F[继续执行]
E -->|不匹配| G[弹窗/闪退/禁用功能]
动态分析思路:先确认,再绕过
静态看到了可疑函数,不代表它一定执行。下一步要做的是:用 Frida 动态确认。
一条经验
我踩过一个很常见的坑:JADX 里找到一个非常像的校验函数,结果 hook 半天没反应。后来发现那是旧版本遗留代码,真正执行的是另一个混淆类里的实现。
所以别上来就“改返回值”,先打印日志确认函数是否被调用。
实战代码(可运行)
下面给一个完整示例。假设我们在 JADX 里定位到:
- 类名:
com.demo.security.SignCheck - 方法:
public static boolean checkSignature(android.content.Context context)
示例一:直接 hook 最终返回值
Java.perform(function () {
var SignCheck = Java.use('com.demo.security.SignCheck');
SignCheck.checkSignature.overload('android.content.Context').implementation = function (context) {
console.log('[*] checkSignature called');
var original = this.checkSignature(context);
console.log('[*] original result = ' + original);
console.log('[*] force return true');
return true;
};
});
启动方式:
frida -U -f com.demo.app -l hook_sign.js
如果不需要 spawn,也可以 attach:
frida -U com.demo.app -l hook_sign.js
示例二:hook PackageManager.getPackageInfo
有些 App 会在多个地方做签名校验,逐个改业务函数太慢。这时候可以直接盯底层 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);
console.log(' flags = ' + flags);
return this.getPackageInfo(pkg, flags);
};
});
这个脚本先用于观察:
- 哪个包名被查询
- 使用了什么 flags
- 是否在启动早期触发
如果你已经确认某个路径稳定,再进一步 hook 上层校验逻辑会更安全。
示例三:hook 摘要比对函数
有的应用会把签名摘要封装进工具类,例如:
public static boolean equalsExpected(String digest)
那么可以直接改这里:
Java.perform(function () {
var Utils = Java.use('com.demo.security.SecurityUtils');
Utils.equalsExpected.overload('java.lang.String').implementation = function (digest) {
console.log('[*] equalsExpected called, digest=' + digest);
return true;
};
});
这种方式的优点是:
- 不碰系统 API
- 对业务影响范围小
- 成功率通常不错
示例四:通用打印签名摘要,辅助确认目标值
如果你还没找到比对常量,可以先把真实签名摘要打印出来。
Java.perform(function () {
var MessageDigest = Java.use('java.security.MessageDigest');
var Integer = Java.use('java.lang.Integer');
var StringCls = Java.use('java.lang.String');
function toHex(bytes) {
var result = '';
for (var i = 0; i < bytes.length; i++) {
var v = bytes[i];
if (v < 0) v += 256;
var hv = v.toString(16);
if (hv.length < 2) hv = '0' + hv;
result += hv;
}
return result;
}
MessageDigest.digest.overload().implementation = function () {
var ret = this.digest();
try {
var algo = this.getAlgorithm();
console.log('[*] MessageDigest.digest algo=' + algo + ', hex=' + toHex(ret));
} catch (e) {
console.log('[!] print digest error: ' + e);
}
return ret;
};
});
这个脚本不是专门绕过,而是用来帮助你定位摘要值和算法类型。
更完整的定位与绕过流程
sequenceDiagram
participant R as 逆向分析者
participant J as JADX
participant F as Frida
participant A as App
R->>J: 搜索签名相关 API/常量
J-->>R: 返回可疑校验函数与调用链
R->>F: 编写日志型 hook
F->>A: 注入并观察方法调用
A-->>F: 输出命中日志与参数
R->>F: 修改为返回值绕过
F->>A: 强制校验通过
A-->>R: 功能恢复/不再闪退
一个可复现的教学示例
下面给一个简化版 Android Java 校验函数,便于你理解 hook 点。
目标代码示例
package com.demo.security;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.Signature;
import java.security.MessageDigest;
public class SignCheck {
public static boolean checkSignature(Context context) {
try {
PackageInfo pi = context.getPackageManager().getPackageInfo(
context.getPackageName(),
PackageManager.GET_SIGNATURES
);
Signature signature = pi.signatures[0];
byte[] cert = signature.toByteArray();
MessageDigest md = MessageDigest.getInstance("SHA-1");
byte[] digest = md.digest(cert);
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02X", b));
}
return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".equals(sb.toString());
} catch (Exception e) {
return false;
}
}
}
对应 Frida 绕过脚本
Java.perform(function () {
var SignCheck = Java.use('com.demo.security.SignCheck');
SignCheck.checkSignature.overload('android.content.Context').implementation = function (context) {
console.log('[*] bypass SignCheck.checkSignature');
return true;
};
});
运行:
frida -U -f com.demo.app -l bypass.js --no-pause
逐步验证清单
为了避免“看起来 hook 了,实际上没生效”,建议按这个清单走。
第一步:确认进程注入成功
frida-ps -U | grep demo
或者直接看命令行输出里有没有你写的日志。
第二步:确认类名和方法签名无误
如果报错:
ClassNotFoundExceptionTypeError: cannot find overload
优先检查:
- 混淆后类名是否真实
- 方法是否静态/实例
- 参数类型是否匹配
- 是否存在多个重载
第三步:先打印原始返回值
不要一开始就全改,先看:
- 方法是不是被调用了
- 原始返回值是什么
- 调用频率是否异常
第四步:改返回值后验证业务结果
验证不应该只看“没报错”,而要看:
- 页面是否正常打开
- 功能按钮是否恢复
- 是否还有后续二次校验
- 是否存在延迟检测
常见坑与排查
这一节很重要,因为很多问题不是“不会写 hook”,而是“hook 得太晚、hook 错层、被混淆误导”。
1. hook 时机太晚
如果签名校验发生在 Application.attach() 甚至更早,attach 方式可能来不及。
解决办法:
frida -U -f com.demo.app -l hook_sign.js --no-pause
用 spawn 模式,让脚本在应用主逻辑执行前注入。
2. 方法重载选错
例如:
checkSignature(Context)
checkSignature(Context, String)
这时候必须明确 overload(...)。
错误示例:
SignCheck.checkSignature.implementation = function (context) {
return true;
};
更稳妥的写法:
SignCheck.checkSignature.overload('android.content.Context').implementation = function (context) {
return true;
};
3. 混淆后函数名毫无语义
遇到 a.a.a.a() 这类代码很正常。不要依赖函数名,改看:
- 参数类型
- 返回值类型
- 调用位置
- 内部是否调用
PackageManager - 是否出现摘要算法字符串
4. 校验在 Native 层
Java 层怎么改都不生效,可能原因之一是:
- Java 只是做表面检查
- 真正判定在 so 里
- Java 和 Native 双重校验
这时可以先定位 JNI 方法,例如在 JADX 中搜索:
System.loadLibrarynativeJNIregisterNatives
再决定是否继续用 Frida hook native 导出符号或 JNI 桥接函数。
5. 不是签名校验,是完整性联动检测
有些异常现象看起来像签名校验,实际是:
- root 检测
- 调试检测
- 模拟器检测
- Xposed/Frida 检测
- dex/so 完整性校验
如果你绕过了一个函数,但 App 还是崩,说明可能还有联动检测链路。
6. Android 版本差异
不同 Android 版本签名 API 不完全一样:
- 旧版:
GET_SIGNATURES - 新版:
GET_SIGNING_CERTIFICATES
如果只盯一个 API,容易漏掉目标实现。
校验逻辑的常见实现分类
classDiagram
class SignatureCheck {
+checkByPackageManager()
+checkByDigest()
+checkByNative()
+checkByServer()
}
class PackageManagerPath {
+getPackageInfo()
+read signatures/signingInfo
}
class DigestPath {
+MessageDigest SHA1/SHA256/MD5
+bytesToHex()
+equals()
}
class NativePath {
+JNI bridge
+libxxx.so verify
}
class ServerPath {
+upload local fingerprint
+server decision
}
SignatureCheck --> PackageManagerPath
SignatureCheck --> DigestPath
SignatureCheck --> NativePath
SignatureCheck --> ServerPath
安全/性能最佳实践
虽然我们在讲绕过,但做分析脚本时也要讲方法,不然很容易把目标进程搞崩。
1. 优先最小化 hook 范围
我的建议是:
- 先 hook 单个业务函数
- 再考虑 hook 通用工具类
- 最后才去动系统 API
原因很简单:越底层,影响面越大。
例如直接 hook MessageDigest.digest(),虽然好用,但会打印大量日志,甚至影响加密、网络、登录等其他逻辑。
2. 日志要适度
不要一上来在高频函数里疯狂 console.log()。某些函数调用量非常大,日志会:
- 拖慢 App
- 增加卡顿
- 干扰时序
- 导致你误判“App 自己不稳定”
建议加过滤条件,例如只关注目标包名、目标算法、目标线程。
3. 保留原始返回值用于比对
一个很实用的习惯是:
- 先打印原始值
- 再决定强改
- 必要时加开关控制
例如:
var forceBypass = true;
这样你可以快速切换“观察模式”和“绕过模式”。
4. 对异常做兜底
脚本尽量别因为一个打印失败就中断:
try {
// print something
} catch (e) {
console.log(e);
}
特别是 hook 系统类、数组、字节流时,容错很重要。
5. 面对双重校验时,先找最终决策点
有些 App 会同时做:
- 本地签名校验
- Native 指纹校验
- 服务端授权校验
这时最有效的策略通常不是三个都硬怼,而是先找最终阻断功能的决策点,比如:
if (!envOk) return;showRiskDialog();finish();System.exit(0);
从结果反推,比逐个层面穷举更高效。
边界条件:什么时候“绕过了本地签名校验”也没用?
这个问题很现实,值得提前讲清楚。
以下场景中,即使你本地绕过成功,也不一定能拿到完整功能:
- 服务端校验证书指纹
- 关键参数带签名并在服务端验签
- Native 层做核心逻辑,Java 层只是壳
- 完整性检测结果被上报服务器
- 存在 Frida/Xposed 检测并触发风控
所以本文的方法更适合:
- 定位本地保护逻辑
- 验证防护强度
- 做功能可达性分析
- 安全评估与研究
不意味着可以替代全部链路分析。
一个更稳的 Frida 模板
最后给一个我在实战里比较常用的模板:先枚举类是否存在,再 hook,避免脚本启动即报错。
Java.perform(function () {
var targetClass = 'com.demo.security.SignCheck';
try {
var SignCheck = Java.use(targetClass);
console.log('[*] found class: ' + targetClass);
var overload = SignCheck.checkSignature.overload('android.content.Context');
overload.implementation = function (context) {
console.log('[*] checkSignature hit');
var result = overload.call(this, context);
console.log('[*] original = ' + result);
return true;
};
} catch (e) {
console.log('[!] hook failed: ' + e);
}
});
这个模板的好处是:
- 出错信息更明确
- 保留原始调用结果
- 后续便于扩展成条件绕过
总结
用 JADX + Frida 处理 Android 签名校验,最实用的路线其实很朴素:
- JADX 静态搜索高频 API 和摘要比对逻辑
- 沿调用链找到真正生效的校验点
- Frida 先打印日志确认命中
- 优先修改最终返回值,做最小绕过
- 如果无效,再考虑 Native 层或联动检测
如果你是中级读者,我建议你把这套方法沉淀成自己的分析套路,而不是记几个孤立的 hook 片段。因为现实里的 App 会混淆、会分层、会多重校验,但定位思路和验证方法其实是共通的。
最后给几条可执行建议:
- 首选 spawn 注入,避免 hook 太晚
- 静态定位不要只看函数名,要看 API、常量、调用链
- 绕过时尽量从 最终决策点 下手
- 如果 Java 层无效,及时怀疑 JNI/so/服务端联动
- 每次只改一个点,确保你知道“到底是哪一步生效了”
只要你能把“静态定位 + 动态验证 + 最小改动”这三步跑顺,常见的签名校验逻辑基本都能较高效地拆开看清楚。