Scope

The text that came as description with the CTF was the following:

Have you heard of the next big evolution in the calculator space? No more need for batteries, no more need for solar panels. Pure performance and the full set of features you need right from the convience of your terminal. Try it out now! P.S. There might even be a flag hidden for you to find.

Non-sufficient input sanitizing?

The provided archive contained 3 files:

jailed
├─ Dockerfile
├─ flag.txt
├─ server.py

Walkthrough

The sever.py file is pretty short:

from pwn import *

s = server(1337)

variables = {}

def performCalculation(calculation:str):
    if 'system' in calculation:
        return "What are you doing with my calculator!?"

    try:
        code = compile(calculation, '<string>', 'eval')
        return eval(code, {"__builtins__": None}, variables)
    except Exception as e:
        return "It seems like your formula was incorrect."

while True:
    r:remote = s.next_connection()

    r.sendline('''
    ******************************************
    *                                        *
    *   Welcome to the secure calculator!    *
    *                                        *
    ******************************************
    
    Please enter the calculation you would like to perform.
    You can use the following operators: +, -, *, /, %, **, and ().
    Enter exit to quit.
    ''')

    while True:
        r.send(b">>> ")

        try:
            calculation = r.recvline().decode().strip()
        except EOFError:
            break

        if 'exit' in calculation:
            r.sendline(b"Goodbye!")
            r.close()
            break

        result = performCalculation(calculation)
        r.sendline(f"Result: {result}".encode())

It seems that the user input goes straight to performCalculation() and the only criteria for the user input to be valid is that it can’t contain ‘system’. After that, the user input is treated as a Python statement with eval().

Unfortunately, the global variable __builtins__ is set to None, which means we can’t use any of the (who would’ve thought) built-in methods. So there is no simple open() available. This also restricts us from using __import__() or import.

So we need a way around this to be able to read the flag.txt. We have no control over the variables variable outside the eval context where we could do something like store a function pointer to a built-in method.

What we do have access to, however, are basic data types. Basic datatypes in Python inherit directly from the object class and thanks to dunder-methods we can get a reference to this class and all classes that inherit from object in the current scope. More specifically, with:

().__class__.__bases__[0].__subclasses__()

What this does is:

  1. create an empty tuple object
  2. get the tuple class
  3. get parent class (object)
  4. show all classes which directly inherit from object

This returns a really long list but the only interesting thing (which I found) is the element at position 264:

subprocess.Popen

This class is able to execute shell commands. The most straight forward choice of shell command is probably cat flag.txt. Executing this command with the subprocess module would look something like this:

subprocess.Popen(["cat", "flag.txt"], stdout=subprocess.PIPE, text=True).communicate()

This statement does the following:

  1. execute cat flag.txt
  2. wait for it to finish
  3. capture and return shell output in a tuple

subprocess.PIPE is just a constant attribute which holds the value -1, so we can shorten this. Putting it all together the final user input for the ‘calculation’ will look like this:

().__class__.__bases__[0].__subclasses__()[264](['cat', 'flag.txt'], stdout=-1,text=True).communicate()

Which will return the flag as first element in a tuple :)