# Protocolo de anexação de depuração remota

Esta seção descreve o protocolo de baixo nível que permite que ferramentas externas injetem e executem um script Python dentro de um processo CPython em execução.

Este mecanismo forma a base da função `sys.remote_exec()`, que instrui um processo Python remoto a executar um arquivo `.py`. No entanto, esta seção não documenta o uso dessa função. Em vez disso, fornece uma explicação detalhada do protocolo subjacente, que recebe como entrada o `pid` de um processo Python de destino e o caminho para um arquivo-fonte Python a ser executado. Essas informações permitem a reimplementação independente do protocolo, independentemente da linguagem de programação.

Aviso

A execução do script injetado depende de o interpretador atingir um ponto de avaliação seguro. Como resultado, a execução pode ser atrasada dependendo do estado de execução do processo de destino.

Uma vez injetado, o script é executado pelo interpretador dentro do processo de destino na próxima vez que um ponto de avaliação seguro for atingido. Essa abordagem permite recursos de execução remota sem modificar o comportamento ou a estrutura da aplicação Python em execução.

As seções subsequentes fornecem uma descrição passo a passo do protocolo, incluindo técnicas para localizar estruturas do interpretador na memória, acessar campos internos com segurança e disparar a execução de código. Variações específicas da plataforma são indicadas quando aplicável, e exemplos de implementação são incluídos para esclarecer cada operação.

# Localizando a estrutura de PyRuntime

O CPython coloca a estrutura `PyRuntime` em uma seção binária dedicada para ajudar ferramentas externas a encontrá-la em tempo de execução. O nome e o formato desta seção variam de acordo com a plataforma. Por exemplo, `.PyRuntime` é usado em sistemas ELF, e `__DATA,__PyRuntime` é usado no macOS. As ferramentas podem encontrar o deslocamento desta estrutura examinando o binário no disco.

A estrutura `PyRuntime` contém o estado global do interpretador do CPython e fornece acesso a outros dados internos, incluindo a lista de interpretadores, estados de thread e campos de suporte do depurador.

Para trabalhar com um processo Python remoto, um depurador precisa primeiro encontrar o endereço de memória da estrutura `PyRuntime` no processo de destino. Este endereço não pode ser codificado ou calculado a partir de um nome de símbolo, pois depende de onde o sistema operacional carregou o binário.

O método para encontrar `PyRuntime` depende da plataforma, mas os passos são os mesmos em geral:

1. Encontrar o endereço base onde o binário Python ou a biblioteca compartilhada foi carregada no processo de destino.

2. Usar o binário no disco para localizar o deslocamento da seção `.PyRuntime`.

3. Adicionar o deslocamento da seção ao endereço base para calcular o endereço na memória.

As seções abaixo explicam como fazer isso em cada plataforma suportada e incluem código de exemplo.

Linux (ELF)

Para encontrar a estrutura de `PyRuntime` no Linux:

1. Lê o mapa de memória do processo (por exemplo, `/proc/<pid>/maps`) para encontrar o endereço onde o executável Python ou `libpython` foi carregado.

2. Analise os cabeçalhos da seção ELF no binário para obter o deslocamento da seção `.PyRuntime`.

3. Adicione esse deslocamento ao endereço base da etapa 1 para obter o endereço de memória de `PyRuntime`.

A seguir está um exemplo de implementação:

    def find_py_runtime_linux(pid: int) -> int:
        # Etapa 1: tenta encontrar o executável Python na memória
        binary_path, base_address = find_mapped_binary(
            pid, name_contains="python"
        )

        # Etapa 2: recorre à biblioteca compartilhada se não encontrar o executável
        if binary_path is None:
            binary_path, base_address = find_mapped_binary(
                pid, name_contains="libpython"
            )

        # Etapa 3: analisa os cabeçalhos ELF para obter o deslocamento da seção .PyRuntime
        section_offset = parse_elf_section_offset(
            binary_path, ".PyRuntime"
        )

        # Etapa 4: calcula o endereço de PyRuntime na memória
        return base_address + section_offset

