Protokoll zum Anhängen eines Debuggers aus der Ferne

Dieses Protokoll erlaubt es externen Werkzeugen, sich an einen laufenden CPython-Prozess anzuhängen und Python-Code aus der Ferne auszuführen.

Sich an einen anderen Python-Prozess anzuhängen kann je nach Plattform zusätzliche Berechtigungen oder Konfiguration erfordern.

Debuggen aus der Ferne abschalten

Um die Unterstützung für das Debuggen aus der Ferne abzuschalten, verwende eine der folgenden Möglichkeiten:

Anforderungen an die Berechtigungen

Sich zum Debuggen aus der Ferne an einen laufenden Python-Prozess anzuhängen erfordert auf den meisten Plattformen eine besondere Konfiguration. Die genauen Anforderungen und die Schritte zur Fehlersuche hängen von deinem Betriebssystem ab:

Linux

Im Allgemeinen kannst du deine eigenen Prozesse debuggen, aber es gibt mehrere verbreitete Konfigurationen, die das abschalten können. Manche Linux-Distributionen schalten als Form der Systemhärtung ptrace-Beschränkungen ein, auch „Yama“ genannt. Neuere Fassungen des Befehls setpriv (util-linux 2.41, veröffentlicht im Juni 2025) erlauben es dir, die ptrace-Beschränkungen je Prozess zu lockern:

setpriv --ptracer any python3

(Das wird an dem Prozess eingestellt, der gedebuggt werden soll.) Du kannst die ptrace-Beschränkungen bis zum nächsten Neustart auch für alle Prozesse abschalten:

echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope

Das lässt sich auch dauerhaft einstellen, üblicherweise in /etc/sysctl.d.

Bemerkung

ptrace_scope abzuschalten verringert die Systemhärtung und sollte nur in Umgebungen mit geringem Sicherheitsbedarf geschehen.

Es ist auch möglich, dass der Systemaufruf ptrace wegen eines Sicherheitsfilters abgeschaltet ist. Das war insbesondere bei älteren Fassungen mancher Container-Software verbreitet. Docker 19.03 oder neuer (veröffentlicht 2019) und containerd 1.6.7 oder neuer (veröffentlicht 2022) erlauben die Verwendung des Systemaufrufs ptrace innerhalb von Containern automatisch, wenn sie auf Linux-Kernel 4.8 oder höher laufen. Wenn du nicht auf diese Fassungen aktualisieren kannst, kannst du deinen Container mit einer Option wie --security-opt seccomp=unconfined erzeugen, um den Sicherheitsfilter für Systemaufrufe für diesen Container abzuschalten. Das schwächt die Isolation des Containers und sollte nur in Umgebungen mit geringem Sicherheitsbedarf geschehen.

Wenn du einen Prozess verfolgen musst, der dir nicht gehört, brauchst du Superuser-Zugriff oder etwas Gleichwertiges. Das gilt auch für Prozesse, die ihre Sicherheitsmerkmale geändert haben, etwa Prozesse mit set-user-ID oder set-group-ID (was bei Python allerdings unüblich ist). Versuche, den Debug-Befehl mit sudo -E auszuführen.

Bemerkung

Die Fähigkeit CAP_SYS_PTRACE entspricht Superuser-Zugriff, insofern sie das Debuggen jedes Prozesses erlaubt und nicht nur der eigenen. Im Internet findest du vielleicht den Rat, sie zu verwenden, um ptrace-Beschränkungen oder Filter für Systemaufrufe zu umgehen. In der Praxis kann das funktionieren, genau wie sudo, aber es gibt dem Debug-Prozess viel mehr Zugriff, als er braucht, und sollte nur in Umgebungen mit geringem Sicherheitsbedarf geschehen.

Beachte schließlich, dass ein Prozess immer nur einen Verfolger haben kann. Wenn du dich an einen Python-Prozess schon mit strace, gdb oder Ähnlichem angehängt hast, kannst du das Debuggen aus der Ferne nicht gleichzeitig verwenden. (Superuser-Zugriff hilft über diese Beschränkung nicht hinweg.)

macOS

Standardmäßig schaltet macOS die Möglichkeit ab, andere Prozesse zu debuggen.

