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> 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 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 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.");
}
最后来看看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就是以这个方案实现的。
注: 这是参考相关文档总结出的精华,若有侵权问题,请立即联系我删除该文档