GKLBB

当你经历了暴风雨,你也就成为了暴风雨

导航

应用安全 --- apk加固 之 签名验证

重要声明:
本文所有内容仅用于安全研究、渗透测试和学习目的,旨在帮助开发者加固应用安全。严禁用于任何非法攻击和侵权活动。任何不当使用所导致的法律责任,由使用者自行承担。

 

签名验证准确来讲是证书的签名验证,apk在安装时会验证apk内部证书文件是否合法,完整,唯一。

 

一、APK 签名是什么?

简单来说,APK 签名就像给安卓应用(APK 文件)盖上一个独一无二的“数字印章”或“数字身份证”。

这个“印章”使用非对称加密技术生成,包含了两部分:

  1. 一个私钥(Private Key):由开发者秘密保管,用于对APK进行“盖章”(签名)。

  2. 一个公钥(Public Key):包含在APK的签名证书中,任何人都可以查看,用于验证“盖章”(签名)的真伪。

核心比喻:

  • APK文件 -> 一份重要合同

  • 开发者 -> 签署合同的人

  • 私钥 -> 你的个人印章或签字笔

  • 签名过程 -> 你在合同上盖章/签字的过程

  • 公钥/证书 -> 在公安局备案的你的公章样式或笔迹样本

  • Android系统 -> 合同接收方,它有权利用备案的样本核对合同上的签名/印章是否真实、有效。


二、为什么需要APK签名?(主要目的)

APK签名主要为了达成三个核心目标:

  1. 身份认证(Authenticity)

    • 签名可以证明APK的作者是谁。系统可以确认这个APK确实是由持有对应私钥的开发者发布的,而不是某个冒名顶替的黑客。

    • 这是建立用户信任的基础。如果你知道某个应用来自“Google LLC”,那么你会更放心地安装它。

  2. 完整性(Integrity)

    • 签名可以确保APK文件自签名后没有被任何人篡改。

    • 验证过程会对APK的所有内容计算一个哈希值(摘要)。如果APK被修改了哪怕一个字节(例如被植入病毒或广告木马),重新计算的哈希值就会与用公钥解密的原始哈希值不匹配,系统会拒绝安装,并提示“安装包已损坏”。

  3. 防止抵赖(Non-repudiation)

    • 由于私钥由开发者独有,一旦应用被签名,开发者就无法否认这个应用是他发布的。

  4. 应用更新和模块化信任

    • 应用更新:只有用相同证书签名的新版APK才能覆盖安装旧版APK。如果证书不同,系统会认为这是另一个开发者的应用,从而阻止更新。这保护了用户不会被恶意应用“劫持”。

    • 共享数据:如果两个APK由同一证书签名,它们可以在Android系统中配置为相互共享数据(通过android:sharedUserId),系统将它们视为同一个“开发者”。


三、APK签名的类型

在开发过程中,你会遇到两种主要类型的签名:

  1. 调试签名(Debug Signing)

    • 目的:用于开发和调试阶段。

    • 生成:通常由Android Studio(或ADT)自动生成和管理。你无需手动创建。

    • 密钥库:默认使用一个名为 debug.keystore 的密钥库文件。其密码是公开的(通常是 android)。

    • 安全性:绝对不安全!绝对不能用于发布到应用商店。任何人都可以用公开的调试密钥签名APK。

  2. 发布签名(Release Signing)

    • 目的:用于发布到Google Play等应用市场或直接分发。

    • 生成:必须由开发者自己创建并严格保管。这是一个非常重要的步骤。

    • 密钥库:是一个.keystore.jks(Java KeyStore)文件,其中包含私钥和公钥证书。

    • 安全性:至关重要!一旦丢失,你将无法更新你的应用(因为更新需要相同的证书),必须发布一个新应用。Google Play App Signing 服务可以帮助缓解这个问题。


