在企业级应用里,“解压一个 ZIP
包”看似一行代码搞定,实则暗藏两个大坑:安全与性能。安全方面,如果直接拼接解压路径,黑客构造一个名为
../../etc/passwd
的条目,就能把你的文件写到任意目录——这就是著名的 Zip
Slip 攻击。性能方面,传统写法往往一次
File.ReadAllBytes() 把整个文件读进堆内存,几百 MB
的压缩包足以让 GC 颤抖。
本文带你拆解我在 SwitchData 项目里沉淀下来的
SharpZipHelper:它用 Span / stackalloc /
RandomAccess 等现代 C#
特性把解压逻辑做成了零分配的”高速通道”,同时用
ReadOnlySpan
一、Zip Slip 攻击原理
Zip Slip 是 2018 年由 Snyk 公开披露的经典漏洞:攻击者在 ZIP
条目名里塞上相对路径,解压程序不做校验直接
Path.Combine(unZipPath, entry.Name) 拼路径,结果文件就跑到
unZipPath 之外了。
// 危险的写法
string dest = Path.Combine(unZipPath, entry.Name);
File.WriteAllText(dest, stream);
// entry.Name = "../../../../etc/hosts",dest 就跑出沙箱了
正确的防御思路分两步:
- 拒绝含
..段的条目:解析条目名时每一段都必须不是..。 - 合成路径后做”沙箱包含”判断:解压目标路径必须以
unZipPath为前缀(且区分大小写依据系统而定)。
下面看具体实现。
二、EntryName → 本地路径:ReadOnlySpan.SplitAny 的妙用
SharpZipHelper.EntryNameToLocalName 把 ZIP 内部的
/ 分隔条目名转成 Windows
的反斜杠分隔本地路径,并做安全校验。亮点是用
ReadOnlySpan<char>.SplitAny 代替
string.Split,整个过程零字符串分配:
public static string EntryNameToLocalName(string entryName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(entryName);
entryName = entryName.Replace('\\', '/');
List<string> segments = [];
ReadOnlySpan<char> entryNameSpan = entryName.AsSpan();
foreach (var range in entryNameSpan.SplitAny('/'))
{
var span = entryNameSpan[range];
// 空段或 "." 跳过
if (span.IsEmpty || span.Equals(".", StringComparison.Ordinal))
continue;
// 含 ".." 直接拒绝
if (span.Equals("..", StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException($"非法 ZIP 条目名称:{entryName}");
segments.Add(span.ToString());
}
string localName = string.Join(Path.DirectorySeparatorChar, segments);
// 防御绝对路径:不允许条目名解析后是绝对路径
if (Path.IsPathRooted(localName))
throw new InvalidDataException($"非法 ZIP 条目名称:{entryName}");
return localName;
}
SplitAny 是 .NET Core 2.1+ 引入的扩展方法,返回
Range 序列,配合 span[range]
可以拿到不复制任何字符的切片。这里没用
string.Split("/") 那种每次都 new string[]
的老古董写法,热路径下能省不少 GC 压力。
三、解压:沙箱包含判断 + 真实路径
解压循环里,最关键的是合成路径后做”是否在沙箱内”的检查。Path.GetFullPath
会把相对路径规范成绝对路径,再和 unZipPath
比较前缀即可:
string localName = EntryNameToLocalName(entry.Name);
string destinationPath = Path.GetFullPath(Path.Combine(unZipPath, localName));
// 大小写比较依据操作系统
bool isCaseSensitive = IOHelper.IsFileSystemCaseSensitive();
var comparison = DataCommon.GetStringComparison(!isCaseSensitive);
// 不在沙箱内的条目直接跳过
if (!destinationPath.StartsWith(unZipPath, comparison))
continue;
unZipPath 末尾必须带分隔符(代码里有
if (!unZipPath.EndsWith(Path.DirectorySeparatorChar)) unZipPath += ...;),否则
unzip_dir_foo 会被误判成 unzip_dir_foobar
的子路径——这是很多人写漏的细节。
flowchart TD
A[遍历 ZIP 条目] --> B{EntryName 含 ..?}
B -- 是 --> Z1[抛异常/跳过]
B -- 否 --> C{合成路径在沙箱内?}
C -- 否 --> Z2[跳过危险条目]
C -- 是 --> D{是目录还是文件?}
D -- 目录 --> E[CreateDirectory]
D -- 文件 --> F[CreateDirectory 父目录] --> G[CopyTo 写入]
四、GBK vs UTF-8:中文 ZIP 文件名的”老大难”
Windows 自带的 ZIP 工具(以及大量国产压缩软件)默认用 GBK 编码写文件名,但 ZIP 规范规定是 UTF-8。SharpZipLib 默认按 UTF-8 解析,遇到 GBK 的中文文件名直接乱码。
SharpZipHelper 的解法很直接——同时挂上 GBK
解码器:
// 注册 CodePages 编码提供程序,否则 Encoding.GetEncoding("GBK") 抛异常
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
Encoding gbkEncoding = Encoding.GetEncoding("GBK");
// 告诉 SharpZipLib 用 GBK 解析条目名
var gbkCodec = StringCodec.FromEncoding(gbkEncoding);
using var zipInputStream = new ZipInputStream(zipFileStream, gbkCodec);
CodePagesEncodingProvider 在 .NET Core/.NET 5+
需要显式注册,否则 Encoding.GetEncoding("GBK") 会抛
NotSupportedException。这是新手第一次接触中文压缩包最容易踩的坑。
五、用 Span + stackalloc 做零分配 ZIP 头校验
判断一个文件是不是真的 ZIP,不需要装 SharpZipLib 一整个库——ZIP 文件头
4 个字节固定为 50 4B 03 04(即
PK\\x03\\x04)。SharpZipHelper.IsRealZipFile
用现代 C# 把它写成了栈上 4 字节 + 列表模式匹配:
private static bool IsRealZipFile(FileStream stream)
{
if (stream == null || !stream.CanRead) return false;
if (stream.Length < 4) return false;
// 栈上分配 4 字节,无堆内存压力
Span<byte> header = stackalloc byte[4];
// RandomAccess 不会移动流的 Position,后续读取逻辑零打扰
RandomAccess.Read(stream.SafeFileHandle, header, 0);
// C# 11 列表模式:极简直观
return header is [0x50, 0x4B, 0x03, 0x04];
}
几个值得展开的点:
stackalloc Span<byte>:4 字节直接放栈上,避免了一次new byte[4]的堆分配。在批量校验大量文件时差距巨大。RandomAccess.Read(SafeFileHandle, ...):底层调用pread/ReadFile,不修改流的 Position。传统的stream.Read(header, 0, 4)会把光标往后挪 4 个字节,下游逻辑必须手动stream.Seek(0, SeekOrigin.Begin)才能重新读——容易出 bug。header is [0x50, 0x4B, 0x03, 0x04]:C# 11 的列表模式(list pattern),比header[0]==0x50 && header[1]==0x4B && ...优雅得多,编译器和读者都舒服。
六、压缩侧:CRC32 + ArraySegment 的小细节
压缩时除了要写数据,还要算 CRC32 校验值。SharpZipHelper
用 ArraySegment<byte> 把”实际读到的长度”传给
SharpZipLib 的 crc.Update,避免再
Buffer.BlockCopy 一次:
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = inputFileStream.Read(buffer, 0, buffer.Length)) > 0)
{
var segment = new ArraySegment<byte>(buffer, 0, bytesRead);
crc.Update(segment); // 只算真实读到的部分
zipOutputStream.Write(buffer, 0, bytesRead);
}
entry.Crc = crc.Value;
如果直接 crc.Update(buffer),最后一帧不满 4096
字节时会把尾部残留字节也算进去,CRC 就错了。这个坑平时
File.ReadAllBytes
一次读完时遇不到,但凡用了流式读取就必须留心。
七、目录压缩与路径去重
SharpZipHelper.Zip(inputPaths, ...)
支持传一组文件/目录。它在写 ZIP 前先调用
IOHelper.RemoveRedundantPaths 把冗余路径压平——比如同时传
root_dir_A 和
root_dir_A_sub_B,前者已包含后者,留后者即可。
RemoveRedundantPaths 内部依然用
ReadOnlySpan<char>
做前缀比较,区分系统大小写敏感性:
ReadOnlySpan<char> lastSpan = lastAdded.AsSpan()
.TrimEnd([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]);
ReadOnlySpan<char> currentSpan = current.AsSpan();
if (currentSpan.StartsWith(lastSpan, comparison)
&& currentSpan.Length > lastSpan.Length
&& (currentSpan[lastSpan.Length] == Path.DirectorySeparatorChar
|| currentSpan[lastSpan.Length] == Path.AltDirectorySeparatorChar))
{
continue; // 是上一条的子目录,跳过
}
TrimEnd([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar])
是 .NET 引入的 MemoryExtensions.TrimEnd
重载,可以一次传一组分隔符去尾部,简洁高效。
八、整套设计给我们的启示
把这套工具梳理一遍,有三条值得抄走的经验:
- 安全永远在合成路径之后:不要相信
entry.Name,必须Path.GetFullPath+ 前缀包含判定。..段和绝对路径是必查项。 - 热路径用 Span / stackalloc / RandomAccess:哪怕只是校验 4 字节头,写法也能做到零堆分配、零 Position 偏移。这些现代 C# 特性是 .NET 6/7/8 性能优化的”标准答案”。
- 中文环境必须显式注册
CodePagesEncodingProvider:否则
Encoding.GetEncoding("GBK")静默抛异常——很多团队第一次接触老 ZIP 文件时会卡在这一步。
九、总结
ZIP
工具类看上去是个”体力活”,但里面藏着安全、性能和编码三大坑。本文展示的
SharpZipHelper 用 Zip Slip 双层防御 +
ReadOnlySpan.SplitAny 零分配解析 + stackalloc + RandomAccess + 列表模式
+ ArraySegment CRC 这一套组合拳,把”解压一个
ZIP”这件事做成了安全、可观测、零 GC 压力的现代 .NET 实践。
如果你也在维护类似的工具类,不妨对照这几点检查一下:路径校验够不够严?热路径有没有堆分配?中文文件名是否乱码?改完再压一遍大文件看 GC 表现,相信你会立刻看到差别。