ALLES! CTF 2020 Writeup: Pyjail ATricks

September 6, 2020 - September 6, 2020

Description

Category: Misc

Difficulty: Easy

Author: explo1t

Description: Run the secret function you must! Hrrmmm. A flag maybe you will get.

Overview

This challenge takes place in a remote restricted Python shell a.k.a. a Python jail. Usually the goal is to escape the jail, i.e. to shell out and find the flag in the filesystem.

But this challenge description says that one may get a flag by running a secret function inside the jail. It turns out that another challenge called Pyjail Escape takes place inside this same jail, and its goal is to escape. So one can say this challenge is a bit easier than an actual jail escape.

Reconnaissance

After connecting to the challenge server, there is the following Python shell:

The flag is stored super secure in the function ALLES !
>>> a =

So the secret function name is ALLES. Call it:

a = ALLES()
name 'alles' is not defined

The input characters are converted to lowercase. At this point it’s a good idea to try to find other restrictions of this jail.

Character set

Some characters are prohibited:

>>> a = w
w
Denied

But others are OK:

>>> a = e
name 'e' is not defined

There are 95 printable ASCII characters, so it doesn’t take long to try all of them. List of all allowed characters:

["1", "2", "3", "7", "9", "0", "\"", "(", ")", "'", "+", ".", "a", "c", "d", "e", "g", "i", "l", "n", "o", "p", "r", "s", "t", "v", "[", "]", "_"]

Uppercase counterparts of allowed lowercase characters are not prohibited, but are converted to lowercase.

Built-ins

Lots of built-in functions are removed:

>>> a = ord()
name 'ord' is not defined

At this point calling __builtins__ to see which functions weren’t removed is not possible ("__builtins__" contains prohibited characters 'b' and 'u' ). Using Built-in Functions table it is possible to manually check that the only readily available built-ins are: ['repr', 'str', 'print', 'eval', 'all']. There might be others, but it is not possible to check at this point because of the character set restrictions.

Using eval()

Out of that list, eval immediately catches attention. eval is used to evaluate a string as a Python expression and return the result.

Since it is not possible to input uppercase letters directly to get the function ALLES, there is another way which uses eval:

This works locally:

>>> def ALLES(): ...
...
>>> eval(eval("\"alles\".upper()"))
<function ALLES at 0x105725a60>

Unfortunately this wouldn’t work inside the jail since 'upper' contains forbidden characters.

One way to go around this restriction is by finding the string 'upper' in the list of all attributes and methods of str type.

>>> a = print("".__dir__())
['__repr__', '__hash__', '__str__', '__getattribute__', '__lt__', '__le__', '__eq__', '__ne__', '__gt__', '__ge__', '__iter__', '__mod__', '__rmod__', '__len__', '__getitem__', '__add__', '__mul__', '__rmul__', '__contains__', '__new__', 'encode', 'replace', 'split', 'rsplit', 'join', 'capitalize', 'casefold', 'title', 'center', 'count', 'expandtabs', 'find', 'partition', 'index', 'ljust', 'lower', 'lstrip', 'rfind', 'rindex', 'rjust', 'rstrip', 'rpartition', 'splitlines', 'strip', 'swapcase', 'translate', 'upper', 'startswith', 'endswith', 'islower', 'isupper', 'istitle', 'isspace', 'isdecimal', 'isdigit', 'isnumeric', 'isalpha', 'isalnum', 'isidentifier', 'isprintable', 'zfill', 'format', 'format_map', '__format__', 'maketrans', '__sizeof__', '__getnewargs__', '__doc__', '__setattr__', '__delattr__', '__init__', '__reduce_ex__', '__reduce__', '__subclasshook__', '__init_subclass__', '__dir__', '__class__']
>>> a = print("".__dir__()[20+20+2+2+2])
upper

The strange indexing is due to the restriction on the digits that can be used in the jail.

Using this, it is finally possible obtain ALLES function object:

>>> a = eval(eval('"alles".'+"".__dir__()[20+20+2+2+2]+'()'))
>>> a = print(a)
<function ALLES at 0x7f13d1aa0378>
>>> a = eval(eval('"alles".'+"".__dir__()[20+20+2+2+2]+'()'))
>>> a = print(a())
No flag for you!