四、APK签名的技术流程(简化版)

  1. 生成摘要(Hashing):

    • 对APK中的所有文件的内容计算一个唯一的哈希值(如SHA-256)。这个哈希值就像是整个APK的“数字指纹”。

  2. 加密摘要(签名):

    • 使用开发者的私钥对这个“数字指纹”进行加密。加密后的结果就是数字签名。

  3. 打包签名和证书:

    • 将数字签名和包含开发者公钥的证书一起打包进APK文件中(保存在META-INF目录下)。证书本身通常由“自签名”或由证书颁发机构(CA)签名。

验证过程(在安装时由Android系统执行):

  1. 提取公钥和签名:

    • 从APK的证书中提取出公钥。

    • 用这个公钥去解密APK中的数字签名,得到原始的“数字指纹”(哈希值A)。

  2. 重新计算摘要:

    • 对当前待安装的APK文件的所有内容重新计算一次哈希值(哈希值B)。

  3. 比对验证:

    • 系统比较哈希值A(从签名中解密得来的原始值)和哈希值B(刚计算出来的当前值)。

    • 如果两者完全一致:说明APK自签名后未被篡改,且签名者确实持有对应的私钥。验证通过,允许安装。

    • 如果两者不一致:说明APK可能已被破坏或篡改。验证失败,禁止安装。

https://miro.medium.com/v2/resize:fit:1400/1*_pBSzS-8V4p4hWsyHudhJQ.png


五、如何为APK签名?(实践步骤)

对于发布版APK,通常有两种方式:

  1. 通过Android Studio生成签名Bundle/APK:

    • 在菜单栏中选择 Build > Generate Signed Bundle / APK...

    • 如果是新项目,选择“Create new...”来创建一个新的密钥库(.jks文件),并设置强密码、别名等信息。务必妥善保管此文件!

    • 选择签名版本(V1, V2, V3)。通常建议全选以兼容不同Android版本的设备。

  2. 通过命令行工具(jarsigner 或 apksigner):

    • jarsigner是JDK自带的工具,较老。

    • apksigner是Android SDK提供的更高级的工具,专门为APK优化,推荐使用。

    • 示例命令:

      bash
      # 使用 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 文件进行签名,而是采用一种“归档摘要”的方式:

  1. 遍历 APK 中的所有条目:包括 classes.dexresources.arsc, 所有资源文件等。

  2. 为每个文件计算哈希值(如 SHA1):并为每个文件在 META-INF/ 目录下生成一个对应的 .SF(签名文件)条目。

  3. 对 .SF 文件本身进行签名:使用开发者的私钥对 .SF 文件的内容进行加密,生成一个 .RSA 或 .DSA 文件(包含签名和公钥证书)。

  4. 将签名文件(.SF)和签名块文件(.RSA) 一同放入 APK 的 META-INF/ 目录中。

2. 验证过程 (How to Verify)

当系统安装或更新应用时,会进行以下验证:

  1. 提取证书和签名:从 META-INF/ 目录下的 .RSA 文件中提取出开发者的公钥证书。

  2. 验证 .SF 文件:使用公钥解密 .RSA 文件中的签名,得到一个哈希值(我们叫它 H1)。同时,系统会重新计算 .SF 文件内容的哈希值(我们叫它 H2)。对比 H1 和 H2,如果一致,则证明 .SF 文件本身是真实且未被篡改的。

  3. 验证各个文件:遍历 APK 中的每一个文件(除了 META-INF/ 下的文件),使用 .SF 文件中记录的哈希值与该文件当前计算出的哈希值进行比对。任何一个不匹配都会导致验证失败。

  4. 完整性检查: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 视为一个整体,而不是一堆独立文件的集合。

  1. 计算 APK 分块哈希:将整个 APK 文件划分为多个 1MB 大小的分块,并为每个分块计算哈希值。然后像默克尔树(Merkle Tree)一样,将这些分块哈希值层层计算,最终得到一个根哈希(APK 内容的唯一摘要)。

  2. 签名摘要:使用开发者的私钥对这个根哈希进行加密,生成数字签名。

  3. 插入签名块:将签名、公钥证书以及其他辅助信息打包成一个单独的 APK 签名块,并将这个块插入到原 APK 文件中,位置介于 ZIP 文件内容(Contents of ZIP entries)和 ZIP 中央目录(Central Directory)之间。

    (图片来源: Android官方文档)

