遠端偵錯附加協議

此協議使外部工具能夠附加到正在執行的 CPython 行程並遠端執行 Python 程式碼。

大多數平台上都會需要更高的權限才能附加到另一個 Python 行程。

權限需求

在大多數平台上,附加到正在執行的 Python 行程進行遠端偵錯需要更高的權限。具體要求和疑難排解步驟會取決於你的作業系統:

Linux

執行追蹤的行程 (tracer process) 必須具有 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. 右鍵點擊檔案或資料夾並選擇 Properties

  2. 前往 Security 分頁以檢視具有存取權限的使用者和群組。

  3. 點擊 Edit 以修改權限。

  4. 選擇你的使用者帳戶。

  5. Permissions 中,視需要勾選 ReadFull control

  6. 點擊 Apply,然後點擊 OK 來確認。

備註

在繼續之前,請確保你已滿足所有 權限需求

本節描述了低階協議,使外部工具能夠在正在執行的 CPython 行程中注入並執行 Python 腳本。

此機制構成了 sys.remote_exec() 函式的基礎,該函式指示遠端 Python 行程執行 .py 檔案。但本節不記錄該函式的用法,而是提供底層協議的詳細說明,該協議以目標 Python 行程的 pid 和要執行的 Python 原始檔路徑作為輸入。此資訊支援該協議的獨立重新實作,無論使用何種程式語言。

警告

注入腳本的執行取決於直譯器是否到達安全執行點 (safe evaluation point)。因此,執行可能會根據目標行程的 runtime 狀態而延遲。

一旦注入,腳本將在下次到達安全執行點時會由目標行程內的直譯器執行。這種方法使遠端執行功能成為可能,而不需修改正在執行的 Python 應用程式的行為或結構。

後續章節提供了協議的逐步描述,包括在記憶體中定位直譯器結構、安全存取內部欄位以及觸發程式碼執行的技術。在適用的情況下會註明平台特有的變化,並包含範例實作以闡明每個操作。

定位 PyRuntime 結構

CPython 將 PyRuntime 結構放置在專用的二進位區段(section)中,以幫助外部工具在 runtime 找到它。此區段的名稱和格式因平台而異。例如,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:
    # 步驟 1:嘗試在記憶體中找到 Python 可執行檔
    binary_path, base_address = find_mapped_binary(
        pid, name_contains="python"
    )

    # 步驟 2:如果找不到可執行檔,則改用共享函式庫
    if binary_path is None:
        binary_path, base_address = find_mapped_binary(
            pid, name_contains="libpython"
        )

    # 步驟 3:剖析 ELF 標頭以取得 .PyRuntime 區段偏移量
    section_offset = parse_elf_section_offset(
        binary_path, ".PyRuntime"
    )

    # 步驟 4:計算 PyRuntime 在記憶體中的位址
    return base_address + section_offset

在 Linux 系統上,有兩種主要方法可以從另一個行程讀取記憶體。第一種是透過 /proc 檔案系統,特別是從 /proc/[pid]/mem 讀取,這提供了對行程記憶體的直接存取。這需要適當的權限 - 要麼與目標行程是同一使用者,要麼就要擁有 root 存取權限。第二種方法是使用 process_vm_readv() 系統呼叫,它提供了在行程之間複製記憶體的更有效方式。雖然 ptrace 的 PTRACE_PEEKTEXT 操作也可以用來讀取記憶體,但它明顯較慢,因為它一次只讀取一個字,並且需要追蹤器和被追蹤行程之間的多次情境切換。

對於剖析 ELF 區段,該過程涉及從磁碟上的二進位檔案讀取並解釋 ELF 檔案格式結構。ELF標頭包含指向區段標頭表的指標。每個區段標頭包含關於區段的中介資料,包括其名稱(儲存在單獨的字串表中)、偏移量和大小。要找到像 .PyRuntime 這樣的特定區段,你需要遍歷這些標頭並匹配區段名稱。然後區段標頭會提供該區段在檔案中存在的偏移量,當二進位檔案載入到記憶體時,可以用它來計算 runtime 位址。

你可以在 ELF 規範中閱讀更多關於 ELF 檔案格式的資訊。

macOS (Mach-O)

