文章目录
- [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 解压中非常重要的概念:
- ZIP Central Directory;
- 规范化路径;
并使用 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 中的路径转换成统一、明确、可校验的形式。
一般要做以下处理:
- 把
\统一转换成/; - 拒绝空路径;
- 拒绝绝对路径;
- 拒绝 Windows 盘符路径;
- 拒绝 UNC 网络路径;
- 拒绝
..; - 去掉
.; - 合并重复分隔符;
- 最终判断目标路径是否仍然位于解压根目录内部。
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:/xxx、C:\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 实现安全校验与解压
下面给出一个相对完整的示例。
功能包括:
- 打开 ZIP;
- 遍历 Central Directory;
- 规范化 entry 路径;
- 检查文件数量;
- 检查单文件大小;
- 检查总解压大小;
- 检查重复路径;
- 检查是否有统一顶层目录;
- 解压时再次确认目标路径没有逃逸;
- 解压到指定目录。
示例使用 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 解压带来的安全风险。