Em sistemas Linux, existem duas abordagens principais para ler memória de outro processo. A primeira é através do sistema de arquivos `/proc`, especificamente lendo de `/proc/[pid]/mem`, que fornece acesso direto à memória do processo. Isso requer permissões apropriadas – seja o mesmo usuário do processo alvo ou ter acesso root. A segunda abordagem é usar a chamada de sistema `process_vm_readv()`, que fornece uma maneira mais eficiente de copiar memória entre processos. Embora a operação `PTRACE_PEEKTEXT` do ptrace também possa ser usada para ler memória, ela é significativamente mais lenta, pois lê apenas uma palavra por vez e requer múltiplas trocas de contexto entre os processos rastreador e rastreado.

Para analisar seções ELF, o processo envolve a leitura e a interpretação das estruturas do formato de arquivo ELF do arquivo binário em disco. O cabeçalho ELF contém um ponteiro para a tabela de cabeçalhos de seção. Cada cabeçalho de seção contém metadados sobre uma seção, incluindo seu nome (armazenado em uma tabela de strings separada), deslocamento e tamanho. Para encontrar uma seção específica, como .PyRuntime, você precisa percorrer esses cabeçalhos e encontrar o nome da seção. O cabeçalho de seção então fornece o deslocamento onde essa seção existe no arquivo, que pode ser usado para calcular seu endereço de tempo de execução quando o binário é carregado na memória.

Você pode ler mais sobre o formato de arquivo ELF na especificação do ELF.

macOS (Mach-O)

Para encontrar a estrutura de `PyRuntime` no macOS:

1. Chame `task_for_pid()` para obter a porta da tarefa `mach_port_t` para o processo de destino. Este identificador é necessário para ler memória usando APIs como `mach_vm_read_overwrite` e `mach_vm_region`.

2. Examine as regiões da memória para encontrar aquela que contém o executável Python ou `libpython`.

3. Carregue o arquivo binário do disco e analise os cabeçalhos do Mach-O para encontrar a seção chamada `PyRuntime` no segmento `__DATA`. No macOS, os nomes dos símbolos são prefixados automaticamente com um sublinhado, de modo que o símbolo `PyRuntime` aparece como `_PyRuntime` na tabela de símbolos, mas o nome da seção não é afetado.

A seguir está um exemplo de implementação:

    def find_py_runtime_macos(pid: int) -> int:
        # Etapa 1: obtém acesso à memória do processo
        handle = get_memory_access_handle(pid)

        # Etapa 2: tenta encontrar o executável Python na memória
        binary_path, base_address = find_mapped_binary(
            handle, name_contains="python"
        )

        # Etapa 2: recorre à biblioteca compartilhada se não encontrar o executável
        if binary_path is None:
            binary_path, base_address = find_mapped_binary(
                handle, name_contains="libpython"
            )

        # Etapa 4: analisa os cabeçalhos Mach-O para obter o deslocamento da seção __DATA,__PyRuntime
        section_offset = parse_macho_section_offset(
            binary_path, "__DATA", "__PyRuntime"
        )

        # Etapa 5: calcula o endereço de PyRuntime na memória
        return base_address + section_offset

No macOS, acessar a memória de outro processo requer o uso de APIs e formatos de arquivo específicos do Mach-O. O primeiro passo é obter um identificador `task_port` via `task_for_pid()`, que fornece acesso ao espaço de memória do processo de destino. Esse identificador permite operações de memória por meio de APIs como `mach_vm_read_overwrite()`.

A memória do processo pode ser examinada usando `mach_vm_region()` para varrer o espaço da memória virtual, enquanto `proc_regionfilename()` ajuda a identificar quais arquivos binários são carregados em cada região da memória. Quando o binário ou biblioteca Python é encontrado, seus cabeçalhos Mach-O precisam ser analisados para localizar a estrutura `PyRuntime`.

O formato Mach-O organiza código e dados em segmentos e seções. A estrutura `PyRuntime` reside em uma seção chamada `__PyRuntime` dentro do segmento `__DATA`. O cálculo do endereço de tempo de execução envolve encontrar o segmento `__TEXT`, que serve como endereço base do binário, e então localizar o segmento `__DATA` que contém nossa seção de destino. O endereço final é calculado combinando o endereço base com os deslocamentos de seção apropriados dos cabeçalhos do Mach-O.

Observe que acessar a memória de outro processo no macOS normalmente requer privilégios elevados — acesso root ou direitos de segurança especiais concedidos ao processo de depuração.

Windows (PE)

Para encontrar a estrutura de `PyRuntime` no Windows:

1. Use a API ToolHelp para enumerar todos os módulos carregados no processo de destino. Isso é feito usando funções como CreateToolhelp32Snapshot, Module32First e Module32Next.

