应用安全 --- apk加固 之 签名验证
重要声明:
本文所有内容仅用于安全研究、渗透测试和学习目的,旨在帮助开发者加固应用安全。严禁用于任何非法攻击和侵权活动。任何不当使用所导致的法律责任,由使用者自行承担。
签名验证准确来讲是证书的签名验证,apk在安装时会验证apk内部证书文件是否合法,完整,唯一。
一、APK 签名是什么?
简单来说,APK 签名就像给安卓应用(APK 文件)盖上一个独一无二的“数字印章”或“数字身份证”。
这个“印章”使用非对称加密技术生成,包含了两部分:
-
一个私钥(Private Key):由开发者秘密保管,用于对APK进行“盖章”(签名)。
-
一个公钥(Public Key):包含在APK的签名证书中,任何人都可以查看,用于验证“盖章”(签名)的真伪。
核心比喻:
-
APK文件 -> 一份重要合同
-
开发者 -> 签署合同的人
-
私钥 -> 你的个人印章或签字笔
-
签名过程 -> 你在合同上盖章/签字的过程
-
公钥/证书 -> 在公安局备案的你的公章样式或笔迹样本
-
Android系统 -> 合同接收方,它有权利用备案的样本核对合同上的签名/印章是否真实、有效。
二、为什么需要APK签名?(主要目的)
APK签名主要为了达成三个核心目标:
-
身份认证(Authenticity)
-
签名可以证明APK的作者是谁。系统可以确认这个APK确实是由持有对应私钥的开发者发布的,而不是某个冒名顶替的黑客。
-
这是建立用户信任的基础。如果你知道某个应用来自“Google LLC”,那么你会更放心地安装它。
-
-
完整性(Integrity)
-
签名可以确保APK文件自签名后没有被任何人篡改。
-
验证过程会对APK的所有内容计算一个哈希值(摘要)。如果APK被修改了哪怕一个字节(例如被植入病毒或广告木马),重新计算的哈希值就会与用公钥解密的原始哈希值不匹配,系统会拒绝安装,并提示“安装包已损坏”。
-
-
防止抵赖(Non-repudiation)
-
由于私钥由开发者独有,一旦应用被签名,开发者就无法否认这个应用是他发布的。
-
-
应用更新和模块化信任
-
应用更新:只有用相同证书签名的新版APK才能覆盖安装旧版APK。如果证书不同,系统会认为这是另一个开发者的应用,从而阻止更新。这保护了用户不会被恶意应用“劫持”。
-
共享数据:如果两个APK由同一证书签名,它们可以在Android系统中配置为相互共享数据(通过
android:sharedUserId),系统将它们视为同一个“开发者”。
-
三、APK签名的类型
在开发过程中,你会遇到两种主要类型的签名:
-
调试签名(Debug Signing)
-
目的:用于开发和调试阶段。
-
生成:通常由Android Studio(或ADT)自动生成和管理。你无需手动创建。
-
密钥库:默认使用一个名为
debug.keystore的密钥库文件。其密码是公开的(通常是android)。 -
安全性:绝对不安全!绝对不能用于发布到应用商店。任何人都可以用公开的调试密钥签名APK。
-
-
发布签名(Release Signing)
-
目的:用于发布到Google Play等应用市场或直接分发。
-
生成:必须由开发者自己创建并严格保管。这是一个非常重要的步骤。
-
密钥库:是一个
.keystore或.jks(Java KeyStore)文件,其中包含私钥和公钥证书。 -
安全性:至关重要!一旦丢失,你将无法更新你的应用(因为更新需要相同的证书),必须发布一个新应用。Google Play App Signing 服务可以帮助缓解这个问题。
-
四、APK签名的技术流程(简化版)
-
生成摘要(Hashing):
-
对APK中的所有文件的内容计算一个唯一的哈希值(如SHA-256)。这个哈希值就像是整个APK的“数字指纹”。
-
-
加密摘要(签名):
-
使用开发者的私钥对这个“数字指纹”进行加密。加密后的结果就是数字签名。
-
-
打包签名和证书:
-
将数字签名和包含开发者公钥的证书一起打包进APK文件中(保存在
META-INF目录下)。证书本身通常由“自签名”或由证书颁发机构(CA)签名。
-
验证过程(在安装时由Android系统执行):
-
提取公钥和签名:
-
从APK的证书中提取出公钥。
-
用这个公钥去解密APK中的数字签名,得到原始的“数字指纹”(哈希值A)。
-
-
重新计算摘要:
-
对当前待安装的APK文件的所有内容重新计算一次哈希值(哈希值B)。
-
-
比对验证:
-
系统比较哈希值A(从签名中解密得来的原始值)和哈希值B(刚计算出来的当前值)。
-
如果两者完全一致:说明APK自签名后未被篡改,且签名者确实持有对应的私钥。验证通过,允许安装。
-
如果两者不一致:说明APK可能已被破坏或篡改。验证失败,禁止安装。
-
https://miro.medium.com/v2/resize:fit:1400/1*_pBSzS-8V4p4hWsyHudhJQ.png
五、如何为APK签名?(实践步骤)
对于发布版APK,通常有两种方式:
-
通过Android Studio生成签名Bundle/APK:
-
在菜单栏中选择 Build > Generate Signed Bundle / APK...
-
如果是新项目,选择“Create new...”来创建一个新的密钥库(.jks文件),并设置强密码、别名等信息。务必妥善保管此文件!
-
选择签名版本(V1, V2, V3)。通常建议全选以兼容不同Android版本的设备。
-
-
通过命令行工具(
jarsigner或apksigner):-
jarsigner是JDK自带的工具,较老。 -
apksigner是Android SDK提供的更高级的工具,专门为APK优化,推荐使用。 -
示例命令:
# 使用 apksigner(Android SDK Build Tools 24.0.3+) apksigner sign --ks my-release-key.jks --ks-key-alias my-alias --out app-release-signed.apk app-release-unsigned.apk
-
总结
| 方面 | 解释 |
|---|---|
| 是什么 | 一个使用非对称加密技术为APK文件添加的数字印章,包含开发者的身份信息和文件完整性校验码。 |
| 为什么 | 认证开发者身份、保证APK内容未被篡改、允许应用安全更新。 |
| 怎么做 | 开发阶段用Android Studio自动生成的调试密钥;发布时用开发者自己创建并保管的发布密钥。 |
| 核心 | 私钥签名,公钥验证。私钥必须绝对保密,公钥随APK分发用于验证。 |
理解APK签名是Android开发者的必修课,它不仅是应用上架的门槛,更是保障用户安全和自身权益的基石。
深入探讨 Android APK 签名方案 V1、V2、V3 的细节,特别是它们的工作原理和验证过程。
这是一个逐步演进的过程,每一种方案都旨在弥补前一种方案的安全漏洞并提升性能。
概述:演进历程
| 方案 | 引入版本 | 核心特点 | 主要目的 |
|---|---|---|---|
| V1 | Android 1.0 | 基于 JAR 签名 | 基础身份验证和完整性校验 |
| V2 | Android 7.0 (Nougat) | APK 签名方案 v2 | 增强安全性、防止未经授权的修改、更快验证速度 |
| V3 | Android 9.0 (Pie) | APK 签名方案 v3 | 支持密钥轮换,提升密钥安全性 |
一、V1 签名 (JAR 签名)
这是最传统、兼容性最广的签名方案。
1. 原理与签名过程
V1 签名基于 Java 生态系统的 JAR 签名规范。它并非对整个 APK 文件进行签名,而是采用一种“归档摘要”的方式:
-
遍历 APK 中的所有条目:包括
classes.dex,resources.arsc, 所有资源文件等。 -
为每个文件计算哈希值(如 SHA1):并为每个文件在
META-INF/目录下生成一个对应的.SF(签名文件)条目。 -
对
.SF文件本身进行签名:使用开发者的私钥对.SF文件的内容进行加密,生成一个.RSA或.DSA文件(包含签名和公钥证书)。 -
将签名文件(
.SF)和签名块文件(.RSA) 一同放入 APK 的META-INF/目录中。
2. 验证过程 (How to Verify)
当系统安装或更新应用时,会进行以下验证:
-
提取证书和签名:从
META-INF/目录下的.RSA文件中提取出开发者的公钥证书。 -
验证
.SF文件:使用公钥解密.RSA文件中的签名,得到一个哈希值(我们叫它 H1)。同时,系统会重新计算.SF文件内容的哈希值(我们叫它 H2)。对比 H1 和 H2,如果一致,则证明.SF文件本身是真实且未被篡改的。 -
验证各个文件:遍历 APK 中的每一个文件(除了
META-INF/下的文件),使用.SF文件中记录的哈希值与该文件当前计算出的哈希值进行比对。任何一个不匹配都会导致验证失败。 -
完整性检查:
AndroidManifest.xml等关键文件必须存在且通过验证。
3. 缺点
-
安全性漏洞:V1 签名不会验证 APK 的某些部分,如
ZIP Central Directory和End of Central Directory区域。攻击者可以在这些区域恶意添加数据(称为 APK 校验绕过漏洞),而V1签名验证无法发现,这为“套壳”木马提供了可能。 -
验证性能:需要逐个验证APK中的每个文件,速度较慢。
二、V2 签名 (APK 签名方案 v2)
为了解决 V1 的安全漏洞,Google 在 Android 7.0 引入了 V2 签名,它是一种全文件签名方案。
1. 原理与签名过程
V2 签名将整个 APK 视为一个整体,而不是一堆独立文件的集合。
-
计算 APK 分块哈希:将整个 APK 文件划分为多个 1MB 大小的分块,并为每个分块计算哈希值。然后像默克尔树(Merkle Tree)一样,将这些分块哈希值层层计算,最终得到一个根哈希(APK 内容的唯一摘要)。
-
签名摘要:使用开发者的私钥对这个根哈希进行加密,生成数字签名。
-
插入签名块:将签名、公钥证书以及其他辅助信息打包成一个单独的 APK 签名块,并将这个块插入到原 APK 文件中,位置介于 ZIP 文件内容(
Contents of ZIP entries)和 ZIP 中央目录(Central Directory)之间。(图片来源: Android官方文档)
2. 验证过程 (How to Verify)
V2 验证发生在系统解析 APK 之前,速度更快,也更安全:
-
定位签名块:在APK中找到位于
ZIP Entries和Central Directory之间的 APK 签名块。 -
验证签名块:
-
从签名块中提取出公钥证书和签名。
-
使用公钥解密签名,得到原始的根哈希值(H1)。
-
-
重新计算哈希:按照同样的分块规则,对APK的受保护内容(即签名块之前的所有数据 + 签名块之后的所有数据)重新计算分块哈希,并最终得到一个新的根哈希值(H2)。
-
比对:对比 H1 和 H2。如果一致,则说明从签名之后,APK的所有比特都未被修改过。任何改动,无论是内容文件还是ZIP元数据,都会导致H2发生变化,验证失败。
3. 优点
-
更强的安全性:保护了APK的每一个字节,彻底解决了V1签名的漏洞。
-
更快的验证速度:只需要计算一次分块哈希并比对,比V1逐个文件验证要快得多。
三、V3 签名 (APK 签名方案 v3)
V3 签名在 V2 的基础上构建,其结构和验证过程几乎完全相同。它的核心创新是引入了密钥轮换(Key Rotation) 机制。
1. 原理与签名过程
V3 签名块的结构与 V2 类似,但增加了一个新的属性:Proof-of-rotation(旋转证明)。
-
新旧密钥同存:开发者可以使用一个新的签名密钥(未来密钥)对APK进行签名。
-
添加历史证明:在V3签名块中,不仅包含新密钥的签名,还包含一个用旧密钥(历史密钥) 对新密钥的证书进行签名的结构。这个链可以有多级,以记录整个密钥的演变历史。
-
信任链:这样就形成了一个由旧密钥到新密钥的信任链。系统可以验证新密钥是否是由开发者授权的旧密钥认证过的。
2. 验证过程 (How to Verify)
V3 的验证在V2的基础上增加了一个步骤:
-
执行V2验证:首先,像验证V2签名一样,验证当前APK内容的完整性和新密钥签名的有效性。
-
验证密钥轮换链:
-
检查V3签名块中的Proof-of-rotation结构。
-
使用设备上已经信任的旧密钥(或根密钥)的证书,去验证链中的下一个密钥是否有效。
-
一层层向下验证,直到验证到当前用于签名APK的新密钥。
-
-
链式信任:如果整个密钥链验证通过,系统就信任当前的新密钥,就像信任最初的旧密钥一样。
3. 优点
-
支持密钥更新:开发者可以在应用更新时更换签名密钥,而不会破坏连续性。
-
提升安全性:如果旧密钥不慎泄露,开发者可以轮换到一个新的、更安全的新密钥,而无需重新发布应用(需要平台支持)。
总结与最佳实践
| 特性 | V1 (JAR) | V2 (Full APK) | V3 (Key Rotation) |
|---|---|---|---|
| 兼容性 | 所有Android版本 | Android 7.0+ | Android 9.0+ |
| 安全性 | 低(有漏洞) | 高 | 更高(支持密钥轮换) |
| 性能 | 慢 | 快 | 快 |
| 核心 | 逐个文件签名 | 全文件分块签名 | 全文件签名 + 密钥历史链 |
如何选择?
-
最佳实践:在Android Studio中生成签名APK时,应该同时勾选V1、V2和V3。
-
对于 Android 7.0+ 的设备,系统会优先使用更安全的V2/V3方案进行验证。
-
对于旧设备(Android 6.0及以下),由于它们不认识V2/V3块,会自动回退到V1方案进行验证。
-
-
只勾选V2/V3:应用将无法在Android 7.0以下的设备上安装。
-
只勾选V1:应用虽然能安装,但存在安全风险,且无法享受性能优化。
验证工具:
你可以使用Android SDK中的 apksigner 工具来检查APK的签名方案。
apksigner verify -v my_app.apk
这个命令会输出详细的验证信息,并明确告诉你该APK使用了V1、V2还是V3签名。
之后有时间在说明如何举例说明v1v2v3不同的签名验证是如何在二进制下进行的。
上面讲的是安卓系统的签名校验,而我们讲的是app内部的签名校验
一、 理解APK签名校验的原理
要“过签”,首先必须明白应用为什么要校验签名。
-
身份认证: 证明APK由特定开发者发布,未被篡改。
-
完整性校验: 确保APK自签名后,其中的文件没有被修改。
-
防止篡改: 如果攻击者修改了APK(植入木马、修改逻辑),就必须重新签名。而原应用的签名校验逻辑会发现新签名与预期的官方签名不匹配,从而拒绝运行。
校验通常发生在两个地方:
-
Android系统: 在安装时,系统会校验V1/V2/V3签名是否有效。如果修改APK后签名不正确或不一致,系统会拒绝安装。
-
应用程序自身: 开发者可以在代码中调用
PackageManager的getPackageInfo()方法来获取当前应用的签名哈希值,并与预埋在代码中的正确签名进行比对。这是我们“过签”的主要目标。
签名验证举例
1. 在 AndroidManifest.xml 中注册
首先需要在应用的 AndroidManifest.xml 文件中注册这个 ContentProvider:
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppTheme">
<!-- 其他组件注册 -->
<!-- 注册签名验证 ContentProvider -->
<provider
android:name="com.SmaliHelper.Signature.ispro"
android:authorities="com.yourpackage.name.signature.provider"
android:exported="false" />
</application>
2. 获取正确的签名值
在使用前,你需要获取应用的正确签名 Base64 编码值。可以通过以下代码获取:
// 在应用的其他地方调用此方法获取签名
public String getAppSignature(Context context) {
try {
PackageManager pm = context.getPackageManager();
String packageName = context.getPackageName();
PackageInfo packageInfo = pm.getPackageInfo(packageName, PackageManager.GET_SIGNATURES);
Signature[] signatures = packageInfo.signatures;
ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
DataOutputStream dataStream = new DataOutputStream(byteStream);
dataStream.write(signatures.length);
for (Signature sig : signatures) {
X509Certificate cert = X509Certificate.getInstance(sig.toByteArray());
byte[] encodedCert = cert.getEncoded();
dataStream.writeInt(encodedCert.length);
dataStream.write(encodedCert);
}
return Base64.encodeToString(byteStream.toByteArray(), Base64.DEFAULT);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
3. 设置正确的签名值
获取到签名值后,你需要修改 ispro 类中的 Signer 字段:
// 包声明:这个类位于 com.SmaliHelper.Signature 包中 package com.SmaliHelper.Signature; // 导入Android相关的类 import android.content.*; import android.database.Cursor; import android.net.Uri; import android.util.Base64; // 导入Java IO相关的类 import java.io.ByteArrayOutputStream; import java.io.DataOutputStream; // 导入安全证书相关的类 import javax.security.cert.X509Certificate; // 定义一个名为 ispro 的类,继承自 ContentProvider // ContentProvider 是Android四大组件之一,用于在不同应用间共享数据 public class ispro extends ContentProvider { // 声明一个私有字符串变量 Signer,用于存储预期的签名信息 private String Signer; // 声明一个最终的字符串变量 TAG,用于日志记录 private final String TAG; // 构造函数:当创建这个类的实例时调用 public ispro() { // 获取当前类的简单名称作为日志标签 TAG = ispro.class.getSimpleName(); // 初始化 Signer 为空字符串(实际使用时需要设置为正确的签名值) Signer = ""; } // ContentProvider 的生命周期方法:当ContentProvider被创建时调用 @Override public boolean onCreate() { try { // 获取应用的包管理器,用于获取应用信息 PackageManager pm = getContext().getPackageManager(); // 获取当前应用的包名 String packageName = getContext().getPackageName(); // 获取当前应用的包信息,包括签名信息 // GET_SIGNATURES 标志表示我们要获取签名信息 PackageInfo packageInfo = pm.getPackageInfo(packageName, PackageManager.GET_SIGNATURES); // 从包信息中提取签名数组 Signature[] signatures = packageInfo.signatures; // 创建一个字节数组输出流,用于存储处理后的签名数据 ByteArrayOutputStream byteStream = new ByteArrayOutputStream(); // 创建一个数据输出流,包装字节数组输出流,方便写入数据 DataOutputStream dataStream = new DataOutputStream(byteStream); // 将签名数量写入数据流 dataStream.write(signatures.length); // 遍历所有签名 for (Signature sig : signatures) { // 将签名转换为X.509证书格式 X509Certificate cert = X509Certificate.getInstance(sig.toByteArray()); // 获取证书的编码(字节数组形式) byte[] encodedCert = cert.getEncoded(); // 将证书数据的长度写入数据流 dataStream.writeInt(encodedCert.length); // 将证书数据本身写入数据流 dataStream.write(encodedCert); } // 将处理后的签名数据转换为Base64编码的字符串 String currentSign = Base64.encodeToString(byteStream.toByteArray(), Base64.DEFAULT); // 比较当前签名与预期签名是否一致 if (!Signer.equals(currentSign)) { // 如果不一致,强制退出应用 System.exit(0); // 抛出运行时异常(实际上不会执行到这里,因为上面已经退出了) throw new RuntimeException(); } } catch (Exception e) { // 如果过程中出现任何异常,打印异常信息 e.printStackTrace(); // 强制退出应用 System.exit(0); // 抛出运行时异常 throw new RuntimeException(); } // 返回false表示ContentProvider初始化失败 // 但这里的主要目的不是提供数据,而是进行签名验证 return false; } // ContentProvider 必须实现的方法:查询数据 // 这里返回null,表示不提供任何数据 @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { return null; } // ContentProvider 必须实现的方法:返回指定URI的MIME类型 // 这里返回null,表示不提供任何数据 @Override public String getType(Uri uri) { return null; } // ContentProvider 必须实现的方法:插入数据 // 这里返回null,表示不提供任何数据 @Override public Uri insert(Uri uri, ContentValues values) { return null; } // ContentProvider 必须实现的方法:删除数据 // 这里返回0,表示没有删除任何数据 @Override public int delete(Uri uri, String selection, String[] selectionArgs) { return 0; } // ContentProvider 必须实现的方法:更新数据 // 这里返回0,表示没有更新任何数据 @Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { return 0; } }
4. 使用示例
一旦设置完成,这个签名验证机制会在应用启动时自动执行。如果签名验证失败,应用会立即退出。
另一种方法
package com.SmaliHelper.SignaturePro; // 定义包名 // 导入Android相关类 import android.content.ContentProvider; // 内容提供者基类 import android.content.ContentValues; // 内容值类,用于数据库操作 import android.content.Context; // 上下文类 import android.content.pm.Signature; // 签名类 import android.database.Cursor; // 数据库游标类 import android.net.Uri; // 统一资源标识符类 import android.os.Build; // 系统构建信息类 import android.util.Base64; // Base64编码工具类 // 导入Java IO相关类 import java.io.ByteArrayOutputStream; // 字节数组输出流 import java.io.DataOutputStream; // 数据输出流 import java.io.BufferedReader; // 缓冲读取器 import java.io.InputStreamReader; // 输入流读取器 import java.io.File; // 文件类 // 导入Java反射相关类 import java.lang.reflect.Constructor; // 构造函数反射类 import java.lang.reflect.Field; // 字段反射类 import java.lang.reflect.Method; // 方法反射类 import java.util.Objects; // 对象工具类 // 导入X509证书类 import javax.security.cert.X509Certificate; /** * 签名验证内容提供者 - 通过反射和进程调用验证应用签名 * 如果签名不匹配预期值,将强制退出应用 */ public class isPro extends ContentProvider implements Runnable { // 定义类,继承ContentProvider并实现Runnable接口 private final String TAG = isPro.class.getSimpleName(); // 定义日志标签,获取类的简单名称 private String Signer = "com.GKLBB "; // 定义预期的签名哈希值字符串 public isPro() { // 默认构造函数 // 空构造函数 } @Override // 重写父类方法 public boolean onCreate() { // ContentProvider的onCreate方法 // 启动新线程执行签名验证 new Thread(this).start(); // 创建新线程并启动,this作为Runnable对象传入 return false; // 返回false表示此提供者未成功加载 } @Override // 重写Runnable接口的run方法 public void run() { // 线程执行的主方法 Process exec = null; // 声明进程变量,用于执行shell命令 try { // 开始异常处理块 // 获取当前应用的APK路径 exec = Runtime.getRuntime().exec("pm path " + // 执行pm path命令获取APK路径 Objects.requireNonNull(getContext()).getPackageName()); // 获取当前应用包名,requireNonNull确保不为空 // 读取命令输出 BufferedReader reader = new BufferedReader( // 创建缓冲读取器 new InputStreamReader(exec.getInputStream())); // 包装进程的输入流 StringBuilder output = new StringBuilder(); // 创建字符串构建器存储输出 String line; // 声明行变量 while ((line = reader.readLine()) != null) { // 循环读取每一行,直到读取完毕 output.append(line); // 将读取的行添加到输出字符串中 } // 检查输出是否包含包路径信息 if (output.toString().contains("package:")) { // 判断输出是否包含"package:"字符串 // 提取APK文件路径 String apkPath = output.toString() // 将StringBuilder转换为字符串 .replace("package:", "") // 移除"package:"前缀 .trim(); // 去除首尾空白字符 String encodedSignatures = null; // 声明变量存储编码后的签名信息 // 根据Android版本使用不同的反射方法获取签名 if (Build.VERSION.SDK_INT <= 27) { // 判断Android版本是否为8.1及以下 // 使用PackageParser$Package类获取签名信息 Class<?> packageParserClass = Class.forName("android.content.pm.PackageParser$Package"); // 通过反射获取PackageParser$Package类 Constructor<?> constructor = packageParserClass.getDeclaredConstructor(String.class); // 获取接受String参数的构造函数 constructor.setAccessible(true); // 设置构造函数为可访问 Object packageObj = constructor.newInstance(getContext().getPackageName()); // 创建PackageParser$Package实例 // 调用PackageParser.collectCertificates方法收集证书 Class<?> parserClass = Class.forName("android.content.pm.PackageParser"); // 通过反射获取PackageParser类 Method collectCertificatesMethod = parserClass.getDeclaredMethod( // 获取collectCertificates方法 "collectCertificates", packageParserClass, File.class, int.class); // 方法参数类型 collectCertificatesMethod.setAccessible(true); // 设置方法为可访问 collectCertificatesMethod.invoke(null, packageObj, new File(apkPath), 0x40); // 调用静态方法收集证书 // 获取签名字段 Field signaturesField = packageParserClass.getDeclaredField("mSignatures"); // 获取mSignatures字段 signaturesField.setAccessible(true); // 设置字段为可访问 Signature[] signatures = (Signature[]) signaturesField.get(packageObj); // 获取签名数组 // 编码签名信息 encodedSignatures = encodeSignatures(signatures); // 调用方法编码签名信息 } else { // Android 9.0及以上版本的处理逻辑 // 使用PackageParser$SigningDetails类获取签名信息 Class<?> signingDetailsClass = Class.forName("android.content.pm.PackageParser$SigningDetails"); // 通过反射获取SigningDetails类 Class<?> packageParserClass = Class.forName("android.content.pm.PackageParser$Package"); // 通过反射获取PackageParser$Package类 Constructor<?> constructor = packageParserClass.getDeclaredConstructor(String.class); // 获取接受String参数的构造函数 constructor.setAccessible(true); // 设置构造函数为可访问 Object packageObj = constructor.newInstance(getContext().getPackageName()); // 创建PackageParser$Package实例 // 调用PackageParser.collectCertificates方法收集证书 Class<?> parserClass = Class.forName("android.content.pm.PackageParser"); // 通过反射获取PackageParser类 Method collectCertificatesMethod = parserClass.getDeclaredMethod( // 获取collectCertificates方法 "collectCertificates", packageParserClass, File.class, boolean.class); // 方法参数类型(Android 9+使用boolean而非int) collectCertificatesMethod.setAccessible(true); // 设置方法为可访问 collectCertificatesMethod.invoke(null, packageObj, new File(apkPath), false); // 调用静态方法收集证书 // 获取签名详情字段 Field signingDetailsField = packageParserClass.getDeclaredField("mSigningDetails"); // 获取mSigningDetails字段 signingDetailsField.setAccessible(true); // 设置字段为可访问 Object signingDetails = signingDetailsField.get(packageObj); // 获取签名详情对象 // 获取签名字段 Field signaturesField = signingDetailsClass.getDeclaredField("signatures"); // 从SigningDetails类获取signatures字段 signaturesField.setAccessible(true); // 设置字段为可访问 Signature[] signatures = (Signature[]) signaturesField.get(signingDetails); // 获取签名数组 // 编码签名信息 encodedSignatures = encodeSignatures(signatures); // 调用方法编码签名信息 } // 检查签名是否有效 if (encodedSignatures == null) { // 判断编码后的签名是否为空 // 签名无效,退出应用 System.exit(0); // 强制退出应用程序 throw new RuntimeException("签名信息为空"); // 抛出运行时异常 } // 检查签名是否匹配预期值 if (!Signer.equals(encodedSignatures)) { // 比较预期签名与实际签名是否相等 // 签名不匹配,退出应用 System.exit(0); // 强制退出应用程序 throw new RuntimeException("签名验证失败"); // 抛出运行时异常 } } else { // 如果输出不包含"package:" // 无法获取APK路径,退出应用 System.exit(0); // 强制退出应用程序 throw new RuntimeException("无法获取APK路径"); // 抛出运行时异常 } } catch (Exception e) { // 捕获所有异常 e.printStackTrace(); // 打印异常堆栈信息 // 发生异常,退出应用 System.exit(0); // 强制退出应用程序 throw new RuntimeException("签名检查错误", e); // 抛出包装后的运行时异常 } finally { // 无论是否发生异常都会执行的代码块 // 清理进程资源 if (exec != null) { // 检查进程对象是否不为空 exec.destroy(); // 销毁进程,释放资源 } } } /** * 将签名数组编码为Base64字符串 * @param signatures 签名数组 * @return 编码后的签名字符串 */ private String encodeSignatures(Signature[] signatures) throws Exception { // 私有方法,用于编码签名数组 ByteArrayOutputStream byteOut = new ByteArrayOutputStream(); // 创建字节数组输出流 DataOutputStream dataOut = new DataOutputStream(byteOut); // 创建数据输出流包装字节输出流 // 写入签名数量 dataOut.write(signatures.length); // 将签名数组长度写入输出流 // 处理每个签名 for (Signature signature : signatures) { // 遍历签名数组中的每个签名 // 转换为X509证书并获取编码形式 X509Certificate cert = X509Certificate.getInstance(signature.toByteArray()); // 将签名转换为X509证书实例 byte[] encodedCert = cert.getEncoded(); // 获取证书的编码字节数组 // 写入证书长度和内容 dataOut.writeInt(encodedCert.length); // 写入证书字节数组的长度 dataOut.write(encodedCert); // 写入证书的字节数据 } // 返回Base64编码结果 return Base64.encodeToString(byteOut.toByteArray(), Base64.NO_WRAP); // 将字节数组转换为Base64字符串并返回 } // 以下为ContentProvider必须实现的存根方法(未实际使用) @Override // 重写父类方法 public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { // 查询方法 return null; // 返回空,表示不支持查询操作 } @Override // 重写父类方法 public String getType(Uri uri) { // 获取URI对应的MIME类型 return null; // 返回空,表示不支持获取类型操作 } @Override // 重写父类方法 public Uri insert(Uri uri, ContentValues values) { // 插入数据方法 return null; // 返回空,表示不支持插入操作 } @Override // 重写父类方法 public int delete(Uri uri, String selection, String[] selectionArgs) { // 删除数据方法 return 0; // 返回0,表示没有删除任何数据 } @Override // 重写父类方法 public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { // 更新数据方法 return 0; // 返回0,表示没有更新任何数据 } }
反射方法 vs PackageManager 获取签名的区别
1. 访问层级不同
PackageManager方式(常规方法):
PackageManager pm = getPackageManager();
PackageInfo packageInfo = pm.getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES);
Signature[] signatures = packageInfo.signatures;
反射方式(代码中使用的方法):
- 直接访问Android系统内部的
PackageParser类 - 绕过了PackageManager的公开API限制
- 直接从APK文件解析签名信息
2. 安全性和检测难度
PackageManager方式:
- 容易被Hook框架(如Xposed、Frida)拦截和修改
- 攻击者可以轻松替换返回的签名信息
- 检测工具容易识别这种常规的签名检查
反射方式:
- 更难被检测和Hook,因为使用了系统内部API
- 攻击者需要更深入了解Android内部机制才能绕过
- 增加了逆向分析的难度
3. 兼容性考虑
PackageManager方式:
- API稳定,向后兼容性好
- 不会因Android版本更新而失效
反射方式:
- 依赖Android内部实现,可能因系统更新而失效
- 需要针对不同Android版本做适配(如代码中的SDK_INT判断)
反射原理详解
1. Java反射机制基础
反射是Java的一个特性,允许程序在运行时:
- 检查类的结构(字段、方法、构造函数)
- 创建对象实例
- 调用方法
- 访问和修改字段值
2. 代码中的反射应用
让我用具体例子说明:
// 1. 获取类对象
Class<?> packageParserClass = Class.forName("android.content.pm.PackageParser$Package");
// 2. 获取构造函数
Constructor<?> constructor = packageParserClass.getDeclaredConstructor(String.class);
constructor.setAccessible(true); // 绕过访问权限检查
// 3. 创建实例
Object packageObj = constructor.newInstance(getContext().getPackageName());
// 4. 获取方法
Method collectCertificatesMethod = parserClass.getDeclaredMethod(
"collectCertificates", packageParserClass, File.class, int.class);
collectCertificatesMethod.setAccessible(true);
// 5. 调用方法
collectCertificatesMethod.invoke(null, packageObj, new File(apkPath), 0x40);
// 6. 获取字段
Field signaturesField = packageParserClass.getDeclaredField("mSignatures");
signaturesField.setAccessible(true);
// 7. 读取字段值
Signature[] signatures = (Signature[]) signaturesField.get(packageObj);
3. 为什么需要setAccessible(true)
- Java的访问控制(private、protected)在反射中仍然有效
setAccessible(true)可以绕过这些访问限制- 允许访问私有字段和方法
4. Android版本适配的原因
代码中针对不同Android版本使用不同的反射路径:
Android 8.1及以下:
- 使用
PackageParser$Package.mSignatures字段 collectCertificates方法参数为int类型
Android 9.0及以上:
- 引入了
PackageParser$SigningDetails类 - 签名信息存储在
SigningDetails.signatures字段 collectCertificates方法参数改为boolean类型
5. 反射的优势和风险
优势:
- 可以访问系统内部API
- 提高安全检查的隐蔽性
- 绕过常规的Hook点
风险:
- 代码复杂度高,维护困难
- 兼容性问题,可能因系统更新而失效
- 性能开销相对较大
- 可能违反某些应用商店的政策
这种反射方式的签名检查主要用于提高应用的安全性,防止被恶意修改或重新打包,但同时也增加了代码的复杂性和维护成本。
对抗
“过签”的本质是:在修改APK后,让其签名校验逻辑(无论是系统的还是自身的)依然能够通过。
核心思路分为两大类:
-
让校验逻辑拿到“正确”的签名值: 修改APP的逻辑,让它无论当前APK是什么签名,都认为签名是正确的。或者修改安卓系统内部的签名返回正确签名给app。
-
让APK使用“官方”签名文件签名: 这是最理想但极难实现的方式,除非能拿到开发者的原始签名密钥(keystore)。
大致的方法有:
1.最简单的方法替换已安装的应用文件。因为apk验证发生在软件的安装过程,对安装好的app不验证。就是在安装好的原版app目录下/data/app下找到你的app,这里可以用mt管理器的已经安装软件查看文件安装真实路径。在mt下找到要修改的文件替换即可实现过签。
2.自动化过签,mt管理器,np管理器等实现
3.手动过签,修改软件或系统实现
以下是具体的方法,从易到难,从通用到特定:
方法一:修改自身校验代码(最常用)
这是最直接的方法,适用于有自定义签名校验逻辑的APP。
所需工具:
-
APK反编译工具:
MT Manager,NP Manager,APK Easy Tool,Jadx-GUI -
回编译工具: 同上(MT/NP Manager都集成了编辑和回编译功能)
-
文本编辑器: 如 VS Code, Notepad++
详细步骤:
-
反编译APK:
-
使用
MT Manager或APK Easy Tool打开目标APK文件。 -
工具会将APK解包,并将
classes.dex等文件反编译为smali代码或(如果幸运的话)java代码。
-
-
定位签名校验代码:
-
关键词搜索: 在反编译出的代码(smali或java)中搜索以下关键词:
-
signature -
getPackageInfo -
SHA1, `SHA`SHA256,MD5(常见的签名哈希算法) -
PackageManager -
packageInfo -
特定的签名哈希值字符串(如果你能拿到原包的签名,可以搜索这个哈希值)
-
-
调用栈分析: 如果APP在签名校验失败时会崩溃或弹出Toast,可以通过Logcat捕捉崩溃日志,找到校验失败的异常点,从而逆向定位到校验代码的位置。
-
-
分析并修改逻辑:
-
找到关键判断语句,通常是
if-eq,if-ne(相等/不相等跳转)。 -
修改方式:
-
直接返回正确值: 将校验方法的结果强制返回
true或1。 -
NOP掉关键判断: 将
if-ne等判断指令替换为nop(空操作),让程序顺序执行,即跳过校验失败的分支。 -
修改比较值: 将预埋在代码中的正确签名哈希值,改为你重签名后APK的签名哈希值(需要提前计算好)。
-
-
-
回编译与重签名:
-
在
MT Manager等工具中,完成代码修改后,直接点击“编译”或“回编译”按钮。 -
工具会自动将修改后的
smali代码重新编译成classes.dex,并打包成新的APK文件。 -
最关键的一步: 使用你自己的签名证书对这个新APK进行签名。MT Manager等工具在回编译后会自动提示你进行签名。这一步是为了让APK能安装到手机上。
-
-
安装测试:
-
安装重签名后的APK,测试功能是否正常,校验是否已被绕过。
-
方法二:Hook签名校验方法(动态调试)
这种方法不修改APK本身,而是在应用运行时,通过注入代码(Hook)来动态修改函数的返回值,使其永远返回“正确”的结果。
所需工具/环境:
-
已Root的手机 或 模拟器
-
CorePatch,BypassSignatureVerify) -
或 Frida + 过签脚本
详细步骤(以Frida为例):
-
环境搭建:
-
在电脑上安装
frida和frida-tools:pip install frida-tools -
在已Root的手机上运行
frida-server。
-
-
编写或获取Frida脚本:
-
网上有大量现成的过签脚本(如
android-anti-anti-hooking.js等),它们通常包含HookgetPackageInfo等方法的代码。 -
一个简单的示例脚本:
Java.perform(function () { var PackageManager = Java.use("android.app.ApplicationPackageManager"); PackageManager.getPackageInfo.overload('java.lang.String', 'int').implementation = function (pkgName, flags) { var result = this.getPackageInfo(pkgName, flags); // 这里可以伪造签名信息,让返回的Signature是你期望的值 // 但对于简单的校验,直接返回原结果也可能绕过,因为校验代码被Hook了拿不到真实值 console.log("Bypassing getPackageInfo for: " + pkgName); return result; }; });
-
-
执行 Hook:
-
在电脑命令行运行:
frida -U -l your_script.js -f com.example.targetapp -
这会启动目标APP并注入脚本。
-
-
测试:
-
操作APP,检查签名校验是否被绕过。
-
优点: 无需修改APK,方便快捷。
缺点: 需要Root环境,对抗高级混淆或Native校验比较困难。
方法三:对抗 Native 层校验
一些强保护的应用会将校验逻辑放在C/C++代码(.so文件)中,增加逆向难度。
所需工具:
-
IDA Pro, Ghidra, Binary Ninja (用于分析 so 文件)
-
Frida (用于Hook Native函数)
详细步骤:
-
定位校验函数:
-
反编译APK,在Java代码中找到加载so库(
System.loadLibrary("check"))和声明Native方法(native boolean checkSignature())的地方。 -
使用IDA Pro等工具打开对应的
.so文件。
-
-
分析 so 文件 :
-
在IDA中查看导出函数,寻找与Java对应的
Java_com_example_xxx_checkSignature函数。 -
或者通过JNIEnv方法(如
GetMethodID,CallObjectMethod)来寻找哪里调用了getPackageInfo。
-
-
修改或Hook:
-
修改so文件: 类似于修改smali,找到关键汇编指令(如
CMP,BNE),将其修改为无效指令或强制跳转到成功分支。然后用010 Editor等十六进制编辑器修改so文件,并替换回APK中。难度极高。 -
Hook Native函数: 使用Frida的
Interceptor来Hook so文件中的关键函数,强制其返回正确的值。这是更可行的方法。
-
方法四: 通用过签工具(一键过签)
对于常见的签名校验,社区已经有一些集成的工具。
-
MT Manager/NP Manager 的过签功能 :
-
最新版的MT/NP管理器内置了“去除签名校验”的功能。
-
步骤: 用MT管理器打开APK ->
功能->APK签名-> 在签名界面勾选去除签名校验-> 然后执行签名。 -
其原理是自动搜索并破坏常见的签名校验模式,属于黑盒自动化方案,成功率不是100%,但对很多应用有效。
-
有时间我们分析每个过签算法的原理
三、 总结与防御措施
方法选择建议:
-
初学者: 优先尝试 方法四(MT管理器一键过签) 和 方法一(搜索修改简单校验)。
-
进阶者: 遇到强校验时,结合 方法一(深度分析smali) 和 方法二(Frida Hook)。
-
专家: 对付商业加固应用,需要 方法三(Native层分析),并综合运用所有手段。
如何防御?(给开发者的建议)
-
多维度校验: 不要只校验一次。在多个关键功能点、多个时机进行校验。
-
Native化: 将核心校验逻辑放到C/C++层,并加壳混淆。
-
代码混淆: 使用ProGuard、DexGuard等工具混淆代码,增加定位校验逻辑的难度。
-
环境检测: 检测Root、Xposed、Frida等调试和Hook环境,一旦发现就终止运行或触发暗桩。
-
服务端校验: 将APK签名信息发送到服务器进行验证。这是最安全的方式,因为攻击者很难绕过服务器校验。但需要注意网络请求也可能被Hook。
-
签名值混淆/加密: 不要将正确的签名哈希值以明文形式存储在代码中。
-
使用V3/V4签名方案: 更新的签名方案提供了更好的安全性和完整性保护。
记住,这始终是一场“矛”与“盾”的持续较量。
为什么去签名后应用还是闪退?
1.签名校验未完全移除:应用可能有多处签名校验点,工具只移除了部分。需要更深入的手动分析。
2、应用加固:目标应用使用了代码混淆(如ProGuard)或商业加固(如360、腾讯乐固、爱加密),增加了反编译和修改的难度。
3、其他校验机制:除了签名,应用可能还有完整性校验(检查文件是否被改)、防调试、服务器校验等。即使签名通过,其他校验失败也会导致闪退。
4、修改错误:在修改Smali代码或资源时出现语法错误或逻辑错误。
5、重签名失败:重新签名过程出错。
云镜签名验证举例
浙公网安备 33010602011771号