解压炸弹防御是文件处理环节最容易被忽略的一环:一个只有 10 MB 的 ZIP 文件,解压后可以撑到 10 TB,把服务器的系统盘写满、inode 耗尽、进程被 OOM Killer 杀掉。本文从压缩率预检、解压资源限制、沙箱隔离到监控告警,给出一套可直接落地的解压炸弹防御配置,适用于任何接收用户上传压缩包的服务器。
适用场景
- 网站或后台系统接收用户上传的 ZIP / GZ / TAR / 7Z 压缩包,并自动解压后处理(导入数据、解析日志、批量图片、插件安装包)。
- 自动化流水线(CI、定时任务)从外部拉取压缩包并解压到指定目录。
- 邮件网关、文档转换服务、数据交换平台等需要对第三方归档文件做解析的业务。
- 共享主机、容器平台需要对租户上传的归档文件做资源隔离。
前置条件
- Linux 服务器(CentOS 7+ / Ubuntu 20.04+ / Debian 11+),具备 sudo 权限。
- 已安装
unzip、zipinfo、python3、clamav(可选,用于恶意文件扫描)、systemd(用于 cgroup 资源限制)。 - 应用层已接入上传功能,且知道上传文件的实际落地目录与解压目标目录。
原理说明
ZIP 格式使用 DEFLATE 算法,全零数据或高度重复数据的压缩比可以轻松达到 1000:1 以上。攻击者只需构造一个 10 MB 的压缩包,其解压后体积就可能是数百 GB 甚至数 TB。常见的三类解压炸弹:
- 单文件炸弹:一个巨大文件,直接写满磁盘。
- 嵌套炸弹:压缩包内再套压缩包,逐层解压,绕过单次解压的体积上限。
- 海量小文件炸弹:压缩包内含数百万个极小文件,磁盘空间没满,但 inode 被耗尽,整个系统无法新建文件,故障表现比磁盘满更隐蔽。
这三类攻击的共同点是:压缩包本身语法合法,杀毒引擎的特征匹配通常不报毒,WAF 在 HTTP 层也看不到异常——因为恶意载荷是在应用的解压动作中才膨胀的。因此防御必须做在解压动作本身:先限制,再预检,最后隔离执行。
操作步骤
步骤一:入口层限制上传体积
第一步不是防炸弹,而是先把攻击面压到可控范围。Nginx 侧限制请求体大小:
# /etc/nginx/conf.d/upload.conf
client_max_body_size 20m; # 单次上传上限
client_body_timeout 30s;
client_body_buffer_size 1m;
location ^~ /api/upload {
limit_conn perip 5; # 单 IP 并发上传数
limit_req zone=upload burst=10 nodelay;
proxy_pass http://127.0.0.1:8080;
}
PHP 应用同步收紧:
; php.ini
upload_max_filesize = 20M
post_max_size = 24M
max_execution_time = 30
max_input_time = 30
步骤二:解压前做压缩率预检
只限制上传体积远远不够。用 zipinfo 读取归档目录的元数据(不实际解压),计算 解压后总大小 / 压缩包大小 的比值,超过阈值直接拒绝:
#!/usr/bin/env python3
# /opt/safeunzip/check_bomb.py
# 用法: python3 check_bomb.py <归档文件>
import sys, os, zipfile
MAX_RATIO = 200 # 压缩率上限 200:1
MAX_TOTAL = 200 * 1024 * 1024 # 解压后总大小上限 200MB
MAX_COUNT = 10000 # 文件数量上限
MAX_DEPTH = 2 # 允许的嵌套层数
def check(path, depth=0):
if depth > MAX_DEPTH:
raise SystemExit("REJECT 嵌套层数过深: %s" % path)
size = os.path.getsize(path)
with zipfile.ZipFile(path) as z:
total = sum(i.file_size for i in z.infolist())
count = len(z.infolist())
if total > MAX_TOTAL:
raise SystemExit("REJECT 解压后过大: %d bytes" % total)
if count > MAX_COUNT:
raise SystemExit("REJECT 文件数过多: %d" % count)
if size > 0 and total / size > MAX_RATIO:
raise SystemExit("REJECT 压缩率过高: %.0f:1" % (total / size))
for i in z.infolist():
name = i.filename
if os.path.isabs(name) or ".." in name:
raise SystemExit("REJECT 异常路径: %s" % name)
if name.lower().endswith((".zip", ".tar", ".gz", ".7z", ".rar")):
print("NESTED %s (size=%d)" % (name, i.file_size))
print("PASS ratio=%.0f:1 total=%d count=%d" % (total / size, total, count))
check(sys.argv[1])
脚本同时检出绝对路径与 .. 路径穿越(Zip Slip 类风险的入口校验),并对嵌套归档打标交由后续流程单独处理。
步骤三:给解压动作加上硬性资源上限
预检只能挡住形态明显的炸弹,构造精巧的压缩包仍需运行时兜底。使用 ulimit 与 timeout 限制单次解压:
# 在解压脚本开头设置:虚拟内存 512MB、单文件 512MB、CPU 30s、进程数 64
ulimit -v 524288 -f 524288 -t 30 -u 64
timeout -k 5 30 unzip -q -o /srv/upload/pkg.zip -d /srv/scan/out
更稳妥的方式是用 systemd 的 cgroup 限制,超限时直接杀掉整个进程组,不会拖垮宿主机:
systemd-run --scope --unit=safeunzip-$$ \
-p MemoryMax=512M -p MemorySwapMax=0 \
-p CPUQuota=50% -p TasksMax=64 -p IOWeight=10 \
-- timeout 30 unzip -q -o /srv/upload/pkg.zip -d /srv/scan/out
步骤四:把解压目录放进有容量上限的沙箱
关键一步:解压目标必须是带 size 上限的独立文件系统,写满后只影响该挂载点,不波及系统盘:
mkdir -p /srv/scan
mount -t tmpfs -o size=512m,noexec,nodev,nosuid,mode=0700 tmpfs /srv/scan
# 持久化(/etc/fstab)
tmpfs /srv/scan tmpfs size=512m,noexec,nodev,nosuid,mode=0700 0 0
noexec 阻止解压出的脚本被执行,nodev/nosuid 阻断利用设备文件与 setuid 提权的路径。若必须落在物理磁盘(例如需要保留文件),改用独立分区的 XFS 并启用 项目配额:
xfs_quota -x -c 'project -s -p /srv/scan 1001' /srv
xfs_quota -x -c 'limit -p bhard=512m ihard=20000 1001' /srv
其中 ihard=20000 限制 inode 数量,专门针对海量小文件炸弹。
步骤五:低权限用户执行并叠加恶意文件扫描
useradd -r -s /sbin/nologin -M unpacker
chown -R unpacker:unpacker /srv/scan
sudo -u unpacker -- sh -c 'ulimit -v 524288 -f 524288 -t 30; timeout 30 unzip -q -o "$1" -d /srv/scan/out' _ /srv/upload/pkg.zip
# ClamAV 扫描时同样限制资源,防止扫描器本身被拖死
clamscan -r --max-filesize=25M --max-scansize=100M \
--max-recursion=5 --alert-broken /srv/scan/out
clamd 常驻模式需在 /etc/clamav/clamd.conf 中同步设置 MaxFileSize 25M、MaxScanSize 100M、MaxRecursion 5、MaxFiles 10000,否则扫描阶段仍可能被拖垮。
步骤六:加上磁盘与 inode 监控告警
cat > /etc/cron.d/disk-guard <<'EOF'
*/2 * * * * root /opt/safeunzip/guard.sh
EOF
cat > /opt/safeunzip/guard.sh <<'EOF'
#!/bin/bash
# 空间或 inode 使用率超过 85% 立即告警并暂停解压服务
for m in / /srv/scan; do
df -P "$m" | awk 'NR==2{u=$5+0; gsub(/%/,"",u); if(u>85) print "DISK " u "% " $6}' | \
while read -r tag u p; do
logger -t safeunzip "$tag $u $p"
systemctl stop safeunzip-worker 2>/dev/null
done
done
df -Pi /srv | awk 'NR==2{u=$5+0; gsub(/%/,"",u); if(u>85) print "INODE " u "%"}'
EOF
chmod 700 /opt/safeunzip/guard.sh
配置验证
用真实炸弹文件做一次回归测试即可确认防护生效。构造 10 GB 全零数据并压缩:
dd if=/dev/zero bs=1M count=10240 | gzip -9 > /tmp/bomb.gz
ls -lh /tmp/bomb.gz # 约 10MB 的压缩包
cp /tmp/bomb.gz /tmp/bomb.zip
逐项验证:
- 预检拦截:执行
python3 /opt/safeunzip/check_bomb.py /tmp/bomb.zip,应输出REJECT 压缩率过高并以非 0 退出。 - 容量沙箱:
mount | grep /srv/scan应看到size=512m,noexec,nodev,nosuid;df -h /srv/scan显示 512M。 - 禁止执行:在
/srv/scan下写入脚本并chmod +x后执行,应返回Permission denied。 - ulimit 限制:
sudo -u unpacker -- sh -c 'ulimit -v; ulimit -f; ulimit -u',输出应为 524288、524288、64。 - cgroup 限制:解压过程中执行
systemctl status safeunzip-*,观察Memory: ... / 512.0M;超限后进程被SIGKILL,宿主机负载无明显上升。 - 业务回归:上传一个正常 15 MB 的业务压缩包,确认仍能成功解压,避免防护策略把正常流量一并拦掉。
常见问题(FAQ)
Q1:服务器已经装了 WAF,还需要做解压限制吗?
需要。WAF 工作在 HTTP 语义层,它检查的是请求头、URL、参数与请求体特征;压缩包炸弹本身是一个语法完全合法的 ZIP 文件,没有攻击载荷字符串,WAF 的特征库与语义规则通常不会拦截。攻击发生在应用调用 unzip 的那一刻,属于应用层资源耗尽,必须在解压动作上加限制。WAF 能做的只是限制上传频率与体积,属于前置削减,不是替代方案。
Q2:只限制上传文件大小够不够?
不够。压缩率是攻击的核心变量:10 MB 的压缩包解压后可达 10 TB,压缩比 100 万比一。正确的做法是同时限制上传体积、解压后总大小、压缩率比值、文件数量与 inode 配额五项,其中压缩率与文件数量两项只能通过读取归档元数据获得,无法从上传体积推断。
Q3:解压为什么要用独立用户和 tmpfs,直接用 root 解压到 /tmp 不行吗?
不行。/tmp 通常与系统盘同一文件系统,写满即整机故障;root 解压时若归档内包含 setuid 文件或设备节点,会直接获得提权路径。正确做法是独立低权限用户 + tmpfs(或带项目配额的独立分区)+ noexec/nodev/nosuid 挂载选项,三者缺一不可。
Q4:嵌套压缩包如何处理?
不要在主流程里循环递归解压。预检脚本识别到嵌套归档后,应记录原始文件名与层数,转交独立任务队列逐层处理,每层重复执行步骤三、步骤四的全部限制。同时设置绝对层数上限(建议 2 层),超过即拒绝并告警。
总结
解压炸弹防御的核心是四道闸门:入口限体积、预检算压缩率、运行限资源、目录限容量。只做上传体积限制属于无效防护,因为压缩率不受体积约束;只在应用里写一次 os.path.getsize 判断也不够,因为海量小文件炸弹耗尽的是 inode 而不是空间。把 tmpfs 容量、cgroup 内存、ulimit 与 inode 配额作为固定配置固化到部署脚本中,再叠加每两分钟的磁盘与 inode 告警,才能在无人值守的服务器上真正挡住这类低成本、高破坏力的攻击。