上传文件后解压,是后台再常见不过的操作。但很多人对解压的理解还停留在“把 zip 拖进去、点确定”这个阶段。说白了,压缩包不是可信输入,里面随便一个 ../etc/passwd 就能让你栽跟头。今天把解压里最容易出事的几类坑掰开聊,附带可直接用的代码。
压缩包里的文件名可以写成 ../../var/www/html/index.php。如果你直接 extractall(),它真的能覆盖到目标目录外面。这种攻击叫 Zip Slip,tar 同理。
import zipfile
with zipfile.ZipFile("upload.zip") as z:
z.extractall("/var/www/app/static") # 别这么干
看起来人畜无害,但文件名带 ../ 时就会翻车。
核心就两点:
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 之后如果不在目标目录里,直接拒绝。
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)
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)
tar 包里可以塞符号链接。你解压时如果直接按链接写,它可能指向 /etc/shadow 或其他用户的目录。稳妥的做法是:不处理链接,或者单独解析并确认目标在允许范围内。
我的习惯是:普通业务场景直接拒绝 tar 里的 symlink 和 hardlink。真有需求,再单独白名单处理。
除了 zip bomb,压缩包还能靠“海量小文件”来搞你。一个 zip 里有几百万个空文件,光创建 inode 就能把服务拖死。
所以再加几条线:
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]}...")
用户把 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"
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
}
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();
}
}
}
}
extractall() 或 extract() 处理用户上传的压缩包;解压这活儿,你把它当成“执行一段来自外部的小程序”来对待,基本就不会踩大坑。