远程调试附加协议

该协议使外部工具能够附加到正在运行的 CPython 进程并远程执行 Python 代码。

大多数平台需要提升权限才能附加到另一个 Python 进程。

权限要求

在大多数平台上,附加到正在运行的 Python 进程进行远程调试需要提升权限。具体的权限要求和故障排除步骤取决于您的操作系统。

Linux

跟踪器进程必须具有 CAP_SYS_PTRACE 能力或同等权限。您只能跟踪您拥有并可以发送信号的进程。如果进程已被跟踪,或者正在以 set-user-ID 或 set-group-ID 运行,则跟踪可能会失败。Yama 等安全模块可能会进一步限制跟踪。

要暂时放松 ptrace 限制(直到重启),请运行:

echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope

备注

禁用 ptrace_scope 会降低系统强化程度,因此只应在受信任的环境中进行。

如果在容器内运行,请使用 --cap-add=SYS_PTRACE--privileged,并在需要时以 root 身份运行。

尝试以提升的权限重新运行命令:

sudo -E !!

macOS

要附加到另一个进程,您通常需要以提升的权限运行调试工具。这可以通过使用 sudo 或以 root 身份运行来实现。

即使附加到您拥有的进程,macOS 也可能会阻止调试,除非调试器由于系统安全限制而以 root 权限运行。

Windows

要附加到另一个进程,您通常需要以管理员权限运行调试工具。以管理员身份启动命令提示符或终端。

即使具有管理员权限,某些进程仍然无法访问,除非您启用了 SeDebugPrivilege 权限。

要解决文件或文件夹访问问题,请调整安全权限:

  1. 右键单击文件或文件夹并选择 属性

  2. 转到 安全 选项卡以查看具有访问权限的用户和组。

  3. 单击 编辑 以修改权限。

  4. 选择您的用户帐户。

  5. 权限 中,根据需要选中 读取完全控制

  6. 单击 应用,然后单击 确定 进行确认。

备注

在继续之前,请确保您已满足所有权限要求

本节描述了允许外部工具在正在运行的 CPython 进程中注入和执行 Python 脚本的低级协议。

此机制构成了 sys.remote_exec() 函数的基础,该函数指示远程 Python 进程执行 .py 文件。但是,本节不记录该函数的使用。相反,它提供了对底层协议的详细解释,该协议以目标 Python 进程的 pid 和要执行的 Python 源文件的路径作为输入。此信息支持协议的独立重新实现,无论编程语言如何。

警告

注入脚本的执行取决于解释器是否达到安全的评估点。因此,执行可能会根据目标进程的运行时状态而延迟。

一旦注入,脚本将在解释器下次到达安全评估点时由目标进程内的解释器执行。这种方法能够在不修改正在运行的 Python 应用程序的行为或结构的情况下实现远程执行功能。

后续章节提供了协议的逐步描述,包括在内存中定位解释器结构、安全访问内部字段和触发代码执行的技术。适用时会注明平台特定的差异,并包含示例实现以阐明每个操作。

定位 PyRuntime 结构体

CPython 将 PyRuntime 结构体放置在专用二进制节中,以帮助外部工具在运行时找到它。该节的名称和格式因平台而异。例如,ELF 系统上使用 .PyRuntime,macOS 上使用 __DATA,__PyRuntime。工具可以通过检查磁盘上的二进制文件来查找此结构体的偏移量。

PyRuntime 结构体包含 CPython 的全局解释器状态,并提供对其他内部数据的访问,包括解释器列表、线程状态和调试器支持字段。

要与远程 Python 进程一起工作,调试器必须首先在目标进程中找到 PyRuntime 结构体的内存地址。此地址无法硬编码或从符号名称计算,因为它取决于操作系统加载二进制文件的位置。

查找 PyRuntime 的方法取决于平台,但步骤大致相同:

  1. 查找目标进程中 Python 二进制文件或共享库加载的基地址。

  2. 使用磁盘上的二进制文件定位 .PyRuntime 节的偏移量。

  3. 将节偏移量添加到基地址以计算内存中的地址。

以下章节解释了如何在每个受支持的平台上执行此操作,并包含示例代码。

Linux (ELF)

要在 Linux 上找到 PyRuntime 结构体:

  1. 读取进程的内存映射(例如,/proc/<pid>/maps)以查找 Python 可执行文件或 libpython 加载的地址。

  2. 解析二进制文件中的 ELF 节头以获取 .PyRuntime 节的偏移量。

  3. 将该偏移量添加到步骤 1 中的基地址,以获取 PyRuntime 的内存地址。