Du kannst deine Python-Programmdatei so verändern, dass sie dem Debuggen zustimmt – indem du ihr eine Ad-hoc-Codesignatur mit einer Berechtigung gibst, die das Debuggen erlaubt. (Eine Ad-hoc-„Signatur“ ist nur eine Konfiguration ohne echte kryptografische Signatur und ohne die Notwendigkeit eines Zertifikats oder einer Mitgliedschaft im Apple-Entwicklerprogramm.)

Die folgenden Befehle erzeugen eine Datei get-task-allow.plist mit der nötigen Berechtigung und fügen sie der Python-Programmdatei hinzu:

echo '{"com.apple.security.get-task-allow": true}' | plutil -convert xml1 -o get-task-allow.plist -
codesign --sign - --entitlements get-task-allow.plist path/to/bin/python3

wobei path/to/bin/python3 der Pfad zu deiner Python-Programmdatei ist, den du zum Beispiel über which python3 oder durch Auswerten von sys.base_executable in der Python-REPL findest. (Diese Anweisungen gelten für einen Python-Build ohne Framework. Framework-Builds müssen möglicherweise anders eingerichtet werden.)

Danach solltest du deine eigenen Python-Prozesse debuggen können, die mit dieser Programmdatei gestartet wurden.

Wie unter Linux unterliegen Prozesse mit Superuser-Rechten, etwa über sudo, dieser Prüfung nicht und können jeden Prozess jedes Benutzers auf dem System debuggen (wobei es wegen des System Integrity Protection zusätzliche Prüfungen für bestimmte Programmdateien gibt, etwa für vom Betriebssystem bereitgestellte Befehle).

Windows

Um dich an einen anderen Prozess anzuhängen, musst du dein Debug-Werkzeug üblicherweise mit Administratorrechten ausführen. Starte die Eingabeaufforderung oder das Terminal als Administrator.

Manche Prozesse können selbst mit Administratorrechten unzugänglich bleiben, sofern du nicht das Recht SeDebugPrivilege eingeschaltet hast.

Um Zugriffsprobleme bei Dateien oder Ordnern zu lösen, passe die Sicherheitsberechtigungen an:

  1. Klicke die Datei oder den Ordner mit der rechten Maustaste an und wähle Eigenschaften.

  2. Wechsle auf die Registerkarte Sicherheit, um Benutzer und Gruppen mit Zugriff zu sehen.

  3. Klicke auf Bearbeiten, um die Berechtigungen zu ändern.

  4. Wähle dein Benutzerkonto aus.

  5. Setze unter Berechtigungen nach Bedarf ein Häkchen bei Lesen oder Vollzugriff.

  6. Klicke auf Übernehmen und dann auf OK, um zu bestätigen.

Bemerkung

Stelle sicher, dass du alle Anforderungen an die Berechtigungen erfüllt hast, bevor du weitermachst.

Dieser Abschnitt beschreibt das tiefliegende Protokoll, mit dem externe Werkzeuge ein Python-Skript in einen laufenden CPython-Prozess einspeisen und dort ausführen können.

Dieser Mechanismus ist die Grundlage der Funktion sys.remote_exec(), die einen fernen Python-Prozess anweist, eine .py-Datei auszuführen. Dieser Abschnitt dokumentiert allerdings nicht die Verwendung dieser Funktion. Er erklärt vielmehr ausführlich das zugrunde liegende Protokoll, das als Eingabe die pid eines Ziel-Python-Prozesses und den Pfad zu einer auszuführenden Python-Quelldatei nimmt. Diese Informationen erlauben es, das Protokoll unabhängig und in jeder Programmiersprache neu umzusetzen.

Warnung

Die Ausführung des eingespeisten Skripts hängt davon ab, dass der Interpreter einen sicheren Auswertungspunkt erreicht. Die Ausführung kann sich daher je nach Laufzeitzustand des Zielprozesses verzögern.

Nach dem Einspeisen wird das Skript vom Interpreter im Zielprozess ausgeführt, sobald das nächste Mal ein sicherer Auswertungspunkt erreicht wird. Dieser Ansatz erlaubt die Ausführung aus der Ferne, ohne das Verhalten oder den Aufbau der laufenden Python-Anwendung zu verändern.

Die folgenden Abschnitte beschreiben das Protokoll Schritt für Schritt, einschließlich der Techniken, um Interpreter-Strukturen im Speicher zu finden, sicher auf interne Felder zuzugreifen und die Codeausführung anzustoßen. Plattformspezifische Abweichungen sind, wo nötig, angemerkt, und Beispielumsetzungen verdeutlichen jeden Vorgang.

