Skip to content

Commit f12f6c4

Browse files
committed
gh-148178: Validate remote debug offset tables on load
Treat the debug offset tables read from a target process as untrusted input and validate them before the unwinder uses any reported sizes or offsets. Add a shared validator in debug_offsets_validation.h and run it once when _Py_DebugOffsets is loaded and once when AsyncioDebug is loaded. The checks cover section sizes used for fixed local buffers and every offset that is later dereferenced against a local buffer or local object view. This keeps the bounds checks out of the sampling hot path while rejecting malformed tables up front. Also add Linux poison tests that corrupt both the main and asyncio offset tables and assert that RemoteUnwinder fails with an invalid-offset error instead of walking into bad accesses or misleading late failures.
1 parent acf5229 commit f12f6c4

7 files changed

Lines changed: 725 additions & 5 deletions

File tree

Lib/test/test_external_inspection.py

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import unittest
22
import os
3+
import json
34
import textwrap
45
import contextlib
56
import importlib
@@ -418,6 +419,164 @@ def _frame_to_lineno_tuple(frame):
418419
filename, location, funcname, opcode = frame
419420
return (filename, location.lineno, funcname, opcode)
420421

422+
@contextmanager
423+
def _poisoned_debug_offsets_process(self, poison_code):
424+
script = (
425+
textwrap.dedent(
426+
"""\
427+
import json
428+
import os
429+
import struct
430+
import sys
431+
import threading
432+
import time
433+
434+
cookie = b"xdebugpy"
435+
pid = os.getpid()
436+
437+
def find_section_address(section_name, filename_substr):
438+
ehdr_fmt = "<16sHHIQQQIHHHHHH"
439+
phdr_fmt = "<IIQQQQQQ"
440+
shdr_fmt = "<IIQQQQIIQQ"
441+
442+
def search_elf_file_for_section(start_address, elf_file):
443+
with open(elf_file, "rb") as f:
444+
data = f.read()
445+
if data[:4] != b"\\x7fELF" or data[4] != 2 or data[5] != 1:
446+
raise RuntimeError(f"unsupported ELF file: {elf_file}")
447+
448+
ehdr = struct.unpack_from(ehdr_fmt, data, 0)
449+
e_phoff = ehdr[5]
450+
e_shoff = ehdr[6]
451+
e_phentsize = ehdr[9]
452+
e_phnum = ehdr[10]
453+
e_shentsize = ehdr[11]
454+
e_shnum = ehdr[12]
455+
e_shstrndx = ehdr[13]
456+
457+
shstr = struct.unpack_from(
458+
shdr_fmt, data, e_shoff + e_shstrndx * e_shentsize
459+
)
460+
shstrtab = data[shstr[4]:shstr[4] + shstr[5]]
461+
462+
section = None
463+
for i in range(e_shnum):
464+
shdr = struct.unpack_from(
465+
shdr_fmt, data, e_shoff + i * e_shentsize
466+
)
467+
name_end = shstrtab.find(b"\\0", shdr[0])
468+
name = shstrtab[shdr[0]:name_end].decode()
469+
if name.lstrip(".") == section_name:
470+
section = shdr
471+
break
472+
if section is None:
473+
return 0
474+
475+
first_load = None
476+
for i in range(e_phnum):
477+
phdr = struct.unpack_from(
478+
phdr_fmt, data, e_phoff + i * e_phentsize
479+
)
480+
if phdr[0] == 1:
481+
first_load = phdr
482+
break
483+
if first_load is None:
484+
raise RuntimeError(f"no PT_LOAD segment in {elf_file}")
485+
486+
p_vaddr = first_load[3]
487+
p_align = first_load[7] or 1
488+
elf_load_addr = p_vaddr - (p_vaddr % p_align)
489+
return start_address + section[3] - elf_load_addr
490+
491+
with open(f"/proc/{pid}/maps") as f:
492+
for line in f:
493+
parts = line.split(None, 5)
494+
if len(parts) < 6:
495+
continue
496+
path = parts[5].strip()
497+
if path.startswith("["):
498+
continue
499+
if filename_substr not in os.path.basename(path):
500+
continue
501+
start = int(parts[0].split("-")[0], 16)
502+
try:
503+
addr = search_elf_file_for_section(start, path)
504+
except OSError:
505+
continue
506+
if addr:
507+
return addr
508+
raise RuntimeError(f"{section_name} section not found")
509+
510+
def find_debug_offsets():
511+
with open(f"/proc/{pid}/maps") as f:
512+
for line in f:
513+
parts = line.split()
514+
if len(parts) < 2 or "rw" not in parts[1]:
515+
continue
516+
start, end = (int(x, 16) for x in parts[0].split("-"))
517+
if end - start > 10_000_000:
518+
continue
519+
try:
520+
fd = os.open(f"/proc/{pid}/mem", os.O_RDONLY)
521+
os.lseek(fd, start, 0)
522+
data = os.read(fd, end - start)
523+
os.close(fd)
524+
except OSError:
525+
continue
526+
off = data.find(cookie)
527+
if off == -1:
528+
continue
529+
version = struct.unpack_from("<Q", data, off + 8)[0]
530+
if ((version >> 24) & 0xFF) != sys.version_info.major:
531+
continue
532+
return start + off
533+
raise RuntimeError("debug offsets not found")
534+
535+
addr = find_debug_offsets()
536+
"""
537+
)
538+
+ textwrap.dedent(poison_code)
539+
+ "\n"
540+
+ textwrap.dedent(
541+
"""\
542+
print(
543+
json.dumps({"pid": pid, "native_tid": threading.get_native_id()}),
544+
flush=True,
545+
)
546+
time.sleep(60)
547+
"""
548+
)
549+
)
550+
551+
proc = subprocess.Popen(
552+
[sys.executable, "-c", script],
553+
stdout=subprocess.PIPE,
554+
stderr=subprocess.PIPE,
555+
text=True,
556+
)
557+
try:
558+
line = proc.stdout.readline()
559+
if not line:
560+
stderr = proc.stderr.read()
561+
self.fail(
562+
"poisoned child failed to initialize: "
563+
f"{stderr.strip() or 'no stderr output'}"
564+
)
565+
yield proc, json.loads(line)
566+
finally:
567+
try:
568+
proc.terminate()
569+
proc.wait(timeout=SHORT_TIMEOUT)
570+
except subprocess.TimeoutExpired:
571+
proc.kill()
572+
proc.wait(timeout=SHORT_TIMEOUT)
573+
except OSError:
574+
pass
575+
if proc.stdout is not None:
576+
proc.stdout.close()
577+
if proc.stderr is not None:
578+
proc.stderr.close()
579+
421580
def _extract_coroutine_stacks_lineno_only(self, stack_trace):
422581
"""Extract coroutine stacks with line numbers only (no column offsets).
423582
@@ -3574,5 +3733,114 @@ def test_get_stats_disabled_raises(self):
35743733
client_socket.sendall(b"done")
35753734

35763735

3736+
@requires_remote_subprocess_debugging()
3737+
@unittest.skipUnless(sys.platform == "linux", "requires /proc/self/mem")
3738+
@unittest.skipIf(
3739+
not PROCESS_VM_READV_SUPPORTED,
3740+
"Test only runs on Linux with process_vm_readv support",
3741+
)
3742+
class TestInvalidDebugOffsets(RemoteInspectionTestBase):
3743+
@skip_if_not_supported
3744+
def test_rejects_poisoned_thread_state_size(self):
3745+
poison_code = textwrap.dedent(
3746+
"""\
3747+
thread_state_offset = 8 + 8 + 8 + 3 * 8 + 16 * 8
3748+
fd = os.open(f"/proc/{pid}/mem", os.O_WRONLY)
3749+
os.lseek(fd, addr + thread_state_offset, 0)
3750+
os.write(fd, struct.pack("<Q", 0xFFFFFFFFFFFFFFFF))
3751+
os.close(fd)
3752+
"""
3753+
)
3754+
3755+
with self._poisoned_debug_offsets_process(poison_code) as (_, info):
3756+
with self.assertRaisesRegex(RuntimeError, "Invalid debug offsets"):
3757+
RemoteUnwinder(info["pid"], all_threads=True, debug=True)
3758+
3759+
@skip_if_not_supported
3760+
def test_rejects_poisoned_thread_state_offset(self):
3761+
poison_code = textwrap.dedent(
3762+
"""\
3763+
thread_state_offset = 8 + 8 + 8 + 3 * 8 + 16 * 8
3764+
native_thread_id_offset = thread_state_offset + 64
3765+
fd = os.open(f"/proc/{pid}/mem", os.O_WRONLY)
3766+
os.lseek(fd, addr + native_thread_id_offset, 0)
3767+
os.write(fd, struct.pack("<Q", 2000))
3768+
os.close(fd)
3769+
"""
3770+
)
3771+
3772+
with self._poisoned_debug_offsets_process(poison_code) as (_, info):
3773+
with self.assertRaisesRegex(RuntimeError, "Invalid debug offsets"):
3774+
RemoteUnwinder(info["pid"], all_threads=True, debug=True)
3775+
3776+
@skip_if_not_supported
3777+
def test_rejects_poisoned_runtime_state_interpreters_head(self):
3778+
poison_code = textwrap.dedent(
3779+
"""\
3780+
interpreters_head_offset = 8 + 8 + 8 + 8 + 8
3781+
fd = os.open(f"/proc/{pid}/mem", os.O_WRONLY)
3782+
os.lseek(fd, addr + interpreters_head_offset, 0)
3783+
os.write(fd, struct.pack("<Q", 0xFFFFFFFFFFFFFFFF))
3784+
os.close(fd)
3785+
"""
3786+
)
3787+
3788+
with self._poisoned_debug_offsets_process(poison_code) as (_, info):
3789+
with self.assertRaisesRegex(RuntimeError, "Invalid debug offsets"):
3790+
RemoteUnwinder(info["pid"], all_threads=True, debug=True)
3791+
3792+
@skip_if_not_supported
3793+
def test_rejects_poisoned_thread_state_datastack_chunk(self):
3794+
poison_code = textwrap.dedent(
3795+
"""\
3796+
thread_state_offset = 8 + 8 + 8 + 3 * 8 + 16 * 8
3797+
datastack_chunk_offset = thread_state_offset + 72
3798+
fd = os.open(f"/proc/{pid}/mem", os.O_WRONLY)
3799+
os.lseek(fd, addr + datastack_chunk_offset, 0)
3800+
os.write(fd, struct.pack("<Q", 0x10000))
3801+
os.close(fd)
3802+
"""
3803+
)
3804+
3805+
with self._poisoned_debug_offsets_process(poison_code) as (_, info):
3806+
with self.assertRaisesRegex(RuntimeError, "Invalid debug offsets"):
3807+
RemoteUnwinder(info["pid"], all_threads=True, debug=True)
3808+
3809+
@skip_if_not_supported
3810+
def test_rejects_poisoned_asyncio_task_node_offset(self):
3811+
poison_code = textwrap.dedent(
3812+
"""\
3813+
asyncio_addr = find_section_address("AsyncioDebug", "python")
3814+
task_node_offset = 6 * 8
3815+
fd = os.open(f"/proc/{pid}/mem", os.O_WRONLY)
3816+
os.lseek(fd, asyncio_addr + task_node_offset, 0)
3817+
os.write(fd, struct.pack("<Q", 0x10000))
3818+
os.close(fd)
3819+
"""
3820+
)
3821+
3822+
with self._poisoned_debug_offsets_process(poison_code) as (_, info):
3823+
with self.assertRaisesRegex(RuntimeError, "Invalid AsyncioDebug offsets"):
3824+
RemoteUnwinder(info["pid"], debug=True)
3825+
3826+
@skip_if_not_supported
3827+
def test_rejects_poisoned_asyncio_running_task_offset(self):
3828+
poison_code = textwrap.dedent(
3829+
"""\
3830+
asyncio_addr = find_section_address("AsyncioDebug", "python")
3831+
asyncio_thread_state_offset = 7 * 8 + 2 * 8
3832+
asyncio_running_task_offset = asyncio_thread_state_offset + 2 * 8
3833+
fd = os.open(f"/proc/{pid}/mem", os.O_WRONLY)
3834+
os.lseek(fd, asyncio_addr + asyncio_running_task_offset, 0)
3835+
os.write(fd, struct.pack("<Q", 0x10000))
3836+
os.close(fd)
3837+
"""
3838+
)
3839+
3840+
with self._poisoned_debug_offsets_process(poison_code) as (_, info):
3841+
with self.assertRaisesRegex(RuntimeError, "Invalid AsyncioDebug offsets"):
3842+
RemoteUnwinder(info["pid"], debug=True)
3843+
3844+
35773845
if __name__ == "__main__":
35783846
unittest.main()
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Hardened :mod:`!_remote_debugging` by validating remote debug offset tables
2+
before using them to size memory reads or interpret remote layouts.

Modules/_remote_debugging/_remote_debugging.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,9 @@ extern void cached_code_metadata_destroy(void *ptr);
417417
/* Validation */
418418
extern int is_prerelease_version(uint64_t version);
419419
extern int validate_debug_offsets(struct _Py_DebugOffsets *debug_offsets);
420+
enum {
421+
PY_REMOTE_DEBUG_INVALID_ASYNC_DEBUG_OFFSETS = -2,
422+
};
420423

421424
/* ============================================================================
422425
* MEMORY READING FUNCTION DECLARATIONS

Modules/_remote_debugging/asyncio.c

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
******************************************************************************/
77

88
#include "_remote_debugging.h"
9+
#include "debug_offsets_validation.h"
910

1011
/* ============================================================================
1112
* ASYNCIO DEBUG ADDRESS FUNCTIONS
@@ -71,8 +72,13 @@ read_async_debug(RemoteUnwinderObject *unwinder)
7172
int result = _Py_RemoteDebug_PagedReadRemoteMemory(&unwinder->handle, async_debug_addr, size, &unwinder->async_debug_offsets);
7273
if (result < 0) {
7374
set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read AsyncioDebug offsets");
75+
return result;
7476
}
75-
return result;
77+
if (_PyRemoteDebug_ValidateAsyncDebugOffsets(&unwinder->async_debug_offsets) < 0) {
78+
set_exception_cause(unwinder, PyExc_RuntimeError, "Invalid AsyncioDebug offsets");
79+
return PY_REMOTE_DEBUG_INVALID_ASYNC_DEBUG_OFFSETS;
80+
}
81+
return 0;
7682
}
7783

7884
int
@@ -85,7 +91,11 @@ ensure_async_debug_offsets(RemoteUnwinderObject *unwinder)
8591

8692
// Try to load async debug offsets (the target process may have
8793
// loaded asyncio since we last checked)
88-
if (read_async_debug(unwinder) < 0) {
94+
int result = read_async_debug(unwinder);
95+
if (result == PY_REMOTE_DEBUG_INVALID_ASYNC_DEBUG_OFFSETS) {
96+
return -1;
97+
}
98+
if (result < 0) {
8999
PyErr_Clear();
90100
PyErr_SetString(PyExc_RuntimeError, "AsyncioDebug section not available");
91101
set_exception_cause(unwinder, PyExc_RuntimeError,

0 commit comments

Comments
 (0)