# UMDCTF 2025 In this year's UMDCTF, I participated with team [SNI](https://ctftime.org/team/279998/) and mostly did Cryptography and PPC problems. We manage to secured fourth place and barely snagged the consolation prize. ## Cry/Obsidian This challenge involves an encryption scheme spanning two fields by doing addition and bit rotation. ```python= from Crypto.Util.number import bytes_to_long import os def rot(n, r): return (n >> r) | ((n << (256 - r) & (2**256 - 1))) round_constants = [3, 141, 59, 26, 53, 58, 97, 93, 23, 84, 62, 64, 33, 83, 27, 9, 50, 28, 84, 197, 169, 39, 93, 75] M = 2**256 def encrypt(key, block): for i in range(24): block = (block + key) % M block = rot(block, round_constants[i]) return block key = os.urandom(32) key = bytes_to_long(key) p1 = b'please like and subscribe!!!!!!!' p2 = b'UMDCTF{REDACTED}' assert(len(p1) <= 32) assert(len(p2) <= 32) c1 = encrypt(key, bytes_to_long(p1)) c2 = encrypt(key, bytes_to_long(p2)) print(c1) print(c2) ``` We are given one pair of plaintext and ciphertext, and we need to decrypt the flag. At first glance, it seems like the block and the key are not mixed well together, and we might be able to get approximated results of encryption by doing `encrypt(key, block) = encrypt(key, 0) + encrypt(0, block) + c` for some error `c`. After trying this out, it seems like the bit count of `c` tends to be small. Given that we may not need to decrypt `p2` precisely, we can give this a go. ```python= from Crypto.Util.number import bytes_to_long import os def rot(n, r): return (n >> r) | ((n << (256 - r) & (2**256 - 1))) round_constants = [3, 141, 59, 26, 53, 58, 97, 93, 23, 84, 62, 64, 33, 83, 27, 9, 50, 28, 84, 197, 169, 39, 93, 75] M = 2**256 def encrypt(key, block): for i in range(24): block = (block + key) % M block = rot(block, round_constants[i]) return block def decrypt(key, block): for i in range(24): block = rot(block, 256 - round_constants[23-i]) block = (block - key) % M return block % M ct1 = 19970192951896076587357270489167937916618022198129516743091736664525698125224 ct2 = 78876026922201259108741049564691635166471597880603787944801336451046144103203 p1 = b'please like and subscribe!!!!!!!' p2 = b'UMDCTF{REDACTED}' cb = encrypt(0, bytes_to_long(p1)) k = ct1 - cb print(k) res = (ct2 - k) % M res = decrypt(0, res) from libnum import n2s print(n2s(res)) #b'YMDCTFsdiamon\\_p\x89ckaxu_no_w\xe1i!!\x85' # UMDCTF{diamond_pickaxe_no_way!!} ``` After some simple calculation, we got a pretty close approximation of the flag, and with a little bit of guessing, we found the flag. ## Cry/Prime Sponsorship This is another challenge where we are given values from two different fields. The crux of the challenge is in the key generation. ```python= import random from Crypto.Util.number import bytes_to_long, long_to_bytes from sage.arith.misc import crt p1 = 211 p2 = 223 q = 1511 # strip UMDCTF{}\n flag = open('flag.txt', 'rb').read()[7:-2] def encode(msg): m = bin(bytes_to_long(msg))[2:].zfill(p1) return [0 if c == '0' else 1 for c in m] Fq = GF(q) F3 = GF(3) Rq = PolynomialRing(Fq, 'x').quotient(x^p1 - x - 1) R3 = PolynomialRing(F3, 'x').quotient(x^p1 - x - 1) Rq_2 = PolynomialRing(Fq, 'x').quotient(x^p2 - x - 1) R3_2 = PolynomialRing(F3, 'x').quotient(x^p2 - x - 1) Rx.<x> = PolynomialRing(ZZ, 'x') Qx = PolynomialRing(QQ, 'x') # keygen h1, h2 = None, None g_inv, f = None, None while True: g = Rx([random.choice([-1,0,1]) for _ in range(p1)]) g3 = R3(g) g3_2 = R3_2(g) if g3.is_unit() and g3_2.is_unit(): g_inv = pow(g3, -1) f = Rx([random.choice([-1,0,1]) for _ in range(p1)]) h1 = Rq(g) / Rq(3 * f) h2 = Rq_2(g) / Rq_2(3 * f) break def round3(poly): new_poly = [] for c in poly.list(): c = ZZ(c) if c % 3 == 1: new_poly.append(c - 1) elif c % 3 == 0: new_poly.append(c) else: new_poly.append(c+1) return Rq(new_poly) def encrypt(r): return round3(h1 * Rq(r)) def decrypt(ct, f, g_inv): e = Rq(3 * f) * ct e = [c.lift_centered() for c in e] print("e = ", e) return list(g_inv * R3(e)) print("With our new PRIME sponsorship, we bundled an extra public key for you*!") print() print("pk1 =", h1.list()) print("pk2 =", h2.list()) msg = encode(flag) ct1 = encrypt(msg) print("ct =", ct1.list()) print("*ciphertext not included") ``` The challenge gives `h1` and `h2`, and we can retrieve the flag if we can recover secrets `g` and `f`. We have, in `GF(1511)`: - `h1 * 3f = g mod (x^211 - x - 1) = g mod f1(x)` - `h2 * 3f = g mod (x^223 - x - 1) = g mod f2(x)` First, let's look at how `h1 * 3f mod f1(x)` will be calculated. For each element of `f`, we can compute the result of `h1 * 3 * f_i * x^i mod f1(x)` which will have degree `211`. Similarly, for `h2 * 3f mod f2(x)`, we can compute the result of `h2 * 3 * f_i * x^i mod f2(x)` with degree `223`. Lastly, note that with the correct `f`, the difference of the two results should be zero. Now, note that each element of `f` is in `{-1, 0, 1}`. Thus, we can just calculate `h1 * 3 * x^i mod f1(x)` and `h2 * 3 * x^i mod f2(x)` for each `i` up to `211` and find a linear combination that evaluates to zero. This is equivalent to solving for the left kernel. Also note that `f` is of degree `211` so there is no need to compute up to degree `223`. ```python= import random from Crypto.Util.number import bytes_to_long, long_to_bytes from hashlib import sha256 p1 = 211 p2 = 223 q = 1511 # strip UMDCTF{}\n flag = open('flag.txt', 'rb').read()[7:-2] def encode(msg): m = bin(bytes_to_long(msg))[2:].zfill(p1) return [0 if c == '0' else 1 for c in m] def decode(bits): # Convert list of 0s and 1s back into a binary string bin_str = ''.join(str(b) for b in bits) # Convert binary string to integer n = int(bin_str, 2) # Convert integer back to bytes return long_to_bytes(n) Fq = GF(q) F3 = GF(3) Rf = PolynomialRing(Fq, 'x') Rq = PolynomialRing(Fq, 'x').quotient(x^p1 - x - 1) R3 = PolynomialRing(F3, 'x').quotient(x^p1 - x - 1) Rq_2 = PolynomialRing(Fq, 'x').quotient(x^p2 - x - 1) R3_2 = PolynomialRing(F3, 'x').quotient(x^p2 - x - 1) Rx.<x> = PolynomialRing(ZZ, 'x') Qx = PolynomialRing(QQ, 'x') def round3(poly): new_poly = [] for c in poly.list(): c = ZZ(c) if c % 3 == 1: new_poly.append(c - 1) elif c % 3 == 0: new_poly.append(c) else: new_poly.append(c+1) return Rq(new_poly) def encrypt(r): return round3(h1 * Rq(r)) def decrypt(ct, f, g_inv): e = Rq(3 * f) * ct e = [c.lift_centered() for c in e] print("e = ", e) return list(g_inv * R3(e)) from output import pk1, pk2, ct h1 = Rq(pk1) h2 = Rq_2(pk2) h2x = h2 * 3 h1x = h1 * 3 mat = [] for i in range(p1): h1l = list(h1x)[:p1] h2l = list(h2x)[:p2] diff = [i - j % q for i, j in zip(h1l, h2l)] mat.append(diff) h1x = h1x * x h2x = h2x * x print("Kerneling") M = Matrix(GF(q), mat) R = M.left_kernel() res = R.basis() print("found sols: ", len(res)) print(res) f = [i.lift_centered() for i in res[0]] print("Got kernel", f) g = h1 * Rq(f) * 3 print("Got g", list(g)) g = [i.lift_centered() for i in g] g3 = R3(g) g_inv = pow(g3, -1) dec = decrypt(Rq(ct), Rq(f), g_inv) print(decode(dec)) #UMDCTF{no_logan_paul_here} ``` After solving for `f` and `g`, we can just compute the flag using the given decryption algorithm. ## nyt/evaldle This challenge presents a pyjail that limits input to five characters. ```python= #!/usr/local/bin/python f = open('flag.txt').read() target = 'SIGMA' while True: guess = input("Guess: ") if len(guess) != 5: print("Invalid guess.") continue for j in range(5): if target[j] == guess[j]: print('🟩', end='') elif guess[j] in target[j]: print('🟨', end='') else: print('⬛', end='') print('') try: exec(guess) print('🟩🟩🟩🟩🟩') except: print("🟥🟥🟥🟥🟥") ``` One important feature to exploit is the try except block. We can leak some information with the following idea: 1. We can assign any string to a variable by doing: - `A='' ` - `Z='c'` - `A+=Z ` - Repeat 2. We can save the `chr` function to a variable: - `I=chr` 3. We can compare strings to flag and output the result: - `C=A>f` - `C=C-1` - `I(C) ` The final `chr` invocation will throw if `C` is negative, and will not throw if `C` is zero. Thus, we can compare strings to the flag, and we can do this by brute force or binary search. ```python! from azunyan.conn import process, remote import string #r = process(['python3', 'evaldle.py'])#, level='debug') r = remote('challs.umdctf.io', 31601) def send(s: str, verify: bool = True): if len(s) < 5: s += ' ' * (5 - len(s)) r.sendlineafter(b'Guess:', s.encode()) r.recvline() res = r.recvline().decode() f = '🟩' in res if verify: assert(f) return '🟩' in res charset = string.ascii_lowercase + string.ascii_uppercase + string.digits + "{}_" charset = sorted(charset) known: str = '' def gen_str(target: str): send('A=""') for c in target: send(f"Z='{c}'") send(f"A=A+Z") def append_gen(char: str): send(f"Z='{char}'") send('A=A+Z') def init(): send("I=chr") def append_str(char: str): send(f"Z='{char}'") send('B=A+Z') def query_big(): send('C=B<f') send('C=C-1') return send('I(C)', False) def binser(known: str): l = 0 r = len(charset) - 1 while l < r: mid = (l + r) // 2 append_str(charset[mid]) res = query_big() if res: l = mid+1 else: r = mid print(l, r, end='\n') return charset[l-1] init() known = 'UMDCTF{that_took_a_lot_more_than_six_guess' print("Setting up") gen_str(known) print(charset) while len(known) == 0 or known[-1] != '}': res = binser(known) known = known + res append_gen(res) print(f"Known={known}") #UMDCTF{that_took_a_lot_more_than_six_guesses} ``` Binary search is preferred as remote queries can take some time. ## nyt/spindle We are tasked with solving the [Spindle](https://playspindle.com/) puzzle optimally. After some trial and error, it seems like we need to solve it in 8 moves. ![image](https://hackmd.io/_uploads/SkG20TnJxx.png) As the solution needs to be optimal, we can write some BFS algorithm to find a solution. I eventually went with brute force BFS with pruning on a distance metric. The distance itself is calcualted based on how far the board is from making the target word "FANUM", and is estimated by the lower bound as follows: ```python! def distance(self): target = "FANUM" dist = 0 for i in range(5): c = target[i] if c == self.state[0][i]: continue elif c in self.state[0]: dist += 1 elif c in [self.state[idx][i] for idx in range(5)]: dist += 1 else: dist += 2 return dist ``` The logic is based on the fact that a character can only be moved column-wise or row-wise with a single move (not both), so if a character is not in the same column and row as the targetted cell, it will take at least two moves to move it there. With the same logic, it will take at least one move if the character is in the same column or row. Note that this calculation is not the actual lower bound, as multiple characters can be moved within the same row or column at once. ```python! words = open('dictionary.txt').readlines() dictionary = [w.strip() for w in words] visited = set() class Spindle: state = [ list("FAHEM"), list("KAELB"), list("VNTKE"), list("OGUYU"), list("PJPFN") ] def __init__(self, state: str = None): if state is None: return for i in range(5): for j in range(5): self.state[i][j] = state[i * 5 + j] def get_state(self): res = '' for i in range(5): for j in range(5): res += self.state[i][j] return res def get_moves(self): #horizontal valid_moves = [] moves = [] for i in range(5): for j in range(5): for l in range(2, 6): if j + l > 5: break word = ''.join(self.state[i][j:j+l]) if word in dictionary: valid_moves.append(word) moves.append((i, j, l, True)) if word[::-1] in dictionary: valid_moves.append(word[::-1]) moves.append((i, j, l, True)) #vertical for i in range(5): for j in range(5): for l in range(2, 6): if i + l > 5: break word = ''.join([self.state[a][j] for a in range(i, i+l)]) if word in dictionary: valid_moves.append(word) moves.append((i, j, l, False)) if word[::-1] in dictionary: valid_moves.append(word[::-1]) moves.append((i, j, l, False)) return moves, valid_moves def play_move(self, move): ix, jy, l, isHori = move new_state = [[a for a in b] for b in self.state] # horizontal if isHori: letters = [self.state[ix][jy + i] for i in range(l)] rotated = letters[::-1] for i in range(l): new_state[ix][jy + i] = rotated[i] # vertical else: letters = [self.state[ix + i][jy] for i in range(l)] rotated = letters[::-1] for i in range(l): new_state[ix + i][jy] = rotated[i] self.state = [[b for b in c] for c in new_state] def distance(self): target = "FANUM" dist = 0 for i in range(5): c = target[i] if c == self.state[0][i]: continue elif c in self.state[0]: dist += 1 elif c in [self.state[idx][i] for idx in range(5)]: dist += 1 else: dist += 2 return dist game = Spindle() visited.add(game.get_state()) moves, words = game.get_moves() games: list[Spindle] = [(Spindle(game.get_state()),[move]) for move in moves] for i in range(len(moves)): games[i][0].play_move(moves[i]) from tqdm import tqdm start_move = 3 for it in range(10): new_games = [] print(f"Found {len(games)} games") for gh in tqdm(games): game, history = gh if game.distance() + start_move + it > 8: continue if game.get_state() in visited: continue visited.add(game.get_state()) moves, words = game.get_moves() if "FANUM" in words: print("Found") print(game.state) print(history) exit(0) cur_new_games: list[Spindle] = [(Spindle(game.get_state()), history + [move])for move in moves] for i in range(len(moves)): cur_new_games[i][0].play_move(moves[i]) new_games += cur_new_games games = [g for g in new_games] print(f"Next {len(games)} games") print("Failed") ``` Eventually, a solution is found by guessing the first three moves (by trial and error) and running the solver. ![image](https://hackmd.io/_uploads/rym0WRhkee.png)