VYPR
High severity7.5NVD Advisory· Published Apr 27, 2026· Updated Jun 5, 2026

CVE-2026-3087

CVE-2026-3087

Description

On Windows, shutil.unpack_archive() fails to sanitize ZIP entries with drive-prefixed paths, allowing extraction outside the target directory.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

On Windows, shutil.unpack_archive() fails to sanitize ZIP entries with drive-prefixed paths, allowing extraction outside the target directory.

Vulnerability

The shutil.unpack_archive() function on Windows does not properly sanitize ZIP archive entry names that contain a drive prefix (e.g., C:/... or D:\...). The private helper _unpack_zipfile() in Lib/shutil.py only rejects names starting with / or containing .., but fails to handle Windows drive-qualified paths. This allows a crafted ZIP archive to write files outside the intended extract_dir on Windows. The issue affects all Python versions prior to the fix, including 3.12.8 as reported in [4]. Only Windows is vulnerable; other operating systems are unaffected.

Exploitation

An attacker must supply a malicious ZIP archive containing entries with drive-prefixed names (e.g., D:/malicious.txt). When a victim on Windows calls shutil.unpack_archive() with this archive, the extraction process treats the drive prefix as an absolute path, causing files to be written to the specified drive outside the target directory. No special privileges or user interaction beyond opening the archive are required. The attacker can control the file content and destination path.

Impact

Successful exploitation results in arbitrary file write on Windows. An attacker can overwrite system files, place executables in startup folders, or modify configuration files, potentially leading to code execution, privilege escalation, or persistent compromise. The write occurs with the privileges of the user running the extraction.

Mitigation

The vulnerability is fixed in Python 3.13 via commit ab5ef98 [1], in Python 3.14 via commit b01e594 [2], and in the main branch via commit fc829e8 [3]. Users should update to the latest patched version. No workaround is available; the only mitigation is to apply the patch. The issue is not listed on the CISA Known Exploited Vulnerabilities (KEV) catalog as of publication.

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

5

Patches

7
65b255416ae2

[3.15] gh-146581: Update docs for dangerous filenames in ZIP files (GH-149994) (GH-150064)

https://github.com/python/cpythonMiss Islington (bot)May 19, 2026via nvd-ref
2 files changed · +6 6
  • Doc/library/shutil.rst+2 2 modified
    @@ -749,8 +749,8 @@ provided.  They rely on the :mod:`zipfile` and :mod:`tarfile` modules.
     
           Never extract archives from untrusted sources without prior inspection.
           It is possible that files are created outside of the path specified in
    -      the *extract_dir* argument, e.g. members that have absolute filenames
    -      starting with "/" or filenames with two dots "..".
    +      the *extract_dir* argument, for example, members that have absolute filenames
    +      or filenames with ".." components.
     
           Since Python 3.14, the defaults for both built-in formats (zip and tar
           files) will prevent the most dangerous of such security issues,
    
  • Doc/library/zipfile.rst+4 4 modified
    @@ -411,9 +411,9 @@ ZipFile objects
        .. warning::
     
           Never extract archives from untrusted sources without prior inspection.
    -      It is possible that files are created outside of *path*, e.g. members
    -      that have absolute filenames starting with ``"/"`` or filenames with two
    -      dots ``".."``.  This module attempts to prevent that.
    +      It is possible that files are created outside of *path*, for example, members
    +      that have absolute filenames or filenames with ".." components.
    +      This module attempts to prevent that.
           See :meth:`extract` note.
     
        .. versionchanged:: 3.6
    @@ -590,7 +590,7 @@ Path objects
           The :class:`Path` class does not sanitize filenames within the ZIP archive. Unlike
           the :meth:`ZipFile.extract` and :meth:`ZipFile.extractall` methods, it is the
           caller's responsibility to validate or sanitize filenames to prevent path traversal
    -      vulnerabilities (e.g., filenames containing ".." or absolute paths). When handling
    +      vulnerabilities (for example, absolute paths or paths with ".." components). When handling
           untrusted archives, consider resolving filenames using :func:`os.path.abspath`
           and checking against the target directory with :func:`os.path.commonpath`.
     
    
8ee6aff14054

[3.13] gh-146581: Update docs for dangerous filenames in ZIP files (GH-149994) (GH-150066)

