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:
Encontrar o endereço base onde o binário Python ou a biblioteca compartilhada foi carregada no processo de destino.
Usar o binário no disco para localizar o deslocamento da seção
.PyRuntime
.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:
Lê o mapa de memória do processo (por exemplo,
/proc/<pid>/maps
) para encontrar o endereço onde o executável Python oulibpython
foi carregado.Analise os cabeçalhos da seção ELF no binário para obter o deslocamento da seção
.PyRuntime
.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:
Chame
task_for_pid()
para obter a porta da tarefamach_port_t
para o processo de destino. Este identificador é necessário para ler memória usando APIs comomach_vm_read_overwrite
emach_vm_region
.Examine as regiões da memória para encontrar aquela que contém o executável Python ou
libpython
.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ímboloPyRuntime
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:
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.
Identifique o módulo correspondente a
python.exe
oupythonXY.dll
, ondeX
eY
são os números de versão principal e secundária do Python, e registre seu endereço base.Localize a seção
PyRuntim
. Devido ao limite de 8 caracteres do formato PE para nomes de seção (definido comoIMAGE_SIZEOF_SHORT_NAME
), o nome originalPyRuntime
está truncado. Esta seção contém a estruturaPyRuntime
.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:
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óriaPyRuntime
. 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.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.
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:
Use o offset
runtime_state.interpreters_head
para obter o endereço do primeiro interpretador na estruturaPyRuntime
. Este é o ponto de entrada para a lista vinculada de interpretadores ativos.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.Optionally, use the offset
interpreter_state.threads_head
to iterate through the linked list of all thread states. EachPyThreadState
structure contains anative_thread_id
field, which may be compared to a target thread ID to find a specific thread.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 to1
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
, value1U << 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:
Escreve o caminho completo do script no buffer
debugger_script_path
.Define
debugger_pending_call
com1
.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:
Localize a estrutura
PyRuntime
na memória do processo de destino.Lê e valida a estrutura
_Py_DebugOffsets
no início dePyRuntime
.Usa os deslocamentos para localizar um
PyThreadState
válido.Escreve o caminho para um script Python em
debugger_script_path
.Define o sinalizador
debugger_pending_call
com1
.Define
_PY_EVAL_PLEASE_STOP_BIT
no campoeval_breaker
.Retoma o processo (se suspenso). O script será executado no próximo ponto de avaliação seguro.