如何保护自己免受gzip或bzip2炸弹的攻击?

24

这与关于zip炸弹的问题有关,但考虑到gzip或bzip2压缩,例如接受.tar.gz文件的Web服务。

Python提供了一个方便使用的tarfile模块,但似乎没有提供保护措施来防止zip炸弹。

在使用tarfile模块的Python代码中,最优雅的方法是什么,最好不要重复太多逻辑(例如透明解压缩支持),以检测zip炸弹?

而且,为了让它变得不那么简单:没有涉及真实文件;输入是类似文件的对象(由Web框架提供,表示用户上传的文件)。


你不能使用TarInfo.size吗? - damiankolasa
1
@fatfredyy 在解压 tar 文件之前,你可以先执行 gz bomb。 - Jakozaur
1
你担心炸弹会产生什么影响?只有内存使用吗?还是在提取时也会占用磁盘空间(根据参考问题)? - Mark Adler
我的问题被莫名其妙地踩了,而且我也不明白为什么会被关闭:这难道不是一个非常明确和具体的编程任务吗? - Joachim Breitner
1
叹气。看来有些人认为这是一个系统管理员的问题(从快速阅读可能是这样)。因此,我稍微澄清了一下这个问题:这实际上是关于编写代码,使Web应用程序gzip-bomp-safe的问题。 - Joachim Breitner
1
相关:[gzip,bz2,lzma:添加选项以限制输出大小](https://bugs.python.org/issue15955) - jfs
5个回答

15
你可以使用 resource 模块 来限制你的进程及其子进程可用的资源。
如果你需要在内存中解压缩,那么你可以设置 resource.RLIMIT_AS(或 RLIMIT_DATARLIMIT_STACK),例如,使用上下文管理器来自动将其恢复为先前的值:
import contextlib
import resource

@contextlib.contextmanager
def limit(limit, type=resource.RLIMIT_AS):
    soft_limit, hard_limit = resource.getrlimit(type)
    resource.setrlimit(type, (limit, hard_limit)) # set soft limit
    try:
        yield
    finally:
        resource.setrlimit(type, (soft_limit, hard_limit)) # restore

with limit(1 << 30): # 1GB 
    # do the thing that might try to consume all memory
如果达到了限制,将会引发MemoryError错误。

1
一个正确实现的tar.gz解压器没有理由占用超过40K的内存,无论归档文件的大小或未压缩数据的数量。当解压时所占用的磁盘空间是另一回事,但这并不能解决这个问题。 - Mark Adler
@MarkAdler:OP只关心所有数据都在内存中的情况:“没有涉及真实文件”,“我已经将数据存储在内存中”。 - jfs
1
该帖子链接的问题将zip炸弹问题描述为“打开后会填满服务器的磁盘”。因此并不清楚。 - Mark Adler
无论如何,您不应需要任何可观的内存来检查或提取.tar.gz文件,无论其大小。 - Mark Adler
这是处理不受信任数据时的一种可能性,也许是一个很好的一般预防措施。缺点是很难事先确定处理何时失败。如果该代码不适合在异常后回滚,则最好事先检查是否安全。 - Joachim Breitner
显示剩余2条评论

6
这将确定gzip流的未压缩大小,同时使用有限的内存:
#!/usr/bin/python
import sys
import zlib
f = open(sys.argv[1], "rb")
z = zlib.decompressobj(15+16)
total = 0
while True:
    buf = z.unconsumed_tail
    if buf == "":
        buf = f.read(1024)
        if buf == "":
            break
    got = z.decompress(buf, 4096)
    if got == "":
        break
    total += len(got)
print total
if z.unused_data != "" or f.read(1024) != "":
    print "warning: more input after end of gzip stream"

它将略微高估当解压缩tar文件中的所有文件所需的空间。长度包括这些文件以及tar目录信息。

gzip.py代码不控制解压缩的数据量,除了由于输入数据的大小。在gzip.py中,它每次读取1024个压缩字节。因此,如果您对未压缩数据使用高达1056768字节的内存使用量(1032 * 1024,其中1032:1是deflate的最大压缩比),则可以使用gzip.py。此处的解决方案使用具有第二个参数的zlib.decompress,该参数限制未压缩数据的数量。gzip.py没有这个功能。

这将通过解码tar格式准确确定提取的tar条目的总大小:

#!/usr/bin/python

import sys
import zlib

def decompn(f, z, n):
    """Return n uncompressed bytes, or fewer if at the end of the compressed
       stream.  This only decompresses as much as necessary, in order to
       avoid excessive memory usage for highly compressed input.
    """
    blk = ""
    while len(blk) < n:
        buf = z.unconsumed_tail
        if buf == "":
            buf = f.read(1024)
        got = z.decompress(buf, n - len(blk))
        blk += got
        if got == "":
            break
    return blk

f = open(sys.argv[1], "rb")
z = zlib.decompressobj(15+16)
total = 0
left = 0
while True:
    blk = decompn(f, z, 512)
    if len(blk) < 512:
        break
    if left == 0:
        if blk == "\0"*512:
            continue
        if blk[156] in ["1", "2", "3", "4", "5", "6"]:
            continue
        if blk[124] == 0x80:
            size = 0
            for i in range(125, 136):
                size <<= 8
                size += blk[i]
        else:
            size = int(blk[124:136].split()[0].split("\0")[0], 8)
        if blk[156] not in ["x", "g", "X", "L", "K"]:
                total += size
        left = (size + 511) // 512
    else:
        left -= 1
print total
if blk != "":
    print "warning: partial final block"
if left != 0:
    print "warning: tar file ended in the middle of an entry"
if z.unused_data != "" or f.read(1024) != "":
    print "warning: more input after end of gzip stream"

您可以使用此变体来扫描tar文件中的炸弹。这样做有一个优点,即在您甚至不必解压缩数据之前,就能够在头信息中找到大大小。
至于.tar.bz2档案,Python bz2库(至少在3.3版本中)对于消耗过多内存的bz2炸弹是不可避免的不安全的。bz2.decompress函数不像zlib.decompress那样提供第二个参数。这甚至更加严重的原因是,由于运行长度编码,bz2格式具有比zlib高得多的最大压缩比率。bzip2将1 GB的零压缩为722字节。因此,你不能通过像zlib.decompress那样计量输入来计量bz2.decompress的输出,即使没有第二个参数也可以完成。在Python接口中缺少限制解压缩输出大小的功能是一种根本性缺陷。
我查看了3.3版本中的_bz2module.c,看看是否有一种未记录的方法可以避免这个问题。没有办法绕过它。里面的解压缩函数只会不断增加结果缓冲区,直到能够解压缩所有提供的输入。需要修复_bz2module.c。

你确定这个能行吗?如果不解压gzip包装,tar怎么知道大小呢?请注意,我担心的是gzip炸弹,而不是tar炸弹! - Joachim Breitner
我测试过了,它不起作用:将一个10GB的零文件打包成.tar.gz文件后,结果是一个大小为10MB的文件。在设置了ulimit -v 200000的情况下运行您的代码会失败,因此它使用的远远超过了10MB的输入,因此容易受到zipbombs攻击。 - Joachim Breitner
好的,这可能可行,因为z.decompress是安全的,但对于bzip2来说就不行了(除非我漏掉了什么),并且由于bzip库API的缺陷,很难被轻松采用。此外,它似乎比我的解决方案中的代码更复杂和容易出错。 - Joachim Breitner
gzip.py的代码除了输入数据的大小之外,不能控制解压缩的数据量。在gzip.py中,每次读取1024个压缩字节。如果您对未压缩数据使用高达1056768个字节的内存空间感到满意(1032 * 1024,其中1032:1是deflate的最大压缩比),那么您的方法将有效。我的解决方案使用了zlib.decompress的第二个参数,可以限制未压缩数据的数量,而gzip.py则没有这样的功能。 - Mark Adler
关于使用Python bz2库没有内存安全的方法,你是正确的。bz2.decompress函数不像zlib.decompress一样提供第二个参数。更糟糕的是,由于运行长度编码,bz2格式具有比zlib更高得多的最大压缩比。bzip2将1 GB的零压缩为722字节。因此,即使没有第二个参数,您也无法通过计量输入来计量bz2.decompress的输出,就像可以使用zlib.decompress一样。在Python接口中,未对解压缩输出大小进行限制是一个根本性的缺陷。 - Mark Adler
显示剩余2条评论

3
我想答案是:没有简单、现成的解决方案。以下是我目前使用的方法:
class SafeUncompressor(object):
    """Small proxy class that enables external file object
    support for uncompressed, bzip2 and gzip files. Works transparently, and
    supports a maximum size to avoid zipbombs.
    """
    blocksize = 16 * 1024

    class FileTooLarge(Exception):
        pass

    def __init__(self, fileobj, maxsize=10*1024*1024):
        self.fileobj = fileobj
        self.name = getattr(self.fileobj, "name", None)
        self.maxsize = maxsize
        self.init()

    def init(self):
        import bz2
        import gzip
        self.pos = 0
        self.fileobj.seek(0)
        self.buf = ""
        self.format = "plain"

        magic = self.fileobj.read(2)
        if magic == '\037\213':
            self.format = "gzip"
            self.gzipobj = gzip.GzipFile(fileobj = self.fileobj, mode = 'r')
        elif magic == 'BZ':
            raise IOError, "bzip2 support in SafeUncompressor disabled, as self.bz2obj.decompress is not safe"
            self.format = "bz2"
            self.bz2obj = bz2.BZ2Decompressor()
        self.fileobj.seek(0)


    def read(self, size):
        b = [self.buf]
        x = len(self.buf)
        while x < size:
            if self.format == 'gzip':
                data = self.gzipobj.read(self.blocksize)
                if not data:
                    break
            elif self.format == 'bz2':
                raw = self.fileobj.read(self.blocksize)
                if not raw:
                    break
                # this can already bomb here, to some extend.
                # so disable bzip support until resolved.
                # Also monitor https://dev59.com/QmYr5IYBdhLWcg3wg6b9 for ideas
                data = self.bz2obj.decompress(raw)
            else:
                data = self.fileobj.read(self.blocksize)
                if not data:
                    break
            b.append(data)
            x += len(data)

            if self.pos + x > self.maxsize:
                self.buf = ""
                self.pos = 0
                raise SafeUncompressor.FileTooLarge, "Compressed file too large"
        self.buf = "".join(b)

        buf = self.buf[:size]
        self.buf = self.buf[size:]
        self.pos += len(buf)
        return buf

    def seek(self, pos, whence=0):
        if whence != 0:
            raise IOError, "SafeUncompressor only supports whence=0"
        if pos < self.pos:
            self.init()
        self.read(pos - self.pos)

    def tell(self):
        return self.pos

对于bzip2来说,它并不能很好地工作,因此这部分代码已被禁用。原因是bz2.BZ2Decompressor.decompress已经可以产生一大块不需要的数据。


3

如果您开发linux平台,可以在单独的进程中运行解压缩,并使用ulimit来限制内存使用。

import subprocess
subprocess.Popen("ulimit -v %d; ./decompression_script.py %s" % (LIMIT, FILE))

记住,decompression_script.py应该在写入磁盘之前将整个文件在内存中解压缩。

这可能可行,但这不会被视为优雅的解决方案。另外,我需要将数据导入脚本中,这使得与ulimit调用结合起来有点更加困难。 - Joachim Breitner
你可以直接创建一个文件,不需要使用管道,否则可能会变得复杂。 - Jakozaur
但是我已经将数据存储在内存中了,为什么我还要在这里使用文件呢? - Joachim Breitner

0

我还需要处理上传的zip文件中的zip炸弹。

我通过创建固定大小的tmpfs并将其解压缩到其中来实现此目的。如果提取的数据太大,则tmpfs将耗尽空间并显示错误。

以下是用于创建200M tmpfs以进行解压缩的Linux命令。

sudo mkdir -p /mnt/ziptmpfs
echo 'tmpfs   /mnt/ziptmpfs         tmpfs   rw,nodev,nosuid,size=200M          0  0' | sudo tee -a /etc/fstab

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接