diff --git a/Lib/test/test_os/test_posix.py b/Lib/test/test_os/test_posix.py index 0e8495a4eff2ed4..ae50bd338a79a08 100644 --- a/Lib/test/test_os/test_posix.py +++ b/Lib/test/test_os/test_posix.py @@ -2271,6 +2271,52 @@ def test_dup2(self): with open(dupfile, encoding="utf-8") as f: self.assertEqual(f.read(), 'hello') + @support.requires_subprocess() + def test_env_none_with_environ_mutated_during_call(self): + # Regression test for gh-149509: when env is None the C-level + # ``environ`` array is borrowed, not copied. If anything mutates + # ``environ`` between argument parsing and the cleanup block + # (this is what an LD_PRELOAD interposer such as gprofng does), + # the previous identity check ``envlist != environ`` could + # mis-classify the borrowed pointer as owned and try to free it + # using an uninitialised count. + # + # The subprocess uses an audit hook to swap the global ``environ`` + # pointer to a fresh array just before the spawn call. With the + # bug present this triggers a crash in the cleanup path; with the + # fix the borrowed pointer is left alone. + try: + import ctypes # noqa: F401 + except ImportError: + self.skipTest("ctypes required") + spawn_name = self.spawn_func.__name__ + code = textwrap.dedent(f""" + import ctypes + import os + import sys + + libc = ctypes.CDLL(None) + environ_var = ctypes.c_void_p.in_dll(libc, 'environ') + saved = environ_var.value + + # A fresh, empty environ array we substitute in. + replacement = (ctypes.c_char_p * 1)(None) + replacement_addr = ctypes.cast(replacement, ctypes.c_void_p).value + + def hook(event, args): + if event == 'os.posix_spawn': + environ_var.value = replacement_addr + + sys.addaudithook(hook) + try: + pid = os.{spawn_name}(sys.executable, + [sys.executable, '-c', 'pass'], None) + os.waitpid(pid, 0) + finally: + environ_var.value = saved + """) + assert_python_ok('-c', code) + @unittest.skipUnless(hasattr(os, 'posix_spawn'), "test needs os.posix_spawn") @support.requires_subprocess() diff --git a/Misc/NEWS.d/next/Library/2026-05-12-07-01-29.gh-issue-149509.WwKxjE.rst b/Misc/NEWS.d/next/Library/2026-05-12-07-01-29.gh-issue-149509.WwKxjE.rst new file mode 100644 index 000000000000000..b57d06fb646ca6c --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-05-12-07-01-29.gh-issue-149509.WwKxjE.rst @@ -0,0 +1,5 @@ +Fix a crash in :func:`os.posix_spawn` and :func:`os.posix_spawnp` when +``env`` is ``None`` and the global ``environ`` array is mutated during +the call (for example, by an ``LD_PRELOAD`` interposer such as +``gprofng``). The cleanup path no longer attempts to free the borrowed +``environ`` pointer. diff --git a/Modules/posixmodule.c b/Modules/posixmodule.c index 5bd53c2146a822f..4ff94aa76b92e8b 100644 --- a/Modules/posixmodule.c +++ b/Modules/posixmodule.c @@ -7903,11 +7903,12 @@ py_posix_spawn(int use_posix_spawnp, PyObject *module, path_t *path, PyObject *a const char *func_name = use_posix_spawnp ? "posix_spawnp" : "posix_spawn"; EXECV_CHAR **argvlist = NULL; EXECV_CHAR **envlist = NULL; + int envlist_owned = 0; posix_spawn_file_actions_t file_actions_buf; posix_spawn_file_actions_t *file_actionsp = NULL; posix_spawnattr_t attr; posix_spawnattr_t *attrp = NULL; - Py_ssize_t argc, envc; + Py_ssize_t argc, envc = 0; PyObject *result = NULL; PyObject *temp_buffer = NULL; pid_t pid; @@ -7966,6 +7967,7 @@ py_posix_spawn(int use_posix_spawnp, PyObject *module, path_t *path, PyObject *a if (envlist == NULL) { goto exit; } + envlist_owned = 1; } if (file_actions != NULL && file_actions != Py_None) { @@ -8028,7 +8030,11 @@ py_posix_spawn(int use_posix_spawnp, PyObject *module, path_t *path, PyObject *a if (attrp) { (void)posix_spawnattr_destroy(attrp); } - if (envlist && envlist != environ) { + /* Only free envlist if we own it. Code that wraps posix_spawn (e.g. + gprofng) can mutate the global environ during the spawn call, which + would make `envlist != environ` true even for the borrowed case and + cause a free of process-owned memory with an uninitialized count. */ + if (envlist_owned) { free_string_array(envlist, envc); } if (argvlist) {