适用场景
压缩包解压漏洞(Zip Slip)指应用程序在解压归档文件时未校验条目路径,导致条目可写入目标目录之外的任意位置,进而覆盖配置文件、写入 WebShell 或覆盖 SSH 授权密钥。它常与文件上传功能组合成完整的远程代码执行链:上传 zip → 解压 → 路径穿越写入 webshell → 访问触发。
以下功能模块必须做解压安全处理:
- WordPress / 各类 CMS 的主题、插件在线安装(后台 zip 包上传后自动解压)
- 网站”素材包 / 模板包”导入,用户自助上传压缩包
- 备份恢复功能:管理员上传备份 zip,服务端解压覆盖站点目录
- CI/CD 制品处理:流水线解压构建产物、第三方 SDK 包
- 日志归档、批量数据导入等后台批处理任务
本文先说明三类攻击形态,再给出 Python、Java、命令行三条链路的可复制防御代码,并附带恶意压缩包构造方法与自动化验证断言。
前置条件
- Python 3.8+(成品代码用到
os.path.realpath与zipfile),Java 项目需 JDK 8+ - 掌握待处理压缩包的格式:zip、tar、tar.gz 或 7z,不同格式的校验点不同
- 可创建专用的低权限解压目录与运行用户
- 测试环境具备 Python,用于构造恶意样例包做回归验证
原理说明
攻击形态一:相对路径穿越
zip 格式允许条目名中包含 ../。归档中一个名为 ../../var/www/html/shell.php 的条目,在解压时会被许多库直接按路径写入,最终落到站点根目录。tar 格式同理,条目名同样未经限制。
更隐蔽的变体是绝对路径条目,例如 /etc/cron.d/backdoor。部分实现遇到绝对路径时会忽略前导斜杠并按相对路径处理,另一些则直接写入绝对位置。
攻击形态二:符号链接条目
归档内先放一个符号链接条目指向 /etc/,再放一个位于该链接下的普通文件条目。解压时链接先被创建,后续文件写入会跟随链接逃逸到目标目录之外。这类攻击能绕过仅检查”条目名中是否含 ..“的简单过滤,因为条目名本身完全合法。
攻击形态三:解压炸弹与资源耗尽
解压炸弹通过极高压缩比耗尽磁盘。一个 10 MB 的嵌套 zip 可以膨胀到数十 GB,导致磁盘写满、服务不可用,并可能连带影响日志与数据库写入。判断指标是压缩比(解压后总字节 / 压缩包大小),正常业务包很少超过 20:1,而炸弹包可达 1000:1 以上。
另一种变体是”文件数量炸弹”:包内含数十万个小文件,耗尽 inode 而非容量,恢复难度更高。
防御的核心:规范化后的前缀校验
字符串过滤 .. 不可靠,原因是同一路径存在多种等价写法:../、..%2f、连续斜杠 a//../、Windows 下的反斜杠 ..\、混合 ..\/。正确做法是先做路径规范化,再判断规范化结果是否仍位于目标目录前缀之内。这是唯一稳健的判定方式。
操作步骤
1. Python:安全解压 zip 的完整实现
import os, zipfile, pathlib
def safe_extract_zip(zip_path, dest_dir,
max_total_bytes=512 * 1024 * 1024, # 解压后总量上限 512MB
max_files=20000, # 条目数量上限
max_ratio=100): # 压缩比上限
dest = pathlib.Path(dest_dir).resolve()
dest.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(zip_path) as zf:
infos = zf.infolist()
if len(infos) > max_files:
raise ValueError(f"too many entries: {len(infos)}")
compressed = sum(i.compress_size for i in infos) or 1
total = sum(i.file_size for i in infos)
if total > max_total_bytes:
raise ValueError(f"uncompressed size exceeds limit: {total}")
if total / compressed > max_ratio:
raise ValueError(f"compression ratio too high: {total / compressed:.1f}")
for info in infos:
name = info.filename
# 拒绝绝对路径与盘符、反斜杠写法
if name.startswith(("/", "\\")) or ":" in name:
raise ValueError(f"absolute path in archive: {name}")
# 拒绝符号链接条目(zip 中由外部属性标识)
mode = (info.external_attr >> 16) & 0o170000
if mode == 0o120000:
raise ValueError(f"symlink entry rejected: {name}")
# 关键:规范化后做前缀校验
target = (dest / name).resolve()
if target != dest and dest not in target.parents:
raise ValueError(f"path traversal detected: {name}")
if info.is_dir():
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(info) as src, open(target, "wb") as out:
while True:
chunk = src.read(65536)
if not chunk:
break
out.write(chunk)
return dest
要点说明:
target != dest and dest not in target.parents这行是全部安全性的落点。必须用Path.resolve()的结果比较,它会消解..与符号链接,比较字符串则会被绕过。- 显式调用
zf.open()逐块读写,而不是用extractall()。extractall()在 Python 3.12 之前不做穿越校验。 - Python 3.12+ 的
tarfile提供了内置过滤,建议同时启用:
import tarfile
# Python 3.12+:使用内置数据过滤器,自动拒绝穿越、绝对路径与危险链接
with tarfile.open("pkg.tar.gz") as tf:
tf.extractall(path="/srv/unpack", filter="data")
# 若无法确定运行版本,先探测再回退到等价的手工校验
import sys
if sys.version_info >= (3, 12):
tf.extractall(path="/srv/unpack", filter="data")
else:
raise RuntimeError("upgrade to Python 3.12+ or use manual validation")
2. Java:ZipEntry 规范化校验
import java.io.*;
import java.nio.file.*;
import java.util.zip.*;
public class SafeUnzip {
public static void unzip(Path zipFile, Path destDir) throws IOException {
Path dest = destDir.toAbsolutePath().normalize();
Files.createDirectories(dest);
try (ZipInputStream zis = new ZipInputStream(new BufferedInputStream(Files.newInputStream(zipFile)))) {
ZipEntry entry;
long total = 0;
while ((entry = zis.getNextEntry()) != null) {
String name = entry.getName();
if (name.contains("\\") || name.startsWith("/") || name.contains(":")) {
throw new IOException("absolute or illegal path: " + name);
}
Path target = dest.resolve(name).normalize();
// 核心断言:规范化后必须仍以解压根目录为前缀
if (!target.startsWith(dest)) {
throw new IOException("zip slip detected: " + name);
}
if (entry.isDirectory()) {
Files.createDirectories(target);
continue;
}
total += entry.getSize();
if (total > 512L * 1024 * 1024) {
throw new IOException("uncompressed size exceeds limit");
}
Files.createDirectories(target.getParent());
Files.copy(zis, target, StandardCopyOption.REPLACE_EXISTING);
zis.closeEntry();
}
}
}
}
Path.startsWith() 基于路径段比较而非字符串前缀,因此 /srv/unpack-evil 不会被误判为位于 /srv/unpack 之内,这正是字符串 startsWith 的典型漏洞点。
3. 命令行解压场景的加固
# 1) 以低权限用户解压,且目标目录不位于 Web 根目录下
install -d -m 0750 -o unpacker -g unpacker /srv/unpack
# 2) tar 显式禁止危险行为
sudo -u unpacker tar \
--no-same-owner \
--no-same-permissions \
--no-overwrite-dir \
-xzf /tmp/pkg.tar.gz -C /srv/unpack
# 3) unzip:先列表审查条目名,确认无穿越后再解压
unzip -l /tmp/pkg.zip | awk '{print $4}' | grep -E '(^/|\.\.|\\|:)' && echo "SUSPICIOUS" || echo "clean"
unzip -q -o /tmp/pkg.zip -d /srv/unpack
# 4) 用 ulimit 兜底限制单文件写入大小(单位为 512 字节块,此处约 2GB)
ulimit -f 4194304
# 5) 使用 quota 或独立小容量分区限制解压目录的磁盘占用
# 6) 解压目录挂载 noexec,nosuid,nodev,即使写入脚本也无法执行
mount -o remount,noexec,nosuid,nodev /srv/unpack
noexec 挂载是最省力的兜底防线:即便攻击者成功穿越写入脚本文件,也无法直接执行,必须再借道其他解释器,攻击成本显著上升。
4. 隔离解压:把风险限制在容器内
docker run --rm \
--network none \
--read-only \
--cap-drop=ALL \
--security-opt=no-new-privileges:true \
--user 65534:65534 \
--pids-limit 128 \
--memory 512m \
--tmpfs /work:rw,noexec,nosuid,size=256m \
-v /tmp/pkg.zip:/in/pkg.zip:ro \
-v /srv/unpack:/out:rw \
alpine:3.20 sh -c 'mkdir -p /work/x && tar -xzf /in/pkg.zip -C /work/x --no-same-owner && cp -r /work/x/. /out/'
--network none 阻断解压过程中的外连,--read-only 配合 tmpfs 保证写入仅发生在受控目录,--pids-limit 与 --memory 则限制资源耗尽型攻击。
配置验证
1. 构造恶意压缩包
python - <<'PY'
import zipfile, os
os.makedirs('evil', exist_ok=True)
# 一:相对路径穿越
with zipfile.ZipFile('evil/traversal.zip', 'w') as z:
z.writestr('../../shell.php', '<?php system($_GET["c"]); ?>')
# 二:绝对路径
with zipfile.ZipFile('evil/absolute.zip', 'w') as z:
z.writestr('/tmp/pwned.txt', 'pwned')
# 三:符号链接条目
with zipfile.ZipFile('evil/symlink.zip', 'w') as z:
info = zipfile.ZipInfo('link')
info.external_attr = (0o120777 << 16) # 标记为 symlink
z.writestr(info, '/etc/')
z.writestr('link/cron.d/x', '* * * * * root id')
# 四:高压缩比炸弹(10MB 的 0 字节,压缩后仅数 KB)
with zipfile.ZipFile('evil/bomb.zip', 'w', zipfile.ZIP_DEFLATED) as z:
z.writestr('zero.bin', b'\0' * (10 * 1024 * 1024))
print('samples ready')
PY
ls -lh evil/
2. 用断言验证防御生效
python - <<'PY'
import os, shutil
from safe_extract import safe_extract_zip # 即第 1 步的实现
cases = ['evil/traversal.zip', 'evil/absolute.zip', 'evil/symlink.zip', 'evil/bomb.zip']
for c in cases:
d = '/srv/unpack/_test'
shutil.rmtree(d, ignore_errors=True)
try:
safe_extract_zip(c, d)
print(f'[FAIL] {c} 未被拦截,存在安全缺陷')
except ValueError as e:
print(f'[PASS] {c} 已拦截 -> {e}')
PY
四个样例全部输出 [PASS] 才算验证通过。同时确认解压目录外层未出现 shell.php、pwned.txt 等文件:
find / -maxdepth 3 -name 'shell.php' -newermt '-10 minutes' 2>/dev/null
ls -l /tmp/pwned.txt 2>/dev/null || echo "OK: no escaped file"
3. 端到端验证业务入口
# 用敏感文件作为上传目标,验证服务端返回错误而非 200
curl -s -o /dev/null -w "%{http_code}\n" \
-F "file=@evil/traversal.zip" https://www.example.com/api/theme/upload
# 期望 400 / 422,且服务端日志出现 path traversal detected
常见问题
FAQ 1:只过滤文件名中的 .. 字符串,为什么仍然被绕过?
因为路径存在多种等价表达,且过滤点往往在解码之前。常见绕过方式包括:使用反斜杠 ..\..\shell.php(在 Windows 或部分库中会被当作分隔符);使用 URL 编码 ..%2f 后再被下游解码;使用连续斜杠 ....// 让一次替换操作产生新的 ../;以及在文件名中混入空字节截断。根本对策不是完善黑名单,而是改为规范化后的前缀校验,让所有等价写法在 resolve() 之后统一收敛为一个绝对路径,再判断它是否越界。
FAQ 2:unzip、tar 这些成熟命令行工具会自己拦截穿越吗?
行为不一致,不能依赖。tar 在 GNU 版本下会对含 .. 的成员名给出警告并跳过,但它不会拦截符号链接条目——攻击者可以用”先建链接、再写链接下文件”的组合绕过。unzip 对相对路径的处理同样依版本而异,部分版本会静默创建到上级目录。因此命令行解压的可靠做法是:先 unzip -l 或 tar -tvf 列出全部条目并做正则筛查,再以低权限用户在 noexec 挂载的目录中解压,最后统一修正属主与权限。
FAQ 3:业务上确实需要解压到 Web 目录(如主题在线安装),怎么降低风险?
采用”两阶段落盘”:第一阶段在 /srv/unpack 这类非 Web 目录、以低权限用户完成安全解压,并通过前缀校验保证无逃逸;第二阶段只允许白名单文件类型(.php .css .js .png .json)从暂存目录同步到 Web 目录,同步过程同样逐文件校验规范化路径并覆盖固定前缀。同时限制解压后的单文件大小与总数量,对 .php 文件额外做一次危险函数扫描(如 eval、system、base64_decode 组合),命中即拒绝并告警。这样即使第一阶段校验存在遗漏,第二阶段仍有一道独立闸门。
总结
解压漏洞的本质是”归档条目名属于不可信输入,却被直接用于拼接文件系统路径”。防御要点:
- 前缀校验而非字符串过滤:
resolve()或normalize()之后判断是否位于解压根目录之内。 - 拒绝符号链接条目:仅校验条目名无法覆盖此类攻击,需读取归档元数据中的文件模式。
- 资源上限:限制解压后总字节数、条目数量与压缩比,抵御解压炸弹。
- 最小权限落盘:低权限用户 +
noexec,nosuid,nodev挂载 + 非 Web 目录,构成兜底防线。 - 回归验证:把四类恶意样例做成自动化测试,纳入发布前检查项。
其中”规范化后前缀校验”是不可让步的一条,其余措施是纵深防御的补充。