Protokoll för fjärrfelsökning¶
I det här avsnittet beskrivs lågnivåprotokollet som gör det möjligt för externa verktyg att injicera och exekvera ett Python-skript i en CPython-process som körs.
Denna mekanism utgör grunden för funktionen sys.remote_exec()
, som instruerar en fjärrstyrd Python-process att köra en .py
-fil. Detta avsnitt dokumenterar dock inte användningen av den funktionen. Istället ger det en detaljerad förklaring av det underliggande protokollet, som tar som indata pid
för en mål-Python-process och sökvägen till en Python-källfil som ska köras. Denna information stödjer oberoende återimplementering av protokollet, oavsett programmeringsspråk.
Varning
Exekveringen av det injicerade skriptet är beroende av att tolken når en säker utvärderingspunkt. Därför kan exekveringen försenas beroende på målprocessens runtime-status.
När skriptet har injicerats exekveras det av tolken i målprocessen nästa gång en säker utvärderingspunkt nås. Detta tillvägagångssätt möjliggör fjärrkörning utan att ändra beteendet eller strukturen i den Python-applikation som körs.
I de följande avsnitten ges en steg-för-steg-beskrivning av protokollet, inklusive tekniker för att lokalisera tolkstrukturer i minnet, få säker åtkomst till interna fält och utlösa kodkörning. Plattformsspecifika variationer noteras där så är tillämpligt, och exempel på implementeringar ingår för att klargöra varje operation.
Lokalisering av PyRuntime-strukturen¶
CPython placerar strukturen PyRuntime
i en dedikerad binär sektion för att hjälpa externa verktyg att hitta den vid körning. Namnet och formatet på denna sektion varierar beroende på plattform. Till exempel används .PyRuntime
på ELF-system och __DATA,__PyRuntime
på macOS. Verktyg kan hitta offset för denna struktur genom att undersöka binärfilen på disken.
Strukturen PyRuntime
innehåller CPythons globala tolkstatus och ger tillgång till andra interna data, inklusive listan över tolkar, trådstatus och fält för felsökningsstöd.
För att arbeta med en Python-process på distans måste en felsökare först hitta minnesadressen för strukturen PyRuntime
i målprocessen. Denna adress kan inte hårdkodas eller beräknas från ett symbolnamn, eftersom den beror på var operativsystemet laddade binärfilen.
Metoden för att hitta PyRuntime
beror på plattformen, men stegen är i allmänhet desamma:
Hitta basadressen där Python-binärfilen eller det delade biblioteket laddades i målprocessen.
Använd binärfilen på disken för att hitta offset för avsnittet
.PyRuntime
.Lägg till sektionsoffset till basadressen för att beräkna adressen i minnet.
Avsnitten nedan förklarar hur du gör detta på varje plattform som stöds och innehåller exempel på kod.
Linux (ELF)
För att hitta PyRuntime
-strukturen på Linux:
Läs processens minneskarta (t.ex.
/proc/<pid>/maps
) för att hitta adressen där den körbara Python-filen ellerlibpython
laddades.Analysera ELF-avsnittshuvudena i binärfilen för att få fram offset för avsnittet
.PyRuntime
.Lägg till den förskjutningen till basadressen från steg 1 för att få minnesadressen för
PyRuntime
.
Följande är ett exempel på en implementering:
def find_py_runtime_linux(pid: int) -> int:
# Steg 1: Försök hitta den körbara Python-filen i minnet
binary_path, base_address = find_mapped_binary(
pid, namn_innehåller="python"
)
# Steg 2: Återgå till delat bibliotek om den körbara filen inte hittas
om binary_path är None:
binary_path, base_address = find_mapped_binary(
pid, namn_innehåller="libpython"
)
# Steg 3: Parsa ELF-rubriker för att få .PyRuntime-sektionens offset
section_offset = parse_elf_section_offset(
binär_väg, ".PyRuntime"
)
# Steg 4: Beräkna PyRuntime-adressen i minnet
returnera bas_adress + sektion_offset
På Linux-system finns det två huvudmetoder för att läsa minne från en annan process. Det första är genom filsystemet /proc
, specifikt genom att läsa från /proc/[pid]/mem
som ger direkt tillgång till processens minne. Detta kräver lämpliga behörigheter - antingen att du är samma användare som målprocessen eller att du har root-åtkomst. Den andra metoden är att använda systemanropet process_vm_readv()
som ger ett mer effektivt sätt att kopiera minne mellan processer. Även om ptraces PTRACE_PEEKTEXT
-operation också kan användas för att läsa minne, är den betydligt långsammare eftersom den bara läser ett ord i taget och kräver flera kontextbyten mellan tracer- och tracee-processerna.
När ELF-sektioner ska analyseras innebär processen att ELF-filformatets strukturer läses och tolkas från den binära filen på disken. ELF-huvudet innehåller en pekare till tabellen för sektionshuvud. Varje sektionshuvud innehåller metadata om en sektion, inklusive dess namn (lagrat i en separat strängtabell), offset och storlek. För att hitta en specifik sektion som .PyRuntime måste du gå igenom dessa rubriker och matcha sektionsnamnet. Sektionshuvudet ger sedan den offset där sektionen finns i filen, vilket kan användas för att beräkna dess runtime-adress när den binära filen laddas in i minnet.
Du kan läsa mer om filformatet ELF i ELF-specifikationen.
macOS (Mach-O)
För att hitta strukturen PyRuntime
på macOS:
Anropa
task_for_pid()
för att få uppgiftsportenmach_port_t
för målprocessen. Detta handtag behövs för att läsa minne med hjälp av API:er sommach_vm_read_overwrite
ochmach_vm_region
.Sök igenom minnesregionerna för att hitta den som innehåller den körbara Python-filen eller
libpython
.Ladda den binära filen från disken och analysera Mach-O-huvudena för att hitta avsnittet med namnet
PyRuntime
i segmentet__DATA
. På macOS prefixeras symbolnamn automatiskt med en understrykning, så symbolenPyRuntime
visas som_PyRuntime
i symboltabellen, men avsnittsnamnet påverkas inte.
Följande är ett exempel på en implementering:
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
På macOS kräver åtkomst till en annan process minne att man använder Mach-O-specifika API:er och filformat. Det första steget är att erhålla ett task_port
-handtag via task_for_pid()
, vilket ger åtkomst till målprocessens minnesutrymme. Detta handtag möjliggör minnesoperationer genom API:er som mach_vm_read_overwrite()
.
Processminnet kan undersökas med hjälp av mach_vm_region()
för att skanna genom det virtuella minnesutrymmet, medan proc_regionfilename()
hjälper till att identifiera vilka binära filer som laddas i varje minnesregion. När Python-binärfilen eller -biblioteket hittas måste dess Mach-O-rubriker analyseras för att lokalisera strukturen PyRuntime
.
Mach-O-formatet organiserar kod och data i segment och sektioner. Strukturen PyRuntime
finns i ett avsnitt med namnet __PyRuntime
inom segmentet __DATA
. Den faktiska beräkningen av runtime-adressen innebär att man hittar segmentet __TEXT
som fungerar som binärfilens basadress och sedan hittar segmentet __DATA
som innehåller vår målsektion. Den slutliga adressen beräknas genom att kombinera basadressen med lämpliga sektionsoffset från Mach-O-huvudena.
Observera att åtkomst till en annan process minne på macOS vanligtvis kräver förhöjda behörigheter - antingen root-åtkomst eller särskilda säkerhetsrättigheter som beviljas felsökningsprocessen.
Windows (PE)
För att hitta PyRuntime
-strukturen på Windows:
Använd ToolHelp API för att räkna upp alla moduler som laddats i målprocessen. Detta görs med hjälp av funktioner som CreateToolhelp32Snapshot, Module32First och Module32Next.
Identifiera den modul som motsvarar
python.exe
ellerpythonXY.dll
, därX
ochY
är Python-versionens större och mindre versionsnummer, och registrera dess basadress.Leta reda på avsnittet
PyRuntim
. På grund av PE-formatets gräns på 8 tecken för sektionsnamn (definierat somIMAGE_SIZEOF_SHORT_NAME
), är det ursprungliga namnetPyRuntime
avkortat. Detta avsnitt innehåller strukturenPyRuntime
.Hämta sektionens relativa virtuella adress (RVA) och lägg till den till modulens basadress.
Följande är ett exempel på en implementering:
def find_py_runtime_windows(pid: int) -> int:
# Steg 1: Försök hitta den körbara Python-modulen i minnet
binary_path, base_address = find_loaded_module(
pid, namn_innehåller="python"
)
# Steg 2: Fallback till delad pythonXY.dll om den körbara filen inte
# hittas
om binary_path är None:
binary_path, base_address = find_loaded_module(
pid, namn_innehåller="python3"
)
# Steg 3: Parsa PE-avsnittshuvuden för att få RVA för PyRuntime
# avsnittet. Avsnittets namn visas som "PyRuntim" på grund av den
# begränsningen på 8 tecken som definieras av PE-formatet (IMAGE_SIZEOF_SHORT_NAME).
section_rva = parse_pe_section_offset(binary_path, "PyRuntim")
# Steg 4: Beräkna PyRuntime-adressen i minnet
return bas_adress + sektion_rva
I Windows kräver åtkomst till en annan process minne användning av Windows API-funktioner som CreateToolhelp32Snapshot()
och Module32First()/Module32Next()
för att räkna upp laddade moduler. Funktionen OpenProcess()
tillhandahåller ett handtag för åtkomst till målprocessens minnesutrymme, vilket möjliggör minnesoperationer genom ReadProcessMemory()
.
Processminnet kan undersökas genom att räkna upp laddade moduler för att hitta Python-binärfilen eller DLL-filen. När den hittas måste dess PE-rubriker analyseras för att lokalisera strukturen PyRuntime
.
PE-formatet organiserar kod och data i sektioner. Strukturen PyRuntime
finns i ett avsnitt som heter ”PyRuntim” (avkortat från ”PyRuntime” på grund av PE:s namnbegränsning på 8 tecken). Den faktiska beräkningen av runtime-adressen innebär att modulens basadress hittas från modulens post och sedan lokaliseras vårt målavsnitt i PE-huvudena. Den slutliga adressen beräknas genom att basadressen kombineras med avsnittets virtuella adress från PE-avsnittsrubrikerna.
Observera att åtkomst till en annan process minne i Windows vanligtvis kräver lämpliga behörigheter - antingen administrativ åtkomst eller behörigheten SeDebugPrivilege
som ges till felsökningsprocessen.
Läsa _Py_DebugOffsets¶
När adressen till strukturen PyRuntime
har bestämts är nästa steg att läsa strukturen _Py_DebugOffsets
som finns i början av blocket PyRuntime
.
Denna struktur tillhandahåller versionsspecifika fältoffset som behövs för att säkert läsa tolk- och trådstatusminnet. Dessa offsets varierar mellan CPython-versioner och måste kontrolleras före användning för att säkerställa att de är kompatibla.
Gör så här för att läsa av och kontrollera debug-offseten:
Läser minne från målprocessen med början på adressen
PyRuntime
, som täcker samma antal byte som strukturen_Py_DebugOffsets
. Denna struktur är placerad i början av minnesblocketPyRuntime
. Dess layout definieras i CPythons interna huvuden och förblir densamma inom en viss mindre version, men kan ändras i större versioner.Kontrollera att strukturen innehåller giltiga data:
Fältet
cookie
måste matcha den förväntade debug-markören.Fältet
version
måste överensstämma med versionen av den Python-tolk som används av felsökaren.Om antingen felsökaren eller målprocessen använder en version före release (t.ex. en alfa-, beta- eller releasekandidat) måste versionerna stämma exakt överens.
Fältet
free_threaded
måste ha samma värde i både debuggern och målprocessen.
Om strukturen är giltig kan de offsets som den innehåller användas för att lokalisera fält i minnet. Om någon kontroll misslyckas bör felsökaren stoppa operationen för att undvika att minnet läses i fel format.
Följande är ett exempel på en implementation som läser och kontrollerar _Py_DebugOffsets
:
def read_debug_offsets(pid: int, py_runtime_addr: int) -> DebugOffsets:
# Steg 1: Läs minne från målprocessen på PyRuntime-adressen
data = read_process_memory(
pid, adress=py_runtime_addr, storlek=DEBUG_OFFSETS_SIZE
)
# Steg 2: Deserialisera de råa bytena till en _Py_DebugOffsets-struktur
debug_offsets = parse_debug_offsets(data)
# Steg 3: Validera innehållet i strukturen
if debug_offsets.cookie != EXPECTED_COOKIE:
raise RuntimeError("Ogiltig eller saknad debug-cookie")
if debug_offsets.version != LOCAL_PYTHON_VERSION:
raise RuntimeError(
"Skillnad mellan anroparens och målets Python-versioner"
)
if debug_offsets.free_threaded != LOCAL_FREE_THREADED:
raise RuntimeError("Fel i konfigurationen för fri trådning")
returnera debug_offsets
Varning
Processavstängning rekommenderas
För att undvika tävlingsförhållanden och säkerställa minneskonsistens rekommenderas starkt att målprocessen avbryts innan några operationer utförs som läser eller skriver internt tolktillstånd. Pythons runtime kan samtidigt mutera tolkens datastrukturer, t.ex. genom att skapa eller förstöra trådar, under normal exekvering. Detta kan resultera i ogiltiga minnesläsningar eller -skrivningar.
En felsökare kan avbryta körningen genom att ansluta till processen med ptrace
eller genom att skicka en SIGSTOP
-signal. Exekveringen bör endast återupptas efter att minnesoperationer på debuggersidan har slutförts.
Anteckning
Vissa verktyg, t.ex. profilerare eller samplingsbaserade debuggar, kan arbeta med en process som körs utan avbrott. I sådana fall måste verktygen uttryckligen utformas för att hantera delvis uppdaterade eller inkonsekventa minnen. För de flesta implementeringar av felsökare är det säkraste och mest robusta tillvägagångssättet att avbryta processen.
Lokalisering av tolk och trådstatus¶
Innan kod kan injiceras och exekveras i en Python-process på distans måste debuggern välja en tråd där exekveringen ska schemaläggas. Detta är nödvändigt eftersom de kontrollfält som används för att utföra fjärrkodinjektion finns i strukturen _PyRemoteDebuggerSupport
, som är inbäddad i ett PyThreadState
-objekt. Dessa fält modifieras av felsökaren för att begära exekvering av injicerade skript.
Strukturen PyThreadState
representerar en tråd som körs i en Python-tolk. Den upprätthåller trådens utvärderingskontext och innehåller de fält som krävs för debugger-samordning. Att hitta en giltig PyThreadState
är därför en viktig förutsättning för att kunna trigga exekvering på distans.
En tråd väljs vanligtvis baserat på dess roll eller ID. I de flesta fall används huvudtråden, men vissa verktyg kan rikta in sig på en specifik tråd genom dess ursprungliga tråd-ID. När måltråden har valts måste felsökaren lokalisera både tolken och de tillhörande trådtillståndsstrukturerna i minnet.
De relevanta interna strukturerna definieras enligt följande:
PyInterpreterState
representerar en isolerad Python-tolkinstans. Varje tolk har sin egen uppsättning av importerade moduler, inbyggt tillstånd och trådtillståndslista. Även om de flesta Python-applikationer använder en enda tolk, stöder CPython flera tolkar i samma process.PyThreadState
representerar en tråd som körs i en tolk. Den innehåller exekveringsstatus och de kontrollfält som används av debuggern.
För att hitta en tråd:
Använd offset
runtime_state.interpreters_head
för att få adressen till den första tolken iPyRuntime
-strukturen. Detta är ingångspunkten till den länkade listan över aktiva tolkar.Använd offset
interpreter_state.threads_main
för att komma åt huvudtrådstillståndet som är associerat med den valda tolken. Detta är vanligtvis den mest tillförlitliga tråden att rikta in sig på.
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.
1. 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.
Följande är ett exempel på en implementering som lokaliserar huvudtrådens tillstånd:
def find_main_thread_state(
pid: int, py_runtime_addr: int, debug_offsets: DebugOffsets,
) -> int:
# Steg 1: Läsa interpreters_head från PyRuntime
interp_head_ptr = (
py_runtime_addr + debug_offsets.runtime_state.interpreters_head
)
interp_addr = read_pointer(pid, interp_head_ptr)
om interp_addr == 0:
raise RuntimeError("Ingen tolk hittades i målprocessen")
# Steg 2: Läs pekaren threads_main från tolken
threads_main_ptr = (
interp_addr + debug_offsets.interpreter_state.threads_main
)
thread_state_addr = read_pointer(pid, threads_main_ptr)
om thread_state_addr == 0:
raise RuntimeError("Huvudtrådens tillstånd är inte tillgängligt")
returnera thread_state_addr
Följande exempel visar hur du hittar en tråd med hjälp av dess ursprungliga tråd-ID:
def find_thread_by_id(
pid: int,
interp_addr: int,
debug_offsets: DebugOffsets,
target_tid: int,
) -> int:
# Starta vid threads_head och gå igenom den länkade listan
thread_ptr = läs_pekare(
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)
om native_tid == target_tid:
returnera thread_ptr
thread_ptr = read_pointer(
pid,
thread_ptr + debug_offsets.thread_state.next
)
raise RuntimeError("Tråden med det angivna ID:t hittades inte")
När ett giltigt trådtillstånd har hittats kan felsökaren fortsätta med att modifiera dess kontrollfält och schemalägga exekveringen, enligt beskrivningen i nästa avsnitt.
Skriva kontrollinformation¶
När en giltig PyThreadState
-struktur har identifierats kan felsökaren ändra kontrollfälten i den för att schemalägga exekveringen av ett angivet Python-skript. Dessa kontrollfält kontrolleras regelbundet av tolken och när de är korrekt inställda utlöser de exekvering av fjärrkod vid en säker punkt i utvärderingsloopen.
Varje PyThreadState
innehåller en _PyRemoteDebuggerSupport
-struktur som används för kommunikation mellan felsökaren och tolken. Platserna för dess fält definieras av strukturen _Py_DebugOffsets
och inkluderar följande:
debugger_script_path
: En buffert med fast storlek som innehåller den fullständiga sökvägen till ettPython-källfil (
.py
). Denna fil måste vara åtkomlig och läsbar av målprocessen när exekveringen utlöses.
debugger_pending_call
: En heltalsflagga. Om den sätts till1
säger dentolk att ett skript är redo att exekveras.
Eval_breaker
: Ett fält som kontrolleras av tolken under exekveringen.Om bit 5 (
_PY_EVAL_PLEASE_STOP_BIT
, värde1U << 5
) i detta fält ställs in gör det att tolken pausar och kontrollerar om det finns någon debugger-aktivitet.
För att slutföra injektionen måste felsökaren utföra följande steg:
Skriv in hela sökvägen till skriptet i bufferten
debugger_script_path
.Sätt
debugger_pending_call
till1
.Läs det aktuella värdet för
eval_breaker
, ställ in bit 5 (_PY_EVAL_PLEASE_STOP_BIT
) och skriv tillbaka det uppdaterade värdet. Detta signalerar till tolken att kontrollera om det finns någon debugger-aktivitet.
Följande är ett exempel på en implementering:
def inject_script(
pid: int,
thread_state_addr: int,
debug_offsets: DebugOffsets,
script_path: str
) -> Ingen:
# Beräkna basförskjutningen för _PyRemoteDebuggerSupport
support_base = (
thread_state_addr +
debug_offsets.debugger_support.remote_debugger_support
)
# Steg 1: Skriv in skriptsökvägen i debugger_script_path
script_path_ptr = (
support_base +
debug_offsets.debugger_support.debugger_script_path
)
write_string(pid, script_path_ptr, script_path)
# Steg 2: Ställ in debugger_pending_call till 1
pending_ptr = (
support_base +
debug_offsets.debugger_support.debugger_pending_call
)
write_int(pid, pending_ptr, 1)
# Steg 3: Ställ in _PY_EVAL_PLEASE_STOP_BIT (bit 5, värde 1 << 5) i
# 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)
När dessa fält har ställts in kan debuggern återuppta processen (om den avbröts). Tolken kommer att behandla begäran vid nästa säkra utvärderingspunkt, ladda skriptet från disken och exekvera det.
Det är felsökarens ansvar att se till att skriptfilen finns kvar och är tillgänglig för målprocessen under körningen.
Anteckning
Exekveringen av skriptet är asynkron. Skriptfilen kan inte tas bort omedelbart efter injektionen. Felsökaren bör vänta tills det injicerade skriptet har gett en observerbar effekt innan filen tas bort. Denna effekt beror på vad skriptet är utformat för att göra. En felsökare kan t.ex. vänta tills fjärrprocessen ansluter tillbaka till ett uttag innan skriptet tas bort. När en sådan effekt har observerats är det säkert att anta att filen inte längre behövs.
Sammanfattning¶
Att injicera och exekvera ett Python-skript i en fjärrprocess:
Leta reda på strukturen
PyRuntime
i målprocessens minne.Läs och validera strukturen
_Py_DebugOffsets
i början avPyRuntime
.Använd offseten för att hitta en giltig
PyThreadState
.Skriv in sökvägen till ett Python-skript i
debugger_script_path
.Sätt flaggan
debugger_pending_call
till1
.Ange
_PY_EVAL_PLEASE_STOP_BIT
i fälteteval_breaker
.Återuppta processen (om den har avbrutits). Skriptet kommer att exekveras vid nästa säkra utvärderingspunkt.