2. Identifique o módulo correspondente a `python.exe` ou `pythonXY.dll`, onde `X` e `Y` são os números de versão principal e secundária do Python, e registre seu endereço base.

3. Localize a seção `PyRuntim`. Devido ao limite de 8 caracteres do formato PE para nomes de seção (definido como `IMAGE_SIZEOF_SHORT_NAME`), o nome original `PyRuntime` está truncado. Esta seção contém a estrutura `PyRuntime`.

4. Recupere o endereço virtual relativo (RVA) da seção e adicione-o ao endereço base do módulo.

A seguir está um exemplo de implementação:

    def find_py_runtime_windows(pid: int) -> int:
        # Etapa 1: tenta encontrar o executável Python na memória
        binary_path, base_address = find_loaded_module(
            pid, name_contains="python"
        )

        # Etapa 2: recorre à pythonXY.dll compartilhada se não encontrar
        # o executável
        if binary_path is None:
            binary_path, base_address = find_loaded_module(
                pid, name_contains="python3"
            )

        # Etapa 3: analisa os cabeçalhos da seção PE para obter o RVA
        # da seção .PyRuntime. O nome da seção aparece como "PyRuntim"
        # devido ao limite de 8 caracteres definido pelo formato PE
        # (IMAGE_SIZEOF_SHORT_NAME).
        section_rva = parse_pe_section_offset(binary_path, "PyRuntim")

        # Etapa 4: calcula o endereço de PyRuntime na memória
        return base_address + section_rva

No Windows, acessar a memória de outro processo requer o uso de funções da API do Windows, como `CreateToolhelp32Snapshot()` e `Module32First()/Module32Next()`, para enumerar os módulos carregados. A função `OpenProcess()` fornece um identificador para acessar o espaço de memória do processo de destino, permitindo operações de memória por meio de `ReadProcessMemory()`.

A memória do processo pode ser examinada enumerando os módulos carregados para encontrar o binário ou DLL Python. Quando encontrados, seus cabeçalhos PE precisam ser analisados para localizar a estrutura `PyRuntime`.

O formato PE organiza código e dados em seções. A estrutura de `PyRuntime` reside em uma seção chamada “PyRuntim” (truncado de “PyRuntime” devido ao limite de 8 caracteres para nomes do PE). O cálculo do endereço de tempo de execução envolve encontrar o endereço base do módulo a partir da entrada do módulo e, em seguida, localizar nossa seção de destino nos cabeçalhos do PE. O endereço final é calculado combinando o endereço base com o endereço virtual da seção, a partir dos cabeçalhos da seção do PE.

Observe que acessar a memória de outro processo no Windows normalmente requer privilégios apropriados — acesso administrativo ou o privilégio de `SeDebugPrivilege` concedidos ao processo de depuração.

# Lendo _Py_DebugOffsets

Depois que o endereço da estrutura `PyRuntime` for determinado, o próximo passo é ler a estrutura `_Py_DebugOffsets` localizada no início do bloco `PyRuntime`.

Esta estrutura fornece deslocamentos de campo específicos da versão, necessários para ler com segurança a memória de estado de thread e do interpretador. Esses deslocamentos variam entre as versões do CPython e devem ser verificados antes do uso para garantir sua compatibilidade.

Para ler e verificar os deslocamentos de depuração, siga estas etapas:

1. Lê a memória do processo de destino, começando no endereço `PyRuntime`, cobrindo o mesmo número de bytes que a estrutura `_Py_DebugOffsets`. Essa estrutura está localizada no início do bloco de memória `PyRuntime`. Seu layout é definido nos cabeçalhos internos do CPython e permanece o mesmo em uma determinada versão secundária, mas pode mudar em versões principais.

2. Verifica se a estrutura contém dados válidos:

   - O campo `cookie` deve corresponder ao marcador de depuração esperado.

   - O campo `version` deve corresponder à versão do interpretador Python usado pelo depurador.

   - Se o depurador ou o processo de destino estiver usando uma versão de pré-lançamento (por exemplo, uma versão alfa, beta ou candidata a lançamento), as versões deverão corresponder exatamente.

   - O campo `free_threaded` deve ter o mesmo valor no depurador e no processo de destino.

