https://github.com/python/cpython/commit/8aa5d657e4d3f1a4cca5ff6c976e2e868e0a9056
commit: 8aa5d657e4d3f1a4cca5ff6c976e2e868e0a9056
branch: 3.14
author: Victor Stinner <[email protected]>
committer: vstinner <[email protected]>
date: 2026-11-10T00:35:37Z
summary:

[3.14] gh-161705: Prevent shadowing stdlib in gzip/tarfile/zipfile CLI 
(#161706) (#161719)

gh-161705: Prevent shadowing stdlib in gzip/tarfile/zipfile CLI (#161706)

Remove sys.path[0] in gzip, tarfile and zipfile command line
interface to prevent shadowing standard library modules from
extracted files.

* Add sys._path0 variable.
* pymain_sys_path_add_path0() sets sys._path0.
* Add runpy._remove_sys_path0().
* gzip, tarfile and zipfile main functions now call
  _remove_sys_path0() to prevent shadowing stdlib modules from
  extracted files.




(cherry picked from commit e891a9117985888df3e5d48145cf07ddda098335)

Co-authored-by: Gregory P. Smith <[email protected]>
Co-authored-by: Ethan Furman <[email protected]>
Co-authored-by: Kirill Podoprigora <[email protected]>

files:
A Misc/NEWS.d/next/Security/2026-11-07-15-56-47.gh-issue-161705.OjDK2d.rst
M Doc/c-api/init_config.rst
M Doc/library/sys.rst
M Doc/using/cmdline.rst
M Lib/gzip.py
M Lib/runpy.py
M Lib/tarfile.py
M Lib/test/test_runpy.py
M Lib/test/test_sys.py
M Lib/test/test_zipfile/test_core.py
M Lib/zipfile/__init__.py
M Modules/main.c
M Python/pathconfig.c
M Python/sysmodule.c

diff --git a/Doc/c-api/init_config.rst b/Doc/c-api/init_config.rst
index 7f2beb365dc1f91..f43c88f4631f427 100644
--- a/Doc/c-api/init_config.rst
+++ b/Doc/c-api/init_config.rst
@@ -1216,8 +1216,8 @@ PyConfig
 
    .. c:member:: int safe_path
 
-      If equals to zero, ``Py_RunMain()`` prepends a potentially unsafe path to
-      :data:`sys.path` at startup:
+      If equals to zero, ``Py_RunMain()`` prepends a potentially unsafe
+      :data:`sys._path0` to :data:`sys.path` at startup:
 
       * If :c:member:`argv[0] <PyConfig.argv>` is equal to ``L"-m"``
         (``python -m module``), prepend the current working directory.
diff --git a/Doc/library/sys.rst b/Doc/library/sys.rst
index 9fc274382ff845a..387edfbbc6b7628 100644
--- a/Doc/library/sys.rst
+++ b/Doc/library/sys.rst
@@ -1516,6 +1516,24 @@ always available. Unless explicitly noted otherwise, all 
variables are read-only
       * Module :mod:`site` This describes how to use .pth files to
         extend :data:`sys.path`.
 
+.. data:: _path0
+
+   Path prepended to :data:`sys.path` at Python startup:
+
+   * Current directory if running a module
+     with ``python -m module`` command line.
+   * Script's directory if running a script
+     with ``python script.py``.
+   * Otherwise an empty string, which means the current working directory,
+     for ``python -c code`` or ``python`` (REPL) command line.
+
+   If :option:`-P` or :option:`-I` command line options are used or if
+   :envvar:`PYTHONSAFEPATH` environment variable is used, :data:`sys._path0` is
+   ``None`` and :data:`sys.path` is left unchanged.
+
+   .. versionadded:: next
+
+
 .. data:: path_hooks
 
     A list of callables that take a path argument to try to create a
diff --git a/Doc/using/cmdline.rst b/Doc/using/cmdline.rst
index fd9605c4fc28df8..6f975b40e226857 100644
--- a/Doc/using/cmdline.rst
+++ b/Doc/using/cmdline.rst
@@ -313,10 +313,12 @@ Miscellaneous options
    Run Python in isolated mode. This also implies :option:`-E`, :option:`-P`
    and :option:`-s` options.
 
-   In isolated mode :data:`sys.path` contains neither the script's directory 
nor
-   the user's site-packages directory. All ``PYTHON*`` environment
-   variables are ignored, too. Further restrictions may be imposed to prevent
-   the user from injecting malicious code.
+   In isolated mode, Python does not prepend the potentially unsafe path
+   :data:`sys._path0` to :data:`sys.path`, and ignores all ``PYTHON*``
+   environment variables, e.g.  :envvar:`PYTHONPATH` and :envvar:`PYTHONHOME`,
+   that might be set.
+   Further restrictions may be imposed to prevent the user from injecting
+   malicious code.
 
    .. versionadded:: 3.4
 
@@ -344,14 +346,8 @@ Miscellaneous options
 
 .. option:: -P
 
-   Don't prepend a potentially unsafe path to :data:`sys.path`:
-
-   * ``python -m module`` command line: Don't prepend the current working
-     directory.
-   * ``python script.py`` command line: Don't prepend the script's directory.
-     If it's a symbolic link, resolve symbolic links.
-   * ``python -c code`` and ``python`` (REPL) command lines: Don't prepend an
-     empty string, which means the current working directory.
+   Don't prepend the potentially unsafe path :data:`sys._path0` to
+   :data:`sys.path`.
 
    See also the :envvar:`PYTHONSAFEPATH` environment variable, and :option:`-E`
    and :option:`-I` (isolated) options.
@@ -784,7 +780,9 @@ conflict.
 .. envvar:: PYTHONSAFEPATH
 
    If this is set to a non-empty string, don't prepend a potentially unsafe
-   path to :data:`sys.path`: see the :option:`-P` option for details.
+   path :data:`sys._path0` to :data:`sys.path`.
+
+   See also the :option:`-P` option.
 
    .. versionadded:: 3.11
 
diff --git a/Lib/gzip.py b/Lib/gzip.py
index 721c6e2f49eab9d..c366c44999d5dab 100644
--- a/Lib/gzip.py
+++ b/Lib/gzip.py
@@ -695,6 +695,11 @@ def decompress(data):
 
 def main():
     from argparse import ArgumentParser
+    import runpy
+
+    # Prevent shadowing a stdlib module from the extracted file
+    runpy._remove_sys_path0()
+
     parser = ArgumentParser(description=
         "A simple command line interface for the gzip module: act like gzip, "
         "but do not delete the input file.",
diff --git a/Lib/runpy.py b/Lib/runpy.py
index 1d5ecf0cf15bc0e..d4edf2ed096c030 100644
--- a/Lib/runpy.py
+++ b/Lib/runpy.py
@@ -317,6 +317,26 @@ def run_path(path_name, init_globals=None, run_name=None):
                 pass
 
 
+def _remove_sys_path0():
+    """
+    Remove sys._path0 from sys.path. Do nothing if sys._path0 is
+    sys._stdlib_dir or if it has already been removed. Similar to using -P
+    (safe path) command line option.
+    """
+    path0 = sys._path0
+    if path0 is None:
+        return
+
+    if path0 == sys._stdlib_dir:
+        return
+
+    # Remove all path0 copies, not only the first one.
+    # We are intentionally not trying to normalize & match alternate
+    # representations of path0, this is only intended to remove copies
+    # of the exact value the runtime itself may have added.
+    sys.path[:] = [path for path in sys.path if path != path0]
+
+
 if __name__ == "__main__":
     # Run the module specified as the next command line argument
     if len(sys.argv) < 2:
diff --git a/Lib/tarfile.py b/Lib/tarfile.py
index 3a2673d7f112c79..18055b987b9ffcd 100644
--- a/Lib/tarfile.py
+++ b/Lib/tarfile.py
@@ -3136,6 +3136,10 @@ def is_tarfile(name):
 
 def main():
     import argparse
+    import runpy
+
+    # Prevent shadowing stdlib modules from extracted files
+    runpy._remove_sys_path0()
 
     description = 'A simple command-line interface for tarfile module.'
     parser = argparse.ArgumentParser(description=description, color=True)
diff --git a/Lib/test/test_runpy.py b/Lib/test/test_runpy.py
index 7eb4cee36107ab0..9eb3224a9b5fceb 100644
--- a/Lib/test/test_runpy.py
+++ b/Lib/test/test_runpy.py
@@ -20,6 +20,8 @@
     requires_subprocess,
     verbose,
 )
+from test import support
+from test.support import os_helper
 from test.support.import_helper import forget, make_legacy_pyc, unload
 from test.support.os_helper import create_empty_file, temp_dir, FakePath
 from test.support.script_helper import make_script, make_zip_script
@@ -875,5 +877,95 @@ def test_pymain_run_module(self):
         self.assertSigInt(["-m", ham.stem], cwd=ham.parent)
 
 
+class MiscTests(unittest.TestCase):
+    def test_remove_sys_path0(self):
+        # Check that all path0 copies are removed
+        PATH0 = '/path/zero'
+        STDLIB_PATH = '/to/stdlib'
+        OTHER_PATH = '/other/path'
+        sys_path = [PATH0, STDLIB_PATH, PATH0, '', OTHER_PATH, PATH0]
+        expected = [STDLIB_PATH, '', OTHER_PATH]
+        with (support.swap_attr(sys, '_stdlib_dir', STDLIB_PATH),
+              support.swap_attr(sys, '_path0', PATH0),
+              support.swap_attr(sys, 'path', sys_path)):
+            runpy._remove_sys_path0()
+            self.assertEqual(sys.path, expected)
+
+            # Calling it twice does nothing
+            runpy._remove_sys_path0()
+            self.assertEqual(sys.path, expected)
+
+        # Do nothing if sys._path0 is sys._stdlib_dir
+        sys_path = ['', STDLIB_PATH, OTHER_PATH]
+        expected = sys_path.copy()
+        with (support.swap_attr(sys, '_stdlib_dir', STDLIB_PATH),
+              support.swap_attr(sys, '_path0', STDLIB_PATH),
+              support.swap_attr(sys, 'path', sys_path)):
+            runpy._remove_sys_path0()
+            self.assertEqual(sys.path, expected)
+
+        # Do nothing if sys._path0 is None
+        sys_path = ['', STDLIB_PATH, OTHER_PATH]
+        expected = sys_path.copy()
+        with (support.swap_attr(sys, '_stdlib_dir', STDLIB_PATH),
+              support.swap_attr(sys, '_path0', None),
+              support.swap_attr(sys, 'path', sys_path)):
+            runpy._remove_sys_path0()
+            self.assertEqual(sys.path, expected)
+
+    def run_python(self, *args, PYTHONPATH=None):
+        cmd = [sys.executable, *args]
+        env = support.make_clean_env()
+        if PYTHONPATH:
+            env['PYTHONPATH'] = PYTHONPATH
+        proc = subprocess.run(cmd, text=True, stderr=subprocess.PIPE, env=env)
+        self.assertEqual(proc.returncode, 0, proc.stderr)
+
+    @support.requires_subprocess()
+    def test_remove_sys_path0_run_python(self):
+        CODE = textwrap.dedent("""
+            import runpy, sys
+
+            # Remove sys.path[0]
+            orig_path = sys.path.copy()
+            path0 = sys._path0
+            runpy._remove_sys_path0()
+
+            if path0 is not None and path0 in sys.path:
+                raise Exception(f"path0 {path0!r} found in sys.path 
{sys.path!r}")
+            if not sys.flags.safe_path:
+                if sys.path == orig_path:
+                    raise Exception("sys.path was not changed")
+            else:
+                if sys.path != orig_path:
+                    raise Exception("sys.path was changed")
+
+            # Calling it twice must do nothing
+            clean_path = sys.path.copy()
+            runpy._remove_sys_path0()
+            if sys.path != clean_path:
+                raise Exception("sys.path was changed")
+        """)
+
+        # Test "python -c code"
+        self.run_python('-c', CODE)
+        self.run_python('-P', '-c', CODE)
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            name = 'test_remove_sys_path0'
+            filename = os.path.join(tmpdir, f'{name}.py')
+            with open(filename, 'w') as fp:
+                fp.write(CODE)
+
+            # Test "python script.py"
+            self.run_python(filename)
+            self.run_python('-P', filename)
+
+            with os_helper.change_cwd(tmpdir):
+                # Test "python -m module"
+                self.run_python('-m', name)
+                self.run_python('-P', '-m', name, PYTHONPATH=tmpdir)
+
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/Lib/test/test_sys.py b/Lib/test/test_sys.py
index e7f58586c348cee..4da9f3c3179cd1b 100644
--- a/Lib/test/test_sys.py
+++ b/Lib/test/test_sys.py
@@ -1453,6 +1453,15 @@ class PythonType:
         self.assertFalse(is_immortal(b'abc'.decode()))
         self.assertFalse(is_immortal('abc'.encode()))
 
+    def test_path(self):
+        self.assertIsInstance(sys.path, list)
+        for path in sys.path:
+            self.assertIsInstance(path, str)
+
+    def test_path0(self):
+        self.assertTrue(sys._path0 is None or isinstance(sys._path0, str),
+                        sys._path0)
+
 
 @test.support.cpython_only
 class UnraisableHookTest(unittest.TestCase):
diff --git a/Lib/test/test_zipfile/test_core.py 
b/Lib/test/test_zipfile/test_core.py
index 8336d0f27a7cab7..e9710da275f99a2 100644
--- a/Lib/test/test_zipfile/test_core.py
+++ b/Lib/test/test_zipfile/test_core.py
@@ -53,6 +53,15 @@ def get_files(test):
         test.assertFalse(f.closed)
 
 
[email protected]
+def save_restore_sys_path():
+    old_path = sys.path.copy()
+    try:
+        yield
+    finally:
+        sys.path[:] = old_path
+
+
 class LazyImportTest(unittest.TestCase):
     @cpython_only
     def test_lazy_import(self):
@@ -3817,17 +3826,18 @@ def test_add_comment(self):
     def test_cli_with_metadata_encoding(self):
         errmsg = "Non-conforming encodings not supported with -c."
         args = ["--metadata-encoding=shift_jis", "-c", "nonesuch", "nonesuch"]
-        with captured_stdout() as stdout:
-            with captured_stderr() as stderr:
+        with (captured_stdout() as stdout,
+              captured_stderr() as stderr,
+              save_restore_sys_path()):
                 self.assertRaises(SystemExit, zipfile.main, args)
         self.assertEqual(stdout.getvalue(), "")
         self.assertIn(errmsg, stderr.getvalue())
 
-        with captured_stdout() as stdout:
+        with captured_stdout() as stdout, save_restore_sys_path():
             zipfile.main(["--metadata-encoding=shift_jis", "-t", TESTFN])
         listing = stdout.getvalue()
 
-        with captured_stdout() as stdout:
+        with captured_stdout() as stdout, save_restore_sys_path():
             zipfile.main(["--metadata-encoding=shift_jis", "-l", TESTFN])
         listing = stdout.getvalue()
         for name in self.file_names:
@@ -3846,7 +3856,9 @@ def test_cli_with_metadata_encoding_extract(self):
             except UnicodeEncodeError:
                 self.skipTest(f'cannot encode file name {fn!a}')
 
-        zipfile.main(["--metadata-encoding=shift_jis", "-e", TESTFN, TESTFN2])
+        with save_restore_sys_path():
+            zipfile.main(["--metadata-encoding=shift_jis", "-e",
+                          TESTFN, TESTFN2])
         listing = os.listdir(TESTFN2)
         for name in self.file_names:
             self.assertIn(name, listing)
diff --git a/Lib/zipfile/__init__.py b/Lib/zipfile/__init__.py
index cf0c69687be94f7..d4b104056115f86 100644
--- a/Lib/zipfile/__init__.py
+++ b/Lib/zipfile/__init__.py
@@ -2413,6 +2413,10 @@ def _compile(file, optimize=-1):
 
 def main(args=None):
     import argparse
+    import runpy
+
+    # Prevent shadowing stdlib modules from extracted files
+    runpy._remove_sys_path0()
 
     description = 'A simple command-line interface for zipfile module.'
     parser = argparse.ArgumentParser(description=description, color=True)
diff --git 
a/Misc/NEWS.d/next/Security/2026-11-07-15-56-47.gh-issue-161705.OjDK2d.rst 
b/Misc/NEWS.d/next/Security/2026-11-07-15-56-47.gh-issue-161705.OjDK2d.rst
new file mode 100644
index 000000000000000..d9a862ed858e012
--- /dev/null
+++ b/Misc/NEWS.d/next/Security/2026-11-07-15-56-47.gh-issue-161705.OjDK2d.rst
@@ -0,0 +1,6 @@
+.. cve: CVE-2026-115791
+
+Remove ``sys.path[0]`` in :mod:`gzip`, :mod:`tarfile` and :mod:`zipfile`
+command line interface to prevent shadowing standard library modules from
+extracted files. Issue reported by Jelle Zijlstra, patch by Gregory P.
+Smith and Victor Stinner.
diff --git a/Modules/main.c b/Modules/main.c
index d2c3b84f3e12cc6..facfb9a3de8a808 100644
--- a/Modules/main.c
+++ b/Modules/main.c
@@ -181,6 +181,10 @@ pymain_sys_path_add_path0(PyInterpreterState *interp, 
PyObject *path0)
         return -1;
     }
 
+    if (PyDict_SetItemString(sysdict, "_path0", path0) < 0) {
+        return -1;
+    }
+
     if (PyList_Insert(sys_path, 0, path0)) {
         return -1;
     }
diff --git a/Python/pathconfig.c b/Python/pathconfig.c
index 92360c1bb024204..b21bb9c0177b9de 100644
--- a/Python/pathconfig.c
+++ b/Python/pathconfig.c
@@ -332,7 +332,7 @@ Py_GetProgramName(void)
 
 
 /* Compute module search path from argv[0] or the current working
-   directory ("-m module" case) which will be prepended to sys.argv:
+   directory ("-m module" case) which will be prepended to sys.path:
    sys.path[0].
 
    Return 1 if the path is correctly resolved and written into *path0_p.
diff --git a/Python/sysmodule.c b/Python/sysmodule.c
index ec1fc6141093f20..0194d2467b2e567 100644
--- a/Python/sysmodule.c
+++ b/Python/sysmodule.c
@@ -3961,6 +3961,8 @@ _PySys_InitCore(PyThreadState *tstate, PyObject *sysdict)
     SET_SYS("path_importer_cache", PyDict_New());
     SET_SYS("path_hooks", PyList_New(0));
 
+    SET_SYS("_path0", Py_None);
+
     if (_PyErr_Occurred(tstate)) {
         goto err_occurred;
     }

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to