在 【Android V1 V2 签名机制】里面分析了美团在 V1 签名机制下的多渠道打包,但是没有分析 V2 机制的多渠道打包。
由于 V2 机制下多渠道打包涉及到 APK 结构,所以这里专门新起一篇。
由于 APK 也是一个压缩包,所以我们先来看一下 ZIP 文件的结构。
一个普通的 ZIP 文件结构如下:
[文件头+文件数据+数据描述符]{此处可重复n次} + 中央目录 + 中央目录结束标识
[外链图片转存失败(img-QnesL1SI-1565923896312)(https://github.com/aprz512/pic4aprz512/blob/master/Blog/Android-%E9%AB%98%E7%BA%A7/%E7%AD%BE%E5%90%8D/11.png?raw=true)]
我们对着这个图看一下,apk在签名前和一个普通的 zip 文件的结构一样,中央目录 与 中央目录结束标识 对应后面两块。
ZIP 文件分3块,每块内容大致如下:
数据区(Contents of ZIP entries):存储文件压缩内容
中央目录区(Central Directory Header):存储zip文件里面包含的所有目录
中央目录结束标识(End of Central Directory Record:ECDR):存储zip文件的整体信息
其中,中央目录结束标识的结构是我们最需要搞清楚的。
我们拿一个例子来分析 中央目录结束标识 里面都有什么东西。
新建一个名为123.txt的文本文件,内容为123456,将123.txt压缩为123.zip
使用notepad++打开该文件,如下图:
[外链图片转存失败(img-0joozSga-1565923896314)(https://github.com/aprz512/pic4aprz512/blob/master/Blog/Android-%E9%AB%98%E7%BA%A7/%E5%A4%9A%E6%B8%A0%E9%81%93/10.png?raw=true)]
有8根红线,每根红线对应的数据意义如下:
地址 | 字节数 | 字节内容 | 意义 |
---|---|---|---|
00000062 | 4 | 50 4b 05 06 | 中央目录结束标记(0x06054b50,固定内容) |
00000084 | 2 | 00 00 | 当前磁盘编号 |
00000088 | 2 | 00 00 | 中央目录开始位置的磁盘编号 |
0000008a | 2 | 01 00 | 该磁盘上所记录的核心目录数量 |
0000008c | 2 | 01 00 | 中央目录结构总数 |
0000008e | 4 | 59 00 00 00 | 中央目录的大小 |
00000090 | 4 | 2b 00 00 00 | 中央目录开始位置相对于archive开始的位移 |
00000094 | 2 | 00 00 | 注释长度 |
中央目录结束标识 这一块的数据长度是不固定的,因为注释的长度不固定。
但是 中央目录结束标识 的起始位置的内容是固定的,我们就可以从 ZIP 文件的最后往前遍历,直到找到 4 个连续直接的内容与 0x06054b50 相等。找到了起始地址,从这个起始地址往后偏移 20 个字节,我们就知道了注释的长度。
我们看一下 walle 的代码吧:
for (int expectedCommentLength = 0; expectedCommentLength <= maxCommentLength;
expectedCommentLength++) {
// 这里假设 注释的长度为 expectedCommentLength,从 0 开始一个一个试
// 我们从文件的倒数第 22 个字节开始往前遍历
final long eocdStartPos = eocdWithEmptyCommentStartPosition - expectedCommentLength;
// 分配4个字节
final ByteBuffer byteBuffer = ByteBuffer.allocate(4);
// 定位到倒数第22、23、24...个字节的位置
fileChannel.position(eocdStartPos);
// 读取4个字节到buffer中
fileChannel.read(byteBuffer);
// 将字节排序,因为 zip 文件的字节排序与我们阅读的顺序不一样
// 比如:0X0ff8,在 zip 文件中是这样存放的:f8 0f
byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
// 判断该 4 个字节的值是不是 0x06054b50
if (byteBuffer.getInt(0) == ZIP_EOCD_REC_SIG) {
// 分配2个字节
final ByteBuffer commentLengthByteBuffer = ByteBuffer.allocate(2);
// 往前 22 个字节,定位到存放 注释长度 的字节处
fileChannel.position(eocdStartPos + ZIP_EOCD_COMMENT_LENGTH_FIELD_OFFSET);
fileChannel.read(commentLengthByteBuffer);
commentLengthByteBuffer.order(ByteOrder.LITTLE_ENDIAN);
// 获取
final int actualCommentLength = commentLengthByteBuffer.getShort(0);
if (actualCommentLength == expectedCommentLength) {
return actualCommentLength;
}
}
}
拿到注释长度之后,我们就可以准确的定位到记录“中央目录开始位置”的字节处(其实上面如果不往后20个字节,只往后16个字节,就可以直接拿到,但是这里为了分析源码)。
再看代码:
final ByteBuffer zipCentralDirectoryStart = ByteBuffer.allocate(4);
zipCentralDirectoryStart.order(ByteOrder.LITTLE_ENDIAN);
// 6 = 2 (Comment length) + 4 (Offset of start of central directory, relative to start of archive)
fileChannel.position(fileChannel.size() - commentLength - 6);
fileChannel.read(zipCentralDirectoryStart);
final long centralDirStartOffset = zipCentralDirectoryStart.getInt(0);
这都不用说了,很清晰,ZIP 文件的长度 - 注释的长度 - 存放注释长度的2个字节 - 存放中央目录偏移位置的4个字节,就是存放中央目录偏移位置的字节起始位置了。
我们知道,apk 签名之后是多了一块叫做 ‘APK Signing Block’ 的东西,这块内容正好在中央目录的前面。而上面我们又知道了中央目录的偏移位置,所以只要我们知道 ‘APK Signing Block’ 的大小,我们就能将 ‘APK Signing Block’ 这一块里面的所有字节都取出来。
要想知道 ‘APK Signing Block’ 的大小,我们还是要了解 ‘APK Signing Block’ 的结构:
偏移 | 字节数 | 描述 |
---|---|---|
@+0 (从前往后第0个字节) | 8 | 这个 block 的长度 (不包括这8个字节) |
@+8 (从前往后第8个字节) | n | 一组 ID-VALUE |
@-24 (从后往前第24个字节) | 8 | 这个 block 的长度(与上面的值一样) |
@-16 (从后往前第16个字节) | 16 | 魔数 “APK Sig Block 42” |
可以看出,APK Signing Block 分为4小块,第1、3块都储存的是 APK Signing Block 的大小。第 4 块是固定的。第 3块是不固定的,我们的渠道信息就是写在这个键值对区域的。
我们知道了中央目录的偏移位置,知道了中央目录的偏移位置前是一个固定的魔数,魔数前就是 APK Signing Block 的大小,这样,我们就可以拿到整个 APK Signing Block 块里面的所有东西了。
看看代码:
// 中央目录偏移位置往前24个字节,就是存 APK Signing Block 的长度的字节起始位置
fileChannel.position(centralDirOffset - 24);
// 分配24个字节空间
final ByteBuffer footer = ByteBuffer.allocate(24);
fileChannel.read(footer);
// 读取前8个字节,就是 APK Signing Block 的长度
final long apkSigBlockSizeInFooter = footer.getLong(0);
// 拿到储存的 APK Signing Block 的长度,还要加上 8 个字节,才是整个 APK Signing Block 的长度
final int totalSize = (int) (apkSigBlockSizeInFooter + 8);
// 计算 APK Signing Block 的起始偏移位置
final long apkSigBlockOffset = centralDirOffset - totalSize;
// 定位到 APK Signing Block 的起始偏移位置
fileChannel.position(apkSigBlockOffset);
final ByteBuffer apkSigBlock = ByteBuffer.allocate(totalSize);
// 读取整个 APK Signing Block 块
fileChannel.read(apkSigBlock);
apkSigBlock.order(ByteOrder.LITTLE_ENDIAN);
拿到了 APK Signing Block 块,我们就可以拿到里面 ID-VALUE 键值对了,由于我们在打包时放入了渠道名,现在我们就可以取出来。
我们先看看一个 ID-VALUE 键值对的结构:
字节数 | 描述 |
---|---|
8 | 该 ID-VALUE 的长度(不包括自己) |
4 | ID |
n - 4 | VALUE |
看看代码:
// 去掉前8与后24个字节
final ByteBuffer pairs = sliceFromTo(apkSigningBlock, 8, apkSigningBlock.capacity() - 24);
int entryCount = 0;
while (pairs.hasRemaining()) {
entryCount++;
// 取当前这个 ID-VALUE 对的长度
final long lenLong = pairs.getLong();
final int len = (int) lenLong;
// 下一个 ID-VALUE 对的起始位置
final int nextEntryPos = pairs.position() + len;
// 取 ID 的值
final int id = pairs.getInt();
// 取 VALUE 的值
idValues.put(id, getByteBuffer(pairs, len - 4));
pairs.position(nextEntryPos);
}
这里还需要判断一下,是否有V2签名标识:
https://source.android.com/security/apksigning/v2.html#apk-signing-block
根据官方文档的描述,V2签名信息储存在 ID 为 0x7109871a 的键值对中。
我们可以判断一下:
// Find the APK Signature Scheme v2 Block inside the APK Signing Block.
final ByteBuffer apkSignatureSchemeV2Block = originIdValues.get(ApkUtil.APK_SIGNATURE_SCHEME_V2_BLOCK_ID);
if (apkSignatureSchemeV2Block == null) {
throw new IOException(
"No APK Signature Scheme v2 block in APK Signing Block");
}
到此为止,我们就完整了分析了多渠道相关的知识,现在我们看看 walle 是如何注入 ID-VALUE 渠道信息的,因为注入信息之后,必然需要更新相关的地址值,否则就错乱了。
我们可以回想一下,需要更新哪些值。
当我们写完渠道信息之后,中央目录起始偏移肯定变了,所以需要更新储存这个值的字节。
APK Signing Block 块的大小也变了,也需要更新,这有两个地方存,所以一共需要更新3个地方。
但是由于我们有了 APK Signing Block 的所有字节信息,所以我们可以直接覆盖原来的 APK Signing Block 块,最后更新 中央目录起始偏移 位置就好了。
// 将中央目录与中央目录结尾标识的所有字节存起来
centralDirBytes = new byte[(int) (fileChannel.size() - centralDirStartOffset)];
fIn.read(centralDirBytes);
// 写更新之后的 APK Signing Block
fileChannel.position(apkSigningBlockOffset);
final long length = apkSigningBlock.writeApkSigningBlock(fIn);
// 写中央目录与中央目录结尾标识
fIn.write(centralDirBytes);
// 更新长度
fIn.setLength(fIn.getFilePointer());
// 定位到储存中央目录结束标志的字节位置
fIn.seek(fileChannel.size() - commentLength - 6);
// 更新该值
// 6 = 2(Comment length) + 4 (Offset of start of central directory, relative to start of archive)
final ByteBuffer temp = ByteBuffer.allocate(4);
temp.order(ByteOrder.LITTLE_ENDIAN);
temp.putInt((int) (centralDirStartOffset + length + 8 - (centralDirStartOffset - apkSigningBlockOffset)));
// 8 = size of block in bytes (excluding this field) (uint64)
temp.flip();
fIn.write(temp.array());
好了, walle 的核心代码就分析的差不多了,多渠道打包的原理也讲的差不多了。
随便取一个 apk,用 notepad++ 打开,定位到文件末尾:
[外链图片转存失败(img-wt5Pg0PS-1565923896315)(https://github.com/aprz512/pic4aprz512/blob/master/Blog/Android-%E9%AB%98%E7%BA%A7/%E5%A4%9A%E6%B8%A0%E9%81%93/1.png?raw=true)]
可以看到,确实是有一个固定的标识的。
然后我们找到 中央目录的偏移位置:
[外链图片转存失败(img-tnH2uC43-1565923896315)(https://github.com/aprz512/pic4aprz512/blob/master/Blog/Android-%E9%AB%98%E7%BA%A7/%E5%A4%9A%E6%B8%A0%E9%81%93/2.png?raw=true)]
中央目录结束标记 0x06054b50 的后面 16 个就是中央目录的偏移地址了。
偏移位置为 74 ee 50 02
,但是我们知道这个地址是反过来的,所以正确的地址为 0x0250ee74
。
我们搜索这个地址:
[外链图片转存失败(img-SM6PHpLs-1565923896316)(https://github.com/aprz512/pic4aprz512/blob/master/Blog/Android-%E9%AB%98%E7%BA%A7/%E5%A4%9A%E6%B8%A0%E9%81%93/3.png?raw=true)]
在这个地址的前面,应该是也是固定的字节,为魔数"APK Sig Block 42",将它换成字节,是 41 50 4B 20 53 69 67 20 42 6C 6F 63 6B 20 34 32
,我们对比一下:
[外链图片转存失败(img-BP10UlWR-1565923896316)(https://github.com/aprz512/pic4aprz512/blob/master/Blog/Android-%E9%AB%98%E7%BA%A7/%E5%A4%9A%E6%B8%A0%E9%81%93/4.png?raw=true)]
果然是一摸一样的。
魔数前面8个字节存放的是 APK Signing Block 的长度,我们往前找8个字节看看:
[外链图片转存失败(img-zrEzBrdq-1565923896317)(https://github.com/aprz512/pic4aprz512/blob/master/Blog/Android-%E9%AB%98%E7%BA%A7/%E5%A4%9A%E6%B8%A0%E9%81%93/5.png?raw=true)]
看来,APK Signing Block 的长度为 0x0ff8
。由于中央目录偏移地址为 0x025ee74
,减去这个地址得到0x0250de7c
:
[外链图片转存失败(img-VasvjC8V-1565923896318)(https://github.com/aprz512/pic4aprz512/blob/master/Blog/Android-%E9%AB%98%E7%BA%A7/%E5%A4%9A%E6%B8%A0%E9%81%93/6.png?raw=true)]
[外链图片转存失败(img-6QNMvHez-1565923896319)(https://github.com/aprz512/pic4aprz512/blob/master/Blog/Android-%E9%AB%98%E7%BA%A7/%E5%A4%9A%E6%B8%A0%E9%81%93/7.png?raw=true)]
再搜索这个地址:
[外链图片转存失败(img-AP6r6KIl-1565923896320)(https://github.com/aprz512/pic4aprz512/blob/master/Blog/Android-%E9%AB%98%E7%BA%A7/%E5%A4%9A%E6%B8%A0%E9%81%93/8.png?raw=true)]
可以看到,这个地址往前8个字节也是储存的 APK Signing Block 的长度,这样就说明,我们分析的过程是正确的。所以 APK Signing Block 的所有字节如下:
[外链图片转存失败(img-dBcmzOZf-1565923896320)(https://github.com/aprz512/pic4aprz512/blob/master/Blog/Android-%E9%AB%98%E7%BA%A7/%E5%A4%9A%E6%B8%A0%E9%81%93/9.png?raw=true)]
图中 1 和 2 之间的内容,就是 ID-VALUE 键值对了。由于我分析的这个 apk 没有写入渠道信息,所以我找了一个网上的图片:
[外链图片转存失败(img-RLOv6dQJ-1565923896321)(https://github.com/aprz512/pic4aprz512/blob/master/Blog/Android-%E9%AB%98%E7%BA%A7/%E5%A4%9A%E6%B8%A0%E9%81%93/11.png?raw=true)]
这个里面,写入了渠道信息:
{"channel":"meituan"}
我们直接在 ID-VALUE 里面搜索 0x71777777
,就可以得到上面的图了。
至此,我们就成功的通过分析APK的二进制数据,得到了walle注入的数据。