3. Se a estrutura for válida, os deslocamentos que ela contém podem ser usados para localizar campos na memória. Se alguma verificação falhar, o depurador deve interromper a operação para evitar a leitura da memória no formato errado.

A seguir está um exemplo de implementação que lê e verifica `_Py_DebugOffsets`:

    def read_debug_offsets(pid: int, py_runtime_addr: int) -> DebugOffsets:
        # Etapa 1: lê a memória do processo de destino no endereço PyRuntime
        data = read_process_memory(
            pid, address=py_runtime_addr, size=DEBUG_OFFSETS_SIZE
        )

        # Etapa 2: desserializa os bytes brutos em uma estrutura _Py_DebugOffsets
        debug_offsets = parse_debug_offsets(data)

        # Etapa 3: valida o conteúdo da estrutura
        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

Aviso

Suspensão de processo recomendada

Para evitar condições de corrida e garantir a consistência da memória, é altamente recomendável suspender o processo de destino antes de executar qualquer operação que leia ou grave o estado interno do interpretador. O tempo de execução do Python pode, simultaneamente, alterar as estruturas de dados do interpretador — como criar ou destruir threads — durante a execução normal. Isso pode resultar em leituras ou gravações de memória inválidas.

Um depurador pode suspender a execução anexando-se ao processo com `ptrace` ou enviando um sinal `SIGSTOP`. A execução só deve ser retomada após a conclusão das operações de memória do lado do depurador.

Nota

Algumas ferramentas, como perfiladores ou depuradores baseados em amostragem, podem operar em um processo em execução sem suspensão. Nesses casos, as ferramentas devem ser explicitamente projetadas para lidar com memória parcialmente atualizada ou inconsistente. Para a maioria das implementações de depuradores, suspender o processo continua sendo a abordagem mais segura e robusta.

# Localizando o estado de thread e do interpretador

Antes que o código possa ser injetado e executado em um processo Python remoto, o depurador deve escolher uma thread para agendar a execução. Isso é necessário porque os campos de controle usados para realizar a injeção remota de código estão localizados na estrutura `_PyRemoteDebuggerSupport`, que está incorporada em um objeto `PyThreadState`. Esses campos são modificados pelo depurador para solicitar a execução dos scripts injetados.

A estrutura `PyThreadState` representa uma thread em execução dentro de um interpretador Python. Ela mantém o contexto de avaliação da thread e contém os campos necessários para a coordenação do depurador. Localizar um `PyThreadState` válido é, portanto, um pré-requisito essencial para acionar a execução remotamente.

Uma thread é normalmente selecionada com base em sua função ou ID. Na maioria dos casos, a thread principal é usada, mas algumas ferramentas podem direcionar uma thread específica por seu ID nativo. Uma vez escolhida a thread de destino, o depurador deve localizar o interpretador e as estruturas de estado de thread associadas na memória.

As estruturas internas relevantes são definidas da seguinte forma:

- `PyInterpreterState` representa uma instância isolada de um interpretador Python. Cada interpretador mantém seu próprio conjunto de módulos importados, estado embutido e lista de estados de thread. Embora a maioria das aplicações Python use um único interpretador, o CPython oferece suporte a múltiplos interpretadores no mesmo processo.

- `PyThreadState` representa uma thread em execução dentro de um interpretador. Ele contém o estado de execução e os campos de controle usados pelo depurador.

Para localizar uma thread:

1. Use o offset `runtime_state.interpreters_head` para obter o endereço do primeiro interpretador na estrutura `PyRuntime`. Este é o ponto de entrada para a lista vinculada de interpretadores ativos.

2. Use o deslocamento `interpreter_state.threads_main` para acessar o estado da thread principal associada ao interpretador selecionado. Normalmente, esta é a thread mais confiável para ser alvo.

3. Optionally, use the offset `interpreter_state.threads_head` to iterate through the linked list of all thread states. Each `PyThreadState` structure contains a `native_thread_id` field, which may be compared to a target thread ID to find a specific thread.

4. Once a valid `PyThreadState` has been found, its address can be used in later steps of the protocol, such as writing debugger control fields and scheduling execution.

A seguir está um exemplo de implementação que localiza o estado da thread principal:

    def find_main_thread_state(
        pid: int, py_runtime_addr: int, debug_offsets: DebugOffsets,
    ) -> int:
        # Etapa 1: lê interpreters_head do 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")

        # Etapa 2: lê o ponteiro threads_main do interpretador
        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

