Skip to content

Commit 9a9bceb

Browse files
authored
[3.14] gh-151029: Fix sys.remote_exec() unable to find writable memory when libpython replaced on disk (GH-151032) (#152464)
1 parent af3c912 commit 9a9bceb

3 files changed

Lines changed: 235 additions & 10 deletions

File tree

Lib/test/test_sys.py

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import operator
77
import os
88
import random
9+
import shutil
910
import socket
1011
import struct
1112
import subprocess
@@ -1979,7 +1980,8 @@ def tearDown(self):
19791980
test.support.reap_children()
19801981

19811982
def _run_remote_exec_test(self, script_code, python_args=None, env=None,
1982-
prologue='',
1983+
python_executable=None, prologue='',
1984+
after_ready=None,
19831985
script_path=os_helper.TESTFN + '_remote.py'):
19841986
# Create the script that will be remotely executed
19851987
self.addCleanup(os_helper.unlink, script_path)
@@ -2027,7 +2029,10 @@ def _run_remote_exec_test(self, script_code, python_args=None, env=None,
20272029
''')
20282030

20292031
# Start the target process and capture its output
2030-
cmd = [sys.executable]
2032+
if python_executable is None:
2033+
python_executable = sys.executable
2034+
2035+
cmd = [python_executable]
20312036
if python_args:
20322037
cmd.extend(python_args)
20332038
cmd.append(target)
@@ -2052,6 +2057,9 @@ def _run_remote_exec_test(self, script_code, python_args=None, env=None,
20522057
response = client_socket.recv(1024)
20532058
self.assertEqual(response, b"ready")
20542059

2060+
if after_ready is not None:
2061+
after_ready(proc)
2062+
20552063
# Try remote exec on the target process
20562064
sys.remote_exec(proc.pid, script_path)
20572065

@@ -2074,6 +2082,19 @@ def _run_remote_exec_test(self, script_code, python_args=None, env=None,
20742082
proc.terminate()
20752083
proc.wait(timeout=SHORT_TIMEOUT)
20762084

2085+
def _run_remote_exec_with_deleted_mapping(self, deleted_path, **kwargs):
2086+
def delete_loaded_mapping(proc):
2087+
os_helper.unlink(deleted_path)
2088+
with open(f'/proc/{proc.pid}/maps', encoding='utf-8') as maps:
2089+
self.assertIn(f'{deleted_path} (deleted)', maps.read())
2090+
2091+
script = 'print("Remote script executed successfully!")'
2092+
returncode, stdout, stderr = self._run_remote_exec_test(
2093+
script, after_ready=delete_loaded_mapping, **kwargs)
2094+
self.assertEqual(returncode, 0)
2095+
self.assertIn(b"Remote script executed successfully!", stdout)
2096+
self.assertEqual(stderr, b"")
2097+
20772098
def test_remote_exec(self):
20782099
"""Test basic remote exec functionality"""
20792100
script = 'print("Remote script executed successfully!")'
@@ -2200,6 +2221,75 @@ def test_remote_exec_invalid_script_path(self):
22002221
with self.assertRaises(OSError):
22012222
sys.remote_exec(os.getpid(), "invalid_script_path")
22022223

2224+
@unittest.skipUnless(sys.platform == 'linux', 'Linux-only regression test')
2225+
@unittest.skipUnless(
2226+
sysconfig.get_config_var('Py_ENABLE_SHARED') == 1,
2227+
'requires a shared libpython build')
2228+
def test_remote_exec_deleted_libpython(self):
2229+
"""Test remote exec when the target libpython was deleted."""
2230+
build_dir = sysconfig.get_config_var('abs_builddir')
2231+
ldlibrary = sysconfig.get_config_var('LDLIBRARY')
2232+
instsoname = sysconfig.get_config_var('INSTSONAME')
2233+
if not build_dir or not ldlibrary or not instsoname:
2234+
self.skipTest('cannot determine shared libpython location')
2235+
2236+
source_libpython = os.path.join(build_dir, instsoname)
2237+
if not os.path.exists(source_libpython):
2238+
self.skipTest(f'{source_libpython!r} does not exist')
2239+
2240+
with os_helper.temp_dir() as lib_dir:
2241+
copied_libpython = os.path.join(lib_dir, instsoname)
2242+
shutil.copy2(source_libpython, copied_libpython)
2243+
if ldlibrary != instsoname:
2244+
os.symlink(instsoname, os.path.join(lib_dir, ldlibrary))
2245+
2246+
env = os.environ.copy()
2247+
ld_library_path = env.get('LD_LIBRARY_PATH')
2248+
env['LD_LIBRARY_PATH'] = lib_dir if not ld_library_path else (
2249+
lib_dir + os.pathsep + ld_library_path)
2250+
2251+
self._run_remote_exec_with_deleted_mapping(copied_libpython,
2252+
env=env)
2253+
2254+
@unittest.skipUnless(sys.platform == 'linux', 'Linux-only regression test')
2255+
@unittest.skipUnless(
2256+
sysconfig.get_config_var('Py_ENABLE_SHARED') == 0,
2257+
'requires a static Python build')
2258+
def test_remote_exec_deleted_static_executable(self):
2259+
"""Test remote exec when the target static executable was deleted."""
2260+
build_dir = sysconfig.get_config_var('abs_builddir')
2261+
srcdir = sysconfig.get_config_var('srcdir')
2262+
if not build_dir or not srcdir:
2263+
self.skipTest('cannot determine build-tree locations')
2264+
2265+
pybuilddir_txt = os.path.join(build_dir, 'pybuilddir.txt')
2266+
if not os.path.exists(pybuilddir_txt):
2267+
self.skipTest(f'{pybuilddir_txt!r} does not exist')
2268+
2269+
with open(pybuilddir_txt, encoding='utf-8') as pybuilddir_file:
2270+
pybuilddir = pybuilddir_file.read().strip()
2271+
source_ext_dir = os.path.join(build_dir, pybuilddir)
2272+
if not os.path.isdir(source_ext_dir):
2273+
self.skipTest(f'{source_ext_dir!r} does not exist')
2274+
2275+
with os_helper.temp_dir() as copied_root:
2276+
copied_build_dir = os.path.join(copied_root, 'build')
2277+
copied_pybuilddir = os.path.join(copied_build_dir, pybuilddir)
2278+
os.makedirs(os.path.dirname(copied_pybuilddir))
2279+
os.symlink(os.path.join(srcdir, 'Lib'),
2280+
os.path.join(copied_root, 'Lib'))
2281+
os.symlink(source_ext_dir, copied_pybuilddir)
2282+
shutil.copy2(pybuilddir_txt,
2283+
os.path.join(copied_build_dir, 'pybuilddir.txt'))
2284+
2285+
copied_python = os.path.join(copied_build_dir,
2286+
os.path.basename(sys.executable))
2287+
shutil.copy2(sys.executable, copied_python)
2288+
2289+
self._run_remote_exec_with_deleted_mapping(
2290+
copied_python, python_args=['-S'],
2291+
python_executable=copied_python)
2292+
22032293
def test_remote_exec_in_process_without_debug_fails_envvar(self):
22042294
"""Test remote exec in a process without remote debugging enabled"""
22052295
script = os_helper.TESTFN + '_remote.py'
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
On Linux, fix :func:`sys.remote_exec` unable to find remote writable memory
2+
when ``libpython`` replaced on disk.

Python/remote_debug.h

Lines changed: 141 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,13 @@ extern "C" {
8383
# define HAVE_PROCESS_VM_READV 0
8484
#endif
8585

86+
static inline int
87+
_Py_RemoteDebug_HasPermissionError(void)
88+
{
89+
return PyErr_Occurred()
90+
&& PyErr_ExceptionMatches(PyExc_PermissionError);
91+
}
92+
8693
#define _set_debug_exception_cause(exception, format, ...) \
8794
do { \
8895
if (!PyErr_ExceptionMatches(PyExc_PermissionError)) { \
@@ -686,6 +693,106 @@ search_elf_file_for_section(
686693
return result;
687694
}
688695

696+
static const char *
697+
find_debug_cookie(const char *buffer, size_t len)
698+
{
699+
const char *cookie = _Py_Debug_Cookie;
700+
const size_t cookie_len = sizeof(_Py_Debug_Cookie) - 1;
701+
if (len < cookie_len) {
702+
return NULL;
703+
}
704+
705+
size_t pos = 0;
706+
size_t last = len - cookie_len;
707+
while (pos <= last) {
708+
const char *candidate = memchr(
709+
buffer + pos, cookie[0], last - pos + 1);
710+
if (candidate == NULL) {
711+
return NULL;
712+
}
713+
pos = (size_t)(candidate - buffer);
714+
if (memcmp(candidate, cookie, cookie_len) == 0) {
715+
return candidate;
716+
}
717+
pos++;
718+
}
719+
return NULL;
720+
}
721+
722+
static int
723+
linux_map_path_is_deleted(const char *path)
724+
{
725+
static const char deleted_suffix[] = " (deleted)";
726+
size_t path_len = strlen(path);
727+
size_t suffix_len = sizeof(deleted_suffix) - 1;
728+
return path_len >= suffix_len
729+
&& strcmp(path + path_len - suffix_len, deleted_suffix) == 0;
730+
}
731+
732+
static int
733+
linux_map_perms_are_readwrite(const char *perms)
734+
{
735+
return perms[0] == 'r' && perms[1] == 'w';
736+
}
737+
738+
static uintptr_t
739+
scan_linux_mapping_for_pyruntime_cookie(
740+
proc_handle_t *handle,
741+
uintptr_t start,
742+
uintptr_t end)
743+
{
744+
if (end <= start) {
745+
return 0;
746+
}
747+
748+
const size_t cookie_len = sizeof(_Py_Debug_Cookie) - 1;
749+
const size_t overlap = cookie_len - 1;
750+
const size_t chunk_size = 1024 * 1024;
751+
char *buffer = PyMem_Malloc(chunk_size);
752+
if (buffer == NULL) {
753+
PyErr_NoMemory();
754+
_set_debug_exception_cause(PyExc_MemoryError,
755+
"Cannot allocate memory while scanning PID %d for PyRuntime cookie",
756+
handle->pid);
757+
return 0;
758+
}
759+
760+
uintptr_t retval = 0;
761+
uintptr_t mapping_size = end - start;
762+
uintptr_t offset = 0;
763+
while (offset < mapping_size) {
764+
uintptr_t remaining = mapping_size - offset;
765+
size_t wanted = remaining > chunk_size
766+
? chunk_size : (size_t)remaining;
767+
if (_Py_RemoteDebug_ReadRemoteMemory(
768+
handle, start + offset, wanted, buffer) < 0) {
769+
if (_Py_RemoteDebug_HasPermissionError()) {
770+
goto exit;
771+
}
772+
// A candidate mapping can disappear or contain unreadable holes while
773+
// the target process keeps running. Treat those as non-matches and
774+
// keep scanning other candidate mappings.
775+
PyErr_Clear();
776+
}
777+
else {
778+
const char *hit = find_debug_cookie(buffer, wanted);
779+
if (hit != NULL) {
780+
retval = start + offset + (uintptr_t)(hit - buffer);
781+
goto exit;
782+
}
783+
}
784+
785+
if (wanted <= overlap) {
786+
break;
787+
}
788+
offset += wanted - overlap;
789+
}
790+
791+
exit:
792+
PyMem_Free(buffer);
793+
return retval;
794+
}
795+
689796
static uintptr_t
690797
search_linux_map_for_section(proc_handle_t *handle, const char* secname, const char* substr,
691798
section_validator_t validator)
@@ -739,16 +846,22 @@ search_linux_map_for_section(proc_handle_t *handle, const char* secname, const c
739846
linelen = 0;
740847

741848
unsigned long start = 0;
742-
unsigned long path_pos = 0;
743-
sscanf(line, "%lx-%*x %*s %*s %*s %*s %ln", &start, &path_pos);
849+
unsigned long end = 0;
850+
int path_pos = 0;
851+
char perms[5] = "";
852+
int fields = sscanf(line, "%lx-%lx %4s %*s %*s %*s %n",
853+
&start, &end, perms, &path_pos);
744854

745-
if (!path_pos) {
855+
if (fields < 3 || !path_pos) {
746856
// Line didn't match our format string. This shouldn't be
747857
// possible, but let's be defensive and skip the line.
748858
continue;
749859
}
750860

751861
const char *path = line + path_pos;
862+
if (path[0] == '\0') {
863+
continue;
864+
}
752865
if (path[0] == '[' && path[strlen(path)-1] == ']') {
753866
// Skip [heap], [stack], [anon:cpython:pymalloc], etc.
754867
continue;
@@ -762,11 +875,31 @@ search_linux_map_for_section(proc_handle_t *handle, const char* secname, const c
762875
}
763876

764877
if (strstr(filename, substr)) {
765-
PyErr_Clear();
766-
retval = search_elf_file_for_section(handle, secname, start, path);
767-
if (retval
768-
&& (validator == NULL || validator(handle, retval)))
769-
{
878+
int deleted_pyruntime_mapping =
879+
strcmp(secname, "PyRuntime") == 0
880+
&& linux_map_path_is_deleted(path);
881+
if (deleted_pyruntime_mapping
882+
&& linux_map_perms_are_readwrite(perms)) {
883+
PyErr_Clear();
884+
retval = scan_linux_mapping_for_pyruntime_cookie(
885+
handle, (uintptr_t)start, (uintptr_t)end);
886+
}
887+
if (!deleted_pyruntime_mapping
888+
&& retval == 0 && !PyErr_Occurred()) {
889+
PyErr_Clear();
890+
retval = search_elf_file_for_section(
891+
handle, secname, start, path);
892+
}
893+
if (retval) {
894+
if (validator == NULL || validator(handle, retval)) {
895+
break;
896+
}
897+
if (_Py_RemoteDebug_HasPermissionError()) {
898+
retval = 0;
899+
break;
900+
}
901+
}
902+
else if (_Py_RemoteDebug_HasPermissionError()) {
770903
break;
771904
}
772905
retval = 0;

0 commit comments

Comments
 (0)