Android 应用签名

2,205 阅读8分钟

Android 应用签名是应用打包过程的重要步骤之一,Google 要求所有的应用必须被签名才可以安装到 Android 操作系统中。Android 的签名机制也为开发者识别和更新自己应用提供了方便。

数字摘要 Digital Digest

数字摘要主要作用是将任意长度的消息使用单向 HASH 算法摘要成一串固定长度的密文。常用的 HASH 算法包括 SHA-1, SHA-256, MD5 等等,MD5 中的 MD 就是 Message Digest 的缩写。数字摘要有时也被称为数字指纹,消息摘要等等,其实表达的都是一个意思。它有以下三个特点:

  • 输出长度固定 例如 MD5 算法摘要信息有 128 比特,而 SHA-1 有 160 比特
  • 不考虑碰撞的情况下,只要输入原始数据不同,摘要也不会相同。即使稍微改变输出,摘要就会变得完全不同。相同的输入也会产生相同的输出
  • 单向不可逆。从摘要无法恢复原始消息

Keystore 文件

Android 签名需要用到一种后缀名为 keystore 的文件。在打 Debug 包的时候,如果没有在 build.gradle 文件中指定的话,Gradle 就会自动为我们生成一个 keystore文件,保存在系统用户根目录 .android 文件夹内,名称为 debug.keystore. 我们以它为例看看 keystore 文件包含了什么内容。 通过 keytool 工具来查看,默认的密码是 android. $ keytool -list -v -keystore debug.keystore -storepass android 结果如下:

密钥库类型: jks
密钥库提供方: SUN

您的密钥库包含 1 个条目

别名: androiddebugkey
创建日期: 2013-4-26
条目类型: PrivateKeyEntry
证书链长度: 1
证书[1]:
所有者: CN=Android Debug, O=Android, C=US
发布者: CN=Android Debug, O=Android, C=US
序列号: 517a38f2
有效期为 Fri Apr 26 15:46:58 CST 2013 至 Sun Apr 19 15:46:58 CST 2043
证书指纹:
	 MD5:  37:09:10:A9:F1:AE:9C:E4:C0:85:B9:35:D9:93:93:52
	 SHA1: F1:60:3F:72:2A:F2:3A:BC:BE:1C:DB:F6:F4:5B:FD:5E:34:8C:01:A9
	 SHA256: 86:C7:CB:D1:56:E7:D8:B8:AD:67:A7:A1:8F:C0:F6:E6:FC:E1:3D:45:AE:BC:F5:DF:B4:A9:F9:9A:F7:89:F7:0D
签名算法名称: SHA1withRSA
主体公共密钥算法: 1024 位 RSA 密钥
版本: 3

其实 keystore 类似一个钥匙仓库,里面有证书的所有者和发布者信息,包含了私钥和公钥信息,并设置了密码进行保护。

公共密钥系统 RSA

公钥和私钥都是公共密钥系统里的概念。最初所有的加密算法都属于对称加密,也就是说加密和解密使用的相同的密码,通信双方如何安全沟通和保存密码,是这种加密方法的主要难题。难保没有猪队友。 而在公共密钥系统中,加密和解密使用的是不同的密钥,分别称为公钥和私钥,公钥意思就是所有人都可以知道,私钥则只有所有者才持有,单从公钥无法在现有的计算能力下推导出私钥。这样一来就不存在沟通过程中泄露密钥的问题,只要私钥不泄露,通信就一直是安全的。 公共密钥系统可以说是现在最最最重要密钥系统,是互联网的基石之一。 公共密钥系统可以用来加密,也可以用来签名。加密方案中,是不希望别人知道我的消息,所以公钥用于加密信息,私钥用于解密信息;而签名方案中,是不希望别人冒充我发消息,只有我才能发布这个签名,所以需要用私钥进行签名,公钥用于验证签名。

应用签名

我们先来看看 Android 应用签名发生在构建的哪一步。

在编译过程中,编译器首先会将源代码和资源进行编译,生成 DEX 文件和一些编译后的资源文件,然后 APK Manager 会根据配置使用 keystore 文件进行签名,签名后才会将所有资源压缩到一个 ZIP 包里,这个 ZIP 包其实我们安装的时候用的 APK 文件。可以看到签名是在构建基本完成的时候发生的。

签名过程

