STACK The Flags 2022 Writeups
Encryptdle
A popular word game, but encrypted!
Attachment: crypto_encryptdle.zip
Challenge server.py
KEY = os.urandom(32) IV = os.urandom(16) MAX_LEN = 32 def pad(bstring): return (bstring + FLAG + b'\x00' * MAX_LEN)[:MAX_LEN] def encrypt(bstring): padded_data = pad(bstring) cipher = Cipher(algorithms.Camellia(KEY), modes.CBC(IV)) encryptor = cipher.encryptor() return encryptor.update(padded_data) + encryptor.finalize()
From the webserver we get first 32 bytes of the Camellia encryption of
user_input + FLAG + pad in CBC mode. The server reuses its key and IV each
time. This is vulnerable to chosen plaintext attack.
import requests from string import ascii_letters, digits, punctuation url = "http://167.99.77.149:30930/api/compare" def query(x): res = requests.post(f"{url}", params={"guess": x}) enc = "".join([ i["letter"] for i in res.json()]) return enc charset = ascii_letters + digits + punctuation def brute_force_tail(head, find): assert len(head) == 31 for i in charset: res = query(f"{head}{i}") if find == res: return i return None pad = "@" * 32 known = "" while known[-1:] != "}": pad = pad[:-1] find = query(pad) tail = brute_force_tail(pad + known, find) known = known + tail print(known)
S ST STF STF2 [...many lines...] STF22{iNS3CuR3!_S+4+iC_IVs STF22{iNS3CuR3!_S+4+iC_IVs! STF22{iNS3CuR3!_S+4+iC_IVs!! STF22{iNS3CuR3!_S+4+iC_IVs!!}
jagacha
JAGATSA
Attachment: crypto_jagacha.zip
Challenge jagacha.py
# ... import random # ... async def handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): # ... rand = random.Random(get_seed()) try: option = None while option != 3: await print_menu(writer) option = await read_number(reader, writer) if option == 1: num = rand.getrandbits(64) gacha = GACHA_KEYS[num % len(GACHA_KEYS)] writer.writelines( ( f"Congrats! You have pulled a {gacha}!\n".encode(), GACHAS[gacha], b"Here are the stats of your character:\n", f"STR: {num>>48 & 0xffff}\n".encode(), f"DEX: {num>>32 & 0xffff}\n".encode(), f"INT: {num>>16 & 0xffff}\n".encode(), f"LUK: {num & 0xffff}\n".encode(), b'\n', ) ) # ... elif option == 2: num = rand.getrandbits(32) lucky_number = await read_number( reader, writer, b"Enter your lucky number: " ) if lucky_number == num: writer.writelines(( b"Congrats! You have pulled the limited SSS-rated rarity flag-chan!\n", FLAG, )) # ... # ...
The server uses the python PRNG function .getrandbits(64) to randomly generate
a character and its stats. With enough rolls we can recover the state of the
PRNG using z3 and predict the next roll to claim the flag.
# My stdlib @ https://github.com/4yn/sage_army_knife from sage_army_knife.untwister import Untwister from pwn import * r = remote("167.99.77.149", 32219) def query(): r.recvuntil(b"\n> ") r.sendline(b"1") data = 0 for s in ["STR", "DEX", "INT", "LUK"]: r.recvuntil(f"{s}: ".encode()) data = data * (2 ** 16) + int(r.recvline().decode().strip()) return data ut = Untwister() for _ in range(624 // 2): res = bin(query())[2:].zfill(64) ut.submit(res[32:]) ut.submit(res[:32]) rnd = ut.get_random() ans = rnd.getrandbits(64) r.sendline(f"2\n{ans}".encode()) r.recvuntil(b"Enter your lucky number: ") print(r.recvuntil(b"}").decode())
[x] Opening connection to 167.99.77.149 on port 32219 [x] Opening connection to 167.99.77.149 on port 32219: Trying 167.99.77.149 [+] Opening connection to 167.99.77.149 on port 32219: Done Solving... sat Solved! (in 1.266s) Congrats! You have pulled the limited SSS-rated rarity flag-chan! STF22{W@IFU5_L@1FU5}
Pad the flag
Pad the Flag
Attachment: crypto_padtheflag.zip
Challenge source.py
PADDING = b"\x00\x04" with open("private_key.pem", "rb") as f: PRIVATE_KEY = rsa.PrivateKey.load_pkcs1(f.read()) class RSA(): def __init__(self): self.private_key = PRIVATE_KEY self.padding = PADDING def valid(self, plaintext): if len(plaintext) < 11: return False return plaintext[:2] == self.padding def decrypt(self, plaintext): # ... # ... def main(): rsa = RSA() while True: # ... elif option == 3: encrypted = input("Enter Encrypted Flag: ") plaintext = rsa.decrypt(encrypted) if plaintext is None: print("Decryption Failed!") continue if not rsa.valid(plaintext): print("Invalid Padding!") continue
The service returns a special error message if the padding is invalid. This is a
textbook Bleichenbacher padding oracle attack except with padding bytes
\x00\x04 instead of \x00\x02. We can steal an implementation from elsewhere
and make the necessary edits.
from gmpy2 import mpz, powmod from pwn import * conn = remote("167.99.77.149", 30302) conn.recvuntil(b"option: ") conn.sendline(b"1") conn.recvuntil(b"n: ") n = mpz(int(conn.recvline().decode().strip())) conn.recvuntil(b"e: ") e = mpz(int(conn.recvline().decode().strip())) conn.sendline(b"2") conn.recvuntil(b"Encrypted Flag: ") cipher = mpz(conn.recvline().decode().strip(), 16) print(f"{n = }") print(f"{e = }") print(f"{cipher = }") # Custom oracle implementation query_ctr = 0 def query(base, morphic, log): global query_ctr print(log, end="", flush=True) if query_ctr % 100 == 99: print() query_ctr += 1 x = (base * powmod(mpz(morphic), e, n)) % n conn.recvuntil(b"ion: ") conn.sendline(b"3") conn.recvuntil(b"Enter Encrypted Flag: ") conn.sendline(hex(x)[2:].zfill(512).encode()) res = conn.recvuntil(b"\nopt") return b"Invalid Padding" not in res # From https://github.com/alexandru-dinu/bleichenbacher/blob/master/src/main.py k = 256 B = mpz(pow(2, 8 * (k - 2))) # Adjust padding bounds B2 = 4 * B B3 = B2 + B def ceildiv(a, b): return (a // b) + 1 if (a % b) else 0 def floordiv(a, b): return a // b def interval(a, b): return range(a, b + 1) c_0 = cipher set_m_old = {(B2, B3 - 1)} i = 1 s_old = 0 while True: if i == 1: s_new = ceildiv(n, B3) while not query(c_0, s_new, "."): s_new += 1 elif i > 1 and len(set_m_old) >= 2: s_new = s_old + 1 while not query(c_0, s_new, "?"): s_new += 1 elif len(set_m_old) == 1: a, b = next(iter(set_m_old)) found = False r = ceildiv(2 * (b * s_old - B2), n) while not found: for s in interval(ceildiv(B2 + r*n, b), floordiv(B3 - 1 + r*n, a)): if query(c_0, s, "!"): found = True s_new = s break r += 1 set_m_new = set() for a, b in set_m_old: r_min = ceildiv(a * s_new - B3 + 1, n) r_max = floordiv(b * s_new - B2, n) for r in interval(r_min, r_max): new_lb = max(a, ceildiv(B2 + r*n, s_new)) new_ub = min(b, floordiv(B3 - 1 + r*n, s_new)) if new_lb <= new_ub: # intersection must be non-empty set_m_new |= {(new_lb, new_ub)} if len(set_m_new) == 1: a, b = next(iter(set_m_new)) if a == b: print() print("Found decryption", a) break i += 1 s_old = s_new set_m_old = set_m_new flag = bytes.fromhex(hex(a)[2:].zfill(512)) print(flag)
[x] Opening connection to 167.99.77.149 on port 30302 [x] Opening connection to 167.99.77.149 on port 30302: Trying 167.99.77.149 [+] Opening connection to 167.99.77.149 on port 30302: Done n = mpz(277454988382683422703905418324[...]00768185786779871199) e = mpz(65537) cipher = mpz(215563187754089626071528576644[...]59265685370170619393) .................................................................................................... .................................................................................................... [...many lines...] .................................................................................................... .................................................................................................... ...........................................................................!!!!!!!!!!!!!!!!!!!!!!!!! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! [...many more lines...] !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! Found decryption 211361501571634421707424965336[...]60402282842373108605 b'\x00\x04IE\xde+\x97\x1fPU3\x81\n\\[...]\x9d\xa7\x0c\x90\x00STF22{p@dd1ng_pr0b13m_3v3rywh3r3}'
MysteryCrypt
We found this encryption algorithm being used after the key exchange. We suspect that it is very weak and can be cracked in a short amount of time.
Attachment: crypto_mysterycrypt.zip
Challenge crypt_generate.py
key = random.randint(2, 2**32-1) delta = 0x9E37 ^ 0x79B9 def mod(x): return x % (2**16) k1 = mod(key >> 16) k2 = mod(key) def ror(n, k): return (n << k) | (n >> (16 - k)) def rol(n, k): return (n >> k) | (n << (16 - k)) def round(l, r, k1, k2): res = mod((ror(r,4) ^ rol(r,5) ^ k1) + r) ^ mod(k2 + r + delta) return (r, l ^ res) def encrypt(num): l, r = mod(num >> 16), mod(num) for i in range(128): l, r = round(l, r, k1, k2) return (l << 16) + r
We get an encryption service that does a feistel-like cipher operation on a 32
bit ciphertext with a 32 bit key that is randomized on each connection. We can
do a meet-in-the-middle attack by precomputing the encryption of some fixed
input e.g. 0x8ac31542 with keys 0 to 2**24. When connecting to the server,
we encrypt the fixed input and look for the output in our precomputation, then
submit a candidate key if we have one. This took around 15 seconds to calculate
the 16 million key-output pairs (with the help of numba) and we expect to need
256 connections to get a connection with a key that has been precomputed.
from numba import njit from tqdm.auto import tqdm from pwn import * context.log_level = "error" @njit def ror(n, k): return ((n << k) | (n >> (16 - k))) & 0xffff @njit def rol(n, k): return ((n >> k) | (n << (16 - k))) & 0xffff @njit def round(l, r, k1, k2): res = (((ror(r,4) ^ rol(r,5) ^ k1) + r) ^ (k2 + r + 0xe78e)) & 0xffff return (r, l ^ res) @njit def encrypt(num, k): l, r = (num >> 16) & 0xffff, num & 0xffff k1, k2 = (k >> 16) & 0xffff, k & 0xffff for i in range(128): l, r = round(l, r, k1, k2) return (l << 16) + r @njit def encrypt_magic(k): MAGIC = 0x8ac31542 return encrypt(MAGIC, k) # Precompute 2**24 outputs lookup = {} precompute_to = 2 ** 24 for x in tqdm(range(precompute_to)): lookup[encrypt_magic(x)] = x MAGIC = 0x8ac31542 # Repeatedly connect until we get a known output ctr = 0 while True: outcome = "." r = remote("167.99.77.149", 30708) r.recvuntil(b"1 = encrypt, 2 = submit key\n") r.sendline(f"1\n{MAGIC}".encode()) r.recvuntil(b"encrypted = ") res = int(r.recvline().decode().strip()) if res in lookup: outcome = "?" r.recvuntil(b"1 = encrypt, 2 = submit key\n") r.sendline(f"2\n{lookup[res]}".encode()) res = r.clean(timeout=1).decode() if "Wrong" not in res: print("!") print(res) break r.close() print(outcome, end="", flush=True) if ctr % 100 == 99: print() ctr += 1
..................................................................! key = ? STF22{mc1_8c7d5b1b2a503822}
ThatsALotOfRSA
We found this is how the keys are generated. The set of public keys are distributed. We need to crack the private keys generated as fast as possible.
Attachment: crypto_thatsalotofrsa.zip
Challenge rsa_challenge.py
privkeydir = os.getenv('PRIVKEYS') keys = os.listdir(privkeydir) # ... for i in range(200): k1file, k2file = random.sample(keys, 2) k1 = RSA.import_key(open(f'{privkeydir}/{k1file}').read()) k2 = RSA.import_key(open(f'{privkeydir}/{k2file}').read()) p = random.choice([k1.p,k1.q]) q = random.choice([k2.p,k2.q]) n = p * q nstr += f'n[{i}] = {n}\n' ans.append((p,q)) # ...
GCD each challenge n with every other n in the folder of private keys, this
will reveal at least one factor p. We can speed this up to run within the 10
seconds time limit using multiprocessing.
from Crypto.PublicKey import RSA from math import gcd from pwn import * from tqdm.auto import tqdm from multiprocessing import Pool # Load all n keyfiles = os.listdir("crypto_thatsalotofrsa/pubkeys") ns = [] for keyfile in tqdm(keyfiles): with open("crypto_thatsalotofrsa/pubkeys/" + keyfile, "r") as f: k = RSA.import_key(f.read()) assert k.e == 0x10001 ns.append(k.n) len(keyfiles) def solve(n): for on in ns: p = gcd(n, on) if p != 1: return p r = remote("167.99.77.149", 32382) r.recvuntil(b"n[") # Collect all inputs chal_ns = [] for _ in tqdm(range(200)): r.recvuntil(b"] = ") n = int(r.recvline().decode()) chal_ns.append(n) # Multithreaded GCD finding chal_ps = [] with Pool(processes=4) as pool: for p in tqdm(pool.imap(solve, chal_ns), total=200): chal_ps.append(p) r.sendline("\n".join([str(p) for p in chal_ps]).encode()) r.recvuntil(b"[199] = ?\n") print(r.recvline().decode())
0%| | 0/20000 [00:00<?, ?it/s] [x] Opening connection to 167.99.77.149 on port 32382 [x] Opening connection to 167.99.77.149 on port 32382: Trying 167.99.77.149 [+] Opening connection to 167.99.77.149 on port 32382: Done 0%| | 0/200 [00:00<?, ?it/s] 0%| | 0/200 [00:00<?, ?it/s] STF22{rsa_823564830a826421}