https://github.com/python/cpythonMiss Islington (bot)May 19, 2026via nvd-ref
2 files changed · +6 6
  • Doc/library/shutil.rst+2 2 modified
    @@ -728,8 +728,8 @@ provided.  They rely on the :mod:`zipfile` and :mod:`tarfile` modules.
     
           Never extract archives from untrusted sources without prior inspection.
           It is possible that files are created outside of the path specified in
    -      the *extract_dir* argument, e.g. members that have absolute filenames
    -      starting with "/" or filenames with two dots "..".
    +      the *extract_dir* argument, for example, members that have absolute filenames
    +      or filenames with ".." components.
     
        .. versionchanged:: 3.7
           Accepts a :term:`path-like object` for *filename* and *extract_dir*.
    
  • Doc/library/zipfile.rst+4 4 modified
    @@ -374,9 +374,9 @@ ZipFile objects
        .. warning::
     
           Never extract archives from untrusted sources without prior inspection.
    -      It is possible that files are created outside of *path*, e.g. members
    -      that have absolute filenames starting with ``"/"`` or filenames with two
    -      dots ``".."``.  This module attempts to prevent that.
    +      It is possible that files are created outside of *path*, for example, members
    +      that have absolute filenames or filenames with ".." components.
    +      This module attempts to prevent that.
           See :meth:`extract` note.
     
        .. versionchanged:: 3.6
    @@ -547,7 +547,7 @@ Path objects
           The :class:`Path` class does not sanitize filenames within the ZIP archive. Unlike
           the :meth:`ZipFile.extract` and :meth:`ZipFile.extractall` methods, it is the
           caller's responsibility to validate or sanitize filenames to prevent path traversal
    -      vulnerabilities (e.g., filenames containing ".." or absolute paths). When handling
    +      vulnerabilities (for example, absolute paths or paths with ".." components). When handling
           untrusted archives, consider resolving filenames using :func:`os.path.abspath`
           and checking against the target directory with :func:`os.path.commonpath`.
     
    
8e13025747e1

[3.14] gh-146581: Update docs for dangerous filenames in ZIP files (GH-149994) (GH-150065)

https://github.com/python/cpythonMiss Islington (bot)May 19, 2026via nvd-ref
2 files changed · +6 6
  • Doc/library/shutil.rst+2 2 modified
    @@ -745,8 +745,8 @@ provided.  They rely on the :mod:`zipfile` and :mod:`tarfile` modules.
     
           Never extract archives from untrusted sources without prior inspection.
           It is possible that files are created outside of the path specified in
    -      the *extract_dir* argument, e.g. members that have absolute filenames
    -      starting with "/" or filenames with two dots "..".
    +      the *extract_dir* argument, for example, members that have absolute filenames
    +      or filenames with ".." components.
     
           Since Python 3.14, the defaults for both built-in formats (zip and tar
           files) will prevent the most dangerous of such security issues,
    
  • Doc/library/zipfile.rst+4 4 modified
    @@ -414,9 +414,9 @@ ZipFile objects
        .. warning::
     
           Never extract archives from untrusted sources without prior inspection.
    -      It is possible that files are created outside of *path*, e.g. members
    -      that have absolute filenames starting with ``"/"`` or filenames with two
    -      dots ``".."``.  This module attempts to prevent that.
    +      It is possible that files are created outside of *path*, for example, members
    +      that have absolute filenames or filenames with ".." components.
    +      This module attempts to prevent that.
           See :meth:`extract` note.
     
        .. versionchanged:: 3.6
    @@ -593,7 +593,7 @@ Path objects
           The :class:`Path` class does not sanitize filenames within the ZIP archive. Unlike
           the :meth:`ZipFile.extract` and :meth:`ZipFile.extractall` methods, it is the
           caller's responsibility to validate or sanitize filenames to prevent path traversal
    -      vulnerabilities (e.g., filenames containing ".." or absolute paths). When handling
    +      vulnerabilities (for example, absolute paths or paths with ".." components). When handling
           untrusted archives, consider resolving filenames using :func:`os.path.abspath`
           and checking against the target directory with :func:`os.path.commonpath`.
     
    
ba0aca3bffce

gh-146581: Update docs for dangerous filenames in ZIP files (GH-149994)

https://github.com/python/cpythonSerhiy StorchakaMay 19, 2026via nvd-ref
2 files changed · +6 6
  • Doc/library/shutil.rst+2 2 modified
    @@ -749,8 +749,8 @@ provided.  They rely on the :mod:`zipfile` and :mod:`tarfile` modules.
     
           Never extract archives from untrusted sources without prior inspection.
           It is possible that files are created outside of the path specified in
    -      the *extract_dir* argument, e.g. members that have absolute filenames
    -      starting with "/" or filenames with two dots "..".
    +      the *extract_dir* argument, for example, members that have absolute filenames
    +      or filenames with ".." components.
     
           Since Python 3.14, the defaults for both built-in formats (zip and tar
           files) will prevent the most dangerous of such security issues,
    
  • Doc/library/zipfile.rst+4 4 modified
    @@ -411,9 +411,9 @@ ZipFile objects
        .. warning::
     
           Never extract archives from untrusted sources without prior inspection.
    -      It is possible that files are created outside of *path*, e.g. members
    -      that have absolute filenames starting with ``"/"`` or filenames with two
    -      dots ``".."``.  This module attempts to prevent that.
    +      It is possible that files are created outside of *path*, for example, members
    +      that have absolute filenames or filenames with ".." components.
    +      This module attempts to prevent that.
           See :meth:`extract` note.
     
        .. versionchanged:: 3.6
    @@ -590,7 +590,7 @@ Path objects
           The :class:`Path` class does not sanitize filenames within the ZIP archive. Unlike
           the :meth:`ZipFile.extract` and :meth:`ZipFile.extractall` methods, it is the
           caller's responsibility to validate or sanitize filenames to prevent path traversal
    -      vulnerabilities (e.g., filenames containing ".." or absolute paths). When handling
    +      vulnerabilities (for example, absolute paths or paths with ".." components). When handling
           untrusted archives, consider resolving filenames using :func:`os.path.abspath`
           and checking against the target directory with :func:`os.path.commonpath`.
     
    
