• 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-值”对。
            构造签名块的代码逻辑如下:

    1. /**
    2. * 生成签名区块数据
    3. * @param apkSignatureSchemeBlockPairs
    4. * @return
    5. */
    6. public static byte[] generateApkSigningBlock(
    7. Listbyte[], Integer>> apkSignatureSchemeBlockPairs) {
    8. // FORMAT:
    9. // uint64: size (excluding this field)
    10. // repeated ID-value pairs:
    11. // uint64: size (excluding this field)
    12. // uint32: ID
    13. // (size - 4) bytes: value
    14. // (extra verity ID-value for padding to make block size a multiple of 4096 bytes)
    15. // uint64: size (same as the one above)
    16. // uint128: magic
    17. int blocksSize = 0;
    18. for (Pair<byte[], Integer> schemeBlockPair : apkSignatureSchemeBlockPairs) {
    19. blocksSize += 8 + 4 + schemeBlockPair.getFirst().length; // size + id + value
    20. }
    21. int resultSize =
    22. 8 // size
    23. + blocksSize
    24. + 8 // size
    25. + 16 // magic
    26. ;
    27. ByteBuffer paddingPair = null;
    28. //若是大小不是4096的倍数,那么需要增加填充块,填充块没有value
    29. if (resultSize % ANDROID_COMMON_PAGE_ALIGNMENT_BYTES != 0) {
    30. int padding = ANDROID_COMMON_PAGE_ALIGNMENT_BYTES -
    31. (resultSize % ANDROID_COMMON_PAGE_ALIGNMENT_BYTES);
    32. if (padding < 12) { // minimum size of an ID-value pair,键值对最小也得8+4
    33. padding += ANDROID_COMMON_PAGE_ALIGNMENT_BYTES;
    34. }
    35. paddingPair = ByteBuffer.allocate(padding).order(ByteOrder.LITTLE_ENDIAN);
    36. //填充块键值对的大小
    37. paddingPair.putLong(padding - 8);
    38. //ID
    39. paddingPair.putInt(VERITY_PADDING_BLOCK_ID);
    40. paddingPair.rewind();
    41. resultSize += padding;
    42. }
    43. ByteBuffer result = ByteBuffer.allocate(resultSize);
    44. result.order(ByteOrder.LITTLE_ENDIAN);
    45. //除了当前记录大小的8字节之外的剩余字节大小
    46. long blockSizeFieldValue = resultSize - 8L;
    47. result.putLong(blockSizeFieldValue);
    48. for (Pair<byte[], Integer> schemeBlockPair : apkSignatureSchemeBlockPairs) {
    49. byte[] apkSignatureSchemeBlock = schemeBlockPair.getFirst();
    50. int apkSignatureSchemeId = schemeBlockPair.getSecond();
    51. long pairSizeFieldValue = 4L + apkSignatureSchemeBlock.length;
    52. // ID -Value键值端的大小
    53. result.putLong(pairSizeFieldValue);
    54. // 4字节的ID,比如:v2签名ID: APK_SIGNATURE_SCHEME_V2_BLOCK_ID = 0x7109871a
    55. result.putInt(apkSignatureSchemeId);
    56. // Value数据
    57. result.put(apkSignatureSchemeBlock);
    58. }
    59. if (paddingPair != null) {
    60. result.put(paddingPair);
    61. }
    62. //倒数第24字节开始的8个字节,也是写入签名区块的大小
    63. result.putLong(blockSizeFieldValue);
    64. // 16字节:APK Sig Block 42的 ASCII 码
    65. result.put(APK_SIGNING_BLOCK_MAGIC);
    66. return result.array();
    67. }

    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签名分块:

    1. /**
    2. * 查找APK签名分块
    3. * @param apk apk文件
    4. * @param centralDirOffset 中央目录开始位置的偏移量
    5. * @return
    6. * @throws IOException
    7. * @throws SignatureNotFoundException
    8. */
    9. static Pair findApkSigningBlock(
    10. RandomAccessFile apk, long centralDirOffset)
    11. throws IOException, SignatureNotFoundException {
    12. // FORMAT:
    13. // OFFSET DATA TYPE DESCRIPTION
    14. // * @+0 bytes uint64: size in bytes (excluding this field)
    15. // * @+8 bytes payload
    16. // * @-24 bytes uint64: size in bytes (same as the one above)
    17. // * @-16 bytes uint128: magic
    18. //中央目录的开始位置偏移小于32,抛异常,因为 APK签名分块不算上键值对的大小,就至少32字节(8字节表示区块大小+8字节表示区块大小+16字节魔数)了
    19. if (centralDirOffset < APK_SIG_BLOCK_MIN_SIZE) {
    20. throw new SignatureNotFoundException(
    21. "APK too small for APK Signing Block. ZIP Central Directory offset: "
    22. + centralDirOffset);
    23. }
    24. // Read the magic and offset in file from the footer section of the block:
    25. // * uint64: size of block
    26. // * 16 bytes: magic
    27. ByteBuffer footer = ByteBuffer.allocate(24);
    28. footer.order(ByteOrder.LITTLE_ENDIAN);
    29. //指针指向中央目录开始位置往前移动24个字节的位置
    30. apk.seek(centralDirOffset - footer.capacity());
    31. //从指针位置开始读取24个字节的数据放进footer中
    32. apk.readFully(footer.array(), footer.arrayOffset(), footer.capacity());
    33. //下面主要比较是否为等于“APK Sig Block 42”小端排序的值
    34. //footer.getLong(8):从第8个字节开始读取8个字节
    35. if ((footer.getLong(8) != APK_SIG_BLOCK_MAGIC_LO)
    36. //footer.getLong(16) : 从第16个字节开始读取8个字节
    37. || (footer.getLong(16) != APK_SIG_BLOCK_MAGIC_HI)) {
    38. throw new SignatureNotFoundException(
    39. "No APK Signing Block before ZIP Central Directory");
    40. }
    41. // Apk签名分块尾部记录的分块大小
    42. long apkSigBlockSizeInFooter = footer.getLong(0);
    43. // 大小 < 24 或者大于 整型最大值-8,抛异常
    44. if ((apkSigBlockSizeInFooter < footer.capacity())
    45. || (apkSigBlockSizeInFooter > Integer.MAX_VALUE - 8)) {
    46. throw new SignatureNotFoundException(
    47. "APK Signing Block size out of range: " + apkSigBlockSizeInFooter);
    48. }
    49. // Apk签名分块的总大小
    50. int totalSize = (int) (apkSigBlockSizeInFooter + 8);
    51. // Apk签名分块开始位置的偏移量 = 中央目录开始位置偏移量- Apk签名分块的总大小
    52. long apkSigBlockOffset = centralDirOffset - totalSize;
    53. if (apkSigBlockOffset < 0) {
    54. throw new SignatureNotFoundException(
    55. "APK Signing Block offset out of range: " + apkSigBlockOffset);
    56. }
    57. ByteBuffer apkSigBlock = ByteBuffer.allocate(totalSize);
    58. apkSigBlock.order(ByteOrder.LITTLE_ENDIAN);
    59. //指针指向APK签名分块开始位置
    60. apk.seek(apkSigBlockOffset);
    61. //从指针位置开始读取totalSize个字节的数据存到apkSigBlock中
    62. apk.readFully(apkSigBlock.array(), apkSigBlock.arrayOffset(), apkSigBlock.capacity());
    63. //从第0字节开始读取8个字节,就是记录在APK签名分块开头的“Apk签名分块的总大小”
    64. long apkSigBlockSizeInHeader = apkSigBlock.getLong(0);
    65. //判断开头跟结尾记录的总大小是否相等
    66. if (apkSigBlockSizeInHeader != apkSigBlockSizeInFooter) {
    67. throw new SignatureNotFoundException(
    68. "APK Signing Block sizes in header and footer do not match: "
    69. + apkSigBlockSizeInHeader + " vs " + apkSigBlockSizeInFooter);
    70. }
    71. return Pair.create(apkSigBlock, apkSigBlockOffset);
    72. }
    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签名信息: 

    1. /**
    2. * 从APK签名分块中找到blockId指定的键值
    3. * @param apkSigningBlock 签名分块数据
    4. * @param blockId 分块键id
    5. * @return
    6. * @throws SignatureNotFoundException
    7. */
    8. static ByteBuffer findApkSignatureSchemeBlock(ByteBuffer apkSigningBlock, int blockId)
    9. throws SignatureNotFoundException {
    10. checkByteOrderLittleEndian(apkSigningBlock);
    11. // FORMAT:
    12. // OFFSET DATA TYPE DESCRIPTION
    13. // * @+0 bytes uint64: size in bytes (excluding this field)
    14. // * @+8 bytes pairs
    15. // * @-24 bytes uint64: size in bytes (same as the one above)
    16. // * @-16 bytes uint128: magic
    17. //从Apk签名分块的第8字节开始读到APK签名分块的倒数24字节,这一块也刚好是键值对数据区
    18. ByteBuffer pairs = sliceFromTo(apkSigningBlock, 8, apkSigningBlock.capacity() - 24);
    19. int entryCount = 0;
    20. while (pairs.hasRemaining()) {
    21. entryCount++;
    22. //因为表示键值对的长度是8个字节,小于8字节是有问题的
    23. if (pairs.remaining() < 8) {
    24. throw new SignatureNotFoundException(
    25. "Insufficient data to read size of APK Signing Block entry #" + entryCount);
    26. }
    27. //读取当前键值对的长度,因为8字节,所以使用getLong读取,每次get之后,指针都会往前移动一定字节
    28. long lenLong = pairs.getLong();
    29. //因为键ID的长度用4个字节表示,小于4个字节有问题,同时键值对的长度设置不超过整型的最大值
    30. if ((lenLong < 4) || (lenLong > Integer.MAX_VALUE)) {
    31. throw new SignatureNotFoundException(
    32. "APK Signing Block entry #" + entryCount
    33. + " size out of range: " + lenLong);
    34. }
    35. int len = (int) lenLong;
    36. //下一个键值对开始位置,就是当前键值对开始位置(当前键值对长度值之后) + 当前键值对长度
    37. int nextEntryPos = pairs.position() + len;
    38. //要是记录的键值对长度超过剩余的数据长度也是有问题
    39. if (len > pairs.remaining()) {
    40. throw new SignatureNotFoundException(
    41. "APK Signing Block entry #" + entryCount + " size out of range: " + len
    42. + ", available: " + pairs.remaining());
    43. }
    44. //读取当前键值对的键ID, 因为是4个字节,所以用getInt读取
    45. int id = pairs.getInt();
    46. //假如键跟传进来的一致,那么返回值的数据,值的长度 = 键值对的长度-键ID的长度
    47. if (id == blockId) {
    48. return getByteBuffer(pairs, len - 4);
    49. }
    50. //指针指向下一个键值对开始位置
    51. pairs.position(nextEntryPos);
    52. }
    53. //最后都没有找到指定ID的键值对,那么抛异常
    54. throw new SignatureNotFoundException(
    55. "No block with ID " + blockId + " in APK Signing Block.");
    56. }
    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就是以这个方案实现的。

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

  • 相关阅读:
    信息流广告投放的技巧
    云服务器带宽对上传下载速度的影响
    光电数鸡算法《java》
    终于拿到了爆火全网的进一线大厂程序员必看的1700道java面试题
    Desthiobiotin-PEG4-Acid|脱硫生物素-PEG4-酸| 供应商和制造商
    破解小程序禁止使用JS解释器动态执行JS(eval5、estime、evil-eval等)代码的终极解决方案
    让 CHAT 充分发挥优势
    spring的学习【1】
    8年经验之谈 —— 记一次接口压力测试与性能调优!
    Linux 查看 CPU核数 及 内存
  • 原文地址:https://blog.csdn.net/qincheng168/article/details/125935673