2. 验证过程 (How to Verify)

V2 验证发生在系统解析 APK 之前,速度更快,也更安全:

  1. 定位签名块:在APK中找到位于 ZIP Entries 和 Central Directory 之间的 APK 签名块。

  2. 验证签名块:

    • 从签名块中提取出公钥证书和签名。

    • 使用公钥解密签名,得到原始的根哈希值(H1)。

  3. 重新计算哈希:按照同样的分块规则,对APK的受保护内容(即签名块之前的所有数据 + 签名块之后的所有数据)重新计算分块哈希,并最终得到一个新的根哈希值(H2)。

  4. 比对:对比 H1 和 H2。如果一致,则说明从签名之后,APK的所有比特都未被修改过。任何改动,无论是内容文件还是ZIP元数据,都会导致H2发生变化,验证失败。

3. 优点

  • 更强的安全性:保护了APK的每一个字节,彻底解决了V1签名的漏洞。

  • 更快的验证速度:只需要计算一次分块哈希并比对,比V1逐个文件验证要快得多。


三、V3 签名 (APK 签名方案 v3)

V3 签名在 V2 的基础上构建,其结构和验证过程几乎完全相同。它的核心创新是引入了密钥轮换(Key Rotation) 机制。

1. 原理与签名过程

V3 签名块的结构与 V2 类似,但增加了一个新的属性:Proof-of-rotation(旋转证明)。

  1. 新旧密钥同存:开发者可以使用一个新的签名密钥(未来密钥)对APK进行签名。

  2. 添加历史证明:在V3签名块中,不仅包含新密钥的签名,还包含一个用旧密钥(历史密钥) 对新密钥的证书进行签名的结构。这个链可以有多级,以记录整个密钥的演变历史。

  3. 信任链:这样就形成了一个由旧密钥到新密钥的信任链。系统可以验证新密钥是否是由开发者授权的旧密钥认证过的。

2. 验证过程 (How to Verify)

V3 的验证在V2的基础上增加了一个步骤:

  1. 执行V2验证:首先,像验证V2签名一样,验证当前APK内容的完整性和新密钥签名的有效性。

  2. 验证密钥轮换链:

    • 检查V3签名块中的Proof-of-rotation结构。

    • 使用设备上已经信任的旧密钥(或根密钥)的证书,去验证链中的下一个密钥是否有效。

    • 一层层向下验证,直到验证到当前用于签名APK的新密钥。

  3. 链式信任:如果整个密钥链验证通过,系统就信任当前的新密钥,就像信任最初的旧密钥一样。

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的签名方案。

bash
apksigner verify -v my_app.apk

这个命令会输出详细的验证信息,并明确告诉你该APK使用了V1、V2还是V3签名。

之后有时间在说明如何举例说明v1v2v3不同的签名验证是如何在二进制下进行的。

 

 

上面讲的是安卓系统的签名校验,而我们讲的是app内部的签名校验


一、 理解APK签名校验的原理

要“过签”,首先必须明白应用为什么要校验签名。

  1. 身份认证: 证明APK由特定开发者发布,未被篡改。

  2. 完整性校验: 确保APK自签名后,其中的文件没有被修改。

  3. 防止篡改: 如果攻击者修改了APK(植入木马、修改逻辑),就必须重新签名。而原应用的签名校验逻辑会发现新签名与预期的官方签名不匹配,从而拒绝运行。

