From 4e1d94f1ea6ea92996b86c0efc8867e77962a16c Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Tue, 10 Nov 2026 01:35:37 +0100 Subject: [PATCH] [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 <68491+gpshead@users.noreply.github.com> Co-authored-by: Ethan Furman Co-authored-by: Kirill Podoprigora (cherry picked from commit 8aa5d657e4d3f1a4cca5ff6c976e2e868e0a9056) --- Doc/c-api/init_config.rst | 4 +- Doc/library/sys.rst | 18 ++++ Doc/using/cmdline.rst | 24 +++-- Lib/gzip.py | 5 + Lib/runpy.py | 20 ++++ Lib/tarfile.py | 4 + Lib/test/test_runpy.py | 92 +++++++++++++++++++ Lib/test/test_sys.py | 9 ++ Lib/test/test_zipfile/test_core.py | 23 ++++- Lib/zipfile/__init__.py | 4 + ...-11-07-15-56-47.gh-issue-161705.OjDK2d.rst | 6 ++ Modules/main.c | 4 + Python/pathconfig.c | 2 +- Python/sysmodule.c | 2 + 14 files changed, 196 insertions(+), 21 deletions(-) create mode 100644 Misc/NEWS.d/next/Security/2026-11-07-15-56-47.gh-issue-161705.OjDK2d.rst diff --git a/Doc/c-api/init_config.rst b/Doc/c-api/init_config.rst index 7053b53537c5552..76598ad64076528 100644 --- a/Doc/c-api/init_config.rst +++ b/Doc/c-api/init_config.rst @@ -581,8 +581,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] ` 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 0b8c244482ac574..40b31fd6ccb5598 100644 --- a/Doc/library/sys.rst +++ b/Doc/library/sys.rst @@ -1394,6 +1394,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 738ffac5c6e8b56..e50ca6b2e6fcaaf 100644 --- a/Doc/using/cmdline.rst +++ b/Doc/using/cmdline.rst @@ -310,10 +310,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 @@ -341,14 +343,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. @@ -746,7 +742,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 cb13095da4315d3..8e9230e472a57e7 100644 --- a/Lib/gzip.py +++ b/Lib/gzip.py @@ -643,6 +643,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 28d27577fe2961d..ef4289d99ca7021 100755 --- a/Lib/tarfile.py +++ b/Lib/tarfile.py @@ -3058,6 +3058,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 a4addbeaa2bd0a5..9f8fe88fe2f68f9 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 fd6536536e4cd16..29e019e64e4fc16 100644 --- a/Lib/test/test_sys.py +++ b/Lib/test/test_sys.py @@ -1416,6 +1416,15 @@ def test_pystats(self): def test_disable_gil_abi(self): self.assertEqual('t' in sys.abiflags, support.Py_GIL_DISABLED) + 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 05e39faa1045551..6bdab322e873e11 100644 --- a/Lib/test/test_zipfile/test_core.py +++ b/Lib/test/test_zipfile/test_core.py @@ -49,6 +49,16 @@ def get_files(test): yield f test.assertFalse(f.closed) + +@contextlib.contextmanager +def save_restore_sys_path(): + old_path = sys.path.copy() + try: + yield + finally: + sys.path[:] = old_path + + class AbstractTestsWithSourceFile: @classmethod def setUpClass(cls): @@ -3674,17 +3684,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: @@ -3703,7 +3714,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 a64f831ecddac34..22c93e27a567800 100644 --- a/Lib/zipfile/__init__.py +++ b/Lib/zipfile/__init__.py @@ -2340,6 +2340,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) 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 d1fb740aba7fc3b..c28430d9f74868e 100644 --- a/Modules/main.c +++ b/Modules/main.c @@ -178,6 +178,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 33abaddc1b5df43..910aa8d6667cc33 100644 --- a/Python/pathconfig.c +++ b/Python/pathconfig.c @@ -357,7 +357,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 f3132f3a1211adb..757e5e0dc02cf25 100644 --- a/Python/sysmodule.c +++ b/Python/sysmodule.c @@ -3702,6 +3702,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; }