别让解压变成“炸包”——防止解压类漏洞的几个姿势

Published on with 0 views and 0 comments

别让解压变成“炸包”——防止解压类漏洞的几个姿势

上传文件后解压,是后台再常见不过的操作。但很多人对解压的理解还停留在“把 zip 拖进去、点确定”这个阶段。说白了,压缩包不是可信输入,里面随便一个 ../etc/passwd 就能让你栽跟头。今天把解压里最容易出事的几类坑掰开聊,附带可直接用的代码。

1. 路径遍历:Zip Slip / Tar Slip

压缩包里的文件名可以写成 ../../var/www/html/index.php。如果你直接 extractall(),它真的能覆盖到目标目录外面。这种攻击叫 Zip Slip,tar 同理。

有问题的写法(Python)

import zipfile

with zipfile.ZipFile("upload.zip") as z:
    z.extractall("/var/www/app/static")  # 别这么干

看起来人畜无害,但文件名带 ../ 时就会翻车。

安全的做法

核心就两点:

  • 把目标目录转成绝对路径;
  • 每个解压条目都 normalize,并确认它仍在目标目录里。
import os
import zipfile


def safe_extract_zip(zip_path, dest, max_size=100 * 1024 * 1024):
    dest = os.path.realpath(dest)
    total = 0

    with zipfile.ZipFile(zip_path) as zf:
        for info in zf.infolist():
            target = os.path.realpath(os.path.join(dest, info.filename))
            if os.path.commonpath([target, dest]) != dest:
                raise ValueError(f"非法路径: {info.filename}")

            if info.is_dir():
                os.makedirs(target, exist_ok=True)
                continue

            os.makedirs(os.path.dirname(target), exist_ok=True)
            with zf.open(info) as src, open(target, "wb") as dst:
                while True:
                    chunk = src.read(65536)
                    if not chunk:
                        break
                    total += len(chunk)
                    if total > max_size:
                        raise ValueError("解压总量超限")
                    dst.write(chunk)

    return dest

os.path.commonpath 是关键。normalize 之后如果不在目标目录里,直接拒绝。

tar 也类似

import os
import tarfile


def safe_extract_tar(tar_path, dest):
    dest = os.path.realpath(dest)

    with tarfile.open(tar_path, "r:*") as tf:
        for member in tf:
            # 符号链接和硬链接先跳过去,后面单独说
            if member.issym() or member.islnk():
                raise ValueError(f"拒绝链接条目: {member.name}")

            target = os.path.realpath(os.path.join(dest, member.name))
            if os.path.commonpath([target, dest]) != dest:
                raise ValueError(f"非法路径: {member.name}")

            tf.extract(member, dest)

2. Zip Bomb 与 Gzip Bomb

42.zip 这种经典 zip 炸弹只有几十 KB,解压后能膨胀到 4.5 PB。你一旦全量解压,磁盘和内存直接扛不住。攻击者不需要路径遍历,光是一层压缩就能 DoS 你。

防御思路

  • 限制单个文件大小;
  • 限制总解压大小;
  • 监控压缩比,超过阈值就停;
  • 流式读取,别一次性读进内存。

上面的 Python 示例已经加了 max_size 和分块读。更严格一点,可以算压缩比:

import os
import zipfile


def safe_extract_zip_limited(zip_path, dest, max_total=100 * 1024 * 1024,
                              max_ratio=100):
    dest = os.path.realpath(dest)
    compressed_size = os.path.getsize(zip_path)
    max_uncompressed = min(max_total, compressed_size * max_ratio)

    total = 0
    with zipfile.ZipFile(zip_path) as zf:
        for info in zf.infolist():
            if info.is_dir():
                continue
            target = os.path.realpath(os.path.join(dest, info.filename))
            if os.path.commonpath([target, dest]) != dest:
                raise ValueError(f"非法路径: {info.filename}")

            with zf.open(info) as src, open(target, "wb") as dst:
                while True:
                    chunk = src.read(65536)
                    if not chunk:
                        break
                    total += len(chunk)
                    if total > max_uncompressed:
                        raise ValueError(
                            f"解压数据过大: {total} 字节,疑似 zip bomb")
                    dst.write(chunk)

    return dest

Gzip bomb 一个套路。不要 gzip.open(f).read(),而是边读边计数。

import gzip


def safe_gunzip(src, dst, max_size=50 * 1024 * 1024):
    total = 0
    with gzip.open(src, "rb") as g, open(dst, "wb") as out:
        while True:
            chunk = g.read(65536)
            if not chunk:
                break
            total += len(chunk)
            if total > max_size:
                raise ValueError("gzip 解压后数据过大")
            out.write(chunk)