There’s more work to be done…

Examining ALLES.__code__

ALLES doesn’t give away the flag when called with no arguments. One way to figure out how ALLES works and how to get the flag is to try passing different arguments to the function and observe its behaviour.

Another way is to look at the code object:

>>> a = eval(eval('"alles".'+"".__dir__()[20+20+2+2+2]+'()'))
>>> a = print(a.__code__)
<code object ALLES at 0x7f13d1b0f0c0, file "./pyjail.py", line 12>
>>> a = eval(eval('"alles".'+"".__dir__()[20+20+2+2+2]+'()'))
>>> a = print(a.__code__.__dir__())
['__repr__', '__hash__', '__getattribute__', '__lt__', '__le__', '__eq__', '__ne__', '__gt__', '__ge__', '__new__', '__sizeof__', 'co_argcount', 'co_kwonlyargcount', 'co_nlocals', 'co_stacksize', 'co_flags', 'co_code', 'co_consts', 'co_names', 'co_varnames', 'co_freevars', 'co_cellvars', 'co_filename', 'co_name', 'co_firstlineno', 'co_lnotab', '__doc__', '__str__', '__setattr__', '__delattr__', '__init__', '__reduce_ex__', '__reduce__', '__subclasshook__', '__init_subclass__', '__format__', '__dir__', '__class__']

There are lots of attributes that are useful in reverse engineering a Python function.

Constants

This is definitely the first attribute to check. A naive implementation of ALLES would store the flag as a string inside the function. This means the string would be stored as a constant in the function bytecode.

Definition

co_consts - tuple of constants used in the bytecode (see documentation of inspect for reference)

How to get

>>> a = eval(eval('"alles".'+"".__dir__()[20+20+2+2+2]+'()'))
>>> a = print(a.__code__.co_consts)
(None, 'p\x7f\x7frbH\x00DR\x07CRUlJ\x07DlRe\x02N', 'No flag for you!')

The string 'p\x7f\x7frbH\x00DR\x07CRUlJ\x07DlRe\x02N' is not the flag format is ALLES{...}.

Arguments

Definition

co_varnames- tuple of names of arguments and local variables

How to get

>>> a = eval(eval('"alles".'+"".__dir__()[20+20+2+2+2]+'()'))
>>> a = print(eval("a.__code__."+a.__code__.__dir__()[19]))
('flag',)

Explanation

a.__code__.__dir__()[19] is "co_varnames".

Local variables

Definition

co_names - tuple of names of global variables

How to get

>>> a = eval(eval('"alles".'+"".__dir__()[20+20+2+2+2]+'()'))
>>> a = print(eval("a.__code__."+a.__code__.__dir__()[17+1]))
('string_xor',)

Explanation

a.__code__.__dir__()[17+1] is "co_names".

Bytecode

Definition

co_code - string of raw compiled bytecode

How to get

>>> a = eval(eval('"alles".'+"".__dir__()[20+20+2+2+2]+'()'))
>>> a = print(a.__code__.co_code)
b'|\x00r\x0et\x00d\x01|\x00\x83\x02S\x00d\x02S\x00d\x00S\x00'

Disassembly

The function bytecode can be disassembled using dis module locally:

>>> import dis
>>> dis.dis(x=b'|\x00r\x0et\x00d\x01|\x00\x83\x02S\x00d\x02S\x00d\x00S\x00')
          0 LOAD_FAST                0 (0)
          2 POP_JUMP_IF_FALSE       14
          4 LOAD_GLOBAL              0 (0)
          6 LOAD_CONST               1 (1)
          8 LOAD_FAST                0 (0)
         10 CALL_FUNCTION            2
         12 RETURN_VALUE
    >>   14 LOAD_CONST               2 (2)
         16 RETURN_VALUE
         18 LOAD_CONST               0 (0)
         20 RETURN_VALUE

Instruction format

Python bytecode instruction disassembly follows this format: offset opname arg (argval)

offset - start index of operation within bytecode sequence

opname - human readable name for operation

arg - numeric argument to operation (if any), otherwise None

