Python-code injecteren in een draaiend proces
Hoe je live Python-code in een draaiend proces laadt zonder het te herstarten. Een diepe duik tot op syscall-niveau: VirtualAllocEx en CreateRemoteThread op Windows, ptrace en remote dlopen op Linux, en hoe een geinjecteerde library de CPython C-API aanroept.
Stel: je hebt een langlopend Python-script dat al twee uur draait, een lastige bug in zijn state heeft opgebouwd, en je wilt weten wat er nu in het geheugen leeft. Herstarten wist het bewijs. Wat je wilt is een regel Python binnenin dat draaiende proces uitvoeren, in zijn eigen adresruimte, zonder het te stoppen.
Dat kan, met een techniek die process injection heet. Deze post gaat niet over een tool of een handige wrapper; hij gaat over de machinerie eronder. We zakken helemaal af naar de systeemaanroepen die een besturingssysteem gebruikt om vreemd geheugen te schrijven en een thread in een ander proces te starten, en naar de C-functies waarmee je vervolgens de Python-interpreter van dat proces aanstuurt.
Scope. Dit is een debug- en instrumentatietechniek, bedoeld voor processen die je zelf bezit of expliciet mag testen. Niet voor het omzeilen van licenties, authenticatie of toegangscontrole.
Waarom dit met Python kan, en met een C-binary bijna niet
De reden dat dit uberhaupt werkt, is dat Python op runtime geinterpreteerd wordt. CPython voert geen vastgevroren machinecode uit; het draait een interpreter-loop over bytecode, en die interpreter is als een levende component altijd aanwezig in elk draaiend Python-proces, met al zijn state: de geladen modules, de globals, de objecten op de heap. Je hoeft die interpreter alleen nog een string source te voeren en hij compileert en draait die ter plekke.
Bij een gewone gecompileerde C-binary is er geen interpreter om code aan te voeren. Wil je daar iets nieuws draaien, dan moet je op assembly-niveau shellcode schrijven en de instructiepointer kapen. Bij Python hoeft dat niet: de tolk die jouw tekst begrijpt draait al mee. Het enige probleem is dat die tolk in een ander proces zit, achter de geheugenisolatie die het besturingssysteem tussen processen zet. Dat is de barriere die injectie moet doorbreken.
De hele operatie valt uiteen in twee lagen. Laag een: hoe krijg je uberhaupt code in de adresruimte van een vreemd proces en laat je die daar draaien? Dat is puur OS-werk en verschilt fundamenteel tussen Windows en Linux. Laag twee: zodra je code binnen draait, hoe roep je veilig de CPython C-API aan? Dat is op elk platform hetzelfde. We nemen ze om de beurt.
Laag een, Windows: geheugen alloceren en een remote thread starten
Op Windows is de klassieke route een aaneenschakeling van vier Win32-aanroepen. Je opent eerst een handle naar het doel met precies de rechten die je nodig hebt:
dwDesiredAccess =
PROCESS_CREATE_THREAD | /* voor CreateRemoteThread() */
PROCESS_VM_OPERATION | /* voor VirtualAllocEx() */
PROCESS_VM_READ |
PROCESS_VM_WRITE; /* voor WriteProcessMemory() */
hProcess = OpenProcess(dwDesiredAccess, FALSE, pid);
Met die handle alloceer je geheugen in het doelproces met VirtualAllocEx. Dat is de kern van de truc: VirtualAllocEx is als VirtualAlloc, maar de eerste parameter is een handle naar een ander proces, dus de pagina's ontstaan in de adresruimte van dat proces, niet die van jou. Vervolgens schrijf je er data naartoe met WriteProcessMemory, dat letterlijk bytes vanuit jouw geheugen naar een adres in het vreemde proces kopieert:
code = VirtualAllocEx(hProcess, NULL, 2 * page_size, MEM_COMMIT, PAGE_...);
WriteProcessMemory(hProcess, code, args, size, &written);
Nu de laatste stap, en dit is het elegante deel. Je wilt dat het doel jouw shared library (een .dll) laadt. Daar bestaat al een Windows-functie voor: LoadLibraryW. En cruciaal: kernel32.dll zit in elk Windows-proces op hetzelfde adres, dus het adres van LoadLibraryW in jouw proces is hetzelfde adres in het doel. Je hoeft dus alleen het pad naar je DLL in het doelgeheugen te schrijven en dan een nieuwe thread in het doel te starten die begint bij LoadLibraryW, met dat pad als argument:
hThread = CreateRemoteThread(
hProcess, NULL, 0,
(LPTHREAD_START_ROUTINE)func_LoadLibraryW, /* startroutine = LoadLibraryW */
remote_path_addr, /* argument = pad naar de DLL */
0, NULL);
WaitForSingleObject(hThread, INFINITE); /* wacht tot het laden klaar is */
Windows start nu, binnen het doelproces, een thread die LoadLibraryW("C:\...\jouw.dll") uitvoert. Het doel laadt jouw library alsof het er zelf om vroeg. Op het moment dat de DLL geladen wordt, roept Windows automatisch zijn DllMain aan met DLL_PROCESS_ATTACH, en daar hangt jouw code aan. Meer over dat aanhaakpunt zo.
Merk op dat er nergens shellcode aan te pas komt. Je hebt het besturingssysteem zijn eigen loader laten gebruiken. Dat is precies waarom deze techniek zo betrouwbaar is.
Laag een, Linux: ptrace, registers kapen en remote dlopen
Linux heeft geen CreateRemoteThread. Wat het wel heeft is ptrace, het mechanisme waarmee debuggers als gdb een proces besturen. Injectie op Linux is in de kern: doe je voor als een debugger, pauzeer het doel, en dwing het zijn eigen dlopen aan te roepen.
Het begint met attachen. PTRACE_ATTACH stopt het doelproces en maakt jou zijn tracer:
ptrace(PTRACE_ATTACH, pid, 0, 0);
waitpid(pid, &status, 0); /* wacht tot het doel echt gestopt is */
Nu het doel bevroren is, lees je zijn CPU-registers uit en bewaar je ze, want je gaat ze zo overschrijven en straks weer netjes terugzetten:
ptrace(PTRACE_GETREGS, pid, 0, &saved_regs); /* huidige toestand bewaren */
Data in het doel schrijven gebeurt hier woord voor woord met PTRACE_POKETEXT (en lezen met PTRACE_PEEKTEXT). Zo plaats je bijvoorbeeld het pad van je .so-bestand in het geheugen van het doel:
ptrace(PTRACE_POKETEXT, pid, addr, word); /* een machinewoord het doel in */
Het kapen zelf: je zet met PTRACE_SETREGS de instructiepointer van het doel op het adres van een functie die je wilt aanroepen, en zijn argumentregisters op de argumenten. Zet je de instructiepointer op het dlopen-adres binnen het doel (met daarin het pad naar je .so) en laat je het proces lopen met PTRACE_CONT, dan voert het doel zijn eigen dlopen("/tmp/jouw.so", ...) uit. De onderliggende injector doet exact dat:
/* roep dlopen() aan binnen het doel, met de padstring die we net schreven */
injector__call_function(injector, &retval, injector->dlopen_addr,
injector->data /* pad */, dlflags);
Het doel laadt jouw shared library. Bij Linux draait de dynamische linker automatisch elke functie die met __attribute__((constructor)) gemarkeerd is zodra de library geladen is, precies zoals Windows DllMain roept. Dat is opnieuw jouw aanhaakpunt. Als de dlopen klaar is, herstel je de bewaarde registers, zodat het doel gewoon verdergaat waar het gebleven was, en detach je:
ptrace(PTRACE_SETREGS, pid, 0, &saved_regs); /* oorspronkelijke toestand terug */
ptrace(PTRACE_DETACH, pid, 0, 0); /* het doel loopt weer vrij verder */
Het doel heeft geen idee dat er iets is gebeurd, behalve dat het heel even bevroren was en nu een extra library geladen heeft. Dit is ook precies waarom Linux hiervoor CAP_SYS_PTRACE of root vraagt: je hebt letterlijk debugger-controle over een vreemd proces genomen.
Laag twee: van geladen library naar draaiende Python
Op beide platforms is het eindresultaat hetzelfde: een kleine, door jou gebouwde shared library is nu geladen in het doel, en zijn init-functie draait binnen dat proces. Die library is verrassend klein. In essentie is het dit:
#include <Python.h>
#define MAX_PYTHON_CODE_SIZE 60500
/* Een globale buffer met een herkenbare marker erin. De payload wordt hier
voor het injecteren ingeschreven door de bytes na de marker te overschrijven. */
volatile char PYTHON_CODE[MAX_PYTHON_CODE_SIZE + 1] =
"\0--- code start ---";
void run_python_code(void) {
if (PYTHON_CODE[0]) {
int saved_errno = errno;
PyGILState_STATE gstate = PyGILState_Ensure(); /* pak de GIL veilig */
PyRun_SimpleString(PYTHON_CODE); /* draai de payload */
PyGILState_Release(gstate); /* geef de GIL terug */
errno = saved_errno;
}
}
En het aanhaakpunt dat op laag een geactiveerd werd, roept simpelweg die functie aan:
#ifdef _WIN32
BOOL WINAPI DllMain(HINSTANCE h, DWORD reason, LPVOID r) {
if (reason == DLL_PROCESS_ATTACH) run_python_code();
return TRUE;
}
#else
__attribute__((constructor))
static void init(void) { run_python_code(); }
#endif
Hier gebeurt de echte magie van laag twee, en ze zit in drie C-functies uit de Python C-API.
PyGILState_Ensure() is onmisbaar. De code draait op een thread die CPython niet zelf heeft aangemaakt (op Windows de remote thread, op Linux de thread die de constructor draait). Zo'n thread bezit de GIL niet, de Global Interpreter Lock die garandeert dat er maar een thread tegelijk Python-bytecode uitvoert. Als je zonder de GIL in de interpreter zou stappen, krijg je vrijwel gegarandeerd een crash, want je zou objecten aanraken terwijl een andere thread ze onder je vandaan wijzigt. PyGILState_Ensure regelt dit netjes: het registreert de huidige thread bij de interpreter en pakt de GIL, en PyGILState_Release geeft alles keurig terug. Daartussen ben je een volwaardige Python-thread.
PyRun_SimpleString(code) is de betaaldag. Het neemt jouw string, compileert die naar bytecode en voert die uit in de __main__-module van het doel, met volledige toegang tot alles wat daar leeft. Dit is exact wat de interpreter doet als je iets in de REPL typt, alleen gebeurt het nu binnen een vreemd proces. Vanaf hier is elke regel Python die je stuurt gewoon Python die in dat proces draait.
Het bewaren en herstellen van errno eromheen is een klein maar belangrijk detail: je draait op een thread van het doel, en je wilt geen zichtbare bijwerking achterlaten (zoals een veranderde errno) die de gastheercode in de war brengt.
De payload het schild in krijgen: de marker-truc
Er is een kip-en-ei-probleem. De shared library wordt gecompileerd voordat je weet welke Python je wilt draaien. Hoe komt jouw code dan in die PYTHON_CODE-buffer?
Het antwoord is een marker. De buffer wordt gecompileerd met een herkenbare bytereeks erin, hierboven --- code start ---. Vlak voor het injecteren lees je de gecompileerde library als ruwe bytes in, zoekt de marker, en overschrijft de bytes ervoor met jouw payload, afgesloten met een null-byte. Dat gepatchte bestand schrijf je naar een tijdelijk pad, en juist dat tijdelijke bestand laat je het doel laden.
lib = library_path.read_bytes()
magic_addr = lib.find(MAGIC) # vind de marker in de gecompileerde .so/.dll
code_addr = magic_addr - 1 # de payload komt er vlak voor
# schrijf een gepatchte kopie: [lib-kop][jouw payload]\0[rest van de lib]
temp.write(lib[:code_addr])
temp.write(python_code)
temp.write(b"\0")
temp.write(lib[code_addr + len(python_code) + 1:])
De bytes direct na de marker coderen de maximale buffergrootte waarmee de library gecompileerd is (MAX_PYTHON_CODE_SIZE), zodat je voor het injecteren kunt controleren of je payload past en anders een nette fout kunt geven in plaats van een buffer-overflow in het doel te veroorzaken. Wil je meer code draaien dan er past, dan injecteer je een klein bootstrap-fragment dat een groter script vanaf schijf inleest met bijvoorbeeld runpy.run_path.
De payload zelf wordt meestal nog verpakt in een base64-exec-oneliner. Dat is puur een kwestie van robuustheid: base64 bevat geen aanhalingstekens, geen newlines en geen null-bytes, dus het kan de C-string in de buffer niet voortijdig afkappen of breken. In het doel wordt het weer gedecodeerd en uitgevoerd:
encoded = base64.b64encode(source.encode()).decode("ascii")
payload = (
'__import__("builtins").exec('
'__import__("builtins").compile('
f'__import__("base64").b64decode("{encoded}"),"<injected>","exec"))'
)
Een laatste subtiliteit op Windows: de init-routine van de geinjecteerde DLL laat men vaak expres falen. Een DLL waarvan DllMain FALSE teruggeeft, wordt door Windows meteen weer uitgeladen, wat handig is: je code heeft gedraaid, de payload is gedaan, en de DLL blijft niet nutteloos in het doel hangen. De specifieke foutcode die daaruit komt betekent dus succes, niet falen, en wordt door de aanroepende kant herkend en ingeslikt.
De valkuil waar iedereen op stuk loopt: de ABI moet matchen
Er zit een harde voorwaarde onder dit alles. Jouw geinjecteerde library is gecompileerd tegen Python.h en linkt tegen een CPython-runtime, pythonXY.dll op Windows of libpython3.Y op POSIX. Wanneer het doel de library laadt, moeten PyGILState_Ensure en PyRun_SimpleString daar bestaan met exact de binaire vorm die jouw library verwacht.
Dat betekent dat het doel dezelfde CPython major.minor en dezelfde bitness moet draaien, en geen frozen of embedded interpreter mag zijn. Zit er een mismatch, dan kan de runtime niet initialiseren in het doel en krijg je op Windows de beruchte LoadLibrary ... initialization routine failed (foutcode -5), of op Linux een dlopen-fout. Cryptisch, tenzij je weet waar je naar kijkt.
Je kunt dit vooraf detecteren zonder ook maar iets te injecteren, door de al geladen bibliotheken van het doel te lezen. Op elk platform staan die in de memory maps van het proces; je zoekt simpelweg naar de CPython-runtime en leest de versie uit de bestandsnaam:
_PY_DLL_RE = re.compile(r"python(\d)(\d+)\.dll$", re.IGNORECASE) # Windows
_PY_SO_RE = re.compile(r"libpython(\d+)\.(\d+)") # POSIX
for entry in proc.memory_maps():
base = os.path.basename(entry.path or "")
if (m := _PY_DLL_RE.search(base) or _PY_SO_RE.search(base)):
target_version = f"{m.group(1)}.{m.group(2)}" # bv. "3.11"
Vergelijk target_version met je eigen sys.version_info en je weet voor het injecteren of het gaat werken. De oplossing bij een mismatch is simpel: draai de injector onder de Python van het doel. Een tool als uv installeert elke CPython on demand, zodat je een venv op precies de juiste versie kunt opzetten.
Waarom je meerdere strategieen wilt
De hierboven beschreven mechanieken werken niet overal. Een Windows-doos zonder de juiste prebuilt library, een kale Linux-server, een systeem met strakke ptrace-beperkingen: elk breekt op een andere plek. Een robuuste injector legt daarom niet zijn lot in een enkele techniek, maar in een geordende fallback-keten. Elke strategie kan zeggen of ze hier en nu bruikbaar is, en de orkestrator probeert ze op volgorde tot er een slaagt:
- De primaire route is de zojuist beschreven techniek: bouw een library met je payload, injecteer die via VirtualAllocEx/CreateRemoteThread (Windows) of ptrace/dlopen (Linux), en laat de init-functie de CPython C-API aanroepen.
- Een puur-ctypes Windows-route stuurt precies diezelfde Win32-aanroepen aan zonder een prebuilt binary die aan de ABI van het doel moet matchen, wat helpt wanneer de versies niet lijnen.
- Een gdb-route als laatste redmiddel doet de ptrace-dans niet zelf, maar laat gdb het doen: attach met gdb en roep, precies zoals wij hierboven,
PyGILState_Ensure,PyRun_SimpleStringenPyGILState_Releaseaan via gdb'scall-commando.
gdb -p <pid> -batch -nx \
-eval-command 'call (int) PyGILState_Ensure()' \
-eval-command 'call (int) PyRun_SimpleString("<code>")' \
-eval-command 'call (void) PyGILState_Release($1)'
Dat is letterlijk laag twee, met de hand gedreven. Het is geen toeval dat elke strategie op dezelfde drie C-functies uitkomt; dat is de kern die je nodig hebt om Python van binnenuit te draaien. De rest, de VirtualAllocEx-dans, de ptrace-choreografie, het gdb-commando, zijn allemaal manieren om bij die kern te komen.
De veiligheidslaag die je er niet af mag halen
Zoveel controle over een vreemd proces vraagt om strakke waarborgen, en die horen in de kern van het gereedschap, niet alleen in de UI.
Altijd verhoogde rechten. OpenProcess met schrijfrechten, CreateRemoteThread en PTRACE_ATTACH vereisen Administrator op Windows of root/CAP_SYS_PTRACE op Linux. Dwing dat af voordat je ook maar een geheugenpagina aanraakt.
Kritieke processen weiger je hard. Injecteren in lsass, csrss, winlogon, services, init of systemd kan de hele machine laten crashen. Weiger die op naam en op PID, en toon ze nooit als doel.
Bescherm tegen PID-hergebruik. Tussen het moment dat je een doel kiest en het moment dat je OpenProcess of PTRACE_ATTACH aanroept, kan het proces afsluiten en kan zijn PID hergebruikt worden door een compleet ander proces. Dan zou je in de verkeerde adresruimte schrijven. De creation-time van het proces vlak voor injectie vergelijken vangt dat:
def still_valid(match) -> bool:
if not psutil.pid_exists(match.pid):
return False
now = psutil.Process(match.pid).create_time()
return abs(now - match.create_time) < 1e-4 # zelfde starttijd = zelfde proces
Een hergebruikte PID heeft een andere starttijd en wordt overgeslagen. Dit is een echte time-of-check-to-time-of-use race, en zonder deze controle een reeel gevaar bij een langlopende UI waarin je een doel kiest en pas seconden later injecteert.
Samengevat
Code in een draaiend Python-proces injecteren is geen enkele slimme truc, maar twee lagen die op elkaar klikken. De onderste laag is puur besturingssysteem: geheugen alloceren en schrijven in een vreemd proces en daar een thread starten, via VirtualAllocEx en CreateRemoteThread op Windows of via ptrace en een gekaapte dlopen op Linux, in beide gevallen door de eigen loader van het OS zijn werk te laten doen. De bovenste laag is puur CPython: de GIL veilig pakken met PyGILState_Ensure, de payload draaien met PyRun_SimpleString, en netjes teruggeven. Alles daaromheen, de marker-truc om de payload in de library te krijgen, de ABI-check, de fallback-keten, de veiligheidswaarborgen, bestaat om die twee lagen betrouwbaar en veilig te laten samenwerken over besturingssystemen en Python-versies heen. Lastige materie, maar goed te begrijpen zodra je ziet waar de barriere zit en hoe elke stap hem doorbreekt.
Tags
python, process-injection, code-injectie, ctypes, cpython, virtualallocex, createremotethread, ptrace, dlopen, gil, debugging, windows-api, reverse-engineering