那 APK Manager 是如何使用 keystore 进行签名的呢?我们一步一步看到底发生了什么。

  1. 首先对编译后生成的所有的文件进行扫描,每个文件生成一个数字摘要,保存在 META-INF/MANIFEST.MF 文件中
Name: res/drawable-xhdpi-v4/im_ic_keyboard_pressed.png
SHA-256-Digest: cqjOi3gUv9O0IBfgLOlIJUZTBwyCPcWbXIs/o6TMfTc

Name: classes.dex
SHA-256-Digest: FJCwLV1TyZuL1qxkDsJ6bXTmaSkK+JkKt5zmpDBc8Tg=

我们看一下 im_ic_keyboard_pressed.png 这个文件的数字摘要到底是如何计算出来的。

  • 第一步对文件进行 SHA-256 散列,得到一串 16 进制的散列值。
$ shasum -a 256 im_ic_keyboard_pressed.png
72a8ce8b7814bfd3b42017e02ce948254653070c823dc59b5c8b3fa3a4cc7d37 im_ic_keyboard_pressed.png
  • 第二步我们将其转换为二进制格式并进行 base64 编码
$ echo "72a8ce8b7814bfd3b42017e02ce948254653070c823dc59b5c8b3fa3a4cc7d37" | xxd -r -p | base64
cqjOi3gUv9O0IBfgLOlIJUZTBwyCPcWbXIs/o6TMfTc=

可以看到跟我们在 MANIFEST.MF 中看到的值是能够对上的。

  1. 对 MANIFEST.MF 文件生成数字摘要,并写入 CERT.SF,这里有一个细节,除了对整个文件做 HASH 外,还会将文件分成多段计算 HASH 同样保存在 CERT.SF 文件中

  2. 计算 CERT.SF 的数字摘要,并使用 RSA 私钥进行加密,生成签名

  3. 将签名、公钥、哈希算法信息写入 CERT.RSA 文文件,并将这些文件添加到 APK 压缩包 META-INF 目录中

目前应用签名不需要申请可信的证书机构 (Certificate Authority) 签发的证书,开发者可以通过 keytool 来创建自签名的证书。

为什么要签名

应用签名不能保证 APK 不被篡改,只是为了能够校验出 APK 是否被篡改。在系统安装过程中,如果发现 APK 被篡改,安装就会失败。那系统是如何校验的呢?

  1. 系统取得已安装 APK 中保存的公钥,用它对新 APK 中的 CERT.RSA 保存的签名信息进行解密;对 CERT.SF 文件计算摘要,与上一步解密出来的信息进行比对,如果不一致说明 CERT.SF 被篡改,拒绝安装
  2. 对 MANIFEST.SF 文件计算摘要,与 CERT.SF 文件中的信息进行对比,如果不一致,则说明 MANIFEST.SF 文件被篡改,拒绝安装
  3. 对 APK 内所有其他文件计算数字摘要,如果文件没有出现在 MANIFEST.SF 或者摘要与 MANIFEST.SF 中包含的不相同,说明加入了新的文件或者文件被篡改,拒绝安装

整个校验过程中,环环相扣,从 CERT.RSA -> CERT.SF -> MANIFEST.SF -> All Other Files,只要有一环失败,系统就会终止 APK 的安装.

如果给新版应用分配了新的签名文件,那就必须更改包名,这样系统才会认为是不同的应用。否则安装就会失败,提示签名不一致。 INSTALL_PARSE_FAILED_INCONSISTENT_CERTIFICATES

签名的其他用途

除了用于安装时校验应用,签名还有一些别的用途。

  1. 应用模块化。Android 允许相同签名的两个应用使用同样的 Linux UserId,这样一来两个应用就可以共享数据存储了。同时如果应用申请的话,两个应用还可以在同一个进程中运行。通过这种方式可以模块化部署应用,每个模块也能独立的进行升级。猜想很多主题资源包可能就是通过这种方式来安装的。
<manifest
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:sharedUserId="com.meng.sharedappid"
        package="meng.mainuicomponents">
  1. 共享代码数据。Android 提供了可以在使用同样签名的应用间共享代码的功能。另外一个前提也是两个应用设置了使用同样的 shareUserId。可以使用包名拿到兄弟 APP 的 Context,然后拿到 ClassLoader 加载兄弟 APP 的类,并可以实例化并通过反射来调用具体的方法。
