[3.15] gh-151029: Fix sys.remote_exec() unable to find writable memory when libpython replaced on disk (GH-151032) (#152443)

gh-151029: Fix sys.remote_exec() unable to find writable memory when libpython replaced on disk (GH-151032)
(cherry picked from commit a69d0fc41e)

Co-authored-by: Łukasz Langa <lukasz@langa.pl>
Co-authored-by: Pablo Galindo <pablogsal@gmail.com>
This commit is contained in:
Miss Islington (bot) 2026-06-27 18:49:40 +02:00 committed by GitHub
parent 3206fc74fb
commit e88d41685b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 218 additions and 7 deletions

View file

@ -6,6 +6,7 @@
import operator
import os
import random
import shutil
import socket
import struct
import subprocess
@ -2013,7 +2014,8 @@ def tearDown(self):
test.support.reap_children()
def _run_remote_exec_test(self, script_code, python_args=None, env=None,
prologue='',
python_executable=None, prologue='',
after_ready=None,
script_path=os_helper.TESTFN + '_remote.py'):
# Create the script that will be remotely executed
self.addCleanup(os_helper.unlink, script_path)
@ -2061,7 +2063,10 @@ def _run_remote_exec_test(self, script_code, python_args=None, env=None,
''')
# Start the target process and capture its output
cmd = [sys.executable]
if python_executable is None:
python_executable = sys.executable
cmd = [python_executable]
if python_args:
cmd.extend(python_args)
cmd.append(target)
@ -2086,6 +2091,9 @@ def _run_remote_exec_test(self, script_code, python_args=None, env=None,
response = client_socket.recv(1024)
self.assertEqual(response, b"ready")
if after_ready is not None:
after_ready(proc)
# Try remote exec on the target process
sys.remote_exec(proc.pid, script_path)
@ -2108,6 +2116,19 @@ def _run_remote_exec_test(self, script_code, python_args=None, env=None,
proc.terminate()
proc.wait(timeout=SHORT_TIMEOUT)
def _run_remote_exec_with_deleted_mapping(self, deleted_path, **kwargs):
def delete_loaded_mapping(proc):
os_helper.unlink(deleted_path)
with open(f'/proc/{proc.pid}/maps', encoding='utf-8') as maps:
self.assertIn(f'{deleted_path} (deleted)', maps.read())
script = 'print("Remote script executed successfully!")'
returncode, stdout, stderr = self._run_remote_exec_test(
script, after_ready=delete_loaded_mapping, **kwargs)
self.assertEqual(returncode, 0)
self.assertIn(b"Remote script executed successfully!", stdout)
self.assertEqual(stderr, b"")
def test_remote_exec(self):
"""Test basic remote exec functionality"""
script = 'print("Remote script executed successfully!")'
@ -2234,6 +2255,75 @@ def test_remote_exec_invalid_script_path(self):
with self.assertRaises(OSError):
sys.remote_exec(os.getpid(), "invalid_script_path")
@unittest.skipUnless(sys.platform == 'linux', 'Linux-only regression test')
@unittest.skipUnless(
sysconfig.get_config_var('Py_ENABLE_SHARED') == 1,
'requires a shared libpython build')
def test_remote_exec_deleted_libpython(self):
"""Test remote exec when the target libpython was deleted."""
build_dir = sysconfig.get_config_var('abs_builddir')
ldlibrary = sysconfig.get_config_var('LDLIBRARY')
instsoname = sysconfig.get_config_var('INSTSONAME')
if not build_dir or not ldlibrary or not instsoname:
self.skipTest('cannot determine shared libpython location')
source_libpython = os.path.join(build_dir, instsoname)
if not os.path.exists(source_libpython):
self.skipTest(f'{source_libpython!r} does not exist')
with os_helper.temp_dir() as lib_dir:
copied_libpython = os.path.join(lib_dir, instsoname)
shutil.copy2(source_libpython, copied_libpython)
if ldlibrary != instsoname:
os.symlink(instsoname, os.path.join(lib_dir, ldlibrary))
env = os.environ.copy()
ld_library_path = env.get('LD_LIBRARY_PATH')
env['LD_LIBRARY_PATH'] = lib_dir if not ld_library_path else (
lib_dir + os.pathsep + ld_library_path)
self._run_remote_exec_with_deleted_mapping(copied_libpython,
env=env)
@unittest.skipUnless(sys.platform == 'linux', 'Linux-only regression test')
@unittest.skipUnless(
sysconfig.get_config_var('Py_ENABLE_SHARED') == 0,
'requires a static Python build')
def test_remote_exec_deleted_static_executable(self):
"""Test remote exec when the target static executable was deleted."""
build_dir = sysconfig.get_config_var('abs_builddir')
srcdir = sysconfig.get_config_var('srcdir')
if not build_dir or not srcdir:
self.skipTest('cannot determine build-tree locations')
pybuilddir_txt = os.path.join(build_dir, 'pybuilddir.txt')
if not os.path.exists(pybuilddir_txt):
self.skipTest(f'{pybuilddir_txt!r} does not exist')
with open(pybuilddir_txt, encoding='utf-8') as pybuilddir_file:
pybuilddir = pybuilddir_file.read().strip()
source_ext_dir = os.path.join(build_dir, pybuilddir)
if not os.path.isdir(source_ext_dir):
self.skipTest(f'{source_ext_dir!r} does not exist')
with os_helper.temp_dir() as copied_root:
copied_build_dir = os.path.join(copied_root, 'build')
copied_pybuilddir = os.path.join(copied_build_dir, pybuilddir)
os.makedirs(os.path.dirname(copied_pybuilddir))
os.symlink(os.path.join(srcdir, 'Lib'),
os.path.join(copied_root, 'Lib'))
os.symlink(source_ext_dir, copied_pybuilddir)
shutil.copy2(pybuilddir_txt,
os.path.join(copied_build_dir, 'pybuilddir.txt'))
copied_python = os.path.join(copied_build_dir,
os.path.basename(sys.executable))
shutil.copy2(sys.executable, copied_python)
self._run_remote_exec_with_deleted_mapping(
copied_python, python_args=['-S'],
python_executable=copied_python)
def test_remote_exec_in_process_without_debug_fails_envvar(self):
"""Test remote exec in a process without remote debugging enabled"""
script = os_helper.TESTFN + '_remote.py'

View file

@ -0,0 +1,2 @@
On Linux, fix :func:`sys.remote_exec` unable to find remote writable memory
when ``libpython`` replaced on disk.

View file

@ -781,6 +781,106 @@ search_elf_file_for_section(
return result;
}
static const char *
find_debug_cookie(const char *buffer, size_t len)
{
const char *cookie = _Py_Debug_Cookie;
const size_t cookie_len = sizeof(_Py_Debug_Cookie) - 1;
if (len < cookie_len) {
return NULL;
}
size_t pos = 0;
size_t last = len - cookie_len;
while (pos <= last) {
const char *candidate = memchr(
buffer + pos, cookie[0], last - pos + 1);
if (candidate == NULL) {
return NULL;
}
pos = (size_t)(candidate - buffer);
if (memcmp(candidate, cookie, cookie_len) == 0) {
return candidate;
}
pos++;
}
return NULL;
}
static int
linux_map_path_is_deleted(const char *path)
{
static const char deleted_suffix[] = " (deleted)";
size_t path_len = strlen(path);
size_t suffix_len = sizeof(deleted_suffix) - 1;
return path_len >= suffix_len
&& strcmp(path + path_len - suffix_len, deleted_suffix) == 0;
}
static int
linux_map_perms_are_readwrite(const char *perms)
{
return perms[0] == 'r' && perms[1] == 'w';
}
static uintptr_t
scan_linux_mapping_for_pyruntime_cookie(
proc_handle_t *handle,
uintptr_t start,
uintptr_t end)
{
if (end <= start) {
return 0;
}
const size_t cookie_len = sizeof(_Py_Debug_Cookie) - 1;
const size_t overlap = cookie_len - 1;
const size_t chunk_size = 1024 * 1024;
char *buffer = PyMem_Malloc(chunk_size);
if (buffer == NULL) {
PyErr_NoMemory();
_set_debug_exception_cause(PyExc_MemoryError,
"Cannot allocate memory while scanning PID %d for PyRuntime cookie",
handle->pid);
return 0;
}
uintptr_t retval = 0;
uintptr_t mapping_size = end - start;
uintptr_t offset = 0;
while (offset < mapping_size) {
uintptr_t remaining = mapping_size - offset;
size_t wanted = remaining > chunk_size
? chunk_size : (size_t)remaining;
if (_Py_RemoteDebug_ReadRemoteMemory(
handle, start + offset, wanted, buffer) < 0) {
if (_Py_RemoteDebug_HasPermissionError()) {
goto exit;
}
// A candidate mapping can disappear or contain unreadable holes while
// the target process keeps running. Treat those as non-matches and
// keep scanning other candidate mappings.
PyErr_Clear();
}
else {
const char *hit = find_debug_cookie(buffer, wanted);
if (hit != NULL) {
retval = start + offset + (uintptr_t)(hit - buffer);
goto exit;
}
}
if (wanted <= overlap) {
break;
}
offset += wanted - overlap;
}
exit:
PyMem_Free(buffer);
return retval;
}
static uintptr_t
search_linux_map_for_section(proc_handle_t *handle, const char* secname, const char* substr,
section_validator_t validator)
@ -835,16 +935,22 @@ search_linux_map_for_section(proc_handle_t *handle, const char* secname, const c
linelen = 0;
unsigned long start = 0;
unsigned long path_pos = 0;
sscanf(line, "%lx-%*x %*s %*s %*s %*s %ln", &start, &path_pos);
unsigned long end = 0;
int path_pos = 0;
char perms[5] = "";
int fields = sscanf(line, "%lx-%lx %4s %*s %*s %*s %n",
&start, &end, perms, &path_pos);
if (!path_pos) {
if (fields < 3 || !path_pos) {
// Line didn't match our format string. This shouldn't be
// possible, but let's be defensive and skip the line.
continue;
}
const char *path = line + path_pos;
if (path[0] == '\0') {
continue;
}
if (path[0] == '[' && path[strlen(path)-1] == ']') {
// Skip [heap], [stack], [anon:cpython:pymalloc], etc.
continue;
@ -858,8 +964,21 @@ search_linux_map_for_section(proc_handle_t *handle, const char* secname, const c
}
if (strstr(filename, substr)) {
PyErr_Clear();
retval = search_elf_file_for_section(handle, secname, start, path);
int deleted_pyruntime_mapping =
strcmp(secname, "PyRuntime") == 0
&& linux_map_path_is_deleted(path);
if (deleted_pyruntime_mapping
&& linux_map_perms_are_readwrite(perms)) {
PyErr_Clear();
retval = scan_linux_mapping_for_pyruntime_cookie(
handle, (uintptr_t)start, (uintptr_t)end);
}
if (!deleted_pyruntime_mapping
&& retval == 0 && !PyErr_Occurred()) {
PyErr_Clear();
retval = search_elf_file_for_section(
handle, secname, start, path);
}
if (retval) {
if (validator == NULL || validator(handle, retval)) {
break;