安卓逆向实战:中级开发者如何用 Frida 定位并绕过常见 APK 签名校验逻辑
做 Android 逆向时,最常见的一道门槛就是 APK 签名校验。很多同学第一次重打包一个 APK,安装倒是成功了,一运行就闪退,或者关键功能直接“装死”。这时候八成不是你改坏了代码,而是应用自己做了签名检查。
这篇文章我会从中级开发者可落地的角度,带你用 Frida 把常见签名校验逻辑“抓出来”,再一步步验证、定位、绕过。不会只讲原理,也不会只扔一段神秘脚本,而是尽量像我带你在真机旁边做一遍。
说明:本文内容仅用于安全研究、加固评估、兼容性测试和授权场景分析,请勿用于未授权目标。
背景与问题
很多应用会在运行时校验自己的签名,目的通常有几个:
- 防止 APK 被二次打包
- 防止调试版、测试版被伪装成正式版
- 做渠道校验或许可证绑定
- 联合反调试、反 Hook 形成更完整的保护链
对逆向分析来说,典型现象包括:
- 重签名后启动即闪退
- 某个核心页面打不开
- 登录、支付、会员等功能提示“环境异常”
- Frida 一附加就崩,或者校验结果直接失败
如果你只会“搜字符串”,往往会卡在两个地方:
- 校验点不止一个
- 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 里,签名校验常见落点主要有三类:
- 系统 API 获取签名后比对
- 应用内置证书摘要(MD5/SHA1/SHA256)进行比对
- 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 静态看过代码,知道有类似:
SignUtilsAppSecurityCheckSignatureSecurityManager
这类类名,最省事的方式是直接 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. 先观察,后修改
我很少一上来就强改返回值。先记录调用链,再动手,能少走很多弯路。
推荐顺序:
- 记录 API 调用
- 记录参数与返回值
- 确认校验链
- 最小化修改点
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.jstrace_digest.jsbypass_verify.jstrace_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
最后给几个可执行建议:
- 不要一开始就改 smali,先用 Frida 验证校验路径
- 不要一上来全量 Hook 热点 API,日志会把你淹没
- 找到最终布尔判断后优先从那里下手
- 多进程、反调试、Native 校验 是最容易漏的三个点
如果你能把“观察上游数据流”和“锁定最终判断”这两件事练熟,绕过常见 APK 签名校验就会从“碰运气”变成一套可复用的方法论。