在企业级应用里,“解压一个 ZIP 包”看似一行代码搞定,实则暗藏两个大坑:安全性能。安全方面,如果直接拼接解压路径,黑客构造一个名为 ../../etc/passwd 的条目,就能把你的文件写到任意目录——这就是著名的 Zip Slip 攻击。性能方面,传统写法往往一次 File.ReadAllBytes() 把整个文件读进堆内存,几百 MB 的压缩包足以让 GC 颤抖。

本文带你拆解我在 SwitchData 项目里沉淀下来的 SharpZipHelper:它用 Span / stackalloc / RandomAccess 等现代 C# 特性把解压逻辑做成了零分配的”高速通道”,同时用 ReadOnlySpan.SplitAny + 路径白名单把 Zip Slip 彻底堵死。

一、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 就跑出沙箱了

正确的防御思路分两步:

  1. 拒绝含 .. 段的条目:解析条目名时每一段都必须不是 ..
  2. 合成路径后做”沙箱包含”判断:解压目标路径必须以 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 校验值。SharpZipHelperArraySegment<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_Aroot_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 重载,可以一次传一组分隔符去尾部,简洁高效。

八、整套设计给我们的启示

把这套工具梳理一遍,有三条值得抄走的经验:

  1. 安全永远在合成路径之后:不要相信 entry.Name,必须 Path.GetFullPath + 前缀包含判定。.. 段和绝对路径是必查项。
  2. 热路径用 Span / stackalloc / RandomAccess:哪怕只是校验 4 字节头,写法也能做到零堆分配、零 Position 偏移。这些现代 C# 特性是 .NET 6/7/8 性能优化的”标准答案”。
  3. 中文环境必须显式注册 CodePagesEncodingProvider:否则 Encoding.GetEncoding("GBK") 静默抛异常——很多团队第一次接触老 ZIP 文件时会卡在这一步。

九、总结

ZIP 工具类看上去是个”体力活”,但里面藏着安全、性能和编码三大坑。本文展示的 SharpZipHelperZip Slip 双层防御 + ReadOnlySpan.SplitAny 零分配解析 + stackalloc + RandomAccess + 列表模式 + ArraySegment CRC 这一套组合拳,把”解压一个 ZIP”这件事做成了安全、可观测、零 GC 压力的现代 .NET 实践。

如果你也在维护类似的工具类,不妨对照这几点检查一下:路径校验够不够严?热路径有没有堆分配?中文文件名是否乱码?改完再压一遍大文件看 GC 表现,相信你会立刻看到差别。