Python 解压 Zip 文件竟成黑客入口?手把手写一个防 Zip Slip 的解压器

2026-08-14 0 1,002

你平时用 Python 解压过 zip 文件吗?估计很多人都直接 zipfile.ZipFile.extractall() 一把梭。但你可能不知道,一个经恶意构造的 zip 文件,能通过文件名里的 ../ 跳出你指定的目录,把你服务器上的任意文件覆盖掉。这就是安全圈里常说的 Zip Slip 漏洞。

这可不是纸上谈兵,之前 npm 上的很多包,以及一些 Java 库都爆过。今天我用 Python 从零写一个安全的解压函数,你看完以后就能直接用到项目里,再也不怕别人传个 zip 上来搞事。

1. 一个简单的 Zip Slip 攻击示例

先看看漏洞长什么样。我们创建一个恶意的 zip 文件,里面的文件名是 ../../evil.txt。如果程序不做任何检查,直接提取到 /tmp/safe_dir 下,那么实际落盘路径就会是 /tmp/evil.txt,成功穿越了目标目录。

下面这段代码就演示了这个攻击:

import zipfile
import os

# 构造恶意 zip 文件
def create_malicious_zip(path):
    with zipfile.ZipFile(path, 'w') as zf:
        # 写入一个故意包含 ../ 的文件名
        zf.writestr('../../evil.txt', '你被攻击了!')

# 正常解压
def vulnerable_extract(zip_path, target_dir):
    with zipfile.ZipFile(zip_path, 'r') as zf:
        zf.extractall(target_dir)

# 演示
create_malicious_zip('/tmp/malicious.zip')
vulnerable_extract('/tmp/malicious.zip', '/tmp/safe_dir')
print("文件实际写入到了:", os.path.exists('/tmp/evil.txt'))  # True

执行完你就会发现,evil.txt 跑到了 /tmp 下面,而不是 /tmp/safe_dir 里。如果攻击者更狠一点,写一个 ../../../../etc/crontab,那你的系统就被篡改了。

这正是 Zip Slip 的核心问题:解压时没有校验文件名,导致路径穿越

2. 用 Python 模拟一个真实场景

很多 Web 应用都支持上传 zip 并自动解压,比如导入模板、批量上传图片。如果后端直接 extractall,那用户就能上传一个恶意 zip 来修改服务器上的文件,甚至写一个 webshell。

我们模拟一个后端解压逻辑:

def handle_upload(file_path):
    extract_dir = "/var/www/uploads/" + str(uuid.uuid4())
    os.makedirs(extract_dir, exist_ok=True)

    with zipfile.ZipFile(file_path, 'r') as zf:
        zf.extractall(extract_dir)  # 危险!

如果 zip 里有一个条目是 ../../shell.php,那么它就会被写到 /var/www/shell.php,直接控制你的服务器。太可怕了。

3. 手写安全解压函数

要防止 Zip Slip,核心思想很简单:逐个成员检查最终路径是否在目标目录之内。用 pathlib 可以很方便地做。

下面是一个安全解压的完整实现:

import zipfile
import os
import pathlib

