Skip to content

Commit a69d0fc

Browse files
ambvpablogsal
andauthored
gh-151029: Fix sys.remote_exec() unable to find writable memory when libpython replaced on disk (#151032)
Co-authored-by: Pablo Galindo <pablogsal@gmail.com>
1 parent 219f7a9 commit a69d0fc

3 files changed

Lines changed: 218 additions & 7 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
@@ -2003,7 +2004,8 @@ def tearDown(self):
20032004
test.support.reap_children()
20042005

20052006
def _run_remote_exec_test(self, script_code, python_args=None, env=None,
2006-
prologue='',
2007+
python_executable=None, prologue='',
2008+
after_ready=None,
20072009
script_path=os_helper.TESTFN + '_remote.py'):
20082010
# Create the script that will be remotely executed
20092011
self.addCleanup(os_helper.unlink, script_path)
@@ -2051,7 +2053,10 @@ def _run_remote_exec_test(self, script_code, python_args=None, env=None,
20512053
''')
20522054

20532055
# Start the target process and capture its output
2054-
cmd = [sys.executable]
2056+
if python_executable is None:
2057+
python_executable = sys.executable
2058+
2059+
cmd = [python_executable]
20552060
if python_args:
20562061
cmd.extend(python_args)
20572062
cmd.append(target)
@@ -2076,6 +2081,9 @@ def _run_remote_exec_test(self, script_code, python_args=None, env=None,
20762081
response = client_socket.recv(1024)
20772082
self.assertEqual(response, b"ready")
20782083

2084+
if after_ready is not None:
2085+
after_ready(proc)
2086+
20792087
# Try remote exec on the target process
20802088
sys.remote_exec(proc.pid, script_path)
20812089

@@ -2098,6 +2106,19 @@ def _run_remote_exec_test(self, script_code, python_args=None, env=None,
20982106
proc.terminate()
20992107
proc.wait(timeout=SHORT_TIMEOUT)
21002108

2109+
def _run_remote_exec_with_deleted_mapping(self, deleted_path, **kwargs):
2110+
def delete_loaded_mapping(proc):
2111+
os_helper.unlink(deleted_path)
2112+
with open(f'/proc/{proc.pid}/maps', encoding='utf-8') as maps:
2113+
self.assertIn(f'{deleted_path} (deleted)', maps.read())
2114+
2115+
script = 'print("Remote script executed successfully!")'
2116+
returncode, stdout, stderr = self._run_remote_exec_test(
2117+
script, after_ready=delete_loaded_mapping, **kwargs)
2118+
self.assertEqual(returncode, 0)
2119+
self.assertIn(b"Remote script executed successfully!", stdout)
2120+
self.assertEqual(stderr, b"")
2121+
21012122
def test_remote_exec(self):
21022123
"""Test basic remote exec functionality"""
21032124
script = 'print("Remote script executed successfully!")'
@@ -2224,6 +2245,75 @@ def test_remote_exec_invalid_script_path(self):
22242245
with self.assertRaises(OSError):
22252246
sys.remote_exec(os.getpid(), "invalid_script_path")
22262247

2248+
@unittest.skipUnless(sys.platform == 'linux', 'Linux-only regression test')
2249+
@unittest.skipUnless(
2250+
sysconfig.get_config_var('Py_ENABLE_SHARED') == 1,
2251+
'requires a shared libpython build')
2252+
def test_remote_exec_deleted_libpython(self):
2253+
"""Test remote exec when the target libpython was deleted."""
2254+
build_dir = sysconfig.get_config_var('abs_builddir')
2255+
ldlibrary = sysconfig.get_config_var('LDLIBRARY')
2256+
instsoname = sysconfig.get_config_var('INSTSONAME')
2257+
if not build_dir or not ldlibrary or not instsoname:
2258+
self.skipTest('cannot determine shared libpython location')
2259+
2260+
source_libpython = os.path.join(build_dir, instsoname)
2261+
if not os.path.exists(source_libpython):
2262+
self.skipTest(f'{source_libpython!r} does not exist')
2263+
2264+
with os_helper.temp_dir() as lib_dir:
2265+
copied_libpython = os.path.join(lib_dir, instsoname)
2266+
shutil.copy2(source_libpython, copied_libpython)
2267+
if ldlibrary != instsoname:
2268+
os.symlink(instsoname, os.path.join(lib_dir, ldlibrary))
2269+
2270+
env = os.environ.copy()
2271+
ld_library_path = env.get('LD_LIBRARY_PATH')
2272+
env['LD_LIBRARY_PATH'] = lib_dir if not ld_library_path else (
2273+
lib_dir + os.pathsep + ld_library_path)
2274+
2275+
self._run_remote_exec_with_deleted_mapping(copied_libpython,
2276+
env=env)
2277+
2278+
@unittest.skipUnless(sys.platform == 'linux', 'Linux-only regression test')
2279+
@unittest.skipUnless(
2280+
sysconfig.get_config_var('Py_ENABLE_SHARED') == 0,
2281+
'requires a static Python build')
2282+
def test_remote_exec_deleted_static_executable(self):
2283+
"""Test remote exec when the target static executable was deleted."""
2284+
build_dir = sysconfig.get_config_var('abs_builddir')
2285+
srcdir = sysconfig.get_config_var('srcdir')
2286+
if not build_dir or not srcdir:
2287+
self.skipTest('cannot determine build-tree locations')
2288+
2289+
pybuilddir_txt = os.path.join(build_dir, 'pybuilddir.txt')
2290+
if not os.path.exists(pybuilddir_txt):
2291+
self.skipTest(f'{pybuilddir_txt!r} does not exist')
2292+
2293+
with open(pybuilddir_txt, encoding='utf-8') as pybuilddir_file:
2294+
pybuilddir = pybuilddir_file.read().strip()
2295+
source_ext_dir = os.path.join(build_dir, pybuilddir)
2296+
if not os.path.isdir(source_ext_dir):
2297+
self.skipTest(f'{source_ext_dir!r} does not exist')
2298+
2299+
with os_helper.temp_dir() as copied_root:
2300+
copied_build_dir = os.path.join(copied_root, 'build')
2301+
copied_pybuilddir = os.path.join(copied_build_dir, pybuilddir)
2302+
os.makedirs(os.path.dirname(copied_pybuilddir))
2303+
os.symlink(os.path.join(srcdir, 'Lib'),
2304+
os.path.join(copied_root, 'Lib'))
2305+
os.symlink(source_ext_dir, copied_pybuilddir)
2306+
shutil.copy2(pybuilddir_txt,
2307+
os.path.join(copied_build_dir, 'pybuilddir.txt'))
2308+
2309+
copied_python = os.path.join(copied_build_dir,
2310+
os.path.basename(sys.executable))
2311+
shutil.copy2(sys.executable, copied_python)
2312+
2313+
self._run_remote_exec_with_deleted_mapping(
2314+
copied_python, python_args=['-S'],
2315+
python_executable=copied_python)
2316+
22272317
def test_remote_exec_in_process_without_debug_fails_envvar(self):
22282318
"""Test remote exec in a process without remote debugging enabled"""
22292319
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: 124 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,106 @@ search_elf_file_for_section(
781781
return result;
782782
}
783783

784+
static const char *
785+
find_debug_cookie(const char *buffer, size_t len)
786+
{
787+
const char *cookie = _Py_Debug_Cookie;
788+
const size_t cookie_len = sizeof(_Py_Debug_Cookie) - 1;
789+
if (len < cookie_len) {
790+
return NULL;
791+
}
792+
793+
size_t pos = 0;
794+
size_t last = len - cookie_len;
795+
while (pos <= last) {
796+
const char *candidate = memchr(
797+
buffer + pos, cookie[0], last - pos + 1);
798+
if (candidate == NULL) {
799+
return NULL;
800+
}
801+
pos = (size_t)(candidate - buffer);
802+
if (memcmp(candidate, cookie, cookie_len) == 0) {
803+
return candidate;
804+
}
805+
pos++;
806+
}
807+
return NULL;
808+
}
809+
810+
static int
811+
linux_map_path_is_deleted(const char *path)
812+
{
813+
static const char deleted_suffix[] = " (deleted)";
814+
size_t path_len = strlen(path);
815+
size_t suffix_len = sizeof(deleted_suffix) - 1;
816+
return path_len >= suffix_len
817+
&& strcmp(path + path_len - suffix_len, deleted_suffix) == 0;
818+
}
819+
820+
static int
821+
linux_map_perms_are_readwrite(const char *perms)
822+
{
823+
return perms[0] == 'r' && perms[1] == 'w';
824+
}
825+
826+
static uintptr_t
827+
scan_linux_mapping_for_pyruntime_cookie(
828+
proc_handle_t *handle,
829+
uintptr_t start,
830+
uintptr_t end)
831+
{
832+
if (end <= start) {
833+
return 0;
834+
}
835+
836+
const size_t cookie_len = sizeof(_Py_Debug_Cookie) - 1;
837+
const size_t overlap = cookie_len - 1;
838+
const size_t chunk_size = 1024 * 1024;
839+
char *buffer = PyMem_Malloc(chunk_size);
840+
if (buffer == NULL) {
841+
PyErr_NoMemory();
842+
_set_debug_exception_cause(PyExc_MemoryError,
843+
"Cannot allocate memory while scanning PID %d for PyRuntime cookie",
844+
handle->pid);
845+
return 0;
846+
}
847+
848+
uintptr_t retval = 0;
849+
uintptr_t mapping_size = end - start;
850+
uintptr_t offset = 0;
851+
while (offset < mapping_size) {
852+
uintptr_t remaining = mapping_size - offset;
853+
size_t wanted = remaining > chunk_size
854+
? chunk_size : (size_t)remaining;
855+
if (_Py_RemoteDebug_ReadRemoteMemory(
856+
handle, start + offset, wanted, buffer) < 0) {
857+
if (_Py_RemoteDebug_HasPermissionError()) {
858+
goto exit;
859+
}
860+
// A candidate mapping can disappear or contain unreadable holes while
861+
// the target process keeps running. Treat those as non-matches and
862+
// keep scanning other candidate mappings.
863+
PyErr_Clear();
864+
}
865+
else {
866+
const char *hit = find_debug_cookie(buffer, wanted);
867+
if (hit != NULL) {
868+
retval = start + offset + (uintptr_t)(hit - buffer);
869+
goto exit;
870+
}
871+
}
872+
873+
if (wanted <= overlap) {
874+
break;
875+
}
876+
offset += wanted - overlap;
877+
}
878+
879+
exit:
880+
PyMem_Free(buffer);
881+
return retval;
882+
}
883+
784884
static uintptr_t
785885
search_linux_map_for_section(proc_handle_t *handle, const char* secname, const char* substr,
786886
section_validator_t validator)
@@ -835,16 +935,22 @@ search_linux_map_for_section(proc_handle_t *handle, const char* secname, const c
835935
linelen = 0;
836936

837937
unsigned long start = 0;
838-
unsigned long path_pos = 0;
839-
sscanf(line, "%lx-%*x %*s %*s %*s %*s %ln", &start, &path_pos);
938+
unsigned long end = 0;
939+
int path_pos = 0;
940+
char perms[5] = "";
941+
int fields = sscanf(line, "%lx-%lx %4s %*s %*s %*s %n",
942+
&start, &end, perms, &path_pos);
840943

841-
if (!path_pos) {
944+
if (fields < 3 || !path_pos) {
842945
// Line didn't match our format string. This shouldn't be
843946
// possible, but let's be defensive and skip the line.
844947
continue;
845948
}
846949

847950
const char *path = line + path_pos;
951+
if (path[0] == '\0') {
952+
continue;
953+
}
848954
if (path[0] == '[' && path[strlen(path)-1] == ']') {
849955
// Skip [heap], [stack], [anon:cpython:pymalloc], etc.
850956
continue;
@@ -858,8 +964,21 @@ search_linux_map_for_section(proc_handle_t *handle, const char* secname, const c
858964
}
859965

860966
if (strstr(filename, substr)) {
861-
PyErr_Clear();
862-
retval = search_elf_file_for_section(handle, secname, start, path);
967+
int deleted_pyruntime_mapping =
968+
strcmp(secname, "PyRuntime") == 0
969+
&& linux_map_path_is_deleted(path);
970+
if (deleted_pyruntime_mapping
971+
&& linux_map_perms_are_readwrite(perms)) {
972+
PyErr_Clear();
973+
retval = scan_linux_mapping_for_pyruntime_cookie(
974+
handle, (uintptr_t)start, (uintptr_t)end);
975+
}
976+
if (!deleted_pyruntime_mapping
977+
&& retval == 0 && !PyErr_Occurred()) {
978+
PyErr_Clear();
979+
retval = search_elf_file_for_section(
980+
handle, secname, start, path);
981+
}
863982
if (retval) {
864983
if (validator == NULL || validator(handle, retval)) {
865984
break;

0 commit comments

Comments
 (0)