Die Struktur PyRuntime finden

CPython legt die Struktur PyRuntime in einen eigenen Abschnitt der Programmdatei, damit externe Werkzeuge sie zur Laufzeit finden können. Name und Format dieses Abschnitts unterscheiden sich je nach Plattform. So wird auf ELF-Systemen .PyRuntime verwendet und unter macOS __DATA,__PyRuntime. Werkzeuge können den Versatz dieser Struktur finden, indem sie die Programmdatei auf der Festplatte untersuchen.

Die Struktur PyRuntime enthält den globalen Interpreter-Zustand von CPython und bietet Zugriff auf weitere interne Daten, darunter die Liste der Interpreter, die Thread-Zustände und die Felder für die Debugger-Unterstützung.

Um mit einem fernen Python-Prozess zu arbeiten, muss ein Debugger zuerst die Speicheradresse der Struktur PyRuntime im Zielprozess finden. Diese Adresse lässt sich nicht fest eintragen oder aus einem Symbolnamen berechnen, weil sie davon abhängt, wohin das Betriebssystem die Programmdatei geladen hat.

Wie man PyRuntime findet, hängt von der Plattform ab, aber die Schritte sind im Allgemeinen dieselben:

  1. Finde die Basisadresse, an der die Python-Programmdatei oder die dynamische Bibliothek im Zielprozess geladen wurde.

  2. Bestimme über die Programmdatei auf der Festplatte den Versatz des Abschnitts .PyRuntime.

  3. Addiere den Versatz des Abschnitts zur Basisadresse, um die Adresse im Speicher zu berechnen.

Die folgenden Abschnitte erklären, wie das auf jeder unterstützten Plattform geht, und enthalten Beispielcode.

Linux (ELF)

Um die Struktur PyRuntime unter Linux zu finden:

  1. Lies die Speicherkarte des Prozesses (zum Beispiel /proc/<pid>/maps), um die Adresse zu finden, an der die Python-Programmdatei oder libpython geladen wurde.

  2. Zerlege die ELF-Abschnittsköpfe in der Programmdatei, um den Versatz des Abschnitts .PyRuntime zu bekommen.

  3. Addiere diesen Versatz zur Basisadresse aus Schritt 1, um die Speicheradresse von PyRuntime zu bekommen.

Hier eine Beispielumsetzung:

def find_py_runtime_linux(pid: int) -> int:
    # Step 1: Try to find the Python executable in memory
    binary_path, base_address = find_mapped_binary(
        pid, name_contains="python"
    )

    # Step 2: Fallback to shared library if executable is not found
    if binary_path is None:
        binary_path, base_address = find_mapped_binary(
            pid, name_contains="libpython"
        )

    # Step 3: Parse ELF headers to get .PyRuntime section offset
    section_offset = parse_elf_section_offset(
        binary_path, ".PyRuntime"
    )

    # Step 4: Compute PyRuntime address in memory
    return base_address + section_offset

Auf Linux-Systemen gibt es zwei wesentliche Ansätze, um Speicher aus einem anderen Prozess zu lesen. Der erste führt über das /proc-Dateisystem, genauer über das Lesen von /proc/[pid]/mem, das unmittelbaren Zugriff auf den Speicher des Prozesses gibt. Das erfordert passende Berechtigungen – entweder derselbe Benutzer wie beim Zielprozess zu sein oder Root-Zugriff zu haben. Der zweite Ansatz ist der Systemaufruf process_vm_readv(), der einen effizienteren Weg bietet, Speicher zwischen Prozessen zu kopieren. Die ptrace-Operation PTRACE_PEEKTEXT lässt sich zum Lesen von Speicher ebenfalls verwenden, ist aber deutlich langsamer, weil sie immer nur ein Wort liest und mehrere Kontextwechsel zwischen dem verfolgenden und dem verfolgten Prozess erfordert.

Zum Zerlegen von ELF-Abschnitten gehört es, die Strukturen des ELF-Dateiformats aus der Programmdatei auf der Festplatte zu lesen und auszuwerten. Der ELF-Kopf enthält einen Zeiger auf die Tabelle der Abschnittsköpfe. Jeder Abschnittskopf enthält Metadaten über einen Abschnitt, darunter seinen Namen (in einer separaten Zeichenkettentabelle gespeichert), seinen Versatz und seine Größe. Um einen bestimmten Abschnitt wie .PyRuntime zu finden, musst du diese Köpfe durchgehen und den Abschnittsnamen vergleichen. Der Abschnittskopf liefert dann den Versatz, an dem dieser Abschnitt in der Datei liegt, mit dem sich seine Laufzeitadresse berechnen lässt, sobald die Programmdatei in den Speicher geladen ist.