val friendContext = this.createPackageContext("packageName", Context.CONTEXT_INCLUDE_CODE)
val friendClass = friendContext.classLoader.loadClass("packageName.className")
val noparams = arrayOf<Class<*>>() //say the function (functionName) required no inputs
friendClass.getMethod("functionName", *noparams).invoke(friendClass.newInstance(), null)
  1. 用来声明安全级别。比如隶属同一个公司的多个应用实现共享登陆功能,可以各自实现自己的 ContentProvider,向外提供访问本应用数据的接口,但这个接口需要限制不能被其他第三方的应用读取,通过限制安全级别为签名级别,系统就能保证只有相同签名的应用才可以访问到这个 ContentProvider。以下是在 AndroidManifest.xml 文件中的使用示例。
<!-- 声明权限供兄弟 APP 使用 -->
<permission
    android:name="com.xxx.permission.SHARED_ACCOUNT"
    android:protectionLevel="signature" />

<!-- 申请获取兄弟 APP 的声明的权限 -->
<uses-permission android:name="com.xxx.broapp1.permission.SHARED_ACCOUNT" />
<uses-permission android:name="com.xxx.broapp2.permission.SHARED_ACCOUNT" />

<!-- 定义 ContentProvider 来供兄弟 APP 获取共享账户信息,指定度权限为签名声明的权限 -->
<provider
    android:name="com.xxx.XXXXSharedAccountProvider"
    android:authorities="com.xxx.shared_account"
    android:exported="true"
    android:readPermission="com.xxx.permission.SHARED_ACCOUNT" />

签名机制的演进

V1

第一代是基于 JAR 文件签名,它主要的缺陷是只保护了一部分文件,而不是对整个 APK 文件做保护。这是因为所有文件都不可能包含了自身的签名,因为它不可能为自己签名后再把签名信息保存到自己内部,这是一个鸡生蛋蛋生鸡的问题,因为这个问题的存在,第一代签名机制会忽略所有以 .SF/.DSA/.RSA 的文件以及 META-INFO 目录下的所有文件。 所以攻击者就可以解压缩后在 APK/META-INF 目录新增一个含有恶意代码的文件,然后再压缩成 APK,同样是可以覆盖安装正版应用的,这样一来好好的应用就会被杀毒软件标记为恶意软件,从而达到攻击应用的目的。 除了容易被攻击外,应用安装起来也比较慢,因为安装器在校验时需要解压计算所有文件的数字摘要,确认没有被恶意修改。

美团打渠道包的方法本质上就利用了这个第一代签名的漏洞,在 META-INF 目录下新建了一个包含 vendor 名称的文件,从而不需要重新编译,只需要解压缩 APK,添加文件,重新压缩就完成了一个渠道包的生成,速度非常快。

V2

Android 7.0 引入了第二代签名,避免了第一代签名模式的问题,主要改进在于它在验证过程中,将整个 APK 文件当作一个整体,只校验 APK 文件的签名就可以了,从而一方面更严苛的避免了 APK 被篡改,另外一方面也不用加压缩后对所有文件进行校验,从而极大提升了安装速度。第二代签名向后兼容,使用新签名的 APK 可以安装到 <7.0 的系统上,但要求 APK 同时也进行 v1 的签名。 具体来说,第二代签名将整个 APK 文件进行签名,并将签名信息保存在了 APK 文件的的尾部 Central Directory 的前边。它可以对第一三四,以及第二块除了签名部分的其他区域提供一致性保护。

在计算签名的时候,会将这些部分的数据切割成 1MB 大小的 CHUNK,分别计算签名,然后汇总后再计算一个总签名,这么做的主要目的是为了并行计算,加快速度。

为了避免攻击者在 7.0 以上系统中绕过 v2 签名机制(比如删除 APK Signing Block?),v2 签名要求如果 APK 同时提供了 V1 签名的话,需要在 META-INF/*.SF 文件中增加一行 X-Android-APK-Signed 属性,这样一来,支持 V2 签名的系统在回滚到 V1 签名的时候就会校验是否存在这个属性,如果存在,就会拒绝安装 APK,这一切都是建立在 *.SF 文件被 V1 签名保护的基础之上。

V3

Android 9.0 引入了第三代签名机制,主要增加一个功能叫 APK key rotation. 意思是允许开发者在更新 APK 的时候更换签名。签名的主要机制跟 V2 其实是一样的,只是重新设计了 APK Signing Block 的存储结构,以支持更换签名。这里就不再细说,可以参考官方的 文档 下图是安装一个 APK 时,系统对三代签名校验的流程示意。