O exemplo a seguir demonstra como localizar uma thread pelo seu ID de thread nativo:

    def find_thread_by_id(
        pid: int,
        interp_addr: int,
        debug_offsets: DebugOffsets,
        target_tid: int,
    ) -> int:
        # Começa em threads_head e percorre a lista vinculada
        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")

Depois que um estado de thread válido for localizado, o depurador pode prosseguir com a modificação de seus campos de controle e agendar a execução, conforme descrito na próxima seção.

# Escrevendo informações de controle

Uma vez identificada uma estrutura `PyThreadState` válida, o depurador pode modificar campos de controle dentro dela para agendar a execução de um script Python especificado. Esses campos de controle são verificados periodicamente pelo interpretador e, quando definidos corretamente, acionam a execução do código remoto em um ponto seguro no laço de avaliação.

Cada `PyThreadState` contém uma estrutura `_PyRemoteDebuggerSupport` usada para comunicação entre o depurador e o interpretador. As localizações de seus campos são definidas pela estrutura `_Py_DebugOffsets` e incluem o seguinte:

- `debugger_script_path`: A fixed-size buffer that holds the full path to a Python source file (`.py`). This file must be accessible and readable by the target process when execution is triggered.

- `debugger_pending_call`: An integer flag. Setting this to `1` tells the interpreter that a script is ready to be executed.

- `eval_breaker`: A field checked by the interpreter during execution. Setting bit 5 (`_PY_EVAL_PLEASE_STOP_BIT`, value `1U << 5`) in this field causes the interpreter to pause and check for debugger activity.

Para concluir a injeção, o depurador deve executar as seguintes etapas:

1. Escreve o caminho completo do script no buffer `debugger_script_path`.

2. Define `debugger_pending_call` com `1`.

3. Lê o valor atual de `eval_breaker`, define o bit 5 (`_PY_EVAL_PLEASE_STOP_BIT`) e grava o valor atualizado de volta. Isso sinaliza ao interpretador para verificar a atividade do depurador.

A seguir está um exemplo de implementação:

    def inject_script(
        pid: int,
        thread_state_addr: int,
        debug_offsets: DebugOffsets,
        script_path: str
    ) -> None:
        # Calcula o deslocamento base de _PyRemoteDebuggerSupport
        support_base = (
            thread_state_addr +
            debug_offsets.debugger_support.remote_debugger_support
        )

        # Etapa 1: Escreve o caminho do script em debugger_script_path
        script_path_ptr = (
            support_base +
            debug_offsets.debugger_support.debugger_script_path
        )
        write_string(pid, script_path_ptr, script_path)

        # Etapa 2: Define debugger_pending_call com 1
        pending_ptr = (
            support_base +
            debug_offsets.debugger_support.debugger_pending_call
        )
        write_int(pid, pending_ptr, 1)

        # Etapa 3: Define _PY_EVAL_PLEASE_STOP_BIT (bit 5, valor 1 << 5)
        # em 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)

Após a configuração desses campos, o depurador pode retomar o processo (caso tenha sido suspenso). O interpretador processará a solicitação no próximo ponto de avaliação seguro, carregará o script do disco e o executará.

É responsabilidade do depurador garantir que o arquivo de script permaneça presente e acessível ao processo de destino durante a execução.

Nota

A execução do script é assíncrona. O arquivo de script não pode ser excluído imediatamente após a injeção. O depurador deve aguardar até que o script injetado produza um efeito observável antes de remover o arquivo. Esse efeito depende do objetivo do script. Por exemplo, um depurador pode aguardar até que o processo remoto se conecte novamente a um soquete antes de remover o script. Uma vez observado esse efeito, é seguro presumir que o arquivo não é mais necessário.

# Resumo

Para injetar e executar um script Python em um processo remoto:

1. Localize a estrutura `PyRuntime` na memória do processo de destino.

2. Lê e valida a estrutura `_Py_DebugOffsets` no início de `PyRuntime`.

3. Usa os deslocamentos para localizar um `PyThreadState` válido.

4. Escreve o caminho para um script Python em `debugger_script_path`.

5. Define o sinalizador `debugger_pending_call` com `1`.

6. Define `_PY_EVAL_PLEASE_STOP_BIT` no campo `eval_breaker`.

7. Retoma o processo (se suspenso). O script será executado no próximo ponto de avaliação seguro.