以下是一个示例实现:

def find_py_runtime_linux(pid: int) -> int:
    # Step 1: Try to find the Python executable in memory
    binary_path, base_address = find_mapped_binary(
        pid, name_contains="python"
    )

    # Step 2: Fallback to shared library if executable is not found
    if binary_path is None:
        binary_path, base_address = find_mapped_binary(
            pid, name_contains="libpython"
        )

    # Step 3: Parse ELF headers to get .PyRuntime section offset
    section_offset = parse_elf_section_offset(
        binary_path, ".PyRuntime"
    )

    # Step 4: Compute PyRuntime address in memory
    return base_address + section_offset

在 Linux 系统上,有两种主要方法可以从另一个进程读取内存。第一种是通过 /proc 文件系统,特别是通过从 /proc/[pid]/mem 读取,它提供对进程内存的直接访问。这需要适当的权限——要么与目标进程是同一个用户,要么具有 root 访问权限。第二种方法是使用 process_vm_readv() 系统调用,它提供了一种更有效的方式在进程之间复制内存。虽然 ptrace 的 PTRACE_PEEKTEXT 操作也可以用于读取内存,但它要慢得多,因为它一次只读取一个字,并且需要跟踪器和被跟踪进程之间进行多次上下文切换。

对于解析 ELF 节,该过程涉及从磁盘上的二进制文件读取和解释 ELF 文件格式结构。ELF 头包含指向节头表的指针。每个节头包含有关节的元数据,包括其名称(存储在单独的字符串表中)、偏移量和大小。要找到特定的节(如 .PyRuntime),您需要遍历这些头并匹配节名称。然后,节头提供该节在文件中存在的偏移量,可用于在二进制文件加载到内存时计算其运行时地址。

您可以在 ELF 规范 中阅读有关 ELF 文件格式的更多信息。

macOS (Mach-O)

要在 macOS 上找到 PyRuntime 结构体:

  1. 调用 task_for_pid() 以获取目标进程的 mach_port_t 任务端口。此句柄是使用 mach_vm_read_overwritemach_vm_region 等 API 读取内存所必需的。

  2. 扫描内存区域以查找包含 Python 可执行文件或 libpython 的区域。

  3. 从磁盘加载二进制文件并解析 Mach-O 头以在 __DATA 段中查找名为 PyRuntime 的节。在 macOS 上,符号名称会自动添加下划线前缀,因此 PyRuntime 符号在符号表中显示为 _PyRuntime,但节名称不受影响。

以下是一个示例实现:

def find_py_runtime_macos(pid: int) -> int:
    # Step 1: Get access to the process's memory
    handle = get_memory_access_handle(pid)

    # Step 2: Try to find the Python executable in memory
    binary_path, base_address = find_mapped_binary(
        handle, name_contains="python"
    )

    # Step 3: Fallback to libpython if the executable is not found
    if binary_path is None:
        binary_path, base_address = find_mapped_binary(
            handle, name_contains="libpython"
        )

    # Step 4: Parse Mach-O headers to get __DATA,__PyRuntime section offset
    section_offset = parse_macho_section_offset(
        binary_path, "__DATA", "__PyRuntime"
    )

    # Step 5: Compute the PyRuntime address in memory
    return base_address + section_offset

在 macOS 上,访问另一个进程的内存需要使用 Mach-O 特定的 API 和文件格式。第一步是通过 task_for_pid() 获取 task_port 句柄,该句柄提供对目标进程内存空间的访问。此句柄通过 mach_vm_read_overwrite() 等 API 实现内存操作。

可以使用 mach_vm_region() 检查进程内存以扫描虚拟内存空间,而 proc_regionfilename() 有助于识别在每个内存区域加载了哪些二进制文件。找到 Python 二进制文件或库后,需要解析其 Mach-O 头以定位 PyRuntime 结构体。

Mach-O 格式将代码和数据组织成段和节。PyRuntime 结构体位于 __DATA 段中名为 __PyRuntime 的节中。实际的运行时地址计算涉及查找作为二进制文件基地址的 __TEXT 段,然后定位包含目标节的 __DATA 段。最终地址通过将基地址与 Mach-O 头中适当的节偏移量组合计算得出。