校验通常发生在两个地方:

  • Android系统: 在安装时,系统会校验V1/V2/V3签名是否有效。如果修改APK后签名不正确或不一致,系统会拒绝安装。

  • 应用程序自身: 开发者可以在代码中调用 PackageManager 的 getPackageInfo() 方法来获取当前应用的签名哈希值,并与预埋在代码中的正确签名进行比对。这是我们“过签”的主要目标。

签名验证举例

1. 在 AndroidManifest.xml 中注册

首先需要在应用的 AndroidManifest.xml 文件中注册这个 ContentProvider:

xml
<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 编码值。可以通过以下代码获取:

java
// 在应用的其他地方调用此方法获取签名
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 字段:

java
// 包声明:这个类位于 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后,让其签名校验逻辑(无论是系统的还是自身的)依然能够通过。

核心思路分为两大类:

  1. 让校验逻辑拿到“正确”的签名值: 修改APP的逻辑,让它无论当前APK是什么签名,都认为签名是正确的。或者修改安卓系统内部的签名返回正确签名给app。

  2. 让APK使用“官方”签名文件签名: 这是最理想但极难实现的方式,除非能拿到开发者的原始签名密钥(keystore)。

大致的方法有:

1.最简单的方法替换已安装的应用文件。因为apk验证发生在软件的安装过程,对安装好的app不验证。就是在安装好的原版app目录下/data/app下找到你的app,这里可以用mt管理器的已经安装软件查看文件安装真实路径。在mt下找到要修改的文件替换即可实现过签。

2.自动化过签,mt管理器,np管理器等实现

3.手动过签,修改软件或系统实现

以下是具体的方法,从易到难,从通用到特定:

方法一:修改自身校验代码(最常用)

这是最直接的方法,适用于有自定义签名校验逻辑的APP。

所需工具:

  • APK反编译工具: MT ManagerNP ManagerAPK Easy ToolJadx-GUI

  • 回编译工具: 同上(MT/NP Manager都集成了编辑和回编译功能)

  • 文本编辑器: 如 VS Code, Notepad++

详细步骤:

  1. 反编译APK:

    • 使用 MT Manager 或 APK Easy Tool 打开目标APK文件。

    • 工具会将APK解包,并将 classes.dex 等文件反编译为 smali 代码或(如果幸运的话)java 代码。

  2. 定位签名校验代码:

    • 关键词搜索: 在反编译出的代码(smali或java)中搜索以下关键词:

      • signature

      • getPackageInfo

      • SHA1, `SHA`SHA256MD5 (常见的签名哈希算法)

      • PackageManager

      • packageInfo

      • 特定的签名哈希值字符串(如果你能拿到原包的签名,可以搜索这个哈希值)

    • 调用栈分析: 如果APP在签名校验失败时会崩溃或弹出Toast,可以通过Logcat捕捉崩溃日志,找到校验失败的异常点,从而逆向定位到校验代码的位置。

  3. 分析并修改逻辑:

    • 找到关键判断语句,通常是 if-eqif-ne (相等/不相等跳转)。

    • 修改方式:

      • 直接返回正确值: 将校验方法的结果强制返回 true 或 1

      • NOP掉关键判断: 将 if-ne 等判断指令替换为 nop (空操作),让程序顺序执行,即跳过校验失败的分支。

      • 修改比较值: 将预埋在代码中的正确签名哈希值,改为你重签名后APK的签名哈希值(需要提前计算好)。

  4. 回编译与重签名:

    • 在 MT Manager 等工具中,完成代码修改后,直接点击“编译”或“回编译”按钮。

    • 工具会自动将修改后的 smali 代码重新编译成 classes.dex,并打包成新的APK文件。

    • 最关键的一步: 使用你自己的签名证书对这个新APK进行签名。MT Manager等工具在回编译后会自动提示你进行签名。这一步是为了让APK能安装到手机上。

  5. 安装测试:

    • 安装重签名后的APK,测试功能是否正常,校验是否已被绕过。