b01e594fbe75

[3.14] gh-146581: Fix vulnerability in shutil.unpack_archive() for ZIP files on Windows (GH-146591) (GH-149064)

https://github.com/python/cpythonMiss Islington (bot)Apr 27, 2026via nvd-ref
4 files changed · +89 28
  • Lib/shutil.py+3 21 modified
    @@ -1314,27 +1314,9 @@ def _unpack_zipfile(filename, extract_dir):
         if not zipfile.is_zipfile(filename):
             raise ReadError("%s is not a zip file" % filename)
     
    -    zip = zipfile.ZipFile(filename)
    -    try:
    -        for info in zip.infolist():
    -            name = info.filename
    -
    -            # don't extract absolute paths or ones with .. in them
    -            if name.startswith('/') or '..' in name:
    -                continue
    -
    -            targetpath = os.path.join(extract_dir, *name.split('/'))
    -            if not targetpath:
    -                continue
    -
    -            _ensure_directory(targetpath)
    -            if not name.endswith('/'):
    -                # file
    -                with zip.open(name, 'r') as source, \
    -                        open(targetpath, 'wb') as target:
    -                    copyfileobj(source, target)
    -    finally:
    -        zip.close()
    +    with zipfile.ZipFile(filename) as zip:
    +        zip._ignore_invalid_names = True
    +        zip.extractall(extract_dir)
     
     def _unpack_tarfile(filename, extract_dir, *, filter=None):
         """Unpack tar/tar.gz/tar.bz2/tar.xz/tar.zst `filename` to `extract_dir`
    
  • Lib/test/test_shutil.py+65 2 modified
    @@ -2110,8 +2110,6 @@ def test_make_zipfile_rootdir_nodir(self):
         def check_unpack_archive(self, format, **kwargs):
             self.check_unpack_archive_with_converter(
                 format, lambda path: path, **kwargs)
    -        self.check_unpack_archive_with_converter(
    -            format, FakePath, **kwargs)
             self.check_unpack_archive_with_converter(format, FakePath, **kwargs)
     
         def check_unpack_archive_with_converter(self, format, converter, **kwargs):
    @@ -2168,6 +2166,71 @@ def test_unpack_archive_zip(self):
             with self.assertRaises(TypeError):
                 self.check_unpack_archive('zip', filter='data')
     
    +    def test_unpack_archive_zip_badpaths(self):
    +        srcdir = self.mkdtemp()
    +        zipname = os.path.join(srcdir, 'test.zip')
    +        abspath = os.path.join(srcdir, 'abspath')
    +        with zipfile.ZipFile(zipname, 'w') as zf:
    +            zf.writestr(abspath, 'badfile')
    +            zf.writestr(os.sep + abspath, 'badfile')
    +            zf.writestr('/abspath', 'badfile')
    +            zf.writestr('C:/abspath', 'badfile')
    +            zf.writestr('D:\\abspath', 'badfile')
    +            zf.writestr('E:abspath', 'badfile')
    +            zf.writestr('F:/G:/abspath', 'badfile')
    +            zf.writestr('//server/share/abspath', 'badfile')
    +            zf.writestr('\\\\server2\\share\\abspath', 'badfile')
    +            zf.writestr('../relpath', 'badfile')
    +            zf.writestr(os.pardir + os.sep + 'relpath2', 'badfile')
    +            zf.writestr('good/file', 'goodfile')
    +            zf.writestr('good..file', 'goodfile')
    +
    +        dstdir = os.path.join(self.mkdtemp(), 'dst')
    +        unpack_archive(zipname, dstdir)
    +        self.assertTrue(os.path.isfile(os.path.join(dstdir, 'good', 'file')))
    +        self.assertTrue(os.path.isfile(os.path.join(dstdir, 'good..file')))
    +        self.assertFalse(os.path.exists(abspath))
    +        self.assertFalse(os.path.exists(os.path.join(dstdir, 'abspath')))
    +        self.assertFalse(os.path.exists(os.path.join(dstdir, 'G_')))
    +        self.assertFalse(os.path.exists(os.path.join(dstdir, 'server')))
    +        if os.name != 'nt':
    +            self.assertTrue(os.path.isfile(os.path.join(dstdir, 'C:', 'abspath')))
    +            self.assertTrue(os.path.isfile(os.path.join(dstdir, 'D:\\abspath')))
    +            self.assertTrue(os.path.isfile(os.path.join(dstdir, 'E:abspath')))
    +            self.assertTrue(os.path.isfile(os.path.join(dstdir, 'F:', 'G:', 'abspath')))
    +            self.assertTrue(os.path.isfile(os.path.join(dstdir, '\\\\server2\\share\\abspath')))
    +        if os.pardir == '..':
    +            self.assertFalse(os.path.exists(os.path.join(dstdir, '..', 'relpath')))
    +            self.assertFalse(os.path.exists(os.path.join(dstdir, 'relpath')))
    +        else:
    +            self.assertTrue(os.path.isfile(os.path.join(dstdir, '..', 'relpath')))
    +        self.assertFalse(os.path.exists(os.path.join(dstdir, os.pardir, 'relpath2')))
    +        self.assertFalse(os.path.exists(os.path.join(dstdir, 'relpath2')))
    +
    +        dstdir2 = os.path.join(self.mkdtemp(), 'dst')
    +        os.mkdir(dstdir2)
    +        with os_helper.change_cwd(dstdir2):
    +            unpack_archive(zipname, '')
    +            self.assertTrue(os.path.isfile(os.path.join('good', 'file')))
    +            self.assertTrue(os.path.isfile('good..file'))
    +            self.assertFalse(os.path.exists(abspath))
    +            self.assertFalse(os.path.exists('abspath'))
    +            self.assertFalse(os.path.exists('C_'))
    +            self.assertFalse(os.path.exists('server'))
    +            if os.name != 'nt':
    +                self.assertTrue(os.path.isfile(os.path.join('C:', 'abspath')))
    +                self.assertTrue(os.path.isfile('D:\\abspath'))
    +                self.assertTrue(os.path.isfile('E:abspath'))
    +                self.assertTrue(os.path.isfile(os.path.join('F:', 'G:', 'abspath')))
    +                self.assertTrue(os.path.isfile('\\\\server2\\share\\abspath'))
    +            if os.pardir == '..':
    +                self.assertFalse(os.path.exists(os.path.join('..', 'relpath')))
    +                self.assertFalse(os.path.exists('relpath'))
    +            else:
    +                self.assertTrue(os.path.isfile(os.path.join('..', 'relpath')))
    +            self.assertFalse(os.path.exists(os.path.join(os.pardir, 'relpath2')))
    +            self.assertFalse(os.path.exists('relpath2'))
    +
         def test_unpack_registry(self):
     
             formats = get_unpack_formats()
    
  • Lib/zipfile/__init__.py+16 5 modified
    @@ -1410,6 +1410,7 @@ class ZipFile:
     
         fp = None                   # Set here since __del__ checks it
         _windows_illegal_name_trans_table = None
    +    _ignore_invalid_names = False
     
         def __init__(self, file, mode="r", compression=ZIP_STORED, allowZip64=True,
                      compresslevel=None, *, strict_timestamps=True, metadata_encoding=None):
    @@ -1890,21 +1891,31 @@ def _extract_member(self, member, targetpath, pwd):
     
             # build the destination pathname, replacing
             # forward slashes to platform specific separators.
    -        arcname = member.filename.replace('/', os.path.sep)
    -
    -        if os.path.altsep:
    +        arcname = member.filename
    +        if os.path.sep != '/':
    +            arcname = arcname.replace('/', os.path.sep)
    +        if os.path.altsep and os.path.altsep != '/':
                 arcname = arcname.replace(os.path.altsep, os.path.sep)
             # interpret absolute pathname as relative, remove drive letter or
             # UNC path, redundant separators, "." and ".." components.
    -        arcname = os.path.splitdrive(arcname)[1]
    +        drive, root, arcname = os.path.splitroot(arcname)
    +        if self._ignore_invalid_names and (drive or root):
    +            return None
    +        if self._ignore_invalid_names and os.path.pardir in arcname.split(os.path.sep):
    +            return None
             invalid_path_parts = ('', os.path.curdir, os.path.pardir)
             arcname = os.path.sep.join(x for x in arcname.split(os.path.sep)
                                        if x not in invalid_path_parts)
             if os.path.sep == '\\':
                 # filter illegal characters on Windows
    -            arcname = self._sanitize_windows_name(arcname, os.path.sep)
    +            arcname2 = self._sanitize_windows_name(arcname, os.path.sep)
    +            if self._ignore_invalid_names and arcname2 != arcname:
    +                return None
    +            arcname = arcname2
     
             if not arcname and not member.is_dir():
    +            if self._ignore_invalid_names:
    +                return None
                 raise ValueError("Empty filename.")
     
             targetpath = os.path.join(targetpath, arcname)
    
  • Misc/NEWS.d/next/Security/2026-03-29-12-51-33.gh-issue-146581.4vZfB0.rst+5 0 added
    @@ -0,0 +1,5 @@
    +Fix vulnerability in :func:`shutil.unpack_archive` for ZIP files on Windows
    +which allowed to write files outside of the destination tree if the patch in
    +the archive contains a Windows drive prefix. Now such invalid paths will be
    +skipped. Files containing ".." in the name (like "foo..bar") are no longer
    +skipped.
    
ab5ef98af693

[3.13] gh-146581: Fix vulnerability in shutil.unpack_archive() for ZIP files on Windows (GH-146591) (GH-149065)

https://github.com/python/cpythonMiss Islington (bot)Apr 27, 2026via nvd-ref
4 files changed · +89 28
  • Lib/shutil.py+3 21 modified
    @@ -1246,27 +1246,9 @@ def _unpack_zipfile(filename, extract_dir):
         if not zipfile.is_zipfile(filename):
             raise ReadError("%s is not a zip file" % filename)
     
    -    zip = zipfile.ZipFile(filename)
    -    try:
    -        for info in zip.infolist():
    -            name = info.filename
    -
    -            # don't extract absolute paths or ones with .. in them
    -            if name.startswith('/') or '..' in name:
    -                continue
    -
    -            targetpath = os.path.join(extract_dir, *name.split('/'))
    -            if not targetpath:
    -                continue
    -
    -            _ensure_directory(targetpath)
    -            if not name.endswith('/'):
    -                # file
    -                with zip.open(name, 'r') as source, \
    -                        open(targetpath, 'wb') as target:
    -                    copyfileobj(source, target)
    -    finally:
    -        zip.close()
    +    with zipfile.ZipFile(filename) as zip:
    +        zip._ignore_invalid_names = True
    +        zip.extractall(extract_dir)
     
     def _unpack_tarfile(filename, extract_dir, *, filter=None):
         """Unpack tar/tar.gz/tar.bz2/tar.xz `filename` to `extract_dir`
    
  • Lib/test/test_shutil.py+65 2 modified
    @@ -2114,8 +2114,6 @@ def test_make_zipfile_rootdir_nodir(self):
         def check_unpack_archive(self, format, **kwargs):
             self.check_unpack_archive_with_converter(
                 format, lambda path: path, **kwargs)
    -        self.check_unpack_archive_with_converter(
    -            format, FakePath, **kwargs)
             self.check_unpack_archive_with_converter(format, FakePath, **kwargs)
     
         def check_unpack_archive_with_converter(self, format, converter, **kwargs):
    @@ -2171,6 +2169,71 @@ def test_unpack_archive_zip(self):
             with self.assertRaises(TypeError):
                 self.check_unpack_archive('zip', filter='data')
     
    +    def test_unpack_archive_zip_badpaths(self):
    +        srcdir = self.mkdtemp()
    +        zipname = os.path.join(srcdir, 'test.zip')
    +        abspath = os.path.join(srcdir, 'abspath')
    +        with zipfile.ZipFile(zipname, 'w') as zf:
    +            zf.writestr(abspath, 'badfile')
    +            zf.writestr(os.sep + abspath, 'badfile')
    +            zf.writestr('/abspath', 'badfile')
    +            zf.writestr('C:/abspath', 'badfile')
    +            zf.writestr('D:\\abspath', 'badfile')
    +            zf.writestr('E:abspath', 'badfile')
    +            zf.writestr('F:/G:/abspath', 'badfile')
    +            zf.writestr('//server/share/abspath', 'badfile')
    +            zf.writestr('\\\\server2\\share\\abspath', 'badfile')
    +            zf.writestr('../relpath', 'badfile')
    +            zf.writestr(os.pardir + os.sep + 'relpath2', 'badfile')
    +            zf.writestr('good/file', 'goodfile')
    +            zf.writestr('good..file', 'goodfile')
    +
    +        dstdir = os.path.join(self.mkdtemp(), 'dst')
    +        unpack_archive(zipname, dstdir)
    +        self.assertTrue(os.path.isfile(os.path.join(dstdir, 'good', 'file')))
    +        self.assertTrue(os.path.isfile(os.path.join(dstdir, 'good..file')))
    +        self.assertFalse(os.path.exists(abspath))
    +        self.assertFalse(os.path.exists(os.path.join(dstdir, 'abspath')))
    +        self.assertFalse(os.path.exists(os.path.join(dstdir, 'G_')))
    +        self.assertFalse(os.path.exists(os.path.join(dstdir, 'server')))
    +        if os.name != 'nt':
    +            self.assertTrue(os.path.isfile(os.path.join(dstdir, 'C:', 'abspath')))
    +            self.assertTrue(os.path.isfile(os.path.join(dstdir, 'D:\\abspath')))
    +            self.assertTrue(os.path.isfile(os.path.join(dstdir, 'E:abspath')))
    +            self.assertTrue(os.path.isfile(os.path.join(dstdir, 'F:', 'G:', 'abspath')))
    +            self.assertTrue(os.path.isfile(os.path.join(dstdir, '\\\\server2\\share\\abspath')))
    +        if os.pardir == '..':
    +            self.assertFalse(os.path.exists(os.path.join(dstdir, '..', 'relpath')))
    +            self.assertFalse(os.path.exists(os.path.join(dstdir, 'relpath')))
    +        else:
    +            self.assertTrue(os.path.isfile(os.path.join(dstdir, '..', 'relpath')))
    +        self.assertFalse(os.path.exists(os.path.join(dstdir, os.pardir, 'relpath2')))
    +        self.assertFalse(os.path.exists(os.path.join(dstdir, 'relpath2')))
    +
    +        dstdir2 = os.path.join(self.mkdtemp(), 'dst')
    +        os.mkdir(dstdir2)
    +        with os_helper.change_cwd(dstdir2):
    +            unpack_archive(zipname, '')
    +            self.assertTrue(os.path.isfile(os.path.join('good', 'file')))
    +            self.assertTrue(os.path.isfile('good..file'))
    +            self.assertFalse(os.path.exists(abspath))
    +            self.assertFalse(os.path.exists('abspath'))
    +            self.assertFalse(os.path.exists('C_'))
    +            self.assertFalse(os.path.exists('server'))
    +            if os.name != 'nt':
    +                self.assertTrue(os.path.isfile(os.path.join('C:', 'abspath')))
    +                self.assertTrue(os.path.isfile('D:\\abspath'))
    +                self.assertTrue(os.path.isfile('E:abspath'))
    +                self.assertTrue(os.path.isfile(os.path.join('F:', 'G:', 'abspath')))
    +                self.assertTrue(os.path.isfile('\\\\server2\\share\\abspath'))
    +            if os.pardir == '..':
    +                self.assertFalse(os.path.exists(os.path.join('..', 'relpath')))
    +                self.assertFalse(os.path.exists('relpath'))
    +            else:
    +                self.assertTrue(os.path.isfile(os.path.join('..', 'relpath')))
    +            self.assertFalse(os.path.exists(os.path.join(os.pardir, 'relpath2')))
    +            self.assertFalse(os.path.exists('relpath2'))
    +
         def test_unpack_registry(self):
     
             formats = get_unpack_formats()
    
  • Lib/zipfile/__init__.py+16 5 modified
    @@ -1340,6 +1340,7 @@ class ZipFile:
     
         fp = None                   # Set here since __del__ checks it
         _windows_illegal_name_trans_table = None
    +    _ignore_invalid_names = False
     
         def __init__(self, file, mode="r", compression=ZIP_STORED, allowZip64=True,
                      compresslevel=None, *, strict_timestamps=True, metadata_encoding=None):
    @@ -1824,21 +1825,31 @@ def _extract_member(self, member, targetpath, pwd):
     
             # build the destination pathname, replacing
             # forward slashes to platform specific separators.
    -        arcname = member.filename.replace('/', os.path.sep)
    -
    -        if os.path.altsep:
    +        arcname = member.filename
    +        if os.path.sep != '/':
    +            arcname = arcname.replace('/', os.path.sep)
    +        if os.path.altsep and os.path.altsep != '/':
                 arcname = arcname.replace(os.path.altsep, os.path.sep)
             # interpret absolute pathname as relative, remove drive letter or
             # UNC path, redundant separators, "." and ".." components.
    -        arcname = os.path.splitdrive(arcname)[1]
    +        drive, root, arcname = os.path.splitroot(arcname)
    +        if self._ignore_invalid_names and (drive or root):
    +            return None
    +        if self._ignore_invalid_names and os.path.pardir in arcname.split(os.path.sep):
    +            return None
             invalid_path_parts = ('', os.path.curdir, os.path.pardir)
             arcname = os.path.sep.join(x for x in arcname.split(os.path.sep)
                                        if x not in invalid_path_parts)
             if os.path.sep == '\\':
                 # filter illegal characters on Windows
    -            arcname = self._sanitize_windows_name(arcname, os.path.sep)
    +            arcname2 = self._sanitize_windows_name(arcname, os.path.sep)
    +            if self._ignore_invalid_names and arcname2 != arcname:
    +                return None
    +            arcname = arcname2
     
             if not arcname and not member.is_dir():
    +            if self._ignore_invalid_names:
    +                return None
                 raise ValueError("Empty filename.")
     
             targetpath = os.path.join(targetpath, arcname)
    
  • Misc/NEWS.d/next/Security/2026-03-29-12-51-33.gh-issue-146581.4vZfB0.rst+5 0 added
    @@ -0,0 +1,5 @@
    +Fix vulnerability in :func:`shutil.unpack_archive` for ZIP files on Windows
    +which allowed to write files outside of the destination tree if the patch in
    +the archive contains a Windows drive prefix. Now such invalid paths will be
    +skipped. Files containing ".." in the name (like "foo..bar") are no longer
    +skipped.
    
