Greetings, fellow hackers and tinkerers, and welcome to the second installation of the “Sneaky Snek” blog post series! This series is dedicated to having fun messing around with CPython’s core functionality and modifying arithmetic operator implementation in runtime. In the Previous Part, we’ve started diving into CPython’s implementation of the +
operator, PyNumber_Add
, intercepted number addition operations using gdb
breakpoint commands, inspected the PyObject
arguments and modified the result in an automated fashion.
In this part, we will explore inline hooking of the function from within a Python script using ctypes
.
Initial Recon
First, we will recall the function definition:
1 | PyAPI_FUNC(PyObject *) PyNumber_Add(PyObject *o1, PyObject *o2); |
Let’s examine PyNumber_Add
and see where it’s code resides in memory.
1 | (gdb) x/10i PyNumber_Add |
Great, we have at least 30 bytes of code. That’s more than enough to insert a jmp
! There is a problem, though - our code is mapped to a segment with r-xp
permissions, so we are missing the “write” permission. This is common for code segments in programs, as there is usually no need to have write permissions for code segments (unless JIT is being used). We will deal with this later.
The Plan
Our objective is to trick Python into believing that “2 + 2 = 5”.
For this purpose, we will redirect execution to a custom function that will check the operands and modify the result if necessary.
We want a Python script that will:
- Create a custom version of the function
- Load it into Python’s memory
- Locate the
PyNumber_Add
‘s memory address - Change it’s page permissions to writable
- Patch the first bytes of
PyNumber_Add
with a jump to our function - Run some tests
Let’s break these down, one by one.
Creating my_add
While there are advanced ways of inserting code into the memory space of a running process, we will keep things simple and go with loading a shared object (.so
). Python has it’s own C API that allows us to extend Python, deal with PyObject
s and even run Python from C. This will come in handy!
Note: You may need to install python3-dev
for the CPython headers.
Since we are planning to practically destroy PyNumber_Add
, we will need a different way to calculate the result. There are safer ways to implement a generic and reusable hook, but for our purposes and in the spirit of having fun, I’ve decided on a quirky approach: a + b
is mathematically equivalent to a - (-b)
! We will use this to our advantage and implement my_add
as follows:
1 |
|
We will compile this into a shared object using the following command:
1 | gcc -shared -fPIC -o my_add.so my_add.c $(python3-config --cflags --ldflags) |
Loading my_add
into Python’s memory
We can now use ctypes.CDLL()
to load my_add.so
into Python’s memory and get a pointer to our function. This proves to be a little tricky, because ctypes
does not expose a direct way to get a pointer to a function. ctypes
does, however, allow us to get a pointer to the variable that holds the function pointer. We can then dereference it to get the actual function pointer.
1 | import ctypes |
Locating PyNumber_Add
‘s memory address
We can use ctypes
again to get a pointer to PyNumber_Add
(It knows…!):
1 | pynumber_add = deref_ptr(ctypes.addressof(ctypes.pythonapi.PyNumber_Add)) |
Changing PyNumber_Add
‘s page permissions to writable
We will use mprotect()
to change the page permissions of PyNumber_Add
to rwx
and make the memory writable. To grab the page address, we can use a simple trick stolen borrowed from this StackOverflow answer. Oh yeah, and ctypes
let’s us call mprotect()
directly from Python!
1 | page_size = os.sysconf('SC_PAGE_SIZE') |
Note: If we wanted to support Windows, we could use VirtualProtect()
instead of mprotect()
.
Patching the first bytes of PyNumber_Add
with a jump to our function
Let’s code a simple trampoline that will redirect execution to my_add
:
1 | mov rax, 0x123456789abcdef0 |
Assembling this code, we get some bytes. Let’s use them and replace the 0x123456789abcdef0
with the address of my_add
:
1 | shellcode = b'\x48\xb8' + my_add.to_bytes(8, 'little') + b'\xff\xe0' |
At this point, we have everything we need to patch PyNumber_Add
with a jump to my_add
. We will use ctypes
again to write our shellcode to the first bytes of PyNumber_Add
:
1 | result = ctypes.memmove(pynumber_add, shellcode, shellcode_size) |
Adding some tests
We’ll insert some number addition operations and see what happens. There is a caveat to be aware of - Python calculates 2 + 2
at compile time and stores the result in the bytecode. We can use eval()
to force Python to recalculate the result every time.
1 | print("1 + 1 =", eval("1 + 1")) |
Putting it all together
1 | import ctypes |
Hammer Time!
Let’s run our script and see what happens:
1 | ➜ ~ python snek.py |
It works!! Good snek!
I hope you enjoyed taking this journey into Python internals with me!
In the Next Part, we will explore further extending this into a backdoor that given specific input, will give us a reverse shell.