方法二:Hook签名校验方法(动态调试)

这种方法不修改APK本身,而是在应用运行时,通过注入代码(Hook)来动态修改函数的返回值,使其永远返回“正确”的结果。

所需工具/环境:

  • 已Root的手机 或 模拟器

  • CorePatchBypassSignatureVerify

  • 或 Frida + 过签脚本

详细步骤(以Frida为例):

  1. 环境搭建:

    • 在电脑上安装 frida 和 frida-toolspip install frida-tools

    • 在已Root的手机上运行 frida-server

  2. 编写或获取Frida脚本:

    • 网上有大量现成的过签脚本(如 android-anti-anti-hooking.js 等),它们通常包含Hook getPackageInfo 等方法的代码。

    • 一个简单的示例脚本:

      javascript
       
      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;
          };
      });
  3. 执行 Hook:

    • 在电脑命令行运行:frida -U -l your_script.js -f com.example.targetapp

    • 这会启动目标APP并注入脚本。

  4. 测试:

    • 操作APP,检查签名校验是否被绕过。

优点: 无需修改APK,方便快捷。
缺点: 需要Root环境,对抗高级混淆或Native校验比较困难。

方法三:对抗 Native 层校验

一些强保护的应用会将校验逻辑放在C/C++代码(.so文件)中,增加逆向难度。

所需工具:

  • IDA Pro, Ghidra, Binary Ninja (用于分析 so 文件)

  • Frida (用于Hook Native函数)

详细步骤:

  1. 定位校验函数:

    • 反编译APK,在Java代码中找到加载so库(System.loadLibrary("check"))和声明Native方法(native boolean checkSignature())的地方。

    • 使用IDA Pro等工具打开对应的 .so 文件。

  2. 分析 so 文件 :

    • 在IDA中查看导出函数,寻找与Java对应的Java_com_example_xxx_checkSignature函数。

    • 或者通过JNIEnv方法(如GetMethodIDCallObjectMethod)来寻找哪里调用了getPackageInfo

  3. 修改或Hook:

    • 修改so文件: 类似于修改smali,找到关键汇编指令(如CMPBNE),将其修改为无效指令或强制跳转到成功分支。然后用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层分析),并综合运用所有手段。

如何防御?(给开发者的建议)

  1. 多维度校验: 不要只校验一次。在多个关键功能点、多个时机进行校验。

  2. Native化: 将核心校验逻辑放到C/C++层,并加壳混淆。

  3. 代码混淆: 使用ProGuard、DexGuard等工具混淆代码,增加定位校验逻辑的难度。

  4. 环境检测: 检测Root、Xposed、Frida等调试和Hook环境,一旦发现就终止运行或触发暗桩。

  5. 服务端校验: 将APK签名信息发送到服务器进行验证。这是最安全的方式,因为攻击者很难绕过服务器校验。但需要注意网络请求也可能被Hook。

  6. 签名值混淆/加密: 不要将正确的签名哈希值以明文形式存储在代码中。

  7. 使用V3/V4签名方案: 更新的签名方案提供了更好的安全性和完整性保护。

记住,这始终是一场“矛”与“盾”的持续较量。

 

 

为什么去签名后应用还是闪退?
1.签名校验未完全移除:应用可能有多处签名校验点,工具只移除了部分。需要更深入的手动分析。
2、应用加固:目标应用使用了代码混淆(如ProGuard)或商业加固(如360、腾讯乐固、爱加密),增加了反编译和修改的难度。

3、其他校验机制:除了签名,应用可能还有完整性校验(检查文件是否被改)、防调试、服务器校验等。即使签名通过,其他校验失败也会导致闪退。
4、修改错误:在修改Smali代码或资源时出现语法错误或逻辑错误。
5、重签名失败:重新签名过程出错。

 

云镜签名验证举例

 

posted on 2025-08-21 04:20  GKLBB  阅读(108)  评论(0)    收藏  举报