Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 99 additions & 2 deletions Lib/test/test_sys.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import operator
import os
import random
import shutil
import socket
import struct
import subprocess
Expand Down Expand Up @@ -1983,7 +1984,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)
Expand Down Expand Up @@ -2031,7 +2033,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)
Expand All @@ -2056,6 +2061,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)

Expand Down Expand Up @@ -2204,6 +2212,95 @@ 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)

def delete_loaded_libpython(proc):
os_helper.unlink(copied_libpython)
with open(f'/proc/{proc.pid}/maps', encoding='utf-8') as maps:
self.assertIn(f'{copied_libpython} (deleted)',
maps.read())

script = 'print("Remote script executed successfully!")'
returncode, stdout, stderr = self._run_remote_exec_test(
script, env=env, after_ready=delete_loaded_libpython)
self.assertEqual(returncode, 0)
self.assertIn(b"Remote script executed successfully!", stdout)
self.assertEqual(stderr, b"")

@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)

def delete_loaded_executable(proc):
os_helper.unlink(copied_python)
with open(f'/proc/{proc.pid}/maps', encoding='utf-8') as maps:
self.assertIn(f'{copied_python} (deleted)',
maps.read())

script = 'print("Remote script executed successfully!")'
returncode, stdout, stderr = self._run_remote_exec_test(
script, python_args=['-S'], python_executable=copied_python,
after_ready=delete_loaded_executable)
self.assertEqual(returncode, 0)
self.assertIn(b"Remote script executed successfully!", stdout)
self.assertEqual(stderr, b"")

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'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
On Linux, fix :func:`sys.remote_exec` unable to find remote writable memory
when ``libpython`` replaced on disk.
126 changes: 121 additions & 5 deletions Python/remote_debug.h
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,103 @@ 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;
}
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)
Expand Down Expand Up @@ -835,16 +932,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;
Expand All @@ -858,8 +961,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;
Expand Down
Loading