Mehr über das ELF-Dateiformat kannst du in der ELF-Spezifikation nachlesen.

macOS (Mach-O)

Um die Struktur PyRuntime unter macOS zu finden:

  1. Rufe task_for_pid() auf, um den mach_port_t-Task-Port des Zielprozesses zu bekommen. Dieses Handle ist nötig, um Speicher über APIs wie mach_vm_read_overwrite und mach_vm_region zu lesen.

  2. Durchsuche die Speicherbereiche, um den zu finden, der die Python-Programmdatei oder libpython enthält.

  3. Lade die Programmdatei von der Festplatte und zerlege die Mach-O-Köpfe, um den Abschnitt namens PyRuntime im Segment __DATA zu finden. Unter macOS werden Symbolnamen automatisch mit einem Unterstrich versehen. Das Symbol PyRuntime erscheint in der Symboltabelle daher als _PyRuntime, der Abschnittsname bleibt davon aber unberührt.

Hier eine Beispielumsetzung:

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

Unter macOS erfordert der Zugriff auf den Speicher eines anderen Prozesses Mach-O-spezifische APIs und Dateiformate. Der erste Schritt ist, über task_for_pid() ein task_port-Handle zu bekommen, das Zugriff auf den Speicherbereich des Zielprozesses gibt. Dieses Handle ermöglicht Speicheroperationen über APIs wie mach_vm_read_overwrite().

Der Speicher des Prozesses lässt sich mit mach_vm_region() untersuchen, um den virtuellen Speicherbereich zu durchsuchen, während proc_regionfilename() dabei hilft, zu erkennen, welche Programmdateien an welchem Speicherbereich geladen sind. Ist die Python-Programmdatei oder -Bibliothek gefunden, müssen ihre Mach-O-Köpfe zerlegt werden, um die Struktur PyRuntime zu finden.

Das Mach-O-Format gliedert Code und Daten in Segmente und Abschnitte. Die Struktur PyRuntime liegt in einem Abschnitt namens __PyRuntime innerhalb des Segments __DATA. Für die eigentliche Berechnung der Laufzeitadresse muss das Segment __TEXT gefunden werden, das als Basisadresse der Programmdatei dient, und dann das Segment __DATA, das unseren Zielabschnitt enthält. Die endgültige Adresse ergibt sich aus der Basisadresse und den passenden Abschnittsversätzen aus den Mach-O-Köpfen.

Beachte, dass der Zugriff auf den Speicher eines anderen Prozesses unter macOS üblicherweise erhöhte Rechte erfordert – entweder Root-Zugriff oder besondere Sicherheitsberechtigungen für den Debug-Prozess.

Windows (PE)

Um die Struktur PyRuntime unter Windows zu finden:

  1. Verwende die ToolHelp-API, um alle im Zielprozess geladenen Module aufzuzählen. Das geschieht über Funktionen wie CreateToolhelp32Snapshot, Module32First und Module32Next.

  2. Bestimme das Modul, das zu python.exe oder pythonXY.dll gehört, wobei X und Y die Haupt- und Nebenversionsnummer der Python-Version sind, und notiere seine Basisadresse.

  3. Finde den Abschnitt PyRuntim. Wegen der Begrenzung von Abschnittsnamen auf 8 Zeichen im PE-Format (festgelegt als IMAGE_SIZEOF_SHORT_NAME) wird der ursprüngliche Name PyRuntime abgeschnitten. Dieser Abschnitt enthält die Struktur PyRuntime.

  4. Hole die relative virtuelle Adresse (RVA) des Abschnitts und addiere sie zur Basisadresse des Moduls.

Hier eine Beispielumsetzung:

def find_py_runtime_windows(pid: int) -> int:
    # Step 1: Try to find the Python executable in memory
    binary_path, base_address = find_loaded_module(
        pid, name_contains="python"
    )

    # Step 2: Fallback to shared pythonXY.dll if the executable is not
    # found
    if binary_path is None:
        binary_path, base_address = find_loaded_module(
            pid, name_contains="python3"
        )

    # Step 3: Parse PE section headers to get the RVA of the PyRuntime
    # section. The section name appears as "PyRuntim" due to the
    # 8-character limit defined by the PE format (IMAGE_SIZEOF_SHORT_NAME).
    section_rva = parse_pe_section_offset(binary_path, "PyRuntim")

    # Step 4: Compute PyRuntime address in memory
    return base_address + section_rva