def safe_extract(zip_path, target_dir):
    """
    解压 zip 文件,防止 Zip Slip 路径穿越
    """
    # 将目标目录转换为绝对路径,并规范化
    target_root = pathlib.Path(target_dir).resolve()
    os.makedirs(target_root, exist_ok=True)

    with zipfile.ZipFile(zip_path, 'r') as zf:
        for member in zf.infolist():
            # 获取成员文件名,并将反斜杠替换为正斜杠
            member_name = member.filename.replace('\', '/')
            # 构建目标路径
            dest_path = (target_root / member_name).resolve()

            # 检查最终路径是否在目标目录内
            if not dest_path.is_relative_to(target_root):
                raise RuntimeError(f"Illegal filename in zip: {member.filename}")

            # 如果是目录,创建目录
            if member.is_dir():
                dest_path.mkdir(parents=True, exist_ok=True)
                continue

            # 确保父目录存在
            dest_path.parent.mkdir(parents=True, exist_ok=True)

            # 写入文件内容
            with zf.open(member, 'r') as source, open(dest_path, 'wb') as target:
                while True:
                    chunk = source.read(1024 * 64)
                    if not chunk:
                        break
                    target.write(chunk)

    print(f"文件已安全解压到: {target_root}")

is_relative_to() 是 Python 3.9+ 的方法,用来判断一个路径是否在另一个路径之下。如果你用的还是旧版 Python,可以用 dest_path.startswith(target_root) 代替,但要注意边界问题。

4. 验证安全函数

我们用同一个恶意 zip 来测试:

try:
    safe_extract('/tmp/malicious.zip', '/tmp/safe_dir')
except Exception as e:
    print("拦截到攻击:", e)

运行后会输出 拦截到攻击: Illegal filename in zip: ../../evil.txt,不会创建任何文件。

5. 处理特殊成员:符号链接

Zip 文件里还能存储符号链接(symlink)。如果攻击者创建一个符号链接指向 /etc,然后在链接目录里继续写入敏感文件,那么路径校验可能会被绕过。所以我们还需要处理 zipfile.ZipInfo.external_attr 中的 Unix 文件类型。

安全增强版:

import stat

def safe_extract_strict(zip_path, target_dir):
    target_root = pathlib.Path(target_dir).resolve()
    os.makedirs(target_root, exist_ok=True)

    with zipfile.ZipFile(zip_path, 'r') as zf:
        for member in zf.infolist():
            member_name = member.filename.replace('\', '/')
            dest_path = (target_root / member_name).resolve()

            if not dest_path.is_relative_to(target_root):
                raise RuntimeError(f"Illegal filename in zip: {member.filename}")

            # 检查是否为符号链接
            mode = member.external_attr >> 16
            if stat.S_ISLNK(mode):
                # 读取链接目标
                link_target = zf.read(member).decode('utf-8')
                link_path = pathlib.Path(link_target)
                # 如果链接目标是绝对路径或包含 ..,拒绝
                if link_path.is_absolute() or '..' in link_path.parts:
                    raise RuntimeError(f"Unsafe symlink: {member.filename} -> {link_target}")
                # 创建符号链接(仅限 Unix)
                os.symlink(link_target, dest_path)
                continue

            # 目录处理
            if member.is_dir():
                dest_path.mkdir(parents=True, exist_ok=True)
                continue

            dest_path.parent.mkdir(parents=True, exist_ok=True)
            with zf.open(member, 'r') as source, open(dest_path, 'wb') as target:
                while True:
                    chunk = source.read(1024 * 64)
                    if not chunk:
                        break
                    target.write(chunk)

这里我们分析了 Unix 文件模式,如果发现是符号链接,就检查它的目标是否安全。但要注意,在 Windows 上创建符号链接需要管理员权限,所以一般实际项目中也可以直接禁止符号链接,避免麻烦。

6. 再加一些防御措施

除了路径穿越,恶意 zip 还可能带来其他攻击,比如 zip bomb(解压后体积巨大)。所以我们可以给解压器加上一些限制:

  • 限制总文件数量(比如最多 10000 个)。
  • 限制单个文件大小和解压后总大小。
  • 拒绝重复的文件名,防止覆盖自身。
  • 检测压缩比,如果压缩比异常(比如压缩后 1KB 解压 100MB),直接拒绝。

这里给一个带体积限制的完整版:

import zipfile
import pathlib
import os

MAX_FILES = 10000
MAX_SIZE_TOTAL = 1024 * 1024 * 500  # 总解压大小不超过 500MB
MAX_SIZE_FILE = 1024 * 1024 * 100   # 单个文件最大 100MB

def safe_extract_with_limits(zip_path, target_dir):
    target_root = pathlib.Path(target_dir).resolve()
    os.makedirs(target_root, exist_ok=True)

    total_size = 0
    file_count = 0

    with zipfile.ZipFile(zip_path, 'r') as zf:
        # 先检查成员数量
        if len(zf.infolist()) > MAX_FILES:
            raise RuntimeError("Zip 文件条目过多")

        for member in zf.infolist():
            file_count += 1
            member_name = member.filename.replace('\', '/')
            dest_path = (target_root / member_name).resolve()

            if not dest_path.is_relative_to(target_root):
                raise RuntimeError(f"Illegal filename in zip: {member.filename}")

            # 如果是文件,累加大小
            if not member.is_dir():
                total_size += member.file_size
                if member.file_size > MAX_SIZE_FILE:
                    raise RuntimeError(f"单个文件过大: {member.filename}")
                if total_size > MAX_SIZE_TOTAL:
                    raise RuntimeError("解压总大小超过限制")

            dest_path.parent.mkdir(parents=True, exist_ok=True)

            if member.is_dir():
                dest_path.mkdir(exist_ok=True)
                continue

            with zf.open(member, 'r') as source, open(dest_path, 'wb') as target:
                while True:
                    chunk = source.read(1024 * 64)
                    if not chunk:
                        break
                    target.write(chunk)

    print(f"解压完成,共 {file_count} 个文件,总大小 {total_size / 1024 / 1024:.2f}MB")

7. 封装成命令行工具

为了方便使用,我们可以加一个简单的命令行入口:

if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description="安全解压 Zip 文件")
    parser.add_argument("zip_path", help="Zip 文件路径")
    parser.add_argument("target_dir", help="目标目录")
    args = parser.parse_args()

    try:
        safe_extract_with_limits(args.zip_path, args.target_dir)
    except Exception as e:
        print(f"解压失败: {e}", file=__import__('sys').stderr)
        exit(1)

这样你在终端里就能直接运行:

python safe_unzip.py malicious.zip /tmp/safe_dir

8. 如果依赖的是老项目,怎么办

如果你没办法替换 extractall 的调用,又希望临时避免漏洞,可以写一个猴子补丁,在运行时把 extractall 替换成安全版本。不过最好的办法还是把所有解压操作都收敛到同一个工具函数里,不要到处直接调 ZipFile.extractall

另外,Python 3.13 的 zipfile 模块并没有自带防护,所以这层检查得靠我们自己写。

9. 总结

Zip Slip 是个很容易被忽略的漏洞,但危害极大。我写这个安全解压器只用了几十行代码,足够应对大部分业务场景。以后只要是处理用户上传的 zip,你都应该带着路径校验。

如果想直接用,复制上面的 safe_extract_with_limits 到你的项目里,改改大小限制就能开工。如果你在代码审查里看到有人直接写 extractall,请把这篇文章甩给他。

安全无小事,尤其是在 Python 里,默认函数往往不帮你挡这些。

行了,这次就分享到这,希望你的服务器安然无恙。

Python 解压 Zip 文件竟成黑客入口?手把手写一个防 Zip Slip 的解压器
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 python Python 解压 Zip 文件竟成黑客入口?手把手写一个防 Zip Slip 的解压器 https://www.taomawang.com/server/python/2542.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务