https://github.com/python/cpython/commit/e891a9117985888df3e5d48145cf07ddda098335
commit: e891a9117985888df3e5d48145cf07ddda098335
branch: main
author: Victor Stinner <[email protected]>
committer: vstinner <[email protected]>
date: 2026-11-09T23:49:01Z
summary:

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.

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 f9190cbafc6a24e..cc96ba67c4e85be 100644
--- a/Doc/c-api/init_config.rst
+++ b/Doc/c-api/init_config.rst
@@ -1227,8 +1227,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 33de85b04f4b0ea..f08a06d3edae9ba 100644
--- a/Doc/library/sys.rst
+++ b/Doc/library/sys.rst
@@ -1651,6 +1651,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 e0ad7945a8ecef2..ffb2587840be5a1 100644
--- a/Doc/using/cmdline.rst
+++ b/Doc/using/cmdline.rst
@@ -322,10 +322,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
 
@@ -353,14 +355,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.
@@ -820,7 +816,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 3ed2055d801a3c8..48c4ae4920f6d35 100644
--- a/Lib/gzip.py
+++ b/Lib/gzip.py
@@ -746,6 +746,11 @@ def opener(path, flags):
 
 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 a535b4f651a5ae8..ff639369da08a1c 100644
--- a/Lib/runpy.py
+++ b/Lib/runpy.py
@@ -315,6 +315,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 227a28766b634d6..abf7786812a0634 100644
--- a/Lib/tarfile.py
+++ b/Lib/tarfile.py
@@ -3172,6 +3172,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)
diff --git a/Lib/test/test_runpy.py b/Lib/test/test_runpy.py
index 55b9673ef6c91c0..f6aae9bed570d21 100644
--- a/Lib/test/test_runpy.py
+++ b/Lib/test/test_runpy.py
@@ -21,6 +21,7 @@
     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
@@ -917,5 +918,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 201c209d8214150..3b65bf5128cdbf2 100644
--- a/Lib/test/test_sys.py
+++ b/Lib/test/test_sys.py
@@ -1493,6 +1493,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
 @test.support.force_not_colorized_test_class
diff --git a/Lib/test/test_zipfile/test_core.py 
b/Lib/test/test_zipfile/test_core.py
index 84000f472c4df9e..2f8fef2a006c381 100644
--- a/Lib/test/test_zipfile/test_core.py
+++ b/Lib/test/test_zipfile/test_core.py
@@ -58,6 +58,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):
@@ -5987,17 +5996,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:
@@ -6016,7 +6026,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 3110759b29f2713..346bc02ce058eef 100644
--- a/Lib/zipfile/__init__.py
+++ b/Lib/zipfile/__init__.py
@@ -2926,6 +2926,10 @@ def _get_code(self, pathname, basename):
 
 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)
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 d2587e9fffdad26..d826ed17e0293c1 100644
--- a/Modules/main.c
+++ b/Modules/main.c
@@ -214,6 +214,10 @@ pymain_sys_path_add_path0(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 b4ba1c52066879b..89684d9da8e6291 100644
--- a/Python/pathconfig.c
+++ b/Python/pathconfig.c
@@ -340,7 +340,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 e8dd956c3f48828..f8c72b096eb4529 100644
--- a/Python/sysmodule.c
+++ b/Python/sysmodule.c
@@ -4161,6 +4161,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