Android 动态写入信息到 APK

作者: 郑海鹏 | 来源:发表于2020-01-18 14:38 被阅读0次

    标签: 多渠道打包 , 动态写入APK , V2签名


    如何实现快速多渠道打包?
    如何将 Git 的 SHA-1 值、打包时间、友盟渠道等自定义信息写入到 APK 中?

    这就需要我们今天要分享的技术了:动态写入信息到 apk

    一、核心干货

    1. 如果只用 V1 签名,放到 apk 的 META-INFO 目录即可。本篇讨论 V2 签名的情况。
    2. 在 V2 签名块中,签名信息是放在了 ID = 0x7109871a 的键值对块中。我们可以把其他自定义数据,也按照键值对块的格式,插入到签名块中。再修改 EoCDR[1] 的中央目录偏移量以及签名块大小,就能在不破坏签名的情况下往 apk 文件中插入自定义数据。为了避免和系统签名冲突,ID不能使用 0x7109871a(V2签名块的ID) 和 0xf05368c0(V3签名块的ID) 。

    如果对 Zip 文件格式和 V2 签名块格式不了解,请移步到:《Android 端 V1/V2/V3 签名的原理》。这里只放一张 Zip 文件的结构图:

    Zip文件格式

    二、具体实现

    这篇文章会用 EoCDR 表示 Zip 文件的 End of Central Directory Record 区域。
    接下来我们用 Kotlin 来实现「动态写入信息到apk」和「从apk中读取信息」:


    1. 写入信息到 apk

    先高屋建瓴地看一下写入时的详细步骤:

    ( 1). 获取注释长度;
    ( 2). 获取 EoCDR 的长度;
    ( 3). 得到 EoCDR 的偏移量;
    ( 4). 找到『保存了「中央目录区偏移量」的偏移量』位置A;
    ( 5). 读取A, 读取中央目录偏移量 centralDirOffset;
    ( 6). 根据 centralDirOffset, 读取和验证 v2 签名的魔数;
    ( 7). 验证通过后,获取两个『保存「签名块的大小」的位置』C1、C2 和签名块大小;
    ( 8). 将想要插入的数据,按照格式转为字节数组 bytes;
    ( 9). 将 bytes 插入到 C2 之前;
    (10). 更新 C1 和 C2 对应的值为 signBlockSize + bytes.size;
    (11). 更新位置A的值为 centralDirOffset + bytes.size;

    下面我们一步步来实现:

    (1) 获取注释长度

    在 Java 中,可以通过 ZipFile 获取注释的长度:

    val file = File("Apk文件路径")
    
    // 创建 ZipFile 文件,只用于读取注释长度
    val zipFile = ZipFile(absolutePath)
    val comment = zipFile.comment
    val commentBytes = comment?.toByteArray()
    val commentLength = commentBytes?.size ?: 0
    

    (2) 获取 EoCDR 的长度

    如果一个 Zip 文件没有注释,它的 EoCDR 长度为 22,所以 EoCDR 的真实长度就是 22 + 注释长度:

    val eocdrLength = commentLength + 22
    

    (3) 得到 EoCDR 的偏移量

    EoCDR 的偏移量 = Apk文件长度 - EoCDR 的长度

    val eocdrOffset = file.length() - eocdrLength
    

    (4) 找到『保存了「中央目录区偏移量」的偏移量』位置

    根据 EoCDR 的结构,「中央目录区的偏移量」保存在距离 EoCDR 开始位置 16 字节处:

    val pointer = eocdrOffset + 16
    

    (5) 读取「中央目录偏移量」

    val centralDirectoryOffset = file.read(pointer, 4)
    

    其中,read 方法是自定义的扩展方法,实现如下:

    /**
     * 从文件制定偏移位置开始,读取制定长度的二进制数据。
     *
     * @param offset 相对文件起始位置的偏移量
     * @param length 读取的数据长度
     */
    fun File.read(offset: Long, length: Int): ByteArray {
        val inputStream = FileInputStream(this)
    
        inputStream.skip(offset)
    
        val buffer = ByteArray(length)
        inputStream.read(buffer, 0, length)
    
        inputStream.close()
    
        return buffer
    }
    

    (6) 读取和验证 v2 签名的魔数

    V2签名数据块的结构.png

    魔数保存在中央目录区前方,共16个字节,内容为 「APK Sig Block 42」:

    val magicBytes = read(centralDirectoryOffset - 16, 16)
    val magicString = magicBytes.toCharSequence()
    if (magicString != "APK Sig Block 42") {
        // 当前安装包不具有 V2 签名
    }
    

    (7) 获取两个『保存「签名块的大小」的位置』

    根据签名块的结构,签名块大小保存在两个地方,一个在签名块的开始位置,一个在魔数前方,都是8个字节:

    // 先获取结束位置的偏移量和大小
    val signBlockSizeOffset = centralDirectoryOffset - 24
    val signBlocksSize = file.read(signBlockSizeOffset, 8).toInt()
    
    // 再获取开始位置的偏移量
    val signBlockSizeOffsetStart = signBlockSizeOffset - signBlockSize + 16
    

    (8) 将想要插入的数据转为字节数组 bytes

    签名块中的数据都是按照 Size-ID-Value 的格式组织的,其中 Size 长8字节、ID 长4字节、Value 不定长,我们将这个组织过程封装为一个方法:

    /**
     * 用于构建一块符合签名块 Size-ID-Value 格式的 Byte 数组。
     */
    fun build(id: Int, value: ByteArray): ByteArray {
        val idBytes = id.toLittleEndianBytes()
        val idValueSize = (4 + value.size).toLong()
        val idValueSizeBytes = idValueSize.toLittleEndianBytes()
    
        val bytes = ByteArray(8 + 4 + value.size)
        System.arraycopy(idValueSizeBytes, 0, bytes, 0, 8)
        System.arraycopy(idBytes, 0, bytes, 8, 4)
        System.arraycopy(value, 0, bytes, 12, value.size)
    
        return bytes
    }
    

    我们可以使用这个方法构建出一个可以插入到签名块中的 bytes 数组:

    val customInfo = build(0x19920511, "要写入APK文件的自定义内容")
    

    (9) 将自定义数据插入到签名块中

    这就是个纯 Java 的问题,和本文关系不大,代码量较多但不难,这里略过具体实现,只给出函数定义:

    /**
     * 将数据value 插入文件的指定位置offset。
     */
    fun File.insert(offset: Int, value: ByteArray) {
        // 比较简单,具体实现略
    }
    

    调用这个函数,将自定义数据插入到 apk 文件:

    file.insert(signBlockSizeOffset, customInfo)
    

    (10) 更新签名块的大小

    签名块的大小在两个地方保存了,我们需要把两个地方都修改了:

    // 计算出新的大小
    val newSize = signBlocksSize + customInfo.size
    
    // 将大小转为字节数组(小端)
    val newSizeBytes = newSize.toLittleEndianBytes()
    
    // 修改签名块头部保存的大小
    file.overwrite(signBlockSizeOffsetStart, newSizeBytes)
    
    // 修改签名块尾部保存的大小
    file.overwrite(signBlockSizeOffset, newSizeBytes)
    

    toLittleEndianBytesoverwrite 是扩展方法,代码量大但简单,这里给出 overwrite 的定义:

    /**
     * 将数据value,从文件的指定位置offset开始覆盖,长度为value.length。
     */
    fun File.overwrite(offset: Int, value: ByteArray) {
        // 比较简单,具体实现略
    }
    

    (11) 更新中央目录偏移量的位置

    由于插入了新的数据,「中央目录的偏移量」会往后移,需要更新。同时,『保存「中央目录偏移量」的位置的偏移量』也会往后移,更新时需要找对地方:

    // 新「中央目录偏移量」
    val newOffset = centralDirectoryOffset + customInfo.size
    // 将 int 型的值,转为字节数组
    val newOffsetBytes = newOffset.toLittleEndianBytes()
    
    // 新 『保存「中央目录偏移量」的位置的偏移量』
    val newOffsetPointer = pointer + customInfo.size
    
    // 更新文件
    file.overwrite(newOffsetAddress, newOffsetBytes)
    

    根据上面11个步骤,我们就能将自定义的信息写入到 apk 中,并且能通过 Android 系统的签名校验。


    2. 从 APK 中读取信息

    根据写入的步骤,很容易知道读取时需要怎么做,读取时的详细步骤:

    (1). 获取注释长度;(和写入相同)
    (2). 获取 EoCDR 的长度;(和写入相同)
    (3). 得到 EoCDR 的偏移量;(和写入相同)
    (4). 找到『保存了「中央目录区偏移量」的偏移量』位置A;(和写入相同)
    (5). 读取A, 读取中央目录偏移量 centralDirOffset;(和写入相同)
    (6). 根据 centralDirOffset, 读取和验证 v2 签名的魔数;(和写入相同)
    (7). 验证通过后,获取两个『保存「签名块的大小」的位置』C1、C2 和签名块大小;(和写入相同)
    (8). 遍历每一个 Size-ID-Value 块,直到找到想要的数据块。

    可以看出,前面7个步骤都和写入时相同,我们看看最后一个步骤怎么实现:


    (8) 遍历签名块找到指定的ID

    从第(7)步我们拿到了签名块的开始位置 signBlockSizeOffsetStart 和 结束位置 signBlockSizeOffset,这一步只需遍历即可:

    val signBlockSizeOffsetStart = ... // 见第(7)步
    val signBlockSizeOffset = ... // 见第(7)步
    
    val dstID = ... // 当初写入时用的ID
    
    val dstSize: Int? = null
    val dstValueBytes: ByteArray? = null
    
    var offset = signBlockSizeOffsetStart + 8
    while (true) {
        // 先读取当前 ID-Value 块的 Size,长度8字节
        val tempSize = read(offset, 8).toInt()
    
        // 再读取 ID,长度4字节
        val tempID = read(offset + 8, 4).toInt()
        
        // 找到了退出循环
        if (tempID == dstID) {
            dstSize = tempSize
            dstValueBytes = read(offset + 12, tempSize - 4)
            break
        }
    
        // 找不到继续
        offset += tempSize + 8
        if (offset >= signBlockSizeOffset) {
            // log { "break; offset = $offset, tailOffset = $tailOffset" }
            break
        }
    }
    
    // 解析 value,这里按照字符串解析
    val value = dstValueBytes.toString()
    

    这样我们就能从 apk 中读取出我们写入的数据了~


    有了该技术,我们可以真的做到每一个 apk 包含的信息都不相同。
    例如,小明将分享链接给到小红,小红下载apk安装打开后,自动弹出「您是小明邀请的用户,具有....特权」等等千人千面的功能。


    1. 即 End of Central Directory Record,Zip文件末尾记录中央目录区偏移量和Zip其他信息的区域。

    相关文章

      网友评论

        本文标题:Android 动态写入信息到 APK

        本文链接:https://www.haomeiwen.com/subject/svvkzctx.html