多渠道打包

在 【Android V1 V2 签名机制】里面分析了美团在 V1 签名机制下的多渠道打包,但是没有分析 V2 机制的多渠道打包。

由于 V2 机制下多渠道打包涉及到 APK 结构,所以这里专门新起一篇。

由于 APK 也是一个压缩包,所以我们先来看一下 ZIP 文件的结构。

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

随便取一个 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)]

这个里面,写入了渠道信息:

  • ID 为:0x71777777
  • 渠道信息为:{"channel":"meituan"}

我们直接在 ID-VALUE 里面搜索 0x71777777,就可以得到上面的图了。

  • 长度:00 00 00 00 00 00 00 19=25 (长度是25字节)
  • ID:71 77 77 77 (我们自定义的ID)
  • 存入内容: {“channel”:“meituan”} 21字节
  • 21+4=25,刚好等于长度的值

至此,我们就成功的通过分析APK的二进制数据,得到了walle注入的数据。

你可能感兴趣的:(Android-高级)