Unter Windows erfordert der Zugriff auf den Speicher eines anderen Prozesses Windows-API-Funktionen wie CreateToolhelp32Snapshot() und Module32First()/Module32Next(), um die geladenen Module aufzuzählen. Die Funktion OpenProcess() liefert ein Handle für den Zugriff auf den Speicherbereich des Zielprozesses und ermöglicht damit Speicheroperationen über ReadProcessMemory().

Der Speicher des Prozesses lässt sich untersuchen, indem man die geladenen Module aufzählt, um die Python-Programmdatei oder -DLL zu finden. Ist sie gefunden, müssen ihre PE-Köpfe zerlegt werden, um die Struktur PyRuntime zu finden.

Das PE-Format gliedert Code und Daten in Abschnitte. Die Struktur PyRuntime liegt in einem Abschnitt namens „PyRuntim“ (von „PyRuntime“ abgeschnitten wegen der Begrenzung der PE-Namen auf 8 Zeichen). Für die eigentliche Berechnung der Laufzeitadresse muss die Basisadresse des Moduls aus dem Moduleintrag ermittelt und dann unser Zielabschnitt in den PE-Köpfen gefunden werden. Die endgültige Adresse ergibt sich aus der Basisadresse und der virtuellen Adresse des Abschnitts aus den PE-Abschnittsköpfen.

Beachte, dass der Zugriff auf den Speicher eines anderen Prozesses unter Windows üblicherweise passende Rechte erfordert – entweder Administratorzugriff oder das Recht SeDebugPrivilege für den Debug-Prozess.

_Py_DebugOffsets lesen

Sobald die Adresse der Struktur PyRuntime bestimmt ist, besteht der nächste Schritt darin, die Struktur _Py_DebugOffsets zu lesen, die am Anfang des PyRuntime-Blocks liegt.

Diese Struktur liefert versionsspezifische Feldversätze, die nötig sind, um den Speicher des Interpreter- und Thread-Zustands sicher zu lesen. Diese Versätze unterscheiden sich zwischen CPython-Versionen und müssen vor der Verwendung geprüft werden, damit sie auch passen.

Um die Debug-Versätze zu lesen und zu prüfen, folge diesen Schritten:

  1. Lies ab der PyRuntime-Adresse Speicher aus dem Zielprozess, und zwar so viele Bytes, wie die Struktur _Py_DebugOffsets groß ist. Diese Struktur liegt am ganz Anfang des PyRuntime-Speicherblocks. Ihre Anordnung ist in den internen Headern von CPython festgelegt und bleibt innerhalb einer Nebenversion gleich, kann sich in Hauptversionen aber ändern.

  2. Prüfe, ob die Struktur gültige Daten enthält:

    • Das Feld cookie muss zur erwarteten Debug-Markierung passen.

    • Das Feld version muss zur Version des Python-Interpreters passen, die der Debugger verwendet.

    • Wenn der Debugger oder der Zielprozess eine Vorabversion verwendet (zum Beispiel eine Alpha-, Beta- oder Release-Candidate-Version), müssen die Versionen genau übereinstimmen.

    • Das Feld free_threaded muss im Debugger und im Zielprozess denselben Wert haben.

  3. Ist die Struktur gültig, lassen sich die darin enthaltenen Versätze verwenden, um Felder im Speicher zu finden. Scheitert eine der Prüfungen, sollte der Debugger den Vorgang abbrechen, um nicht Speicher im falschen Format zu lesen.

Hier eine Beispielumsetzung, die _Py_DebugOffsets liest und prüft:

def read_debug_offsets(pid: int, py_runtime_addr: int) -> DebugOffsets:
    # Step 1: Read memory from the target process at the PyRuntime address
    data = read_process_memory(
        pid, address=py_runtime_addr, size=DEBUG_OFFSETS_SIZE
    )

    # Step 2: Deserialize the raw bytes into a _Py_DebugOffsets structure
    debug_offsets = parse_debug_offsets(data)

    # Step 3: Validate the contents of the structure
    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

Warnung

Anhalten des Prozesses empfohlen

