1. JAR V1签名是在apk文件(其实是ZIP文件)中添加META-INF目录,即需要修改数据区、中央目录,因为添加文件后会导致中央目录大小和偏移量发生变化,还需要修改中央目录结尾记录。关于ZIP包结构的详细介绍见:压缩包Zip格式详析
2. V2方案为加强数据完整性保证,选择在数据区和中央目录之间插入一个APK签名分块,从而保证了原始zip(apk)数据的完整性。具体如下所示:


APK签名分块的前8个字节记录了APK签名分块的大小 size of block(不含自身8字节),其后紧接着键值对数据块,数据块由一个个的键值对块组成。 每个键值对块的开始8字节记录了「键值对的ID」+「键值对的Value」的大小,接下来4字节是键值对的ID,后面紧跟着对应的值。 ID = 0x7109871a 的键值对块就是保存V2签名信息的地方。 键值对数据块的后面还有8个字节,也是用于记录「整个APK签名分块」的大小,它的值和最开始的8字节相同。 签名块的末尾是一个魔数magic,也就是APK Sig Block 42的 ASCII 码(小端排序)。
在解析 APK 时,首先要通过以下方法找到“ZIP 中央目录”的起始位置:在文件末尾找到“ZIP 中央目录结尾”记录,然后从该记录中读取“中央目录”的起始偏移量。通过 magic 值,可以快速确定“中央目录”前方可能是“APK 签名分块”。然后,通过 size of block 值,可以高效地找到该分块在文件中的起始位置,在解译该分块时,应忽略 ID 未知的“ID-值”对。
构造签名块的代码逻辑如下:
- /**
- * 生成签名区块数据
- * @param apkSignatureSchemeBlockPairs
- * @return
- */
- public static byte[] generateApkSigningBlock(
- List
byte[], Integer>> apkSignatureSchemeBlockPairs) { - // FORMAT:
- // uint64: size (excluding this field)
- // repeated ID-value pairs:
- // uint64: size (excluding this field)
- // uint32: ID
- // (size - 4) bytes: value
- // (extra verity ID-value for padding to make block size a multiple of 4096 bytes)
- // uint64: size (same as the one above)
- // uint128: magic
-
- int blocksSize = 0;
- for (Pair<byte[], Integer> schemeBlockPair : apkSignatureSchemeBlockPairs) {
- blocksSize += 8 + 4 + schemeBlockPair.getFirst().length; // size + id + value
- }
-
- int resultSize =
- 8 // size
- + blocksSize
- + 8 // size
- + 16 // magic
- ;
- ByteBuffer paddingPair = null;
- //若是大小不是4096的倍数,那么需要增加填充块,填充块没有value
- if (resultSize % ANDROID_COMMON_PAGE_ALIGNMENT_BYTES != 0) {
- int padding = ANDROID_COMMON_PAGE_ALIGNMENT_BYTES -
- (resultSize % ANDROID_COMMON_PAGE_ALIGNMENT_BYTES);
- if (padding < 12) { // minimum size of an ID-value pair,键值对最小也得8+4
- padding += ANDROID_COMMON_PAGE_ALIGNMENT_BYTES;
- }
- paddingPair = ByteBuffer.allocate(padding).order(ByteOrder.LITTLE_ENDIAN);
- //填充块键值对的大小
- paddingPair.putLong(padding - 8);
- //ID
- paddingPair.putInt(VERITY_PADDING_BLOCK_ID);
- paddingPair.rewind();
- resultSize += padding;
- }
-
- ByteBuffer result = ByteBuffer.allocate(resultSize);
- result.order(ByteOrder.LITTLE_ENDIAN);
-
- //除了当前记录大小的8字节之外的剩余字节大小
- long blockSizeFieldValue = resultSize - 8L;
- result.putLong(blockSizeFieldValue);
-
- for (Pair<byte[], Integer> schemeBlockPair : apkSignatureSchemeBlockPairs) {
- byte[] apkSignatureSchemeBlock = schemeBlockPair.getFirst();
- int apkSignatureSchemeId = schemeBlockPair.getSecond();
- long pairSizeFieldValue = 4L + apkSignatureSchemeBlock.length;
- // ID -Value键值端的大小
- result.putLong(pairSizeFieldValue);
- // 4字节的ID,比如:v2签名ID: APK_SIGNATURE_SCHEME_V2_BLOCK_ID = 0x7109871a
- result.putInt(apkSignatureSchemeId);
- // Value数据
- result.put(apkSignatureSchemeBlock);
- }
-
- if (paddingPair != null) {
- result.put(paddingPair);
- }
-
- //倒数第24字节开始的8个字节,也是写入签名区块的大小
- result.putLong(blockSizeFieldValue);
- // 16字节:APK Sig Block 42的 ASCII 码
- result.put(APK_SIGNING_BLOCK_MAGIC);
-
- return result.array();
- }
PK 签名方案 v2分块是一个签名序列,说明可以使用多个签名者对同一个APK进行签名。每个签名信息中均包含了三个部分的内容:
其中包含了通过一系列算法计算的摘要列表、证书信息,以及extra信息(可选);
通过一系列算法对signed data的签名列表。签名时使用了多个签名算法,在签名校验时会是选择系统支持的安全系数最高的签名进行校验;

