Android系统安全 — 5.3-APK V2签名介绍

1. V2签名原理

1. JAR V1签名是在apk文件(其实是ZIP文件)中添加META-INF目录,即需要修改数据区、中央目录,因为添加文件后会导致中央目录大小和偏移量发生变化,还需要修改中央目录结尾记录。关于ZIP包结构的详细介绍见:压缩包Zip格式详析

2. V2方案为加强数据完整性保证,选择在数据区和中央目录之间插入一个APK签名分块,从而保证了原始zip(apk)数据的完整性。具体如下所示:

2. V2签名格式

2.1 签名块格式

         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<Pair<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();
    }

2.2 信息数据格式

PK 签名方案 v2分块是一个签名序列,说明可以使用多个签名者对同一个APK进行签名。每个签名信息中均包含了三个部分的内容:

  • 带长度前缀的signed data

       其中包含了通过一系列算法计算的摘要列表、证书信息,以及extra信息(可选);

  • 带长度前缀的signatures序列

       通过一系列算法对signed data的签名列表。签名时使用了多个签名算法,在签名校验时会是选择系统支持的安全系数最高的签名进行校验;

  • 证书公钥

2.3 摘要计算

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

  1. ZIP 条目的内容(从偏移量 0 处开始一直到“APK 签名分块”的起始位置)
  2. APK 签名分块
  3. ZIP 中央目录
  4. ZIP 中央目录结尾

第 1、3 和 4 部分的摘要采用以下计算方式,类似于两级 Merkle 树:

① 拆分块chunk
     将每个部分(即上面标注第1、3、4部分)拆分成多个大小为 1 MB大小的块chunk,最后一个块chunk可能小于1MB。之所以分块,是为了可以通过并行计算摘要以加快计算速度;

② 计算块chunk摘要
     字节 0xa5 + 块的长度(字节数) + 块的内容 拼接起来用对应的摘要算法进行计算出每一块的摘要值;

③ 计算整体摘要
     字节 0x5a + chunk数 + 块的摘要(按块在 APK 中的顺序)拼接起来用对应的摘要算法进行计算出整体的摘要值;

3.V2签名验证过程分析

相关安全分析见:安卓安全性概要

因为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+的校验过程如下:

3.1 防回滚保护

 因为在经过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 分块中。

3.2 签名校验过程

我们知道跟安装包相关的处理逻辑都会经过PackageManagerService,在Android Studio中下载对应版本SDK的源码,输入搜索PackageManagerService即可一步步找到V2签名校验的源码

看看怎么从apk中找到APK签名分块:

   /**
     * 查找APK签名分块
     * @param apk apk文件
     * @param centralDirOffset 中央目录开始位置的偏移量
     * @return
     * @throws IOException
     * @throws SignatureNotFoundException
     */
    static Pair<ByteBuffer, Long> 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);
    }
  1. 先从ZIP中央目录开始位置centralDirOffset,指针往前16个字节,然后读取16个字节数据,判断是否等于魔数“APK Sig Block 42”ASCII码值
  2. 再从ZIP中央目录开始位置centralDirOffset,指针往前24个字节,然后读取8个字节的数据,这个值就是尾部记录的APK签名分块大小apkSigBlockSizeInFooter
  3. 尾部记录的APK签名分块大小apkSigBlockSizeInFooter + 8字节,就是APK签名分块整体的大小totalSize,APK签名分块开始位置apkSigBlockOffset = ZIP中央目录开始位置centralDirOffset- APK签名分块整体的大小totalSize
  4. 从APK签名分块开始位置apkSigBlockOffset开始,读取8个字节数据,这个值就是头部记录的APK签名分块大小apkSigBlockSizeInHeader
  5. 假如 头部记录的APK签名分块大小apkSigBlockSizeInHeader = 尾部记录的APK签名分块大小apkSigBlockSizeInFooter,那么从APK签名分块开始位置apkSigBlockOffset 开始,读取APK签名分块整体的大小totalSize个字节数据,这就是整个APK签名分块数据

 怎么从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.");
    }
  1. 键值对数据分块是保存着一个个带有长度前缀的键值对,大致如下:其中,键值对长度 = key的长度(固定4个字节) + value的长度,即键值对长度不含自身的长度(固定8字节)。
  2. ByteBuffer 读取数据时候,读4个字节的数据可以用getInt,读8个字节的数据可以用getLong,使用这两个方法之后,指针会自动往前移动对应的字节。

最后来看看V2签名信息校验流程:

  1. 先从V2签名信息区中读取被签名的数据signedData、多个签名者的签名signatures、公钥字节数据publicKeyBytes
  2. 从多个签名者的签名signatures中找出安全系数最高的签名算法bestSigAlgorithm以及该算法对应的签名bestSigAlgorithmSignatureBytes
  3. 用公钥字节数据publicKeyBytes构造出公钥publicKey,然后使用公钥publicKey对签名bestSigAlgorithmSignatureBytes进行解密得到被签名的数据signedData的hash值H1,然后对被签名的数据signedData计算得到hash值H2, 要是H1 = H2, 那么签名验证通过
  4. 然后读出安全系数最高的签名算法bestSigAlgorithm对应的APK摘要值contentDigest
  5. 接着读取出签名用到的证书certificates,并从第一个证书中读取出公钥字节数据certificatePublicKeyBytes,要是公钥字节数据certificatePublicKeyBytes = 公钥字节数据publicKeyBytes,那么公钥验证通过
  6. 开始计算对压缩包三大组成部分:ZIP条目内容、ZIP中央目录、ZIP中央目录尾部,分别分成1MB的大小(每部分最后一块可能不足1MB), 然后计算出摘要值(注意:计算摘要之前,ZIP中央目录尾部记录的ZIP中央目录开始位置偏移量要修改成APK签名分块开始位置的偏移量,因为给APK进行V2签名时候,就是没有算上加入APK签名分块)

4. 总结

目前众多的快速批量打包方案又是如何绕过签名检验的?

在V2方案出现之前,快速批量打包方案有3类:

  1. 反编译APK后修改渠道值,再重新打包。这种方案实际上是重新签名,因有反编译、重新打包、签名的过程,速度相对后两种方案较慢;
  2. 将渠道信息以文件形式写入META-INF目录中。因为META-INF目录是用来存放签名的,其本身无法加入签名校验中,在META-INF目录中添加文件不会破坏原有签名。此方案需同时修改zip数据区、中央目录和中央目录结尾记录;
  3. 将渠道信息写到zip中央目录结尾记录的comment字段中。通过前面分析zip文件结构,可以发现中央目录结尾记录最后注释字段,这部分内容在JAR签名方案中同样不在签名校验范围中,故添加注释也不会破坏原有签名。此方案只需修改中央目录结尾记录;

在V2方案出现之后,因同时保证了数据区中央目录中央目录结尾记录的完整性,故方案2、3均不适用了。那是不是就没有快速批量打包的可能了呢?当然不是,可以从APK签名分块中着手。再回过头来看一下APK签名分块的结构:

  • size of block,以字节数(不含此字段)计 (uint64)
  • 带 uint64 长度前缀的“ID-值”对序列:
    •  ID (uint32)
    • value(可变长度:“ID-值”对的长度 - 4 个字节)
  • size of block,以字节数计 - 与第一个字段相同 (uint64)
  • magic“APK 签名分块 42”(16 个字节)

 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就是以这个方案实现的。

注:  这是参考相关文档总结出的精华,若有侵权问题,请立即联系我删除该文档

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值