Skip to content

Commit 1071290

Browse files
[3.10] gh-146581: Fix vulnerability in shutil.unpack_archive() for ZIP files on Windows (GH-146591) (GH-149071) (#155440)
[3.11] gh-146581: Fix vulnerability in shutil.unpack_archive() for ZIP files on Windows (GH-146591) (GH-149071) gh-146581: Fix vulnerability in shutil.unpack_archive() for ZIP files on Windows (GH-146591) Use ZipFile.extractall() to sanitize file names and extract files. Files with invalid names (e.g. absolute paths) are now skipped. Files containing ".." in the name are no longer skipped. (cherry picked from commit 7ef7dd0) (cherry picked from commit fc829e8) Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
1 parent bd3939e commit 1071290

4 files changed

Lines changed: 87 additions & 26 deletions

File tree

Lib/shutil.py

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,27 +1200,9 @@ def _unpack_zipfile(filename, extract_dir):
12001200
if not zipfile.is_zipfile(filename):
12011201
raise ReadError("%s is not a zip file" % filename)
12021202

1203-
zip = zipfile.ZipFile(filename)
1204-
try:
1205-
for info in zip.infolist():
1206-
name = info.filename
1207-
1208-
# don't extract absolute paths or ones with .. in them
1209-
if name.startswith('/') or '..' in name:
1210-
continue
1211-
1212-
targetpath = os.path.join(extract_dir, *name.split('/'))
1213-
if not targetpath:
1214-
continue
1215-
1216-
_ensure_directory(targetpath)
1217-
if not name.endswith('/'):
1218-
# file
1219-
with zip.open(name, 'r') as source, \
1220-
open(targetpath, 'wb') as target:
1221-
copyfileobj(source, target)
1222-
finally:
1223-
zip.close()
1203+
with zipfile.ZipFile(filename) as zip:
1204+
zip._ignore_invalid_names = True
1205+
zip.extractall(extract_dir)
12241206

12251207
def _unpack_tarfile(filename, extract_dir, *, filter=None):
12261208
"""Unpack tar/tar.gz/tar.bz2/tar.xz `filename` to `extract_dir`

Lib/test/test_shutil.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1670,6 +1670,71 @@ def test_unpack_archive_zip(self):
16701670
with self.assertRaises(TypeError):
16711671
self.check_unpack_archive('zip', filter='data')
16721672

1673+
def test_unpack_archive_zip_badpaths(self):
1674+
srcdir = self.mkdtemp()
1675+
zipname = os.path.join(srcdir, 'test.zip')
1676+
abspath = os.path.join(srcdir, 'abspath')
1677+
with zipfile.ZipFile(zipname, 'w') as zf:
1678+
zf.writestr(abspath, 'badfile')
1679+
zf.writestr(os.sep + abspath, 'badfile')
1680+
zf.writestr('/abspath', 'badfile')
1681+
zf.writestr('C:/abspath', 'badfile')
1682+
zf.writestr('D:\\abspath', 'badfile')
1683+
zf.writestr('E:abspath', 'badfile')
1684+
zf.writestr('F:/G:/abspath', 'badfile')
1685+
zf.writestr('//server/share/abspath', 'badfile')
1686+
zf.writestr('\\\\server2\\share\\abspath', 'badfile')
1687+
zf.writestr('../relpath', 'badfile')
1688+
zf.writestr(os.pardir + os.sep + 'relpath2', 'badfile')
1689+
zf.writestr('good/file', 'goodfile')
1690+
zf.writestr('good..file', 'goodfile')
1691+
1692+
dstdir = os.path.join(self.mkdtemp(), 'dst')
1693+
unpack_archive(zipname, dstdir)
1694+
self.assertTrue(os.path.isfile(os.path.join(dstdir, 'good', 'file')))
1695+
self.assertTrue(os.path.isfile(os.path.join(dstdir, 'good..file')))
1696+
self.assertFalse(os.path.exists(abspath))
1697+
self.assertFalse(os.path.exists(os.path.join(dstdir, 'abspath')))
1698+
self.assertFalse(os.path.exists(os.path.join(dstdir, 'G_')))
1699+
self.assertFalse(os.path.exists(os.path.join(dstdir, 'server')))
1700+
if os.name != 'nt':
1701+
self.assertTrue(os.path.isfile(os.path.join(dstdir, 'C:', 'abspath')))
1702+
self.assertTrue(os.path.isfile(os.path.join(dstdir, 'D:\\abspath')))
1703+
self.assertTrue(os.path.isfile(os.path.join(dstdir, 'E:abspath')))
1704+
self.assertTrue(os.path.isfile(os.path.join(dstdir, 'F:', 'G:', 'abspath')))
1705+
self.assertTrue(os.path.isfile(os.path.join(dstdir, '\\\\server2\\share\\abspath')))
1706+
if os.pardir == '..':
1707+
self.assertFalse(os.path.exists(os.path.join(dstdir, '..', 'relpath')))
1708+
self.assertFalse(os.path.exists(os.path.join(dstdir, 'relpath')))
1709+
else:
1710+
self.assertTrue(os.path.isfile(os.path.join(dstdir, '..', 'relpath')))
1711+
self.assertFalse(os.path.exists(os.path.join(dstdir, os.pardir, 'relpath2')))
1712+
self.assertFalse(os.path.exists(os.path.join(dstdir, 'relpath2')))
1713+
1714+
dstdir2 = os.path.join(self.mkdtemp(), 'dst')
1715+
os.mkdir(dstdir2)
1716+
with os_helper.change_cwd(dstdir2):
1717+
unpack_archive(zipname, '')
1718+
self.assertTrue(os.path.isfile(os.path.join('good', 'file')))
1719+
self.assertTrue(os.path.isfile('good..file'))
1720+
self.assertFalse(os.path.exists(abspath))
1721+
self.assertFalse(os.path.exists('abspath'))
1722+
self.assertFalse(os.path.exists('C_'))
1723+
self.assertFalse(os.path.exists('server'))
1724+
if os.name != 'nt':
1725+
self.assertTrue(os.path.isfile(os.path.join('C:', 'abspath')))
1726+
self.assertTrue(os.path.isfile('D:\\abspath'))
1727+
self.assertTrue(os.path.isfile('E:abspath'))
1728+
self.assertTrue(os.path.isfile(os.path.join('F:', 'G:', 'abspath')))
1729+
self.assertTrue(os.path.isfile('\\\\server2\\share\\abspath'))
1730+
if os.pardir == '..':
1731+
self.assertFalse(os.path.exists(os.path.join('..', 'relpath')))
1732+
self.assertFalse(os.path.exists('relpath'))
1733+
else:
1734+
self.assertTrue(os.path.isfile(os.path.join('..', 'relpath')))
1735+
self.assertFalse(os.path.exists(os.path.join(os.pardir, 'relpath2')))
1736+
self.assertFalse(os.path.exists('relpath2'))
1737+
16731738
def test_unpack_registry(self):
16741739

16751740
formats = get_unpack_formats()

Lib/zipfile.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,6 +1233,7 @@ class ZipFile:
12331233

12341234
fp = None # Set here since __del__ checks it
12351235
_windows_illegal_name_trans_table = None
1236+
_ignore_invalid_names = False
12361237

12371238
def __init__(self, file, mode="r", compression=ZIP_STORED, allowZip64=True,
12381239
compresslevel=None, *, strict_timestamps=True):
@@ -1696,19 +1697,27 @@ def _extract_member(self, member, targetpath, pwd):
16961697

16971698
# build the destination pathname, replacing
16981699
# forward slashes to platform specific separators.
1699-
arcname = member.filename.replace('/', os.path.sep)
1700-
1701-
if os.path.altsep:
1700+
arcname = member.filename
1701+
if os.path.sep != '/':
1702+
arcname = arcname.replace('/', os.path.sep)
1703+
if os.path.altsep and os.path.altsep != '/':
17021704
arcname = arcname.replace(os.path.altsep, os.path.sep)
17031705
# interpret absolute pathname as relative, remove drive letter or
17041706
# UNC path, redundant separators, "." and ".." components.
1705-
arcname = os.path.splitdrive(arcname)[1]
1707+
drive, arcname = os.path.splitdrive(arcname)
1708+
if self._ignore_invalid_names and (drive or arcname.startswith(os.path.sep)):
1709+
return None
1710+
if self._ignore_invalid_names and os.path.pardir in arcname.split(os.path.sep):
1711+
return None
17061712
invalid_path_parts = ('', os.path.curdir, os.path.pardir)
17071713
arcname = os.path.sep.join(x for x in arcname.split(os.path.sep)
17081714
if x not in invalid_path_parts)
17091715
if os.path.sep == '\\':
17101716
# filter illegal characters on Windows
1711-
arcname = self._sanitize_windows_name(arcname, os.path.sep)
1717+
arcname2 = self._sanitize_windows_name(arcname, os.path.sep)
1718+
if self._ignore_invalid_names and arcname2 != arcname:
1719+
return None
1720+
arcname = arcname2
17121721

17131722
targetpath = os.path.join(targetpath, arcname)
17141723
targetpath = os.path.normpath(targetpath)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Fix vulnerability in :func:`shutil.unpack_archive` for ZIP files on Windows
2+
which allowed to write files outside of the destination tree if the patch in
3+
the archive contains a Windows drive prefix. Now such invalid paths will be
4+
skipped. Files containing ".." in the name (like "foo..bar") are no longer
5+
skipped.

0 commit comments

Comments
 (0)