argval - resolved arg value (if known), otherwise same as arg (this is the case here)

>> at offset 14 indicates that the instruction is a jump target.

Disassembly breakdown

OP Description
0 LOAD_FAST 0 (0) Pushes a reference to the local co_varnames[0] = 'flag' onto the stack.
2 POP_JUMP_IF_FALSE 14 If TOS (top of stack) is false, sets the bytecode counter to target. TOS is popped.
4 LOAD GLOBAL 0 (0) Loads the global named co_names[0] = 'string_xor' onto the stack.
6 LOAD_CONST 1 (1) Pushes co_consts[1] = 'p\x7f\x7frbH\x00DR\x07CRUlJ\x07DlRe\x02N' onto the stack.
8 LOAD_FAST 0 (0) Pushes a reference to the local co_varnames[0] = 'flag' onto the stack.
10 CALL_FUNCTION 2 Calls a callable object with positional arguments. arg indicates the number of positional arguments. The top of the stack contains positional arguments.
12 RETURN_VALUE Returns with TOS to the caller of the function.
>> 14 LOAD_CONST 2 (2) Pushes co_consts[2] = 'No flag for you!' onto the stack.
16 RETURN_VALUE Returns with TOS to the caller of the function.
18 LOAD_CONST 0 (0) Pushes co_consts[0] = None onto the stack.
20 RETURN_VALUE Returns with TOS to the caller of the function.

Decompilation

The disassembly can be decompiled to this Python source code:

def string_xor(x, y): ...

def ALLES(flag):
    if flag:
        return string_xor('p\x7f\x7frbH\x00DR\x07CRUlJ\x07DlRe\x02N', flag)
    else:
        return 'No flag for you!'
    return
        

Sanity check

Now that the dependence between the input flag and the output of ALLES is clearer. It’s best to do a sanity check.

flag is falsy:

>>> a = eval(eval('"alles".'+"".__dir__()[20+20+2+2+2]+'()'))
>>> a = print(a())
No flag for you!

flag is truthy:

>>> a = eval(eval('"alles".'+"".__dir__()[20+20+2+2+2]+'()'))
>>> a = print(a("0000"))
@OOB

It is now clear that string_xor a string of the same length as the input to ALLES.

string_xor decompilation

string_xor is a global function so it is possible to get its code object and disassemble it.

Instead, given that string_xor has a pretty descriptive name, it might be a better idea to save some time and guess how the function works by observing its behavior.

Passing an int:

>>> a = eval(eval('"alles".'+"".__dir__()[20+20+2+2+2]+'()'))
>>> a = print(a(1))
zip argument #2 must support iteration

Passing a list:

>>> a = eval(eval('"alles".'+"".__dir__()[20+20+2+2+2]+'()'))
>>> a = print(a([1]))
ord() expected string of length 1, but int found

Looks like the string_xor(x, y) accepts string inputs. It converts each character of x and y using ord() to an int, XORs them together, passes the result to chr() and uses that to construct an output string.

Possible decompilation:

def string_xor(x, y):
    ret = ''
    for i, j in zip(x, y):
        ret += chr(ord(i) ^ ord(j))
    return ret

Another sanity check

Trying this locally:

>>> def string_xor(x, y):
...     ret = ''
...     for i, j in zip(x, y):
...         ret += chr(ord(i) ^ ord(j))
...     return ret
...
>>> def ALLES(flag):
...     if flag:
...         return string_xor('p\x7f\x7frbH\x00DR\x07CRUlJ\x07DlRe\x02N', flag)
...     else:
...         return 'No flag for you!'
...     return
...
>>> ALLES('0000')
'@OOB'

Same output as seen on the challenge server.

Guessing the correct flag

The flag format is known to be ALLES{...}.

Therefore, the first 6 characters that need to be passed to ALLES to get the flag are the following (checking this locally):

>>> ALLES('ALLES{')
'133713'

It is now obvious Wh47 h42 70 8E d0NE 70 Ge7 7he fl4G…

>>> a = eval(eval('"alles".'+"".__dir__()[20+20+2+2+2]+'()'))
>>> a = print(a('1337133713371337133713'))
ALLES{3sc4ped_y0u_aR3}