基于Zlib的c++流式解压

  1. 1. 前言
  2. 2. 解惑历程
    1. 2.1. ZIP文件的结构
    2. 2.2. zlib格式
  3. 3. 解决方案
  4. 4. End

前言

因为前段时间开源了一个用 C++ 实现的高性能 dex 运行时解析库DexKit.

然后里面借鉴了哔哩漫游里面的解压代码,然后在中途也因为有部分APK的格式解析不了,又去学习了zip文件的格式并且修了修bug,不过这都是后话了。

为什么要提起这个东西呢,因为前几天有人反馈说MIUI14的手机管家APK解析不出东西了,然后我第一反应是是不是路径填错了?但是反复确认后发现确实是解压不出来APK里面的dex文件,就开始了漫长的调试之旅。

解惑历程

首先我们先了解一下Zip文件的格式,当然我们只会选择其中几个需要的部分来讲述,如果想要了解完整的格式,请自行查看官方文档ZIP File Format Specification

ZIP文件的结构

根据官方文档所述,ZIP文件的整体结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[local file header 1]
[file data 1]
[data descriptor 1]
.
.
.
[local file header n]
[file data n]
[data descriptor n]
[archive decryption header] (EFS)
[archive extra data record] (EFS)
[central directory]
[zip64 end of central directory record]
[zip64 end of central directory locator]
[end of central directory record]

我们只会选择其中与文件相关的3个结构来分析:

  • local file header:代表文件的头部信息,包含文件名、文件大小、文件的压缩方式等信息。
  • file data:代表文件的数据,如果文件没有压缩,那么这个数据就是文件的原始数据,如果文件被压缩了,那么这个数据就是压缩后的数据。
  • data descriptor(可选):代表文件的数据描述符,如果文件被压缩了,那么这个数据描述符就会包含文件的原始大小,如果文件没有被压缩,那么这个数据描述符就会和local file header中的数据描述符一样。

每个被打包进ZIP的文件都会存在这样顺序存在的数据区域(data descriptor 不一定会在所有的zip文件中出现,这个我们后面再提),然后就是下一个文件的数据区域,以此类推。

接下来我们看一下 local file header 的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
local file header signature     4 bytes  (0x04034b50)
version needed to extract 2 bytes
general purpose bit flag 2 bytes
compression method 2 bytes
last mod file time 2 bytes
last mod file date 2 bytes
crc-32 4 bytes
compressed size 4 bytes
uncompressed size 4 bytes
file name length 2 bytes
extra field length 2 bytes

file name (variable size)
extra field (variable size)

首先是LocalFileHeader的签名,这个签名固定为(0x04034b50),我们一般都会用这个来判断是否存在下一个文件的数据区域。然后里面比较重要的就是 compressed sizeuncompressed size ,这两个字段分别代表压缩后的文件大小与原始文件大小,如果文件没有被压缩,那么这两个字段的值就是一样的。但是!!!不是所有的压缩文件都会按照标准来填充这两个字段,这也是我们开头提到问题的罪魁祸首。MIUI 14 的手机管家所有的压缩资源,将这两个字段全部填充为0,让我一度怀疑这是故意的还是打包工具的问题。

上面我们提到了,下一个文件是紧跟着上一个文件的 file data 或者 data descriptor 区域,现在我们从 header 中获取到的信息是 file data 区域的大小为0,此时开始陷入了僵局,我开始思考应该怎样处理这个问题。因为这个未定义行为,在使用 010 editor 打开 apk 进行分析的过程中也会存在解析异常的问题。

根据上面的已知信息,我们已知 local file header signature 是固定值,难道我们需要暴力往后遍历搜索下一个文件的签名才能判断数据吗?不,正常的压缩软件以及 JAVA 中的 ZipInputStream 都能正常打开并且解压这个非标准格式的APK文件,一定存在一个稳妥的方式解决我们的问题。

zlib格式

看到这个小节的标题,有的人可能会想:“什么?zlib不是一个库吗,怎么是一个格式???”。没错,zlib是一种数据格式,它的算法与格式都是公开的,我们先搞清楚几个相关的概念:

  • zlib 是一种数据格式,用于存储压缩后的数据
  • gzip 也是一种数据格式,也用于存储压缩后的数据,不过它只保存单个文件
  • zip 文件是一种归档格式,它用来存储多个经过压缩后的 zlib 文件集合
  • deflate 是压缩算法,zlib 与 gzip 都使用它来压缩文件
  • inflate 是对应 deflate 算法的解压算法

既然 zlib 是一种数据格式,那么它是否能得知自身的文件大小呢?是的,inflate 算法在解压过程中可以做到自终止,当读取到了超出数据本身的区域会停止读取,并且将信息返回,我们可以在这上面做文章解决我们开头的问题。