在 macOS 上找 PyRuntime 結構:

  1. 呼叫 task_for_pid() 以取得目標行程的 mach_port_t 任務埠 (task port)。此控制代碼 (handle) 用於使用像 mach_vm_read_overwritemach_vm_region 這樣的 API 來讀取記憶體。

  2. 掃描記憶體區域以找到包含 Python 可執行檔或 libpython 的區域。

  3. 從磁碟載入二進位檔案並剖析 Mach-O 標頭以找到 __DATA 程式段(segment)中名為 PyRuntime 的區段。在 macOS 上,符號名稱會自動加上底線前綴,因此 PyRuntime 符號在符號表中顯示為 _PyRuntime,但區段名稱不受影響。

以下是一個範例實作:

def find_py_runtime_macos(pid: int) -> int:
    # 步驟 1:取得對行程記憶體的存取權限
    handle = get_memory_access_handle(pid)

    # 步驟 2:嘗試在記憶體中找到 Python 可執行檔
    binary_path, base_address = find_mapped_binary(
        handle, name_contains="python"
    )

    # 步驟 3:如果找不到可執行檔,則改用 libpython
    if binary_path is None:
        binary_path, base_address = find_mapped_binary(
            handle, name_contains="libpython"
        )

    # 步驟 4:剖析 Mach-O 標頭以取得 __DATA,__PyRuntime 區段偏移量
    section_offset = parse_macho_section_offset(
        binary_path, "__DATA", "__PyRuntime"
    )

    # 步驟 5:計算 PyRuntime 在記憶體中的位址
    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 的區段中。實際的 runtime 位址計算涉及找到作為二進位檔案基底位址的 __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, relative virtual address) 並將其加到模組的基底位址。

以下是一個範例實作:

def find_py_runtime_windows(pid: int) -> int:
    # 步驟 1:嘗試在記憶體中找到 Python 可執行檔
    binary_path, base_address = find_loaded_module(
        pid, name_contains="python"
    )

    # 步驟 2:如果找不到可執行檔,則改用共享函式庫 pythonXY.dll
    if binary_path is None:
        binary_path, base_address = find_loaded_module(
            pid, name_contains="python3"
        )

    # 步驟 3:剖析 PE 區段標頭以取得 PyRuntime 區段的 RVA。
    # 由於 PE 格式 (IMAGE_SIZEOF_SHORT_NAME) 有8 字元的限制,
    # 區段名稱顯示為 "PyRuntim"。
    section_rva = parse_pe_section_offset(binary_path, "PyRuntim")

    # 步驟 4:計算 PyRuntime 在記憶體中的位址
    return base_address + section_rva

在 Windows 上,存取另一個行程的記憶體需要使用像 CreateToolhelp32Snapshot()Module32First()/Module32Next() 這樣的 Windows API 函式來列舉載入的模組。OpenProcess() 函式提供一個控制代碼來存取目標行程的記憶體空間,並透過 ReadProcessMemory() 啟用記憶體操作。

可以透過列舉載入的模組來檢查行程記憶體,以找到 Python 二進位檔案或 DLL。找到後,需要剖析其 PE 標頭以定位 PyRuntime 結構。

PE 格式將程式碼和資料組織成區段。PyRuntime 結構位於名為 "PyRuntim" 的區段中(由於 PE 的 8 字元名稱限制,從 "PyRuntime" 截斷)。實際的 runtime 位址計算涉及從模組項目中找到模組的基底位址,然後在 PE 標頭中定位我們的目標區段。最終位址是透過將基底位址與 PE 區段標頭中的區段虛擬位址組合來計算的。

請注意,在 Windows 上存取另一個行程的記憶體通常需要適當的權限 - 要麼是系統管理員存取權限,要麼是授予偵錯行程的 SeDebugPrivilege 權限。

讀取 _Py_DebugOffsets

一旦確定了 PyRuntime 結構的位址,下一步就是讀取位於 PyRuntime 區塊開頭的 _Py_DebugOffsets 結構。

此結構提供了安全讀取直譯器和執行緒狀態記憶體所需的版本特定欄位偏移量。這些偏移量在不同的 CPython 版本之間有所不同,必須在使用前檢查以確保它們相容。