fc829e887538

gh-146581: Fix vulnerability in shutil.unpack_archive() for ZIP files on Windows (GH-146591)

https://github.com/python/cpythonSerhiy StorchakaApr 27, 2026via body-scan
4 files changed · +89 28
  • Lib/shutil.py+3 21 modified
    @@ -1317,27 +1317,9 @@ def _unpack_zipfile(filename, extract_dir):
         if not zipfile.is_zipfile(filename):
             raise ReadError("%s is not a zip file" % filename)
     
    -    zip = zipfile.ZipFile(filename)
    -    try:
    -        for info in zip.infolist():
    -            name = info.filename
    -
    -            # don't extract absolute paths or ones with .. in them
    -            if name.startswith('/') or '..' in name:
    -                continue
    -
    -            targetpath = os.path.join(extract_dir, *name.split('/'))
    -            if not targetpath:
    -                continue
    -
    -            _ensure_directory(targetpath)
    -            if not name.endswith('/'):
    -                # file
    -                with zip.open(name, 'r') as source, \
    -                        open(targetpath, 'wb') as target:
    -                    copyfileobj(source, target)
    -    finally:
    -        zip.close()
    +    with zipfile.ZipFile(filename) as zip:
    +        zip._ignore_invalid_names = True
    +        zip.extractall(extract_dir)
     
     def _unpack_tarfile(filename, extract_dir, *, filter=None):
         """Unpack tar/tar.gz/tar.bz2/tar.xz/tar.zst `filename` to `extract_dir`
    
  • Lib/test/test_shutil.py+65 2 modified
    @@ -2136,8 +2136,6 @@ def test_make_zipfile_rootdir_nodir(self):
         def check_unpack_archive(self, format, **kwargs):
             self.check_unpack_archive_with_converter(
                 format, lambda path: path, **kwargs)
    -        self.check_unpack_archive_with_converter(
    -            format, FakePath, **kwargs)
             self.check_unpack_archive_with_converter(format, FakePath, **kwargs)
     
         def check_unpack_archive_with_converter(self, format, converter, **kwargs):
    @@ -2194,6 +2192,71 @@ def test_unpack_archive_zip(self):
             with self.assertRaises(TypeError):
                 self.check_unpack_archive('zip', filter='data')
     
    +    def test_unpack_archive_zip_badpaths(self):
    +        srcdir = self.mkdtemp()
    +        zipname = os.path.join(srcdir, 'test.zip')
    +        abspath = os.path.join(srcdir, 'abspath')
    +        with zipfile.ZipFile(zipname, 'w') as zf:
    +            zf.writestr(abspath, 'badfile')
    +            zf.writestr(os.sep + abspath, 'badfile')
    +            zf.writestr('/abspath', 'badfile')
    +            zf.writestr('C:/abspath', 'badfile')
    +            zf.writestr('D:\\abspath', 'badfile')
    +            zf.writestr('E:abspath', 'badfile')
    +            zf.writestr('F:/G:/abspath', 'badfile')
    +            zf.writestr('//server/share/abspath', 'badfile')
    +            zf.writestr('\\\\server2\\share\\abspath', 'badfile')
    +            zf.writestr('../relpath', 'badfile')
    +            zf.writestr(os.pardir + os.sep + 'relpath2', 'badfile')
    +            zf.writestr('good/file', 'goodfile')
    +            zf.writestr('good..file', 'goodfile')
    +
    +        dstdir = os.path.join(self.mkdtemp(), 'dst')
    +        unpack_archive(zipname, dstdir)
    +        self.assertTrue(os.path.isfile(os.path.join(dstdir, 'good', 'file')))
    +        self.assertTrue(os.path.isfile(os.path.join(dstdir, 'good..file')))
    +        self.assertFalse(os.path.exists(abspath))
    +        self.assertFalse(os.path.exists(os.path.join(dstdir, 'abspath')))
    +        self.assertFalse(os.path.exists(os.path.join(dstdir, 'G_')))
    +        self.assertFalse(os.path.exists(os.path.join(dstdir, 'server')))
    +        if os.name != 'nt':
    +            self.assertTrue(os.path.isfile(os.path.join(dstdir, 'C:', 'abspath')))
    +            self.assertTrue(os.path.isfile(os.path.join(dstdir, 'D:\\abspath')))
    +            self.assertTrue(os.path.isfile(os.path.join(dstdir, 'E:abspath')))
    +            self.assertTrue(os.path.isfile(os.path.join(dstdir, 'F:', 'G:', 'abspath')))
    +            self.assertTrue(os.path.isfile(os.path.join(dstdir, '\\\\server2\\share\\abspath')))
    +        if os.pardir == '..':
    +            self.assertFalse(os.path.exists(os.path.join(dstdir, '..', 'relpath')))
    +            self.assertFalse(os.path.exists(os.path.join(dstdir, 'relpath')))
    +        else:
    +            self.assertTrue(os.path.isfile(os.path.join(dstdir, '..', 'relpath')))
    +        self.assertFalse(os.path.exists(os.path.join(dstdir, os.pardir, 'relpath2')))
    +        self.assertFalse(os.path.exists(os.path.join(dstdir, 'relpath2')))
    +
    +        dstdir2 = os.path.join(self.mkdtemp(), 'dst')
    +        os.mkdir(dstdir2)
    +        with os_helper.change_cwd(dstdir2):
    +            unpack_archive(zipname, '')
    +            self.assertTrue(os.path.isfile(os.path.join('good', 'file')))
    +            self.assertTrue(os.path.isfile('good..file'))
    +            self.assertFalse(os.path.exists(abspath))
    +            self.assertFalse(os.path.exists('abspath'))
    +            self.assertFalse(os.path.exists('C_'))
    +            self.assertFalse(os.path.exists('server'))
    +            if os.name != 'nt':
    +                self.assertTrue(os.path.isfile(os.path.join('C:', 'abspath')))
    +                self.assertTrue(os.path.isfile('D:\\abspath'))
    +                self.assertTrue(os.path.isfile('E:abspath'))
    +                self.assertTrue(os.path.isfile(os.path.join('F:', 'G:', 'abspath')))
    +                self.assertTrue(os.path.isfile('\\\\server2\\share\\abspath'))
    +            if os.pardir == '..':
    +                self.assertFalse(os.path.exists(os.path.join('..', 'relpath')))
    +                self.assertFalse(os.path.exists('relpath'))
    +            else:
    +                self.assertTrue(os.path.isfile(os.path.join('..', 'relpath')))
    +            self.assertFalse(os.path.exists(os.path.join(os.pardir, 'relpath2')))
    +            self.assertFalse(os.path.exists('relpath2'))
    +
         def test_unpack_registry(self):
     
             formats = get_unpack_formats()
    
  • Lib/zipfile/__init__.py+16 5 modified
    @@ -1410,6 +1410,7 @@ class ZipFile:
     
         fp = None                   # Set here since __del__ checks it
         _windows_illegal_name_trans_table = None
    +    _ignore_invalid_names = False
     
         def __init__(self, file, mode="r", compression=ZIP_STORED, allowZip64=True,
                      compresslevel=None, *, strict_timestamps=True, metadata_encoding=None):
    @@ -1890,21 +1891,31 @@ def _extract_member(self, member, targetpath, pwd):
     
             # build the destination pathname, replacing
             # forward slashes to platform specific separators.
    -        arcname = member.filename.replace('/', os.path.sep)
    -
    -        if os.path.altsep:
    +        arcname = member.filename
    +        if os.path.sep != '/':
    +            arcname = arcname.replace('/', os.path.sep)
    +        if os.path.altsep and os.path.altsep != '/':
                 arcname = arcname.replace(os.path.altsep, os.path.sep)
             # interpret absolute pathname as relative, remove drive letter or
             # UNC path, redundant separators, "." and ".." components.
    -        arcname = os.path.splitdrive(arcname)[1]
    +        drive, root, arcname = os.path.splitroot(arcname)
    +        if self._ignore_invalid_names and (drive or root):
    +            return None
    +        if self._ignore_invalid_names and os.path.pardir in arcname.split(os.path.sep):
    +            return None
             invalid_path_parts = ('', os.path.curdir, os.path.pardir)
             arcname = os.path.sep.join(x for x in arcname.split(os.path.sep)
                                        if x not in invalid_path_parts)
             if os.path.sep == '\\':
                 # filter illegal characters on Windows
    -            arcname = self._sanitize_windows_name(arcname, os.path.sep)
    +            arcname2 = self._sanitize_windows_name(arcname, os.path.sep)
    +            if self._ignore_invalid_names and arcname2 != arcname:
    +                return None
    +            arcname = arcname2
     
             if not arcname and not member.is_dir():
    +            if self._ignore_invalid_names:
    +                return None
                 raise ValueError("Empty filename.")
     
             targetpath = os.path.join(targetpath, arcname)
    
  • Misc/NEWS.d/next/Security/2026-03-29-12-51-33.gh-issue-146581.4vZfB0.rst+5 0 added
    @@ -0,0 +1,5 @@
    +Fix vulnerability in :func:`shutil.unpack_archive` for ZIP files on Windows
    +which allowed to write files outside of the destination tree if the patch in
    +the archive contains a Windows drive prefix. Now such invalid paths will be
    +skipped. Files containing ".." in the name (like "foo..bar") are no longer
    +skipped.
    

Vulnerability mechanics

Root cause

"Missing sanitization of Windows drive-letter and root-path prefixes in ZIP member filenames allows path traversal outside the extraction directory."

Attack vector

An attacker crafts a ZIP archive containing a member whose filename includes a Windows absolute path with a drive letter (e.g. `C:\malicious.exe` or `D:\...`). When a victim on Windows calls `shutil.unpack_archive()` on this archive, the old code in `_unpack_zipfile()` joins the drive-prefixed name with the target directory, producing a path that resolves outside the intended extraction directory. The attacker does not require authentication or special privileges; the only precondition is that the victim extracts the malicious ZIP via the vulnerable function [CWE-22].

Affected code

The vulnerability resides in `shutil._unpack_zipfile()` in `Lib/shutil.py` and `ZipFile._extract_member()` in `Lib/zipfile/__init__.py`. The old `_unpack_zipfile()` manually joined archive member names with `os.path.join(extract_dir, *name.split('/'))` without stripping Windows drive letters (e.g. `C:`) or root separators, allowing absolute paths to escape the target directory. The old code also used a naive `'..' in name` check that incorrectly skipped legitimate filenames containing `..` (like `good..file`).

What the fix does

The patch replaces the manual extraction loop in `_unpack_zipfile()` with a call to `ZipFile.extractall()`, delegating path sanitization to `ZipFile._extract_member()`. In `_extract_member()`, the old `os.path.splitdrive()` call is replaced with `os.path.splitroot()` to detect both drive letters and root separators. When the new `_ignore_invalid_names` flag is set (enabled by `_unpack_zipfile()`), members with a drive or root component are skipped by returning `None`, and members whose sanitized Windows name differs from the original are also skipped. This prevents extraction of files with absolute Windows paths while still allowing filenames containing `..` as literal characters (e.g. `good..file`).

Preconditions

  • configThe victim must call shutil.unpack_archive() on a ZIP archive on a Windows system.
  • inputThe attacker must supply a ZIP archive containing a member with an absolute Windows path (e.g. C:\...).
  • authNo authentication or special privileges are required.

Reproduction

No public PoC with explicit reproduction steps is included in the bundle beyond the test case. The linked GitHub issue (https://github.com/python/cpython/issues/146581) may contain additional details, but the bundle does not provide standalone reproduction commands.

Generated on May 23, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

11

News mentions

0

No linked articles in our index yet.