Um Wettläufe zu vermeiden und die Konsistenz des Speichers zu sichern, ist es sehr empfehlenswert, den Zielprozess anzuhalten, bevor Vorgänge ausgeführt werden, die internen Interpreter-Zustand lesen oder schreiben. Die Python-Laufzeit kann die Datenstrukturen des Interpreters im normalen Betrieb gleichzeitig verändern – etwa Threads erzeugen oder zerstören. Das kann zu ungültigen Lese- oder Schreibzugriffen führen.

Ein Debugger kann die Ausführung anhalten, indem er sich mit ptrace an den Prozess anhängt oder ein SIGSTOP-Signal sendet. Die Ausführung sollte erst fortgesetzt werden, wenn die Speicheroperationen auf Debugger-Seite abgeschlossen sind.

Bemerkung

Manche Werkzeuge, etwa Profiler oder abtastende Debugger, arbeiten möglicherweise an einem laufenden Prozess, ohne ihn anzuhalten. In solchen Fällen müssen die Werkzeuge ausdrücklich darauf ausgelegt sein, teilweise aktualisierten oder widersprüchlichen Speicher zu verkraften. Für die meisten Debugger-Umsetzungen bleibt es der sicherste und robusteste Weg, den Prozess anzuhalten.

Den Interpreter- und Thread-Zustand finden

Bevor Code in einen fernen Python-Prozess eingespeist und ausgeführt werden kann, muss der Debugger einen Thread wählen, in dem die Ausführung eingeplant wird. Das ist nötig, weil die Steuerfelder für das Einspeisen von Code aus der Ferne in der Struktur _PyRemoteDebuggerSupport liegen, die in ein PyThreadState-Objekt eingebettet ist. Diese Felder werden vom Debugger verändert, um die Ausführung eingespeister Skripte anzufordern.

Die Struktur PyThreadState stellt einen Thread dar, der innerhalb eines Python-Interpreters läuft. Sie führt den Auswertungskontext des Threads und enthält die Felder, die für die Zusammenarbeit mit dem Debugger nötig sind. Ein gültiges PyThreadState zu finden ist daher eine wesentliche Voraussetzung dafür, die Ausführung aus der Ferne anzustoßen.

Ein Thread wird üblicherweise anhand seiner Rolle oder seiner Kennung gewählt. In den meisten Fällen wird der Haupt-Thread verwendet, aber manche Werkzeuge zielen über die native Thread-Kennung auf einen bestimmten Thread. Sobald der Ziel-Thread gewählt ist, muss der Debugger sowohl die Interpreter- als auch die zugehörigen Thread-Zustandsstrukturen im Speicher finden.

Die betroffenen internen Strukturen sind wie folgt festgelegt:

  • PyInterpreterState stellt eine isolierte Python-Interpreter-Instanz dar. Jeder Interpreter führt seine eigene Menge importierter Module, seinen eigenen integrierten Zustand und seine eigene Liste von Thread-Zuständen. Die meisten Python-Anwendungen verwenden zwar einen einzigen Interpreter, aber CPython unterstützt mehrere Interpreter im selben Prozess.

  • PyThreadState stellt einen Thread dar, der innerhalb eines Interpreters läuft. Es enthält den Ausführungszustand und die Steuerfelder, die der Debugger verwendet.

Um einen Thread zu finden:

  1. Verwende den Versatz runtime_state.interpreters_head, um die Adresse des ersten Interpreters in der Struktur PyRuntime zu bekommen. Das ist der Einstiegspunkt in die verkettete Liste der aktiven Interpreter.

  2. Verwende den Versatz interpreter_state.threads_main, um auf den Zustand des Haupt-Threads zuzugreifen, der zum gewählten Interpreter gehört. Das ist üblicherweise der verlässlichste Thread als Ziel.

  3. Verwende wahlweise den Versatz interpreter_state.threads_head, um die verkettete Liste aller Thread-Zustände durchzugehen. Jede PyThreadState-Struktur enthält ein Feld native_thread_id, das sich mit einer Ziel-Thread-Kennung vergleichen lässt, um einen bestimmten Thread zu finden.

  4. Sobald ein gültiges PyThreadState gefunden ist, lässt sich seine Adresse in den späteren Schritten des Protokolls verwenden, etwa zum Schreiben der Steuerfelder des Debuggers und zum Einplanen der Ausführung.

Hier eine Beispielumsetzung, die den Zustand des Haupt-Threads findet:

def find_main_thread_state(
    pid: int, py_runtime_addr: int, debug_offsets: DebugOffsets,
) -> int:
    # Step 1: Read interpreters_head from 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")

    # Step 2: Read the threads_main pointer from the interpreter
    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

Das folgende Beispiel zeigt, wie man einen Thread über seine native Thread-Kennung findet:

def find_thread_by_id(
    pid: int,
    interp_addr: int,
    debug_offsets: DebugOffsets,
    target_tid: int,
) -> int:
    # Start at threads_head and walk the linked list
    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")

Sobald ein gültiger Thread-Zustand gefunden ist, kann der Debugger damit fortfahren, dessen Steuerfelder zu verändern und die Ausführung einzuplanen, wie im nächsten Abschnitt beschrieben.

Steuerinformationen schreiben

Sobald eine gültige PyThreadState-Struktur bestimmt ist, kann der Debugger Steuerfelder darin verändern, um die Ausführung eines bestimmten Python-Skripts einzuplanen. Diese Steuerfelder werden vom Interpreter regelmäßig geprüft, und wenn sie richtig gesetzt sind, lösen sie die Ausführung des Codes aus der Ferne an einem sicheren Punkt in der Auswertungsschleife aus.

Jedes PyThreadState enthält eine Struktur _PyRemoteDebuggerSupport, die der Verständigung zwischen Debugger und Interpreter dient. Die Lage ihrer Felder ist durch die Struktur _Py_DebugOffsets festgelegt und umfasst Folgendes:

  • debugger_script_path: Ein Puffer fester Größe, der den vollständigen Pfad zu einer Python-Quelldatei (.py) aufnimmt. Diese Datei muss für den Zielprozess zugänglich und lesbar sein, wenn die Ausführung angestoßen wird.

  • debugger_pending_call: Ein ganzzahliges Flag. Es auf 1 zu setzen sagt dem Interpreter, dass ein Skript zur Ausführung bereitsteht.

  • eval_breaker: Ein Feld, das der Interpreter während der Ausführung prüft. Bit 5 (_PY_EVAL_PLEASE_STOP_BIT, Wert 1U << 5) in diesem Feld zu setzen bringt den Interpreter dazu, anzuhalten und auf Debugger-Aktivität zu prüfen.

Um das Einspeisen abzuschließen, muss der Debugger die folgenden Schritte ausführen:

  1. Schreibe den vollständigen Skriptpfad in den Puffer debugger_script_path.

  2. Setze debugger_pending_call auf 1.

  3. Lies den aktuellen Wert von eval_breaker, setze Bit 5 (_PY_EVAL_PLEASE_STOP_BIT) und schreibe den aktualisierten Wert zurück. Damit wird dem Interpreter signalisiert, auf Debugger-Aktivität zu prüfen.

Hier eine Beispielumsetzung:

def inject_script(
    pid: int,
    thread_state_addr: int,
    debug_offsets: DebugOffsets,
    script_path: str
) -> None:
    # Compute the base offset of _PyRemoteDebuggerSupport
    support_base = (
        thread_state_addr +
        debug_offsets.debugger_support.remote_debugger_support
    )

    # Step 1: Write the script path into debugger_script_path
    script_path_ptr = (
        support_base +
        debug_offsets.debugger_support.debugger_script_path
    )
    write_string(pid, script_path_ptr, script_path)

    # Step 2: Set debugger_pending_call to 1
    pending_ptr = (
        support_base +
        debug_offsets.debugger_support.debugger_pending_call
    )
    write_int(pid, pending_ptr, 1)

    # Step 3: Set _PY_EVAL_PLEASE_STOP_BIT (bit 5, value 1 << 5) in
    # 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)

Sobald diese Felder gesetzt sind, kann der Debugger den Prozess fortsetzen (falls er angehalten war). Der Interpreter verarbeitet die Anfrage am nächsten sicheren Auswertungspunkt, lädt das Skript von der Festplatte und führt es aus.

Es liegt in der Verantwortung des Debuggers, dafür zu sorgen, dass die Skriptdatei während der Ausführung vorhanden und für den Zielprozess zugänglich bleibt.

Bemerkung