请注意,在 macOS 上访问另一个进程的内存通常需要提升权限——要么是 root 访问权限,要么是授予调试进程的特殊安全授权。

Windows (PE)

要在 Windows 上找到 PyRuntime 结构体:

  1. 使用 ToolHelp API 枚举目标进程中加载的所有模块。这可以通过使用 CreateToolhelp32SnapshotModule32FirstModule32Next 等函数完成。

  2. 识别与 python.exepythonXY.dll 对应的模块,其中 XY 是 Python 版本的主次版本号,并记录其基地址。

  3. 定位 PyRuntim 节。由于 PE 格式对节名称有 8 个字符的限制(定义为 IMAGE_SIZEOF_SHORT_NAME),原始名称 PyRuntime 被截断。此节包含 PyRuntime 结构体。

  4. 检索节的相对虚拟地址 (RVA) 并将其添加到模块的基地址。

以下是一个示例实现:

def find_py_runtime_windows(pid: int) -> int:
    # Step 1: Try to find the Python executable in memory
    binary_path, base_address = find_loaded_module(
        pid, name_contains="python"
    )

    # Step 2: Fallback to shared pythonXY.dll if the executable is not
    # found
    if binary_path is None:
        binary_path, base_address = find_loaded_module(
            pid, name_contains="python3"
        )

    # Step 3: Parse PE section headers to get the RVA of the PyRuntime
    # section. The section name appears as "PyRuntim" due to the
    # 8-character limit defined by the PE format (IMAGE_SIZEOF_SHORT_NAME).
    section_rva = parse_pe_section_offset(binary_path, "PyRuntim")

    # Step 4: Compute PyRuntime address in memory
    return base_address + section_rva

在 Windows 上,访问另一个进程的内存需要使用 Windows API 函数,如 CreateToolhelp32Snapshot()Module32First()/Module32Next() 来枚举已加载的模块。OpenProcess() 函数提供了一个句柄来访问目标进程的内存空间,从而通过 ReadProcessMemory() 实现内存操作。

可以通过枚举已加载的模块来检查进程内存,以找到 Python 二进制文件或 DLL。找到后,需要解析其 PE 头以定位 PyRuntime 结构体。

PE 格式将代码和数据组织成节。PyRuntime 结构体位于名为“PyRuntim”(由于 PE 的 8 个字符名称限制而从“PyRuntime”截断)的节中。实际的运行时地址计算涉及从模块条目中找到模块的基地址,然后定位 PE 头中的目标节。最终地址通过将基地址与 PE 节头中的节虚拟地址组合计算得出。

请注意,在 Windows 上访问另一个进程的内存通常需要适当的权限——要么是管理权限,要么是授予调试进程的 SeDebugPrivilege 权限。

读取 _Py_DebugOffsets

一旦确定了 PyRuntime 结构体的地址,下一步是读取位于 PyRuntime 块开头的 _Py_DebugOffsets 结构体。

此结构体提供版本特定的字段偏移量,这些偏移量是安全读取解释器和线程状态内存所必需的。这些偏移量在 CPython 版本之间有所不同,在使用前必须检查以确保它们兼容。

要读取和检查调试偏移量,请遵循以下步骤:

  1. 从目标进程的 PyRuntime 地址开始读取内存,覆盖与 _Py_DebugOffsets 结构体相同数量的字节。此结构体位于 PyRuntime 内存块的最开始处。其布局在 CPython 的内部头文件中定义,并在给定的小版本中保持不变,但在主版本中可能会发生变化。

  2. 检查结构体是否包含有效数据:

    • cookie 字段必须与预期的调试标记匹配。

    • version 字段必须与调试器使用的 Python 解释器版本匹配。

    • 如果调试器或目标进程正在使用预发布版本(例如,alpha、beta 或发布候选版本),则版本必须完全匹配。

    • free_threaded 字段在调试器和目标进程中必须具有相同的值。

  3. 如果结构体有效,则其中包含的偏移量可用于在内存中定位字段。如果任何检查失败,调试器应停止操作以避免以错误的格式读取内存。

以下是一个读取和检查 _Py_DebugOffsets 的示例实现:

