标签: 多渠道打包 , 动态写入APK , V2签名
如何实现快速多渠道打包?
如何将 Git 的 SHA-1 值、打包时间、友盟渠道等自定义信息写入到 APK 中?
这就需要我们今天要分享的技术了:动态写入信息到 apk。
一、核心干货
- 如果只用 V1 签名,放到 apk 的 META-INFO 目录即可。本篇讨论 V2 签名的情况。
- 在 V2 签名块中,签名信息是放在了 ID = 0x7109871a 的键值对块中。我们可以把其他自定义数据,也按照键值对块的格式,插入到签名块中。再修改 EoCDR[1] 的中央目录偏移量以及签名块大小,就能在不破坏签名的情况下往 apk 文件中插入自定义数据。为了避免和系统签名冲突,ID不能使用 0x7109871a(V2签名块的ID) 和 0xf05368c0(V3签名块的ID) 。
如果对 Zip 文件格式和 V2 签名块格式不了解,请移步到:《Android 端 V1/V2/V3 签名的原理》。这里只放一张 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 签名的魔数
魔数保存在中央目录区前方,共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
是后来补上的,感谢评论中的 「依然菜刀」发现的问题 。
toLittleEndianBytes
和 overwrite
是扩展方法,代码量大但简单,这里给出 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安装打开后,自动弹出「您是小明邀请的用户,具有....特权」等等千人千面的功能。
-
即 End of Central Directory Record,Zip文件末尾记录中央目录区偏移量和Zip其他信息的区域。 ↩