Die Ausführung des Skripts ist asynchron. Die Skriptdatei darf nicht unmittelbar nach dem Einspeisen gelöscht werden. Der Debugger sollte warten, bis das eingespeiste Skript eine beobachtbare Wirkung erzeugt hat, bevor er die Datei entfernt. Welche Wirkung das ist, hängt davon ab, was das Skript tun soll. Ein Debugger könnte zum Beispiel warten, bis sich der ferne Prozess an einen Socket zurückmeldet, bevor er das Skript entfernt. Sobald eine solche Wirkung beobachtet ist, darf man davon ausgehen, dass die Datei nicht mehr gebraucht wird.

Zusammenfassung

Um ein Python-Skript in einen fernen Prozess einzuspeisen und auszuführen:

  1. Finde die Struktur PyRuntime im Speicher des Zielprozesses.

  2. Lies die Struktur _Py_DebugOffsets am Anfang von PyRuntime und prüfe sie.

  3. Verwende die Versätze, um ein gültiges PyThreadState zu finden.

  4. Schreibe den Pfad zu einem Python-Skript in debugger_script_path.

  5. Setze das Flag debugger_pending_call auf 1.

  6. Setze _PY_EVAL_PLEASE_STOP_BIT im Feld eval_breaker.

  7. Setze den Prozess fort (falls angehalten). Das Skript wird am nächsten sicheren Auswertungspunkt ausgeführt.

Sicherheit und Bedrohungsmodell

Das Protokoll zum Debuggen aus der Ferne stützt sich auf dieselben Betriebssystemmittel, die native Debugger wie GDB und LLDB verwenden. Sich an einen Prozess anzuhängen erfordert dieselben Rechte, die diese Debugger brauchen, zum Beispiel ptrace / Yama LSM unter Linux, task_for_pid unter macOS und SeDebugPrivilege unter Windows. Python führt keinen neuen Weg zur Rechteausweitung ein, wenn ein Angreifer die nötigen Rechte zum Anhängen an einen Prozess schon besitzt, könnte er genauso GDB verwenden, um Speicher zu lesen oder Code einzuspeisen.

Die folgenden Grundsätze legen fest, was bei dieser Funktionalität als Sicherheitslücke gilt und was nicht:

Das Anhängen erfordert Rechte auf Betriebssystemebene

Auf jeder unterstützten Plattform sichert das Betriebssystem den prozessübergreifenden Speicherzugriff hinter Rechteprüfungen ab (CAP_SYS_PTRACE, Root oder Administratorrechte). Eine Meldung, die ein Problem erst zeigt, nachdem diese Rechte schon erlangt wurden, ist keine Sicherheitslücke in CPython, weil die Sicherheitsgrenze des Betriebssystems bereits überschritten war.

Abstürze oder Speicherfehler beim Lesen eines kompromittierten Prozesses sind keine Sicherheitslücken

Ein Werkzeug, das internen Interpreter-Zustand aus einem Zielprozess liest, muss darauf vertrauen, dass dieser Speicher wohlgeformt ist. Ist der Zielprozess beschädigt oder von einem Angreifer gesteuert, kann der Debugger oder Profiler abstürzen, Unsinn ausgeben oder sich unvorhersehbar verhalten. Das ist dasselbe Risiko, das jeder ptrace-basierte Debugger in Kauf nimmt. Fehler dieser Art (Pufferüberläufe, Speicherzugriffsfehler oder undefiniertes Verhalten, ausgelöst durch das Lesen beschädigten Zustands) werden nicht als Sicherheitsprobleme behandelt, Korrekturen zur Verbesserung der Robustheit sind aber willkommen.

Sicherheitslücken im Zielprozess sind nicht Gegenstand

Ist der gedebuggte Python-Prozess schon kompromittiert, steuert der Angreifer die Ausführung in diesem Prozess bereits. Von diesem Ausgangspunkt weitere Auswirkungen vorzuführen stellt keine Sicherheitslücke im Protokoll zum Debuggen aus der Ferne dar.

Wann man PYTHON_DISABLE_REMOTE_DEBUG verwenden sollte

The environment variable PYTHON_DISABLE_REMOTE_DEBUG (and the equivalent -X disable-remote-debug flag) allows operators to disable the in-process side of the protocol as a defence-in-depth measure. This may be useful in hardened or sandboxed deployment environments where no debugging or profiling of the process is expected and reducing attack surface is a priority, even though the OS-level privilege checks already prevent unprivileged access.

Diese Variable zu setzen beeinflusst andere Debug-Schnittstellen auf Betriebssystemebene nicht (ptrace, /proc, task_for_pid usw.). Die bleiben nach ihren eigenen Berechtigungsmodellen verfügbar.