def read_debug_offsets(pid: int, py_runtime_addr: int) -> DebugOffsets:
    # Step 1: Read memory from the target process at the PyRuntime address
    data = read_process_memory(
        pid, address=py_runtime_addr, size=DEBUG_OFFSETS_SIZE
    )

    # Step 2: Deserialize the raw bytes into a _Py_DebugOffsets structure
    debug_offsets = parse_debug_offsets(data)

    # Step 3: Validate the contents of the structure
    if debug_offsets.cookie != EXPECTED_COOKIE:
        raise RuntimeError("Invalid or missing debug cookie")
    if debug_offsets.version != LOCAL_PYTHON_VERSION:
        raise RuntimeError(
            "Mismatch between caller and target Python versions"
        )
    if debug_offsets.free_threaded != LOCAL_FREE_THREADED:
        raise RuntimeError("Mismatch in free-threaded configuration")

    return debug_offsets

警告

建议暂停进程

为避免竞态条件并确保内存一致性,强烈建议在执行任何读取或写入内部解释器状态的操作之前暂停目标进程。Python 运行时可能会在正常执行期间并发地改变解释器数据结构——例如创建或销毁线程。这可能导致无效的内存读取或写入。

调试器可以通过使用 ptrace 附加到进程或发送 SIGSTOP 信号来暂停执行。只有在调试器端的内存操作完成后才应恢复执行。

备注

某些工具,例如分析器或基于采样的调试器,可能在不暂停的情况下操作正在运行的进程。在这种情况下,工具必须明确设计为处理部分更新或不一致的内存。对于大多数调试器实现,暂停进程仍然是最安全、最稳健的方法。

定位解释器和线程状态

在远程 Python 进程中注入和执行代码之前,调试器必须选择一个线程来调度执行。这是必要的,因为用于执行远程代码注入的控制字段位于 _PyRemoteDebuggerSupport 结构体中,该结构体嵌入在 PyThreadState 对象中。这些字段由调试器修改,以请求执行注入的脚本。

PyThreadState 结构体表示在 Python 解释器中运行的线程。它维护线程的评估上下文,并包含调试器协调所需的字段。因此,定位有效的 PyThreadState 是远程触发执行的关键前提。

线程通常根据其角色或 ID 进行选择。在大多数情况下,使用主线程,但某些工具可能会通过其本地线程 ID 定位特定线程。选择目标线程后,调试器必须在内存中定位解释器和关联的线程状态结构体。

相关的内部结构定义如下:

  • PyInterpreterState 表示一个独立的 Python 解释器实例。每个解释器都维护自己的一组导入模块、内置状态和线程状态列表。尽管大多数 Python 应用程序使用单个解释器,但 CPython 支持在同一进程中运行多个解释器。

  • PyThreadState 表示在解释器中运行的线程。它包含执行状态和调试器使用的控制字段。

要定位线程:

  1. 使用偏移量 runtime_state.interpreters_head 获取 PyRuntime 结构体中第一个解释器的地址。这是活动解释器链表的入口点。

  2. 使用偏移量 interpreter_state.threads_main 访问与选定解释器关联的主线程状态。这通常是目标最可靠的线程。

  3. 可选地,使用偏移量 interpreter_state.threads_head 遍历所有线程状态的链表。每个 PyThreadState 结构体都包含一个 native_thread_id 字段,可以将其与目标线程 ID 进行比较以查找特定线程。

  4. 一旦找到有效的 PyThreadState,其地址可以在协议的后续步骤中使用,例如写入调试器控制字段和调度执行。

以下是一个定位主线程状态的示例实现:

def find_main_thread_state(
    pid: int, py_runtime_addr: int, debug_offsets: DebugOffsets,
) -> int:
    # Step 1: Read interpreters_head from PyRuntime
    interp_head_ptr = (
        py_runtime_addr + debug_offsets.runtime_state.interpreters_head
    )
    interp_addr = read_pointer(pid, interp_head_ptr)
    if interp_addr == 0:
        raise RuntimeError("No interpreter found in the target process")

    # Step 2: Read the threads_main pointer from the interpreter
    threads_main_ptr = (
        interp_addr + debug_offsets.interpreter_state.threads_main
    )
    thread_state_addr = read_pointer(pid, threads_main_ptr)
    if thread_state_addr == 0:
        raise RuntimeError("Main thread state is not available")

    return thread_state_addr

以下示例演示如何通过其本地线程 ID 定位线程:

def find_thread_by_id(
    pid: int,
    interp_addr: int,
    debug_offsets: DebugOffsets,
    target_tid: int,
) -> int:
    # Start at threads_head and walk the linked list
    thread_ptr = read_pointer(
        pid,
        interp_addr + debug_offsets.interpreter_state.threads_head
    )

    while thread_ptr:
        native_tid_ptr = (
            thread_ptr + debug_offsets.thread_state.native_thread_id
        )
        native_tid = read_int(pid, native_tid_ptr)
        if native_tid == target_tid:
            return thread_ptr
        thread_ptr = read_pointer(
            pid,
            thread_ptr + debug_offsets.thread_state.next
        )

    raise RuntimeError("Thread with the given ID was not found")

一旦找到了有效的线程状态,调试器就可以继续修改其控制字段并调度执行,如下一节所述。

写入控制信息

一旦识别出有效的 PyThreadState 结构体,调试器可以修改其中的控制字段以调度执行指定的 Python 脚本。解释器会定期检查这些控制字段,如果设置正确,它们会在评估循环中的安全点触发远程代码的执行。

每个 PyThreadState 都包含一个 _PyRemoteDebuggerSupport 结构体,用于调试器和解释器之间的通信。其字段的位置由 _Py_DebugOffsets 结构体定义,包括以下内容:

  • debugger_script_path:一个固定大小的缓冲区,用于存储 Python 源文件 (.py) 的完整路径。当触发执行时,此文件必须可由目标进程访问和读取。

  • debugger_pending_call:一个整数标志。将其设置为 1 会告诉解释器脚本已准备好执行。

  • eval_breaker:解释器在执行期间检查的字段。在此字段中设置位 5 (_PY_EVAL_PLEASE_STOP_BIT,值为 1U << 5) 会导致解释器暂停并检查调试器活动。

要完成注入,调试器必须执行以下步骤:

  1. 将完整的脚本路径写入 debugger_script_path 缓冲区。

  2. debugger_pending_call 设置为 1

  3. 读取 eval_breaker 的当前值,设置位 5 (_PY_EVAL_PLEASE_STOP_BIT),并将更新后的值写回。这会向解释器发出信号,以检查调试器活动。

以下是一个示例实现:

def inject_script(
    pid: int,
    thread_state_addr: int,
    debug_offsets: DebugOffsets,
    script_path: str
) -> None:
    # Compute the base offset of _PyRemoteDebuggerSupport
    support_base = (
        thread_state_addr +
        debug_offsets.debugger_support.remote_debugger_support
    )

    # Step 1: Write the script path into debugger_script_path
    script_path_ptr = (
        support_base +
        debug_offsets.debugger_support.debugger_script_path
    )
    write_string(pid, script_path_ptr, script_path)

    # Step 2: Set debugger_pending_call to 1
    pending_ptr = (
        support_base +
        debug_offsets.debugger_support.debugger_pending_call
    )
    write_int(pid, pending_ptr, 1)

    # Step 3: Set _PY_EVAL_PLEASE_STOP_BIT (bit 5, value 1 << 5) in
    # eval_breaker
    eval_breaker_ptr = (
        thread_state_addr +
        debug_offsets.debugger_support.eval_breaker
    )
    breaker = read_int(pid, eval_breaker_ptr)
    breaker |= (1 << 5)
    write_int(pid, eval_breaker_ptr, breaker)

一旦设置了这些字段,调试器可以恢复进程(如果它已暂停)。解释器将在下一个安全评估点处理请求,从磁盘加载脚本并执行它。

调试器有责任确保脚本文件在执行期间保持存在且可由目标进程访问。

备注

脚本执行是异步的。注入后不能立即删除脚本文件。调试器应等待注入的脚本产生可观察的效果,然后再删除文件。此效果取决于脚本的设计用途。例如,调试器可能会等待远程进程重新连接到套接字,然后再删除脚本。一旦观察到此类效果,就可以安全地假定不再需要该文件。

总结

要在远程进程中注入和执行 Python 脚本:

  1. 在目标进程的内存中定位 PyRuntime 结构体。

  2. 读取并验证 PyRuntime 开头的 _Py_DebugOffsets 结构体。

  3. 使用偏移量定位有效的 PyThreadState

  4. 将 Python 脚本的路径写入 debugger_script_path

  5. debugger_pending_call 标志设置为 1

  6. eval_breaker 字段中设置 _PY_EVAL_PLEASE_STOP_BIT

  7. 恢复进程(如果已暂停)。脚本将在下一个安全评估点执行。