为了保护 APK 内容,APK 包含以下 4 个部分:

第 1、3 和 4 部分的摘要采用以下计算方式,类似于两级 Merkle 树:
① 拆分块chunk
将每个部分(即上面标注第1、3、4部分)拆分成多个大小为 1 MB大小的块chunk,最后一个块chunk可能小于1MB。之所以分块,是为了可以通过并行计算摘要以加快计算速度;
② 计算块chunk摘要
字节 0xa5 + 块的长度(字节数) + 块的内容 拼接起来用对应的摘要算法进行计算出每一块的摘要值;
③ 计算整体摘要
字节 0x5a + chunk数 + 块的摘要(按块在 APK 中的顺序)拼接起来用对应的摘要算法进行计算出整体的摘要值;
相关安全分析见:安卓安全性概要
因为V2签名机制是在Android 7.0中引入的,为了使APK可在Android 7.0以下版本中安装,应先用V1签名对APK进行签名,再用V2方案进行签名。要注意顺序一定是先V1签名再V2签名,因为V1签名的改动会修改到ZIP三大部分的内容,先使用V2签名再V1签名会破坏V2签名的完整性。
在 Android 7.0 以上版本,会优先以 v2方案验证 APK,在Android 7.0以下版本中,系统会忽略 v2 签名,仅验证 v1 签名。Android 7.0+的校验过程如下:
因为在经过V2签名的APK中同时带有V1签名,攻击者可能将APK的V2签名删除,使得Android系统只校验V1签名。为了防范此类攻击,带 v2 签名的 APK 如果还带 V1 签名,其 META-INF/*.SF 文件的主要部分中必须包含 X-Android-APK-Signed 属性。该属性的值是一组以英文逗号分隔的 APK 签名方案 ID(v2 方案的 ID 为 2)。在验证 v1 签名时,对于此组中验证程序首选的 APK 签名方案(例如,v2 方案),如果 APK 没有相应的签名,APK 验证程序必须要拒绝这些 APK。此项保护依赖于 META-INF/*.SF 文件受 v1 签名保护这一事实。

攻击者可能还会试图从“APK 签名方案 v2 分块”中删除安全系数较高的签名。为了防范此类攻击,对 APK 进行签名时使用的签名算法 ID 的列表会存储在通过各个签名保护的 signed data 分块中。
我们知道跟安装包相关的处理逻辑都会经过PackageManagerService,在Android Studio中下载对应版本SDK的源码,输入搜索PackageManagerService即可一步步找到V2签名校验的源码
看看怎么从apk中找到APK签名分块:
- /**
- * 查找APK签名分块
- * @param apk apk文件
- * @param centralDirOffset 中央目录开始位置的偏移量
- * @return
- * @throws IOException
- * @throws SignatureNotFoundException
- */
- static Pair
findApkSigningBlock( - RandomAccessFile apk, long centralDirOffset)
- throws IOException, SignatureNotFoundException {
- // FORMAT:
- // OFFSET DATA TYPE DESCRIPTION
- // * @+0 bytes uint64: size in bytes (excluding this field)
- // * @+8 bytes payload
- // * @-24 bytes uint64: size in bytes (same as the one above)
- // * @-16 bytes uint128: magic
-
- //中央目录的开始位置偏移小于32,抛异常,因为 APK签名分块不算上键值对的大小,就至少32字节(8字节表示区块大小+8字节表示区块大小+16字节魔数)了
- if (centralDirOffset < APK_SIG_BLOCK_MIN_SIZE) {
- throw new SignatureNotFoundException(
- "APK too small for APK Signing Block. ZIP Central Directory offset: "
- + centralDirOffset);
- }
- // Read the magic and offset in file from the footer section of the block:
- // * uint64: size of block
- // * 16 bytes: magic
- ByteBuffer footer = ByteBuffer.allocate(24);
- footer.order(ByteOrder.LITTLE_ENDIAN);
- //指针指向中央目录开始位置往前移动24个字节的位置
- apk.seek(centralDirOffset - footer.capacity());
- //从指针位置开始读取24个字节的数据放进footer中
- apk.readFully(footer.array(), footer.arrayOffset(), footer.capacity());
- //下面主要比较是否为等于“APK Sig Block 42”小端排序的值
- //footer.getLong(8):从第8个字节开始读取8个字节
- if ((footer.getLong(8) != APK_SIG_BLOCK_MAGIC_LO)
- //footer.getLong(16) : 从第16个字节开始读取8个字节
- || (footer.getLong(16) != APK_SIG_BLOCK_MAGIC_HI)) {
- throw new SignatureNotFoundException(
- "No APK Signing Block before ZIP Central Directory");
- }
- // Apk签名分块尾部记录的分块大小
- long apkSigBlockSizeInFooter = footer.getLong(0);
- // 大小 < 24 或者大于 整型最大值-8,抛异常
- if ((apkSigBlockSizeInFooter < footer.capacity())
- || (apkSigBlockSizeInFooter > Integer.MAX_VALUE - 8)) {
- throw new SignatureNotFoundException(
- "APK Signing Block size out of range: " + apkSigBlockSizeInFooter);
- }
- // Apk签名分块的总大小
- int totalSize = (int) (apkSigBlockSizeInFooter + 8);
- // Apk签名分块开始位置的偏移量 = 中央目录开始位置偏移量- Apk签名分块的总大小
- long apkSigBlockOffset = centralDirOffset - totalSize;
- if (apkSigBlockOffset < 0) {
- throw new SignatureNotFoundException(
- "APK Signing Block offset out of range: " + apkSigBlockOffset);
- }
- ByteBuffer apkSigBlock = ByteBuffer.allocate(totalSize);
- apkSigBlock.order(ByteOrder.LITTLE_ENDIAN);
- //指针指向APK签名分块开始位置
- apk.seek(apkSigBlockOffset);
- //从指针位置开始读取totalSize个字节的数据存到apkSigBlock中
- apk.readFully(apkSigBlock.array(), apkSigBlock.arrayOffset(), apkSigBlock.capacity());
- //从第0字节开始读取8个字节,就是记录在APK签名分块开头的“Apk签名分块的总大小”
- long apkSigBlockSizeInHeader = apkSigBlock.getLong(0);
- //判断开头跟结尾记录的总大小是否相等
- if (apkSigBlockSizeInHeader != apkSigBlockSizeInFooter) {
- throw new SignatureNotFoundException(
- "APK Signing Block sizes in header and footer do not match: "
- + apkSigBlockSizeInHeader + " vs " + apkSigBlockSizeInFooter);
- }
-
- return Pair.create(apkSigBlock, apkSigBlockOffset);
- }
怎么从APK签名分块中找到V2签名信息:
- /**
- * 从APK签名分块中找到blockId指定的键值
- * @param apkSigningBlock 签名分块数据
- * @param blockId 分块键id
- * @return
- * @throws SignatureNotFoundException
- */
- static ByteBuffer findApkSignatureSchemeBlock(ByteBuffer apkSigningBlock, int blockId)
- throws SignatureNotFoundException {
- checkByteOrderLittleEndian(apkSigningBlock);
- // FORMAT:
- // OFFSET DATA TYPE DESCRIPTION
- // * @+0 bytes uint64: size in bytes (excluding this field)
- // * @+8 bytes pairs
- // * @-24 bytes uint64: size in bytes (same as the one above)
- // * @-16 bytes uint128: magic
-
- //从Apk签名分块的第8字节开始读到APK签名分块的倒数24字节,这一块也刚好是键值对数据区
- ByteBuffer pairs = sliceFromTo(apkSigningBlock, 8, apkSigningBlock.capacity() - 24);
-
- int entryCount = 0;
- while (pairs.hasRemaining()) {
- entryCount++;
- //因为表示键值对的长度是8个字节,小于8字节是有问题的
- if (pairs.remaining() < 8) {
- throw new SignatureNotFoundException(
- "Insufficient data to read size of APK Signing Block entry #" + entryCount);
- }
- //读取当前键值对的长度,因为8字节,所以使用getLong读取,每次get之后,指针都会往前移动一定字节
- long lenLong = pairs.getLong();
- //因为键ID的长度用4个字节表示,小于4个字节有问题,同时键值对的长度设置不超过整型的最大值
- if ((lenLong < 4) || (lenLong > Integer.MAX_VALUE)) {
- throw new SignatureNotFoundException(
- "APK Signing Block entry #" + entryCount
- + " size out of range: " + lenLong);
- }
- int len = (int) lenLong;
- //下一个键值对开始位置,就是当前键值对开始位置(当前键值对长度值之后) + 当前键值对长度
- int nextEntryPos = pairs.position() + len;
- //要是记录的键值对长度超过剩余的数据长度也是有问题
- if (len > pairs.remaining()) {
- throw new SignatureNotFoundException(
- "APK Signing Block entry #" + entryCount + " size out of range: " + len
- + ", available: " + pairs.remaining());
- }
- //读取当前键值对的键ID, 因为是4个字节,所以用getInt读取
- int id = pairs.getInt();
- //假如键跟传进来的一致,那么返回值的数据,值的长度 = 键值对的长度-键ID的长度
- if (id == blockId) {
- return getByteBuffer(pairs, len - 4);
- }
- //指针指向下一个键值对开始位置
- pairs.position(nextEntryPos);
- }
-
- //最后都没有找到指定ID的键值对,那么抛异常
- throw new SignatureNotFoundException(
- "No block with ID " + blockId + " in APK Signing Block.");
- }
其中,键值对长度 = key的长度(固定4个字节) + value的长度,即键值对长度不含自身的长度(固定8字节)。最后来看看V2签名信息校验流程:
目前众多的快速批量打包方案又是如何绕过签名检验的?
在V2方案出现之前,快速批量打包方案有3类:

在V2方案出现之后,因同时保证了数据区、中央目录和中央目录结尾记录的完整性,故方案2、3均不适用了。那是不是就没有快速批量打包的可能了呢?当然不是,可以从APK签名分块中着手。再回过头来看一下APK签名分块的结构:
APK签名分块中有一个ID-VALUE序列, 签名信息(APK 签名方案 v2 分块)只存储在ID 为 0x7109871a的ID-VALUE中,通过分析签名校验源码可以发现,其它ID-VALUE数据是未被解析的,也就是说除APK 签名方案 v2 分块外,其余ID-VALUE是不影响签名校验的。故可以定义一个新的ID-VALUE,将渠道信息写入APK签名分块中。因为V2方案只保证了第1、3、4部分和第 2 部分(APK签名分块)包含的APK 签名方案 v2分块中的 signed data 分块的完整性。新写入的ID-VALUE不受保护,所以此方案可行。实际上美团新一代渠道包生成工具Walle就是以这个方案实现的。
注: 这是参考相关文档总结出的精华,若有侵权问题,请立即联系我删除该文档