Injecting Python Code Into a Running Process
How to load live Python code into a running process without restarting it. A deep dive down to the syscall level: VirtualAllocEx and CreateRemoteThread on Windows, ptrace and remote dlopen on Linux, and how an injected library calls into the CPython C API.
Picture this: you have a long-running Python script that has been up for two hours, has built up a nasty bug in its state, and you want to know what is living in memory right now. Restarting wipes the evidence. What you want is to run one line of Python inside that running process, in its own address space, without stopping it.
You can, with a technique called process injection. This post is not about a tool or a convenient wrapper; it is about the machinery underneath. We go all the way down to the system calls an operating system uses to write foreign memory and start a thread in another process, and to the C functions you then use to drive that process's Python interpreter.
Scope. This is a debugging and instrumentation technique, meant for processes you own or are explicitly authorized to test. Not for bypassing licensing, authentication or access control.
Why this works with Python, and barely with a C binary
The reason this is possible at all is that Python is interpreted at runtime. CPython does not execute frozen machine code; it runs an interpreter loop over bytecode, and that interpreter, as a living component, is always present in every running Python process, along with all its state: the loaded modules, the globals, the objects on the heap. All you have to do is hand that interpreter a string of source and it compiles and runs it on the spot.
With an ordinary compiled C binary there is no interpreter to hand code to. To run something new there you would have to write shellcode at the assembly level and hijack the instruction pointer. With Python you do not: the interpreter that understands your text is already running. The only problem is that it lives in a different process, behind the memory isolation the operating system puts between processes. That is the barrier injection has to break.
The whole operation splits into two layers. Layer one: how do you get any code into a foreign process's address space and make it run there? That is pure OS work and differs fundamentally between Windows and Linux. Layer two: once your code is running inside, how do you safely call the CPython C API? That part is the same on every platform. We take them in turn.
Layer one, Windows: allocate memory and start a remote thread
On Windows the classic route is a chain of four Win32 calls. You first open a handle to the target with exactly the rights you need:
dwDesiredAccess =
PROCESS_CREATE_THREAD | /* for CreateRemoteThread() */
PROCESS_VM_OPERATION | /* for VirtualAllocEx() */
PROCESS_VM_READ |
PROCESS_VM_WRITE; /* for WriteProcessMemory() */
hProcess = OpenProcess(dwDesiredAccess, FALSE, pid);
With that handle you allocate memory inside the target process using VirtualAllocEx. That is the heart of the trick: VirtualAllocEx is like VirtualAlloc, but its first parameter is a handle to another process, so the pages appear in that process's address space, not yours. Then you write data into them with WriteProcessMemory, which literally copies bytes from your memory to an address in the foreign process:
code = VirtualAllocEx(hProcess, NULL, 2 * page_size, MEM_COMMIT, PAGE_...);
WriteProcessMemory(hProcess, code, args, size, &written);
Now the final step, and this is the elegant part. You want the target to load your shared library (a .dll). A Windows function for that already exists: LoadLibraryW. And crucially, kernel32.dll is mapped at the same address in every Windows process, so the address of LoadLibraryW in your process is the same address in the target. So you only need to write the path to your DLL into the target's memory and then start a new thread in the target that begins at LoadLibraryW, with that path as its argument:
hThread = CreateRemoteThread(
hProcess, NULL, 0,
(LPTHREAD_START_ROUTINE)func_LoadLibraryW, /* start routine = LoadLibraryW */
remote_path_addr, /* argument = path to the DLL */
0, NULL);
WaitForSingleObject(hThread, INFINITE); /* wait until loading finishes */
Windows now starts, inside the target process, a thread that runs LoadLibraryW("C:\...\your.dll"). The target loads your library as if it had asked for it itself. The moment the DLL loads, Windows automatically calls its DllMain with DLL_PROCESS_ATTACH, and that is where your code hangs. More on that hook point shortly.
Note that no shellcode is involved anywhere. You let the operating system use its own loader. That is precisely why this technique is so reliable.
Layer one, Linux: ptrace, hijacking registers and a remote dlopen
Linux has no CreateRemoteThread. What it does have is ptrace, the mechanism debuggers like gdb use to drive a process. Injection on Linux is, at its core: pretend to be a debugger, pause the target, and force it to call its own dlopen.
It starts with attaching. PTRACE_ATTACH stops the target process and makes you its tracer:
ptrace(PTRACE_ATTACH, pid, 0, 0);
waitpid(pid, &status, 0); /* wait until the target has actually stopped */
Now that the target is frozen, you read out its CPU registers and save them, because you are about to overwrite them and put them back cleanly afterwards:
ptrace(PTRACE_GETREGS, pid, 0, &saved_regs); /* save the current state */
Writing data into the target happens here word by word with PTRACE_POKETEXT (and reading with PTRACE_PEEKTEXT). That is how you place, for instance, the path to your .so file into the target's memory:
ptrace(PTRACE_POKETEXT, pid, addr, word); /* one machine word into the target */
The hijack itself: with PTRACE_SETREGS you set the target's instruction pointer to the address of a function you want to call, and its argument registers to the arguments. Set the instruction pointer to the dlopen address inside the target (with the path to your .so in place) and let the process run with PTRACE_CONT, and the target executes its own dlopen("/tmp/your.so", ...). The underlying injector does exactly that:
/* call dlopen() inside the target, with the path string we just wrote */
injector__call_function(injector, &retval, injector->dlopen_addr,
injector->data /* path */, dlflags);
The target loads your shared library. On Linux the dynamic linker automatically runs any function marked with __attribute__((constructor)) as soon as the library is loaded, exactly like Windows calls DllMain. That is your hook point again. When the dlopen is done, you restore the saved registers, so the target simply continues where it left off, and detach:
ptrace(PTRACE_SETREGS, pid, 0, &saved_regs); /* original state back */
ptrace(PTRACE_DETACH, pid, 0, 0); /* the target runs free again */
The target has no idea anything happened, except that it was frozen for a moment and now has one extra library loaded. This is also exactly why Linux requires CAP_SYS_PTRACE or root for this: you have literally taken debugger control of a foreign process.
Layer two: from loaded library to running Python
On both platforms the end result is the same: a small, self-built shared library is now loaded in the target, and its init function is running inside that process. That library is surprisingly small. In essence it is this:
#include <Python.h>
#define MAX_PYTHON_CODE_SIZE 60500
/* A global buffer with a recognizable marker inside it. The payload is written
in here before injection by overwriting the bytes ahead of the marker. */
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(); /* acquire the GIL safely */
PyRun_SimpleString(PYTHON_CODE); /* run the payload */
PyGILState_Release(gstate); /* hand the GIL back */
errno = saved_errno;
}
}
And the hook point that layer one triggered simply calls that function:
#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
This is where the real magic of layer two happens, and it lives in three C functions from the Python C API.
PyGILState_Ensure() is indispensable. The code runs on a thread that CPython did not create itself (on Windows the remote thread, on Linux the thread running the constructor). Such a thread does not hold the GIL, the Global Interpreter Lock that guarantees only one thread at a time executes Python bytecode. If you stepped into the interpreter without the GIL, you would almost certainly crash, because you would touch objects while another thread mutates them under you. PyGILState_Ensure handles this cleanly: it registers the current thread with the interpreter and acquires the GIL, and PyGILState_Release gives it all back. In between, you are a first-class Python thread.
PyRun_SimpleString(code) is payday. It takes your string, compiles it to bytecode and runs it in the target's __main__ module, with full access to everything living there. This is exactly what the interpreter does when you type something in the REPL, only now it happens inside a foreign process. From here on, every line of Python you send is simply Python running in that process.
Saving and restoring errno around it is a small but important detail: you are running on a thread of the target, and you do not want to leave a visible side effect (like a changed errno) that confuses the host code.
Getting the payload past the shield: the marker trick
There is a chicken-and-egg problem. The shared library is compiled before you know which Python you want to run. So how does your code end up in that PYTHON_CODE buffer?
The answer is a marker. The buffer is compiled with a recognizable byte sequence inside it, above --- code start ---. Right before injecting, you read the compiled library in as raw bytes, find the marker, and overwrite the bytes ahead of it with your payload, terminated by a null byte. You write that patched file to a temporary path, and it is that temporary file you have the target load.
lib = library_path.read_bytes()
magic_addr = lib.find(MAGIC) # find the marker in the compiled .so/.dll
code_addr = magic_addr - 1 # the payload goes just before it
# write a patched copy: [lib header][your payload]\0[rest of the lib]
temp.write(lib[:code_addr])
temp.write(python_code)
temp.write(b"\0")
temp.write(lib[code_addr + len(python_code) + 1:])
The bytes right after the marker encode the maximum buffer size the library was compiled with (MAX_PYTHON_CODE_SIZE), so before injecting you can check whether your payload fits and otherwise raise a clean error instead of causing a buffer overflow in the target. If you need to run more code than fits, you inject a small bootstrap that reads a larger script from disk with, for example, runpy.run_path.
The payload itself is usually further wrapped in a base64 exec one-liner. That is purely a matter of robustness: base64 contains no quotes, no newlines and no null bytes, so it cannot prematurely truncate or break the C string in the buffer. Inside the target it is decoded again and executed:
encoded = base64.b64encode(source.encode()).decode("ascii")
payload = (
'__import__("builtins").exec('
'__import__("builtins").compile('
f'__import__("base64").b64decode("{encoded}"),"<injected>","exec"))'
)
A final subtlety on Windows: the injected DLL's init routine is often made to fail on purpose. A DLL whose DllMain returns FALSE is unloaded again by Windows immediately, which is handy: your code has run, the payload is done, and the DLL does not linger uselessly in the target. The specific error code that results therefore means success, not failure, and the calling side recognizes and swallows it.
The pitfall everyone hits: the ABI has to match
There is a hard precondition under all of this. Your injected library is compiled against Python.h and links against a CPython runtime, pythonXY.dll on Windows or libpython3.Y on POSIX. When the target loads the library, PyGILState_Ensure and PyRun_SimpleString must exist there with exactly the binary shape your library expects.
That means the target must run the same CPython major.minor and the same bitness, and must not be a frozen or embedded interpreter. If there is a mismatch, the runtime cannot initialise in the target and you get, on Windows, the infamous LoadLibrary ... initialization routine failed (error code -5), or on Linux a dlopen error. Cryptic, unless you know what you are looking at.
You can detect this up front without injecting anything, by reading the target's already-loaded libraries. On every platform they show up in the process's memory maps; you simply look for the CPython runtime and read the version from the file name:
_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)}" # e.g. "3.11"
Compare target_version with your own sys.version_info and you know before injecting whether it will work. The fix for a mismatch is simple: run the injector under the target's Python. A tool like uv installs any CPython on demand, so you can spin up a venv on exactly the right version.
Why you want multiple strategies
The mechanisms above do not work everywhere. A Windows box without the right prebuilt library, a bare Linux server, a system with tight ptrace restrictions: each breaks in a different place. So a robust injector does not stake its fate on a single technique, but on an ordered fallback chain. Each strategy can report whether it is usable here and now, and the orchestrator tries them in order until one succeeds:
- The primary route is the technique just described: build a library with your payload, inject it via VirtualAllocEx/CreateRemoteThread (Windows) or ptrace/dlopen (Linux), and have the init function call the CPython C API.
- A pure-ctypes Windows route drives those very same Win32 calls without a prebuilt binary that has to match the target's ABI, which helps when the versions do not line up.
- A gdb route as a last resort does not do the ptrace dance itself, but lets gdb do it: attach with gdb and call, exactly like we did above,
PyGILState_Ensure,PyRun_SimpleStringandPyGILState_Releasethrough gdb'scallcommand.
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)'
That is literally layer two, driven by hand. It is no coincidence that every strategy converges on the same three C functions; that is the core you need to run Python from the inside. The rest, the VirtualAllocEx dance, the ptrace choreography, the gdb command, are all just ways to reach that core.
The safety layer you must not strip off
This much control over a foreign process demands tight safeguards, and they belong in the core of the tool, not just in the UI.
Always elevated. OpenProcess with write rights, CreateRemoteThread and PTRACE_ATTACH require Administrator on Windows or root/CAP_SYS_PTRACE on Linux. Enforce that before you touch a single memory page.
Refuse critical processes outright. Injecting into lsass, csrss, winlogon, services, init or systemd can crash the entire machine. Refuse them by name and by PID, and never present them as a target.
Guard against PID reuse. Between the moment you pick a target and the moment you call OpenProcess or PTRACE_ATTACH, the process can exit and its PID can be reused by a completely different process. You would then write into the wrong address space. Comparing the process's creation time right before injecting catches that:
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 # same start time = same process
A reused PID has a different start time and is skipped. This is a genuine time-of-check-to-time-of-use race, and without this check a real danger in a long-running UI where you pick a target and only inject seconds later.
In summary
Injecting code into a running Python process is not one clever trick, but two layers that click together. The bottom layer is pure operating system: allocate and write memory in a foreign process and start a thread there, via VirtualAllocEx and CreateRemoteThread on Windows or via ptrace and a hijacked dlopen on Linux, in both cases by letting the OS's own loader do the work. The top layer is pure CPython: acquire the GIL safely with PyGILState_Ensure, run the payload with PyRun_SimpleString, and hand it back cleanly. Everything around it, the marker trick to get the payload into the library, the ABI check, the fallback chain, the safety guards, exists to make those two layers work together reliably and safely across operating systems and Python versions. Hard material, but tractable once you see where the barrier is and how each step breaks through it.
Tags
python, process-injection, code-injection, ctypes, cpython, virtualallocex, createremotethread, ptrace, dlopen, gil, debugging, windows-api, reverse-engineering