ZIP 解压安全:Central Directory、规范化路径与 miniz 示例

文章目录

  • [ZIP 解压安全:Central Directory、规范化路径与 miniz 示例](#ZIP 解压安全:Central Directory、规范化路径与 miniz 示例)
  • [一、为什么 ZIP 解压需要做安全校验?](#一、为什么 ZIP 解压需要做安全校验?)
  • [二、什么是 ZIP Central Directory?](#二、什么是 ZIP Central Directory?)
    • [2.1 ZIP 文件的基本结构](#2.1 ZIP 文件的基本结构)
    • [2.2 Central Directory 是什么?](#2.2 Central Directory 是什么?)
    • [2.3 为什么要先读取 Central Directory?](#2.3 为什么要先读取 Central Directory?)
    • [2.4 好处一:可以在解压前做完整校验](#2.4 好处一:可以在解压前做完整校验)
    • [2.5 好处二:避免"解压一半才失败"](#2.5 好处二:避免“解压一半才失败”)
    • [2.6 好处三:便于实施统一安全策略](#2.6 好处三:便于实施统一安全策略)
  • 三、什么是规范化路径?
    • [3.1 ZIP entry 路径为什么危险?](#3.1 ZIP entry 路径为什么危险?)
    • [3.2 规范化路径的目的](#3.2 规范化路径的目的)
    • [3.3 应该拒绝的路径示例](#3.3 应该拒绝的路径示例)
    • [3.4 不推荐的简单判断方式](#3.4 不推荐的简单判断方式)
  • 四、解压过程中还需要注意什么?
    • [4.1 限制文件数量](#4.1 限制文件数量)
    • [4.2 限制单文件大小](#4.2 限制单文件大小)
    • [4.3 限制总解压大小](#4.3 限制总解压大小)
    • [4.4 防止压缩炸弹](#4.4 防止压缩炸弹)
    • [4.5 检查重复路径](#4.5 检查重复路径)
    • [4.6 检查目标路径是否仍在根目录内](#4.6 检查目标路径是否仍在根目录内)
    • [4.7 谨慎处理符号链接](#4.7 谨慎处理符号链接)
    • [4.8 避免覆盖已有文件](#4.8 避免覆盖已有文件)
    • [4.9 使用临时目录进行解压](#4.9 使用临时目录进行解压)
  • 五、推荐的安全解压流程
  • [六、一个更安全的 ZIP 包结构](#六、一个更安全的 ZIP 包结构)
  • [七、miniz 简介](#七、miniz 简介)
  • [八、使用 miniz 读取 ZIP Central Directory](#八、使用 miniz 读取 ZIP Central Directory)
  • 九、路径规范化示例代码
  • [十、使用 miniz 实现安全校验与解压](#十、使用 miniz 实现安全校验与解压)
  • [十一、关于 `std::filesystem` 路径校验的说明](#十一、关于 std::filesystem 路径校验的说明)
  • 十二、常见错误做法总结
    • [12.1 直接拼接路径](#12.1 直接拼接路径)
    • [12.2 只判断是否包含 `"../"`](#12.2 只判断是否包含 "../")
    • [12.3 不限制解压大小](#12.3 不限制解压大小)
    • [12.4 不检查重复 entry](#12.4 不检查重复 entry)
    • [12.5 直接解压到正式目录](#12.5 直接解压到正式目录)
  • 十三、总结

ZIP 解压安全:Central Directory、规范化路径与 miniz 示例

ZIP 是非常常见的压缩格式,广泛用于安装包、资源包、插件包、升级包、数据包等场景。很多程序都会提供"导入 ZIP""安装 ZIP""解压 ZIP 到指定目录"的功能。

但是,ZIP 解压并不是简单地把压缩包里的文件逐个写到磁盘。如果没有做路径校验和安全限制,可能会导致严重问题,例如:

  • 文件被解压到目标目录之外;
  • 覆盖应用程序自身文件;
  • 覆盖用户文件;
  • 写入系统敏感目录;
  • 利用路径穿越攻击执行恶意代码;
  • 通过压缩炸弹消耗磁盘或内存资源。

本文主要介绍两个 ZIP 解压中非常重要的概念:

  1. ZIP Central Directory;
  2. 规范化路径;

并使用 miniz 给出一个相对安全的 ZIP 校验和解压示例。

miniz GitHub 地址:

text 复制代码
https://github.com/richgel999/miniz

一、为什么 ZIP 解压需要做安全校验?

很多程序在解压 ZIP 时,容易写出类似下面的代码:

cpp 复制代码
std::string outputPath = outputDir + "/" + entryName;
mz_zip_reader_extract_to_file(&zip, index, outputPath.c_str(), 0);

这种做法的问题在于:entryName 来自 ZIP 文件本身,并不可信。

ZIP 里的文件名可能是正常的:

text 复制代码
images/a.png
config/settings.json
docs/readme.txt

也可能是恶意构造的:

text 复制代码
../evil.exe
../../outside.txt
C:/Windows/System32/evil.dll
C:\Users\Public\evil.exe
/var/tmp/evil.sh
\\server\share\evil.exe
folder/../../evil.txt

如果程序直接把 entryName 和目标目录拼接后写入磁盘,就可能发生路径穿越攻击。

这种问题通常被称为:

text 复制代码
Zip Slip

它的核心原因是:

text 复制代码
ZIP entry 里的路径是用户输入,不能直接信任。

因此,安全解压 ZIP 的第一原则是:

text 复制代码
先校验,再解压。

二、什么是 ZIP Central Directory?

2.1 ZIP 文件的基本结构

一个普通 ZIP 文件大致由以下几部分组成:

text 复制代码
[Local File Header 1]
[File Data 1]

[Local File Header 2]
[File Data 2]

[Local File Header 3]
[File Data 3]

...

[Central Directory]
[End of Central Directory Record]

可以简单理解为:

结构 作用
Local File Header 每个文件数据前面的局部文件头
File Data 真实压缩后的文件内容
Central Directory ZIP 文件尾部的总目录
End of Central Directory ZIP 结尾记录,用来定位 Central Directory

2.2 Central Directory 是什么?

Central Directory 可以理解为 ZIP 文件里的"目录索引表"。

它记录了 ZIP 中所有 entry 的元信息,例如:

  • 文件名;
  • 是否为目录;
  • 压缩方法;
  • 压缩前大小;
  • 压缩后大小;
  • CRC32;
  • 文件注释;
  • 文件属性;
  • 文件数据在 ZIP 文件中的偏移;
  • 是否加密;
  • ZIP64 扩展信息等。

也就是说,Central Directory 记录了 ZIP 中"有哪些文件"和"这些文件的基本信息"。


2.3 为什么要先读取 Central Directory?

安全解压时,推荐的流程不是:

text 复制代码
读一个文件,解压一个文件。

而是:

text 复制代码
先读取 Central Directory,拿到所有 entry;
先校验所有 entry;
全部通过后,再正式解压。

这样做有几个好处。


2.4 好处一:可以在解压前做完整校验

通过 Central Directory,我们可以在真正写磁盘之前拿到所有文件信息,然后统一检查:

  • 是否存在空文件名;
  • 是否存在绝对路径;
  • 是否存在 Windows 盘符路径;
  • 是否存在 .. 路径穿越;
  • 是否存在重复文件名;
  • 文件数量是否超限;
  • 单文件解压后大小是否超限;
  • 总解压后大小是否超限;
  • 是否包含不允许的文件类型;
  • 是否所有文件都在允许的目录结构下。

如果有任意一个 entry 不合法,可以直接拒绝整个 ZIP,不进行任何正式解压。


2.5 好处二:避免"解压一半才失败"

如果一边解压一边校验,可能出现这种情况:

text 复制代码
file1.txt 合法,已经写入磁盘
file2.txt 合法,已经写入磁盘
../../evil.txt 非法,发现问题,停止解压

此时虽然程序停止了,但前面的文件已经落盘。

这会带来几个问题:

  • 目标目录变成半更新状态;
  • 需要额外清理已经写入的文件;
  • 清理失败可能留下垃圾文件;
  • 业务状态可能不一致;
  • 如果前面已经写入了危险文件,可能产生安全风险。

所以更推荐:

text 复制代码
第一阶段:只读取 ZIP 目录信息并校验;
第二阶段:校验全部通过后再解压。

2.6 好处三:便于实施统一安全策略

例如可以规定:

text 复制代码
ZIP 内所有文件必须位于同一个顶层目录下。

合法结构:

text 复制代码
PackageName/
    config.json
    bin/tool.exe
    resources/logo.png

不推荐或拒绝的结构:

text 复制代码
config.json
bin/tool.exe
resources/logo.png

或者:

text 复制代码
PackageA/file1.txt
PackageB/file2.txt

统一顶层目录的好处是:

  • 解压后结构清晰;
  • 便于整体删除;
  • 便于安装、升级、回滚;
  • 避免污染目标根目录;
  • 便于把一个 ZIP 映射为一个独立包。

三、什么是规范化路径?

3.1 ZIP entry 路径为什么危险?

ZIP entry 的文件名本质上只是一个字符串。

例如:

text 复制代码
assets/image.png

这是一个正常路径。

但攻击者可以构造:

text 复制代码
assets/../../evil.exe

如果目标目录是:

text 复制代码
D:/App/Data

程序简单拼接后得到:

text 复制代码
D:/App/Data/assets/../../evil.exe

经过操作系统路径解析后,实际位置可能变成:

text 复制代码
D:/App/evil.exe

也就是说,文件被写到了目标目录之外。


3.2 规范化路径的目的

规范化路径,就是把 ZIP entry 中的路径转换成统一、明确、可校验的形式。

一般要做以下处理:

  1. \ 统一转换成 /
  2. 拒绝空路径;
  3. 拒绝绝对路径;
  4. 拒绝 Windows 盘符路径;
  5. 拒绝 UNC 网络路径;
  6. 拒绝 ..
  7. 去掉 .
  8. 合并重复分隔符;
  9. 最终判断目标路径是否仍然位于解压根目录内部。

3.3 应该拒绝的路径示例

下面这些路径都应该被拒绝:

text 复制代码
../evil.txt
../../evil.txt
folder/../../evil.txt
/evil.txt
/tmp/evil.txt
C:/Windows/System32/evil.dll
C:\Windows\System32\evil.dll
\\server\share\evil.exe
folder\..\evil.txt

还要注意混合分隔符:

text 复制代码
folder/..\evil.txt
folder\../evil.txt

所以不能只判断是否包含 "../",而应该先统一分隔符,再逐段分析路径。


3.4 不推荐的简单判断方式

例如:

cpp 复制代码
if (entryName.find("../") != std::string::npos) {
    return false;
}

这个判断不够安全,因为它可能漏掉:

text 复制代码
..\evil.txt
folder\..\evil.txt
folder//../evil.txt

也不能只判断是否以 / 开头,因为 Windows 还有:

text 复制代码
C:\xxx
C:/xxx
\\server\share\xxx

更可靠的方式是:

text 复制代码
先统一路径格式;
再拆分路径段;
逐段判断是否合法。

四、解压过程中还需要注意什么?

除了 Central Directory 和规范化路径,还需要考虑以下安全点。


4.1 限制文件数量

恶意 ZIP 可以包含大量文件,例如:

text 复制代码
几十万甚至上百万个小文件

这会导致:

  • 解压时间过长;
  • 文件系统性能下降;
  • 磁盘 inode 或目录项耗尽;
  • 程序卡死;
  • 界面无响应。

建议设置最大文件数量,例如:

cpp 复制代码
constexpr mz_uint kMaxFiles = 10000;

实际值应根据业务场景调整。


4.2 限制单文件大小

ZIP 中某个文件解压后可能非常大。

例如:

text 复制代码
entry name: large.bin
compressed size: 5 MB
uncompressed size: 10 GB

如果直接解压,可能导致磁盘写满。

建议设置单文件最大解压大小,例如:

cpp 复制代码
constexpr mz_uint64 kMaxSingleFileSize = 200ull * 1024 * 1024;

4.3 限制总解压大小

即使单个文件不大,总大小也可能非常大。

例如:

text 复制代码
10000 个文件,每个 10 MB

总解压大小就是 100 GB。

因此还需要限制总解压后大小:

cpp 复制代码
constexpr mz_uint64 kMaxTotalUncompressedSize = 2ull * 1024 * 1024 * 1024;

4.4 防止压缩炸弹

压缩炸弹的特点是:

text 复制代码
压缩包很小,解压后极大。

例如:

text 复制代码
10 MB ZIP -> 100 GB 数据

可以通过以下方式降低风险:

  • 检查单文件解压后大小;
  • 检查总解压后大小;
  • 检查压缩比;
  • 限制解压耗时;
  • 限制最大文件数量。

例如可以对压缩比做简单判断:

text 复制代码
uncompressed_size / compressed_size > 某个阈值

需要注意,如果 compressed_size 为 0,要单独处理,避免除零。


4.5 检查重复路径

ZIP 中可能存在重复 entry:

text 复制代码
config.json
config.json

也可能存在大小写冲突:

text 复制代码
Readme.txt
README.txt

在 Windows 文件系统上,这两个路径通常会指向同一个文件。

如果不处理重复路径,可能出现:

  • 后一个文件覆盖前一个文件;
  • 不同平台解压结果不一致;
  • 校验的是第一个文件,实际落盘的是第二个文件;
  • 产生安全绕过。

建议做法:

  • 维护一个规范化路径集合;
  • 如果出现重复路径,拒绝;
  • Windows 下可以使用小写路径做重复判断。

4.6 检查目标路径是否仍在根目录内

即使已经对 ZIP entry 做过规范化,正式写文件前仍建议再次检查:

text 复制代码
目标文件路径必须位于解压根目录内部。

例如解压根目录是:

text 复制代码
D:/ExtractRoot

那么最终目标路径必须在:

text 复制代码
D:/ExtractRoot/...

内部,而不能变成:

text 复制代码
D:/evil.txt
D:/OtherDir/file.txt

这是防御式编程中的重要原则:

text 复制代码
关键边界处重复校验。

4.7 谨慎处理符号链接

一些 ZIP 文件可能包含符号链接信息。

如果解压库或自定义逻辑会还原符号链接,需要特别小心。

例如:

text 复制代码
dir/link -> ../../outside
dir/link/evil.txt

如果后续写入 dir/link/evil.txt,可能会通过符号链接逃逸到目标目录之外。

如果业务不需要符号链接,建议:

text 复制代码
直接拒绝符号链接。

4.8 避免覆盖已有文件

正式解压到目标目录时,不建议直接覆盖已有目录。

例如:

text 复制代码
目标目录/
    old_file.txt

如果新 ZIP 解压失败,可能导致:

text 复制代码
部分新文件 + 部分旧文件

最终目录状态不一致。

更安全的做法是:

text 复制代码
1. 解压到临时目录;
2. 校验临时目录内容;
3. 校验通过后再移动或替换目标目录。

4.9 使用临时目录进行解压

推荐流程:

text 复制代码
1. 创建临时目录;
2. 将 ZIP 解压到临时目录;
3. 对解压结果做必要检查;
4. 检查通过后,再移动到最终目录;
5. 失败则删除临时目录。

这样可以减少半成品文件污染正式目录的风险。


五、推荐的安全解压流程

一个相对安全的 ZIP 解压流程可以设计为:

text 复制代码
1. 打开 ZIP 文件;
2. 读取 Central Directory;
3. 获取所有 entry;
4. 对每个 entry 的路径做规范化;
5. 拒绝绝对路径、盘符路径、UNC 路径、..;
6. 检查文件数量;
7. 检查单文件大小;
8. 检查总解压大小;
9. 检查重复路径;
10. 可选:检查是否所有文件都位于同一个顶层目录;
11. 校验全部通过后,解压到临时目录;
12. 解压时再次确认目标路径位于临时目录内;
13. 解压完成后,根据业务需要移动到最终目录。

核心原则是:

text 复制代码
不要边信任边解压。
不要直接相信 ZIP entry 的文件名。
不要把未校验的 ZIP 直接解压到正式目录。

六、一个更安全的 ZIP 包结构

对于需要分发一组文件的 ZIP 包,推荐使用统一顶层目录。

推荐结构:

text 复制代码
PackageName/
    config.json
    bin/
        tool.exe
    resources/
        icon.png
        images/
            background.png
    docs/
        readme.txt

不推荐结构:

text 复制代码
config.json
tool.exe
resources/icon.png
readme.txt

原因是所有文件直接散落在根目录下,解压后容易污染目标目录。

也不推荐一个 ZIP 里混合多个顶层目录:

text 复制代码
PackageA/
    file1.txt
PackageB/
    file2.txt

除非业务明确支持这种格式,否则建议拒绝。

统一顶层目录的好处是:

  • 目录结构清晰;
  • 容易整体删除;
  • 容易整体替换;
  • 便于版本管理;
  • 避免和目标目录中的其他文件混在一起;
  • 便于做校验和回滚。

七、miniz 简介

miniz 是一个轻量级的 C/C++ ZIP/zlib 库,适合嵌入到客户端程序、工具程序或小型服务中。

GitHub 地址:

text 复制代码
https://github.com/richgel999/miniz

常用 API 包括:

cpp 复制代码
mz_zip_reader_init_file()
mz_zip_reader_get_num_files()
mz_zip_reader_file_stat()
mz_zip_reader_is_file_a_directory()
mz_zip_reader_extract_to_file()
mz_zip_reader_end()

使用 miniz 读取 ZIP 时,可以先遍历 ZIP 中的所有 entry,读取每个 entry 的文件名、压缩大小、解压大小等信息,再决定是否解压。


八、使用 miniz 读取 ZIP Central Directory

下面示例演示如何打开 ZIP 并遍历所有 entry。

cpp 复制代码
#include "miniz.h"

#include <iostream>
#include <string>

int main()
{
    const char* zipPath = "test.zip";

    mz_zip_archive zip{};
    if (!mz_zip_reader_init_file(&zip, zipPath, 0)) {
        std::cerr << "Failed to open zip file\n";
        return 1;
    }

    const mz_uint fileCount = mz_zip_reader_get_num_files(&zip);

    std::cout << "File count: " << fileCount << "\n";

    for (mz_uint i = 0; i < fileCount; ++i) {
        mz_zip_archive_file_stat stat{};

        if (!mz_zip_reader_file_stat(&zip, i, &stat)) {
            std::cerr << "Failed to get file stat\n";
            mz_zip_reader_end(&zip);
            return 1;
        }

        std::cout << "Entry: " << stat.m_filename << "\n";
        std::cout << "  compressed size: " << stat.m_comp_size << "\n";
        std::cout << "  uncompressed size: " << stat.m_uncomp_size << "\n";
    }

    mz_zip_reader_end(&zip);
    return 0;
}

这里获取到的 entry 信息主要来自 ZIP 的 Central Directory。


九、路径规范化示例代码

下面是一个简单的 ZIP entry 路径规范化函数。

功能包括:

  • \ 转换成 /
  • 拒绝空路径;
  • 拒绝 /xxx 绝对路径;
  • 拒绝 C:/xxxC:\xxx 盘符路径;
  • 拒绝 //server/share\\server\share
  • 拒绝 ..
  • 忽略 .
  • 合并多余的 /
cpp 复制代码
#include <algorithm>
#include <cctype>
#include <sstream>
#include <string>
#include <vector>

static bool isWindowsDrivePath(const std::string& path)
{
    return path.size() >= 2 &&
           std::isalpha(static_cast<unsigned char>(path[0])) &&
           path[1] == ':';
}

static bool normalizeZipEntryPath(const std::string& input, std::string& output)
{
    output.clear();

    if (input.empty()) {
        return false;
    }

    std::string path = input;

    // ZIP 中可能出现 Windows 风格分隔符,统一转换为 '/'
    std::replace(path.begin(), path.end(), '\\', '/');

    if (path.empty()) {
        return false;
    }

    // 拒绝 Unix/Linux/macOS 绝对路径
    if (path[0] == '/') {
        return false;
    }

    // 拒绝 Windows 盘符路径,例如 C:/xxx
    if (isWindowsDrivePath(path)) {
        return false;
    }

    // 拒绝 UNC 路径,例如 //server/share
    if (path.rfind("//", 0) == 0) {
        return false;
    }

    std::vector<std::string> parts;
    std::stringstream stream(path);
    std::string part;

    while (std::getline(stream, part, '/')) {
        if (part.empty() || part == ".") {
            continue;
        }

        if (part == "..") {
            return false;
        }

        parts.push_back(part);
    }

    if (parts.empty()) {
        return false;
    }

    for (size_t i = 0; i < parts.size(); ++i) {
        if (i > 0) {
            output += '/';
        }

        output += parts[i];
    }

    return true;
}

测试示例:

cpp 复制代码
std::string out;

normalizeZipEntryPath("folder/file.txt", out);
// true, out = "folder/file.txt"

normalizeZipEntryPath("folder\\file.txt", out);
// true, out = "folder/file.txt"

normalizeZipEntryPath("./folder//file.txt", out);
// true, out = "folder/file.txt"

normalizeZipEntryPath("../evil.txt", out);
// false

normalizeZipEntryPath("folder/../../evil.txt", out);
// false

normalizeZipEntryPath("C:\\Windows\\evil.txt", out);
// false

normalizeZipEntryPath("/tmp/evil.txt", out);
// false

十、使用 miniz 实现安全校验与解压

下面给出一个相对完整的示例。

功能包括:

  1. 打开 ZIP;
  2. 遍历 Central Directory;
  3. 规范化 entry 路径;
  4. 检查文件数量;
  5. 检查单文件大小;
  6. 检查总解压大小;
  7. 检查重复路径;
  8. 检查是否有统一顶层目录;
  9. 解压时再次确认目标路径没有逃逸;
  10. 解压到指定目录。

示例使用 C++17 的 std::filesystem

cpp 复制代码
#include "miniz.h"

#include <algorithm>
#include <cctype>
#include <filesystem>
#include <iostream>
#include <set>
#include <sstream>
#include <string>
#include <vector>

namespace fs = std::filesystem;

struct ZipEntryInfo {
    mz_uint index = 0;
    std::string originalName;
    std::string normalizedName;
    mz_uint64 compressedSize = 0;
    mz_uint64 uncompressedSize = 0;
    bool isDirectory = false;
};

static bool isWindowsDrivePath(const std::string& path)
{
    return path.size() >= 2 &&
           std::isalpha(static_cast<unsigned char>(path[0])) &&
           path[1] == ':';
}

static std::string toLowerCopy(std::string value)
{
    std::transform(value.begin(), value.end(), value.begin(),
                   [](unsigned char ch) {
                       return static_cast<char>(std::tolower(ch));
                   });
    return value;
}

static bool normalizeZipEntryPath(const std::string& input, std::string& output)
{
    output.clear();

    if (input.empty()) {
        return false;
    }

    std::string path = input;
    std::replace(path.begin(), path.end(), '\\', '/');

    if (path.empty()) {
        return false;
    }

    if (path[0] == '/') {
        return false;
    }

    if (isWindowsDrivePath(path)) {
        return false;
    }

    if (path.rfind("//", 0) == 0) {
        return false;
    }

    std::vector<std::string> parts;
    std::stringstream stream(path);
    std::string part;

    while (std::getline(stream, part, '/')) {
        if (part.empty() || part == ".") {
            continue;
        }

        if (part == "..") {
            return false;
        }

        parts.push_back(part);
    }

    if (parts.empty()) {
        return false;
    }

    for (size_t i = 0; i < parts.size(); ++i) {
        if (i > 0) {
            output += '/';
        }

        output += parts[i];
    }

    return true;
}

static std::string getTopLevelDirectory(const std::string& normalizedPath)
{
    const auto pos = normalizedPath.find('/');
    if (pos == std::string::npos) {
        return {};
    }

    return normalizedPath.substr(0, pos);
}

static bool isPathInsideDirectory(const fs::path& root, const fs::path& target)
{
    const fs::path absRoot = fs::absolute(root).lexically_normal();
    const fs::path absTarget = fs::absolute(target).lexically_normal();

    auto rootIt = absRoot.begin();
    auto targetIt = absTarget.begin();

    for (; rootIt != absRoot.end(); ++rootIt, ++targetIt) {
        if (targetIt == absTarget.end()) {
            return false;
        }

        if (*rootIt != *targetIt) {
            return false;
        }
    }

    return true;
}

static bool validateZipEntries(
    mz_zip_archive& zip,
    std::vector<ZipEntryInfo>& entries,
    std::string& commonRoot,
    std::string& errorMessage)
{
    constexpr mz_uint kMaxFiles = 10000;
    constexpr mz_uint64 kMaxSingleFileSize = 200ull * 1024 * 1024;
    constexpr mz_uint64 kMaxTotalUncompressedSize = 2ull * 1024 * 1024 * 1024;
    constexpr mz_uint64 kMaxCompressionRatio = 200;

    const mz_uint fileCount = mz_zip_reader_get_num_files(&zip);

    if (fileCount == 0) {
        errorMessage = "empty zip";
        return false;
    }

    if (fileCount > kMaxFiles) {
        errorMessage = "too many files";
        return false;
    }

    std::set<std::string> uniquePaths;
    mz_uint64 totalUncompressedSize = 0;

    for (mz_uint i = 0; i < fileCount; ++i) {
        mz_zip_archive_file_stat stat{};

        if (!mz_zip_reader_file_stat(&zip, i, &stat)) {
            errorMessage = "failed to read zip entry stat";
            return false;
        }

        std::string normalizedName;
        if (!normalizeZipEntryPath(stat.m_filename, normalizedName)) {
            errorMessage = std::string("unsafe entry path: ") + stat.m_filename;
            return false;
        }

        const bool isDirectory = mz_zip_reader_is_file_a_directory(&zip, i) != 0;

        // 目录 entry 可以记录,但一般不计入文件大小
        if (!isDirectory) {
            if (stat.m_uncomp_size > kMaxSingleFileSize) {
                errorMessage = std::string("single file too large: ") + stat.m_filename;
                return false;
            }

            if (stat.m_uncomp_size > UINT64_MAX - totalUncompressedSize) {
                errorMessage = "total size overflow";
                return false;
            }

            totalUncompressedSize += stat.m_uncomp_size;

            if (totalUncompressedSize > kMaxTotalUncompressedSize) {
                errorMessage = "total uncompressed size too large";
                return false;
            }

            // 简单压缩比检查
            if (stat.m_comp_size > 0 &&
                stat.m_uncomp_size / stat.m_comp_size > kMaxCompressionRatio) {
                errorMessage = std::string("suspicious compression ratio: ") + stat.m_filename;
                return false;
            }

#if defined(_WIN32)
            const std::string key = toLowerCopy(normalizedName);
#else
            const std::string key = normalizedName;
#endif

            if (!uniquePaths.insert(key).second) {
                errorMessage = std::string("duplicate entry path: ") + normalizedName;
                return false;
            }
        }

        // 可选策略:要求所有 entry 都在同一个顶层目录下
        const std::string root = getTopLevelDirectory(normalizedName);
        if (root.empty()) {
            errorMessage = std::string("entry is not under a top-level directory: ") + normalizedName;
            return false;
        }

        if (commonRoot.empty()) {
            commonRoot = root;
        } else if (commonRoot != root) {
            errorMessage = "entries are not under the same top-level directory";
            return false;
        }

        entries.push_back({
            i,
            stat.m_filename,
            normalizedName,
            stat.m_comp_size,
            stat.m_uncomp_size,
            isDirectory
        });
    }

    if (entries.empty()) {
        errorMessage = "no valid entries";
        return false;
    }

    return true;
}

static bool extractValidatedZip(
    mz_zip_archive& zip,
    const std::vector<ZipEntryInfo>& entries,
    const fs::path& outputRoot,
    std::string& errorMessage)
{
    std::error_code ec;
    fs::create_directories(outputRoot, ec);
    if (ec) {
        errorMessage = "failed to create output directory: " + ec.message();
        return false;
    }

    for (const auto& entry : entries) {
        const fs::path targetPath = outputRoot / fs::path(entry.normalizedName);

        if (!isPathInsideDirectory(outputRoot, targetPath)) {
            errorMessage = std::string("target path escapes output root: ") + entry.normalizedName;
            return false;
        }

        if (entry.isDirectory) {
            fs::create_directories(targetPath, ec);
            if (ec) {
                errorMessage = "failed to create directory: " + targetPath.string();
                return false;
            }
            continue;
        }

        fs::create_directories(targetPath.parent_path(), ec);
        if (ec) {
            errorMessage = "failed to create parent directory: " + targetPath.parent_path().string();
            return false;
        }

        if (!mz_zip_reader_extract_to_file(&zip, entry.index, targetPath.string().c_str(), 0)) {
            errorMessage = std::string("failed to extract: ") + entry.normalizedName;
            return false;
        }
    }

    return true;
}

int main()
{
    const fs::path zipPath = "test.zip";
    const fs::path outputRoot = "output";

    mz_zip_archive zip{};
    if (!mz_zip_reader_init_file(&zip, zipPath.string().c_str(), 0)) {
        std::cerr << "failed to open zip\n";
        return 1;
    }

    std::vector<ZipEntryInfo> entries;
    std::string commonRoot;
    std::string errorMessage;

    if (!validateZipEntries(zip, entries, commonRoot, errorMessage)) {
        std::cerr << "zip validation failed: " << errorMessage << "\n";
        mz_zip_reader_end(&zip);
        return 1;
    }

    std::cout << "zip validation success\n";
    std::cout << "common root: " << commonRoot << "\n";

    if (!extractValidatedZip(zip, entries, outputRoot, errorMessage)) {
        std::cerr << "extract failed: " << errorMessage << "\n";
        mz_zip_reader_end(&zip);
        return 1;
    }

    std::cout << "extract success\n";

    mz_zip_reader_end(&zip);
    return 0;
}

十一、关于 std::filesystem 路径校验的说明

示例中使用了:

cpp 复制代码
fs::absolute(path).lexically_normal()

它主要做词法层面的路径规范化,不一定访问真实文件系统。

如果你的场景中涉及符号链接,或者目标目录中可能已经存在符号链接,则需要更加谨慎。

例如:

text 复制代码
output/link -> C:/Outside
output/link/file.txt

即使字符串路径看起来在 output 内部,实际写入时也可能通过符号链接跳到外面。

这种情况下可以考虑:

  • 禁止 ZIP 内符号链接;
  • 解压前确保目标目录是新创建的空目录;
  • 不跟随符号链接写文件;
  • 使用平台相关 API 做更严格的路径和文件句柄校验;
  • 解压到临时空目录,避免已有符号链接影响。

十二、常见错误做法总结

12.1 直接拼接路径

错误示例:

cpp 复制代码
std::string path = outputDir + "/" + entryName;
mz_zip_reader_extract_to_file(&zip, index, path.c_str(), 0);

问题:

text 复制代码
entryName 可能包含 ../,导致路径逃逸。

12.2 只判断是否包含 "../"

错误示例:

cpp 复制代码
if (entryName.find("../") != std::string::npos) {
    return false;
}

问题是无法覆盖:

text 复制代码
..\evil.txt
folder\..\evil.txt
folder//../evil.txt

12.3 不限制解压大小

错误做法:

text 复制代码
只要 ZIP 能打开,就全部解压。

问题:

text 复制代码
可能被压缩炸弹打满磁盘。

12.4 不检查重复 entry

错误做法:

text 复制代码
ZIP 中有同名文件时,后面的覆盖前面的。

问题:

text 复制代码
校验结果和最终落盘文件可能不一致。

12.5 直接解压到正式目录

错误做法:

text 复制代码
不经过临时目录,直接覆盖目标目录。

问题:

text 复制代码
解压失败后可能留下半成品状态。

十三、总结

安全解压 ZIP 的关键不是"能不能解压",而是:

text 复制代码
能不能保证只解压到允许的位置;
能不能保证路径不会逃逸;
能不能保证资源消耗可控;
能不能保证失败后不会污染正式目录。

推荐原则:

text 复制代码
1. 先读取 Central Directory;
2. 先校验所有 entry;
3. 对路径做规范化;
4. 拒绝绝对路径、盘符路径、UNC 路径和 ..;
5. 限制文件数量、单文件大小和总解压大小;
6. 检查重复路径;
7. 可选:要求统一顶层目录;
8. 解压时再次确认目标路径位于目标根目录内;
9. 优先解压到临时目录;
10. 校验成功后再移动到正式目录。

miniz 是一个轻量、易集成的 ZIP 库,非常适合在 C/C++ 项目中实现 ZIP 读取和解压功能。

项目地址:

text 复制代码
https://github.com/richgel999/miniz

只要在使用 miniz 解压前增加 Central Directory 校验和路径规范化处理,就可以显著降低 ZIP 解压带来的安全风险。