解压炸弹防御实战:Zip Bomb 解压限流配置

解压炸弹防御是文件处理环节最容易被忽略的一环:一个只有 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 告警,才能在无人值守的服务器上真正挡住这类低成本、高破坏力的攻击。