要讀取並檢查偵錯偏移量,請按照以下步驟操作:

  1. 從目標行程中的 PyRuntime 位址開始讀取記憶體,涵蓋與 _Py_DebugOffsets 結構相同的位元組數。此結構位於 PyRuntime 記憶體區塊的最開始處。其佈局 (layout) 在 CPython 的內部標頭中定義,並在給定的次要版本中維持不變,但在主要版本中可能會更改。

  2. 檢查結構是否包含有效資料:

    • cookie 欄位必須匹配預期的偵錯標記。

    • version 欄位必須匹配偵錯器使用的 Python 直譯器版本。

    • 如果偵錯器或目標行程使用的是預發行版本(例如 alpha、beta 或候選版本),版本必須完全匹配。

    • free_threaded 欄位在偵錯器和目標行程中必須具有相同的值。

  3. 如果結構有效,其中包含的偏移量可用於定位記憶體中的欄位。如果任何檢查失敗,偵錯器應停止操作以避免以錯誤的格式讀取記憶體。

以下是一個讀取並檢查 _Py_DebugOffsets 的範例實作:

def read_debug_offsets(pid: int, py_runtime_addr: int) -> DebugOffsets:
    # 步驟 1:在 PyRuntime 位址從目標行程讀取記憶體
    data = read_process_memory(
        pid, address=py_runtime_addr, size=DEBUG_OFFSETS_SIZE
    )

    # 步驟 2:將原始位元組反序列化為 _Py_DebugOffsets 結構
    debug_offsets = parse_debug_offsets(data)

    # 步驟 3:驗證結構的內容
    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

警告

建議暫停行程

為了避免競態條件 (race conditions) 並確保記憶體一致性,強烈建議在執行任何讀取或寫入內部直譯器狀態的操作之前暫停目標行程。Python runtime 可能在正常執行期間同時變更直譯器資料結構 - 例如建立或銷毀執行緒。這可能導致無效的記憶體讀取或寫入。

偵錯器可以透過使用 ptrace 附加到行程或發送 SIGSTOP 訊號來暫停執行。只有在偵錯器端的記憶體操作完成後才應恢復執行。

備註

一些工具,例如分析器 (profilers) 或基於取樣的偵錯器,可能在不暫停的情況下對正在執行的行程進行操作。在這種情況下,工具必須明確設計為能夠處理部分更新或不一致的記憶體。對於大多數偵錯器實作來說,暫停行程仍然是最安全可靠的方法。

定位直譯器和執行緒狀態

在可以於遠端 Python 行程中注入並執行程式碼之前,偵錯器必須選擇一個用於排程執行的執行緒。這是必要的,因為用於執行遠端程式碼注入的控制欄位位於 _PyRemoteDebuggerSupport 結構中,該結構嵌入在 PyThreadState 物件中。偵錯器會修改這些欄位以請求執行注入的腳本。

PyThreadState 結構代表在 Python 直譯器內執行的執行緒。它維護執行緒的求值情境 (evaluation context),並包含偵錯器協調所需的欄位。因此,定位有效的 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:
    # 步驟 1:從 PyRuntime 讀取 interpreters_head
    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")

    # 步驟 2:從直譯器讀取 threads_main 指標
    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:
    # 從 threads_head 開始並遍歷鏈結串列
    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:
    # 計算 _PyRemoteDebuggerSupport 的基底偏移量
    support_base = (
        thread_state_addr +
        debug_offsets.debugger_support.remote_debugger_support
    )

    # 步驟 1:將腳本路徑寫入 debugger_script_path
    script_path_ptr = (
        support_base +
        debug_offsets.debugger_support.debugger_script_path
    )
    write_string(pid, script_path_ptr, script_path)

    # 步驟 2:將 debugger_pending_call 設定為 1
    pending_ptr = (
        support_base +
        debug_offsets.debugger_support.debugger_pending_call
    )
    write_int(pid, pending_ptr, 1)

    # 步驟 3:在 eval_breaker 中設定 _PY_EVAL_PLEASE_STOP_BIT(位元 5,值為 1 << 5)
    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. 恢復行程(如果已暫停)。腳本將在下一個安全執行點執行。