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:

  1. Hitta basadressen där Python-binärfilen eller det delade biblioteket laddades i målprocessen.

  2. Använd binärfilen på disken för att hitta offset för avsnittet .PyRuntime.

  3. 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:

  1. Läs processens minneskarta (t.ex. /proc/<pid>/maps) för att hitta adressen där den körbara Python-filen eller libpython laddades.

  2. Analysera ELF-avsnittshuvudena i binärfilen för att få fram offset för avsnittet .PyRuntime.

  3. 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:

  1. Anropa task_for_pid() för att få uppgiftsporten mach_port_t för målprocessen. Detta handtag behövs för att läsa minne med hjälp av API:er som mach_vm_read_overwrite och mach_vm_region.

  2. Sök igenom minnesregionerna för att hitta den som innehåller den körbara Python-filen eller libpython.

  3. 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å symbolen PyRuntime 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:

  1. 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.

  2. Identifiera den modul som motsvarar python.exe eller pythonXY.dll, där X och Y är Python-versionens större och mindre versionsnummer, och registrera dess basadress.

  3. Leta reda på avsnittet PyRuntim. På grund av PE-formatets gräns på 8 tecken för sektionsnamn (definierat som IMAGE_SIZEOF_SHORT_NAME), är det ursprungliga namnet PyRuntime avkortat. Detta avsnitt innehåller strukturen PyRuntime.

  4. 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:

  1. 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 minnesblocket PyRuntime. Dess layout definieras i CPythons interna huvuden och förblir densamma inom en viss mindre version, men kan ändras i större versioner.

  2. 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.

  3. 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:

  1. Använd offset runtime_state.interpreters_head för att få adressen till den första tolken i PyRuntime-strukturen. Detta är ingångspunkten till den länkade listan över aktiva tolkar.

  2. 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 ett

    Python-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 till 1 säger den

    tolk 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ärde 1U << 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:

  1. Skriv in hela sökvägen till skriptet i bufferten debugger_script_path.

  2. Sätt debugger_pending_call till 1.

  3. 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:

  1. Leta reda på strukturen PyRuntime i målprocessens minne.

  2. Läs och validera strukturen _Py_DebugOffsets i början av PyRuntime.

  3. Använd offseten för att hitta en giltig PyThreadState.

  4. Skriv in sökvägen till ett Python-skript i debugger_script_path.

  5. Sätt flaggan debugger_pending_call till 1.

  6. Ange _PY_EVAL_PLEASE_STOP_BIT i fältet eval_breaker.

  7. Återuppta processen (om den har avbrutits). Skriptet kommer att exekveras vid nästa säkra utvärderingspunkt.