Skip to content

Commit b83961a

Browse files
[3.13] gh-151497: Avoid huge pre-allocation for oversized tarfile extended headers (GH-151498) (GH-151978)
tarfile reads a member's extended header (a GNU long name/link or a pax header) with a single read sized by the header's size field: buf = tarfile.fileobj.read(self._block(self.size)) The size is taken from the archive and is not validated, so a ~512-byte crafted file can claim several gigabytes (or, via base-256 encoding, far more) and make read() pre-allocate that much memory -- on open/iterate, before any extraction filter runs. Read the extended-header data in bounded chunks instead, so an oversized or truncated header can no longer force a huge allocation. The bytes returned for valid archives are unchanged. (cherry picked from commit da99711) Co-authored-by: Shardul Deshpande <iamsharduld@users.noreply.github.com>
1 parent 43c188d commit b83961a

3 files changed

Lines changed: 79 additions & 2 deletions

File tree

Lib/tarfile.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,32 @@ def copyfileobj(src, dst, length=None, exception=OSError, bufsize=None):
259259
dst.write(buf)
260260
return
261261

262+
# Maximum number of bytes read in a single call when reading a member's
263+
# extended header (a GNU long name/link or a pax header). The size of such
264+
# a header is taken from the archive and is not trustworthy, so it is read in
265+
# bounded chunks to avoid a huge up-front allocation when a crafted or
266+
# truncated archive claims far more data than the file actually contains
267+
# (gh-151497).
268+
_EXTHEADER_READ_CHUNK = 1024 * 1024 # 1 MiB
269+
270+
def _safe_read(fileobj, size):
271+
"""Read up to *size* bytes from *fileobj* in bounded chunks.
272+
273+
Returns the same bytes as ``fileobj.read(size)`` would (including a short
274+
result at end of file), but limits pre-allocation, so an
275+
oversized size field in a crafted header cannot force a huge allocation.
276+
"""
277+
if size <= _EXTHEADER_READ_CHUNK:
278+
return fileobj.read(size)
279+
chunks = []
280+
while size > 0:
281+
chunk = fileobj.read(min(size, _EXTHEADER_READ_CHUNK))
282+
if not chunk:
283+
break
284+
chunks.append(chunk)
285+
size -= len(chunk)
286+
return b"".join(chunks)
287+
262288
def _safe_print(s):
263289
encoding = getattr(sys.stdout, 'encoding', None)
264290
if encoding is not None:
@@ -1420,7 +1446,7 @@ def _proc_gnulong(self, tarfile):
14201446
"""Process the blocks that hold a GNU longname
14211447
or longlink member.
14221448
"""
1423-
buf = tarfile.fileobj.read(self._block(self.size))
1449+
buf = _safe_read(tarfile.fileobj, self._block(self.size))
14241450

14251451
# Fetch the next header and process it.
14261452
try:
@@ -1476,7 +1502,7 @@ def _proc_pax(self, tarfile):
14761502
POSIX.1-2008.
14771503
"""
14781504
# Read the header information.
1479-
buf = tarfile.fileobj.read(self._block(self.size))
1505+
buf = _safe_read(tarfile.fileobj, self._block(self.size))
14801506

14811507
# A pax header stores supplemental information for either
14821508
# the following file (extended) or all following files

Lib/test/test_tarfile.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,53 @@ def test_extractfile_attrs(self):
534534
self.assertIs(fobj.seekable(), True)
535535

536536

537+
class ReadSizeRecorder(io.BytesIO):
538+
# Records the largest size ever passed to read(), so a test can check
539+
# that tarfile does not request far more data than the archive holds
540+
# (which on a real file would pre-allocate it).
541+
def __init__(self, *args, **kwargs):
542+
super().__init__(*args, **kwargs)
543+
self.max_read_size = 0
544+
545+
def read(self, size=-1):
546+
if size is not None and size >= 0:
547+
self.max_read_size = max(self.max_read_size, size)
548+
return super().read(size)
549+
550+
551+
@support.cpython_only
552+
class ExtendedHeaderMemoryTest(unittest.TestCase):
553+
# gh-151497: the size of a GNU long name/link or a pax extended header is
554+
# read from the archive and is untrusted. A crafted header can claim a
555+
# size far larger than the file actually contains; opening such an archive
556+
# must not try to read (and so pre-allocate) the claimed size in one go.
557+
558+
def crafted_archive(self, hdrtype):
559+
tarinfo = tarfile.TarInfo("A")
560+
tarinfo.type = hdrtype
561+
tarinfo.size = 0xFFFFFFFF # ~4 GiB claimed in a 512-byte header
562+
return tarinfo.tobuf(format=tarfile.GNU_FORMAT)
563+
564+
def check(self, hdrtype):
565+
fobj = ReadSizeRecorder(self.crafted_archive(hdrtype))
566+
try:
567+
with tarfile.open(fileobj=fobj, mode="r:") as tar:
568+
tar.getmembers()
569+
except tarfile.ReadError:
570+
pass # a truncated header is fine; we only check the allocation
571+
# The bogus ~4 GiB size must never reach a single read() call.
572+
self.assertLessEqual(fobj.max_read_size, tarfile._EXTHEADER_READ_CHUNK)
573+
574+
def test_gnu_longname_oversized_size(self):
575+
self.check(tarfile.GNUTYPE_LONGNAME)
576+
577+
def test_gnu_longlink_oversized_size(self):
578+
self.check(tarfile.GNUTYPE_LONGLINK)
579+
580+
def test_pax_header_oversized_size(self):
581+
self.check(tarfile.XHDTYPE)
582+
583+
537584
class MiscReadTestBase(CommonReadTest):
538585
is_stream = False
539586

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Opening a :mod:`tarfile` archive no longer attempts to pre-allocate a huge
2+
buffer when a crafted or truncated member claims an oversized extended header
3+
(a GNU long name/link or a pax header). The extended header is now read in
4+
bounded chunks, so its size field can no longer trigger memory exhaustion.

0 commit comments

Comments
 (0)