遠端除錯附加協議

該協議使外部工具能夠附加到正在執行的 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. 恢復程序(如果已暫停)。指令碼將在下一個安全評估點執行。