解决方案

通过对于 zlib 格式的相关了解,我们可以重新定制以下我们原本的解压步骤:

  • 读取 apk 文件,获取第一个 local file header
  • 判断 header 中的 compressed size 是否为0,如果为0,我们假设这是错误的数据,我们获取数据区域的指针,并且将它开始的一部分数据作为本压缩文件的数据传递给 inflate 函数尝试解压,如果读取字节数为0,则表示数据区域确实为0,否则逐步向后读取尝试解压,直到 inflate 算法终止。到这里我们可以获得总共读取了多少字节,以及解压后的字节数。将真实值填充回结构体。
  • 解析完所有的 LocalFile 数据区域,并且将 fileName 以及 data 区域的指针存入 map。
  • 暴力查找 classes.dex, classes2.dex, .., classesN.dex, 逐一解压,再将数据交给 DexKit 进行后续处理。

这里给出我的处理方式作为参考,如果想实现一个类似 JAVA 的 ZipInputStream 可以尝试自己封装一下。

zip_helper.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
#include <zlib.h>
#include <sstream>

#define UNZIP_BUF_CHUNK 512

static void *myalloc([[maybe_unused]] void *q, unsigned n, unsigned m) {
return calloc(n, m);
}

static void myfree([[maybe_unused]] void *q, void *p) {
(void) q;
free(p);
}

struct [[gnu::packed]] ZipFileRecord {
[[maybe_unused]] uint32_t signature;
[[maybe_unused]] uint16_t version;
[[maybe_unused]] uint16_t flags;
[[maybe_unused]] uint16_t compress;
[[maybe_unused]] uint16_t last_modify_time;
[[maybe_unused]] uint16_t last_modify_date;
[[maybe_unused]] uint32_t crc;
[[maybe_unused]] uint32_t compress_size;
[[maybe_unused]] uint32_t uncompress_size;
[[maybe_unused]] uint16_t file_name_length;
[[maybe_unused]] uint16_t extra_length;
// [[maybe_unused]] uint8_t file_name[0];

// fuck apk (compress_size | uncompress_size) == 0
std::pair<size_t, size_t> getRealSizeInfo() {
if (compress_size && uncompress_size) {
return {compress_size, uncompress_size};
}
z_stream stream{};
auto ret = inflateInit2(&stream, -MAX_WBITS);
if (ret != Z_OK) {
return {0, 0};
}

// 如果需要流式解压,使用 sstream 将解压后的结果写入即可
// std::stringstream ss;

char buf[UNZIP_BUF_CHUNK];
size_t total_read = 0;
size_t total_write = 0;
size_t input_pos = 0;

stream.zalloc = myalloc;
stream.zfree = myfree;
stream.opaque = nullptr;
stream.next_in = this->data();
stream.avail_in = UNZIP_BUF_CHUNK;

do {
if (input_pos == UNZIP_BUF_CHUNK) {
stream.next_in = this->data() + total_read;
stream.avail_in = UNZIP_BUF_CHUNK;
input_pos = 0;
}
stream.next_out = (u_char *) buf;
stream.avail_out = UNZIP_BUF_CHUNK;
ret = inflate(&stream, Z_PARTIAL_FLUSH);
switch (ret) {
case Z_OK: {
// 将解压后的数据接入 sstream
// ss.write(buf, UNZIP_BUF_CHUNK - stream.avail_out);
size_t input_used = (UNZIP_BUF_CHUNK - input_pos) - stream.avail_in;
total_write += UNZIP_BUF_CHUNK - stream.avail_out;
input_pos += input_used;
total_read += input_used;
break;
}
case Z_BUF_ERROR:
return {0, 0};
case Z_DATA_ERROR:
case Z_MEM_ERROR: {
inflateEnd(&stream);
return {0, 0};
}
default:
break;
}
} while (ret != Z_STREAM_END);

inflateEnd(&stream);
// 压缩后总大小
total_read += (UNZIP_BUF_CHUNK - input_pos) - stream.avail_in;
// 压缩前总大小
total_write += UNZIP_BUF_CHUNK - stream.avail_out;
// 最后还需要补充写入最后一次循环的数据
// ss.write(buf, UNZIP_BUF_CHUNK - stream.avail_out);

// 之后只需要读取 ss.str().data() 即可获取解压后的数据

return {total_read, total_write};
}

std::string_view file_name() {
return {reinterpret_cast<char *>(reinterpret_cast<uint8_t *>(this) + sizeof(ZipFileRecord)), file_name_length};
}

uint8_t *data() {
return reinterpret_cast<uint8_t *>(this) + sizeof(ZipFileRecord) + file_name_length + extra_length;
}
};

End

至此,问题的定位、分析、追踪以及修复处理总算告一段落,愿世上再无未定义