Android 动态写入信息到 APK

标签: 多渠道打包 , 动态写入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 文件的结构图:

Android 动态写入信息到 APK_第1张图片
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(长度必须是4096的整数倍);
( 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 签名的魔数

Android 动态写入信息到 APK_第2张图片
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 + customInfo.size, newSizeBytes)

第 11 行的 + customInfo.size 是后来补上的,感谢评论中的 「依然菜刀」发现的问题 。

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)