3. 符号链接和硬链接

tar 包里可以塞符号链接。你解压时如果直接按链接写,它可能指向 /etc/shadow 或其他用户的目录。稳妥的做法是:不处理链接,或者单独解析并确认目标在允许范围内。

我的习惯是:普通业务场景直接拒绝 tar 里的 symlink 和 hardlink。真有需求,再单独白名单处理。

4. 内存和 DoS

除了 zip bomb,压缩包还能靠“海量小文件”来搞你。一个 zip 里有几百万个空文件,光创建 inode 就能把服务拖死。

所以再加几条线:

  • 限制条目数量;
  • 限制文件名长度;
  • 限制嵌套层级(zip 里套 zip 里套 zip);
  • 在独立进程或容器里跑解压,OOM 只影响它。
MAX_ENTRIES = 10000
MAX_NAME_LEN = 256


def check_zip_metadata(zip_path):
    with zipfile.ZipFile(zip_path) as zf:
        if len(zf.infolist()) > MAX_ENTRIES:
            raise ValueError("文件条目过多")
        for info in zf.infolist():
            if len(info.filename) > MAX_NAME_LEN:
                raise ValueError(f"文件名过长: {info.filename[:50]}...")

5. 文件类型校验别只看后缀

用户把 evil.php 改成 evil.jpg 再压缩上传,光检查后缀没用。应该在解压前后读取 magic bytes,确认文件类型是否在你允许的白名单里。

import binascii


def file_type(path):
    with open(path, "rb") as f:
        header = f.read(8)
    if header.startswith(b"\x89PNG"):
        return "png"
    if header.startswith(b"\xff\xd8"):
        return "jpeg"
    if header.startswith(b"PK"):
        return "zip"
    return "unknown"

6. 其他语言怎么写

Go

package main

import (
    "archive/zip"
    "errors"
    "io"
    "os"
    "path/filepath"
    "strings"
)

func safeExtractZip(src, dest string) error {
    destAbs, err := filepath.Abs(dest)
    if err != nil {
        return err
    }

    r, err := zip.OpenReader(src)
    if err != nil {
        return err
    }
    defer r.Close()

    for _, f := range r.File {
        target := filepath.Join(destAbs, f.Name)
        target, err = filepath.Abs(target)
        if err != nil {
            return err
        }
        if !strings.HasPrefix(target, destAbs+string(os.PathSeparator)) && target != destAbs {
            return errors.New("zip slip: " + f.Name)
        }

        if f.FileInfo().IsDir() {
            os.MkdirAll(target, 0755)
            continue
        }

        os.MkdirAll(filepath.Dir(target), 0755)
        out, err := os.Create(target)
        if err != nil {
            return err
        }
        rc, err := f.Open()
        if err != nil {
            out.Close()
            return err
        }
        _, err = io.Copy(out, rc)
        rc.Close()
        out.Close()
        if err != nil {
            return err
        }
    }
    return nil
}

Java

import java.io.*;
import java.nio.file.*;
import java.util.zip.*;

public class SafeZip {
    public static void extract(InputStream in, Path dest) throws IOException {
        Path destAbs = dest.toAbsolutePath().normalize();
        try (ZipInputStream zis = new ZipInputStream(in)) {
            ZipEntry entry;
            while ((entry = zis.getNextEntry()) != null) {
                Path target = destAbs.resolve(entry.getName()).normalize();
                if (!target.startsWith(destAbs)) {
                    throw new SecurityException("Zip slip: " + entry.getName());
                }
                if (entry.isDirectory()) {
                    Files.createDirectories(target);
                } else {
                    Files.createDirectories(target.getParent());
                    Files.copy(zis, target, StandardCopyOption.REPLACE_EXISTING);
                }
                zis.closeEntry();
            }
        }
    }
}

7. 一份可落地的 checklist

  • 绝不直接用 extractall()extract() 处理用户上传的压缩包;
  • 每次解压前都 normalize 路径并校验是否在目标目录内;
  • 限制总解压大小、压缩比、文件数、文件名长度;
  • 拒绝或严格限制符号链接和硬链接;
  • 按 magic bytes 校验真实文件类型,不只信后缀;
  • 流式读取,避免一次性把整个文件读进内存;
  • 敏感业务把解压扔到独立进程或容器里,出问题可控。

解压这活儿,你把它当成“执行一段来自外部的小程序”来对待,基本就不会踩大坑。