angstromctf 2021 writeups
I participated in AngstromCTF 2021 as part of The WinRaRs, as it was the first CTF we did as a team in 2020. The Angstrom team did a very good job at making
toxic interesting challenges, and it was also nice to try out some other categories for a change. We placed 30th, and here I will write up the challenges that I solved that I found interesting.
Home Rolled - Crypto
Aplet made his own block cipher! Can you break it? Author: EvilMuffinHa
#!/usr/bin/python import binascii from random import choice class Cipher: BLOCK_SIZE = 16 ROUNDS = 3 def __init__(self, key): assert(len(key) == self.BLOCK_SIZE*self.ROUNDS) self.key = key def __block_encrypt(self, block): enc = int.from_bytes(block, "big") for i in range(self.ROUNDS): k = int.from_bytes(self.key[i*self.BLOCK_SIZE:(i+1)*self.BLOCK_SIZE], "big") enc &= k enc ^= k return hex(enc)[2:].rjust(self.BLOCK_SIZE*2, "0") def __pad(self, msg): if len(msg) % self.BLOCK_SIZE != 0: return msg + (bytes() * (self.BLOCK_SIZE - (len(msg) % self.BLOCK_SIZE))) else: return msg def encrypt(self, msg): m = self.__pad(msg) e = "" for i in range(0, len(m), self.BLOCK_SIZE): e += self.__block_encrypt(m[i:i+self.BLOCK_SIZE]) return e.encode() key = binascii.unhexlify("".join([choice(list("abcdef0123456789")) for a in range(Cipher.BLOCK_SIZE*Cipher.ROUNDS*2)])) with open("flag", "rb") as f: flag = f.read() cipher = Cipher(key) while True: a = input("Would you like to encrypt , or try encrypting ? ") if a == "1": p = input("What would you like to encrypt: ") try: print(cipher.encrypt(binascii.unhexlify(p)).decode()) except: print("Invalid input. ") elif a == "2": for i in range(10): p = "".join([choice(list("abcdef0123456789")) for a in range(64)]) print("Encrypt this:", p) e = cipher.encrypt(binascii.unhexlify(p)).decode() c = input() if e != c: print("L") exit() print("W") print(flag.decode()) elif a.lower() == "quit": print("Bye") exit() else: print("Invalid input. ")
We have a server which implements a custom block cipher, allowing us to encrypt anything, and then asking us for the encryption of a random plaintext it gives. The block cipher just uses standard ECB mode, so we only have the block cipher to attack.
The interesting function here is the
__block_encrypt function, which, we can assume encrypts a single block of ciphertext. Looking closely, it only performs a logical AND and a logical XOR on the state and the key. This means that none of the bits affect each other. Knowing this, it is very easy to encrypt a chosen plaintext, as each bit will always become a certain value, and we can create a lookup table for each bit, that is, what bit it becomes if it is originally a 0 bit, and what bit it becomes when it is originally a 1 bit.
from pwn import * s = remote("crypto.2021.chall.actf.co", 21602) s.recvuntil("?").decode().split("\n") s.sendline("1") s.sendline("0" * 32) ct0 = bin(int(s.recvuntil("?").decode().split("\n")[-32:], 16))[2:].zfill(128) s.sendline("1") s.sendline("f" * 32) ct1 = bin(int(s.recvuntil("?").decode().split("\n")[-32:], 16))[2:].zfill(128) s.sendline("2") for i in range(10): ct = bin(int(s.recvline().decode()[-65:-1], 16))[2:].zfill(256) o = "" for c,x in enumerate(ct): if x == "0": o += ct0[c%128] else: o += ct1[c%128] ct = int(o, 2) s.sendline(hex(ct)[2:].zfill(16)) s.interactive() # i whipped this up in 3 minutes before ctf end please dont judge kthanks
I’m So Random - Crypto
Aplet's quirky and unique so he made my own PRNG! It's not like the other PRNGs, its absolutely unbreakable! Author: EvilMuffinHa
import time import random import os 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 r1 = Generator(random.randint(10000000, 99999999)) r2 = Generator(random.randint(10000000, 99999999)) query_counter = 0 while True: query = input("Would you like to get a random output [r], or guess the next random number [g]? ") if query.lower() not in ["r", "g"]: print("Invalid input.") break else: if query.lower() == "r" and query_counter < 3: print(r1.getNum() * r2.getNum()) query_counter += 1; elif query_counter >= 3 and query.lower() == "r": print("You don't get more random numbers!") else: for i in range(2): guess = int(input("What is your guess to the next value generated? ")) if guess != r1.getNum() * r2.getNum(): print("Incorrect!") exit() with open("flag", "r") as f: fleg = f.read() print("Congrats! Here's your flag: ") print(fleg) exit()
This challenge gives us 2 PRNGS, and then returns the product of them. Our goal is to recover the next product of 2 states.
Knowing that the states have 8 digits in them, we just factor the given number states until one of them is 8 digits, in which case we can simply reseed the PRNGS, and get the next product.
I did this one manually, it usually doesn’t take many tries to get one state with a factor.
Thunderbolt - Crypto
This challenge gives us a binary. ~how toxic for a crypto challenge~
I spent a long time trying to reverse it, as I thought this was crypto rev, but I was in fact proven wrong. The gist of the reversing is that it reads in our plaintext, appends the flag to the plaintext, then it reads in 2 keys, one being 16 bytes and the other being the length of the plaintext, and then does some weird keystream generation. It then does some weird XOR things and encrypts the plaintext.
I didn’t realise it at the time, but this was just an implementation of RC4 (how silly of me)
Anyway, the vulnerability is in the implementation of RC4, more specifically how they implement the swapping part.
Opening it up in ghidra:
It seems to use the XOR swap method to do swapping during encryption. This removes the need to create a temporary variable, which is pretty useful. However, problems arise when the two values being swapped are the same. The two values XORed together will result in both of the values turning into 0’s instead of being swapped. Interesting.
If we send a lot of bytes to the server, this means that over time, the keystream will be corrupted with null bytes. We should then be able to find the flag at the end, as it should not be XORed with anything.
Therefore, our solve script simply becomes:
python -c 'print("\x01" * 15000)' | nc crypto.2021.chall.actf.co 21603 | tail -c 200
(We have to use \x01 bytes because since the binary uses fgets, which terminates at null bytes, so it doesn’t actually become part of the plaintext)
Our flag therefore is: