# Alpacahack Round 5 (Crypto) - Writeups (all solved) ## Xorshift Stream --- ### Summary This challenge implements a toy stream cipher using a **64-bit Xorshift** PRNG. The plaintext is: 1. `key.hex().encode()` (ASCII hex, only `0-9a-f`) 2. `strxor(key, FLAG)` (key XOR flag) Ciphertext is `plaintext XOR keystream`, and the keystream is produced by the Xorshift outputs. --- ### Key observations #### 1) Flag length from ciphertext length Let `L = len(FLAG)`. - key length is `L` - `key.hex()` length is `2L` bytes (ASCII) - `key XOR FLAG` length is `L` Total plaintext length is `3L`, hence ciphertext length is also `3L`. So: - `L = len(ciphertext) / 3` --- #### 2) The first 2L bytes must be hex ASCII Since the plaintext begins with `key.hex().encode()`, every early plaintext byte is one of: ``` 0123456789abcdef ``` With `C[i] = P[i] XOR KS[i]`, we get: - `KS[i] = C[i] XOR P[i]` Thus each constrained `KS[i]` has only **16 possible values**. --- #### 3) Xorshift is linear over GF(2) Xorshift uses only XOR and shifts, which are linear over GF(2). Therefore, each keystream bit is a linear XOR-combination of the 64 seed bits. We can model “keystream byte equals value” as a system of linear equations over GF(2). --- ### Attack approach 1. **Build a linear basis**: - For each `j` in `[0..63]`, set seed to `1<<j` and generate some output bytes. - This gives basis streams, one per seed bit. 2. **Hex constraints**: - Use ~126 constrained bytes. - Each position provides 16 candidates for `KS[i]`. 3. **Backtracking + incremental GF(2) elimination**: - Branch on the position with the fewest feasible candidates. - Each chosen byte adds 8 equations. - Backtrack on contradiction. - Once rank hits 64, extract the seed and verify. 4. **Decrypt and recover flag**: - Generate full keystream and decrypt plaintext. - Parse `key` from the hex prefix. - Compute `FLAG = (key XOR FLAG) XOR key`. --- ## Solver (Python) Save as `solve.py`, then run: ```bash python3 solve.py output.txt ``` ```python #!/usr/bin/env python3 import sys MASK64 = (1 << 64) - 1 HEXCHARS = b"0123456789abcdef" def xorshift_next(state: int) -> int: state &= MASK64 state ^= ((state << 13) & MASK64) state ^= (state >> 7) state ^= ((state << 17) & MASK64) return state & MASK64 def gen_keystream(seed: int, nbytes: int) -> bytes: nblocks = (nbytes + 7) // 8 st = seed & MASK64 out = bytearray() for _ in range(nblocks): st = xorshift_next(st) out += st.to_bytes(8, "little") return bytes(out[:nbytes]) # --------- GF(2) linear basis (XOR Gaussian elimination) ---------- def add_equation(basis, mask: int, rhs: int) -> bool: """basis: list of (mask, rhs) rows, kept in (rough) RREF by pivot elimination.""" m = mask r = rhs & 1 # Reduce by existing rows for bm, br in basis: piv = bm.bit_length() - 1 if (m >> piv) & 1: m ^= bm r ^= br if m == 0: return (r == 0) piv = m.bit_length() - 1 # Eliminate this pivot from other rows new_basis = [] for bm, br in basis: if (bm >> piv) & 1: bm ^= m br ^= r new_basis.append((bm, br)) new_basis.append((m, r)) new_basis.sort(key=lambda x: x[0].bit_length(), reverse=True) basis[:] = new_basis return True def add_byte_assignment(basis, byte_bit_masks, pos: int, value: int) -> bool: """Add constraints: output_byte[pos] == value.""" for bit in range(8): mask = byte_bit_masks[pos][bit] rhs = (value >> bit) & 1 if not add_equation(basis, mask, rhs): return False return True def solve_from_basis(basis) -> int: """ Produce one solution by setting free vars = 0 and back-substituting. Works because we keep a pivot-eliminated basis. """ rows = [(m, r, m.bit_length() - 1) for (m, r) in basis] rows.sort(key=lambda x: x[2]) # low pivot -> high pivot values = [0] * 64 solved = [False] * 64 for m, r, piv in rows: acc = r mm = m & ~(1 << piv) while mm: lsb = mm & -mm idx = lsb.bit_length() - 1 if solved[idx]: acc ^= values[idx] # free var assumed 0 mm ^= lsb values[piv] = acc & 1 solved[piv] = True seed = 0 for i, v in enumerate(values): if v: seed |= 1 << i return seed # --------- Build linear masks for keystream bytes (first 16 blocks = 128 bytes) ---------- def build_byte_bit_masks(nblocks: int = 16): nbytes = nblocks * 8 # basis_bytes[i] = keystream bytes when seed = 1<<i basis_bytes = [] for i in range(64): seed = 1 << i st = seed out = bytearray() for _ in range(nblocks): st = xorshift_next(st) out += st.to_bytes(8, "little") basis_bytes.append(bytes(out)) byte_bit_masks = [[0] * 8 for _ in range(nbytes)] for pos in range(nbytes): for bit in range(8): m = 0 for i in range(64): if (basis_bytes[i][pos] >> bit) & 1: m |= 1 << i byte_bit_masks[pos][bit] = m return byte_bit_masks def dfs_find_seed(c: bytes, byte_bit_masks): # Constrain first 126 bytes: plaintext must be hex chars allowed = [] for i in range(126): allowed.append([c[i] ^ h for h in HEXCHARS]) allowed_sets = [set(x) for x in allowed] positions = list(range(126)) def stream126(seed: int) -> bytes: return gen_keystream(seed, 126) # DFS with heuristic: pick position with minimum feasible candidates def rec(basis, unassigned): if len(basis) == 64: seed = solve_from_basis(basis) s = stream126(seed) for pos in unassigned: if s[pos] not in allowed_sets[pos]: return None return seed if not unassigned: return solve_from_basis(basis) best_pos = None best_vals = None best_len = 10**9 for pos in unassigned: feas = [] for v in allowed[pos]: b2 = basis.copy() if add_byte_assignment(b2, byte_bit_masks, pos, v): feas.append(v) if len(feas) >= best_len: break if not feas: return None if len(feas) < best_len: best_len = len(feas) best_pos = pos best_vals = feas if best_len == 1: break rest = [p for p in unassigned if p != best_pos] for v in best_vals: b2 = basis.copy() if not add_byte_assignment(b2, byte_bit_masks, best_pos, v): continue ans = rec(b2, rest) if ans is not None: return ans return None return rec([], positions) def main(): if len(sys.argv) < 2: print(f"Usage: {sys.argv[0]} output.txt") sys.exit(1) c_hex = open(sys.argv[1], "r", encoding="utf-8").read().strip() c = bytes.fromhex(c_hex) if len(c) % 3 != 0: raise ValueError("Ciphertext length is not multiple of 3; unexpected.") L = len(c) // 3 byte_bit_masks = build_byte_bit_masks(nblocks=16) # 128 bytes covers first 126 constraints seed = dfs_find_seed(c, byte_bit_masks) if seed is None: raise RuntimeError("Failed to recover seed.") ks = gen_keystream(seed, len(c)) pt = bytes(ci ^ ki for ci, ki in zip(c, ks)) key_hex = pt[: 2 * L] key = bytes.fromhex(key_hex.decode()) key_xor_flag = pt[2 * L :] flag = bytes(a ^ b for a, b in zip(key, key_xor_flag)) print("seed =", seed) print("flag =", flag.decode(errors="replace")) if __name__ == "__main__": main() ``` --- ## Output Running the solver prints the recovered seed and the flag: `Alpaca{I'v3_n3v3r_seen_4_c1ient_51d3_CryptoWeb_ch4ll3ng3_0nce!}` ## NNNN ### 1. Challenge summary The script generates four RSA moduli: - `n0 = p * q` where `p, q` are 768-bit primes. - For three more moduli, it samples `delta < 2^192` until both `p+delta` and `q+delta` are prime, then: - `n1 = (p+δ1)(q+δ1)` - `n2 = (p+δ2)(q+δ2)` - `n3 = (p+δ3)(q+δ3)` The flag is split into 4 chunks. Each chunk is encrypted with `e=65537` under its modulus, producing `c0..c3`. Goal: factor each `ni`, derive private exponents, decrypt `ci`, then concatenate the plaintext chunks. --- ### 2. Key observation All moduli share the same hidden primes, shifted by the same delta on both sides. Let: - `P = n0 = pq` - `S = p + q` For `i = 1..3`: $$ n_i = (p+\delta_i)(q+\delta_i)=pq+\delta_i(p+q)+\delta_i^2 $$ Define: $$ D_i = n_i - n_0 = \delta_i S + \delta_i^2 = \delta_i(S+\delta_i) $$ Since `δi < 2^192` while `S` is about `2^768`, we have `δi << S`. So: $$ \frac{D_1}{D_2}=\frac{\delta_1(S+\delta_1)}{\delta_2(S+\delta_2)}\approx\frac{\delta_1}{\delta_2} $$ The relative error is on the order of `δ/S ≈ 2^-576`, extremely small. This is why rational recovery works. --- ### 3. Recover δ1/δ2 via continued fractions We know `δ1, δ2 < 2^192`. Therefore the reduced fraction `δ1/δ2 = a/b` has `b < 2^192`. Compute `D1=n1-n0`, `D2=n2-n0`, then take the best rational approximation to `D1/D2` with denominator bounded by `2^192-1`. In Python: ```python Fraction(D1, D2).limit_denominator(2**192 - 1) ``` This returns `a/b`, which with overwhelming probability equals the reduced ratio: $$ \frac{a}{b}=\frac{\delta_1}{\delta_2},\quad gcd(a,b)=1 $$ So there exists an integer scale `t`: $$ \delta_1 = ta,\quad \delta_2 = tb $$ --- ### 4. Compute the scale t without knowing p or q Use: $$ D_1=ta(S+ta),\quad D_2=tb(S+tb) $$ Eliminate `S`: $$ bD_1-aD_2=t^2ab(a-b) $$ Thus: $$ t^2=\frac{bD_1-aD_2}{ab(a-b)} $$ The right-hand side is an exact perfect square, so `t = sqrt(t^2)` is an integer. Then: - `δ1 = t*a` - `δ2 = t*b` --- ### 5. Recover S and factor n0 From: $$ D_1=\delta_1(S+\delta_1) \Rightarrow S=\frac{D_1}{\delta_1}-\delta_1 $$ Now we have: - `S = p+q` - `P = pq` So `p` and `q` are roots of: $$ x^2 - Sx + P = 0 $$ Compute: $$ \Delta = S^2 - 4P = (p-q)^2 $$ Let `r = sqrt(Δ)` (integer), then: $$ p=\frac{S+r}{2},\quad q=\frac{S-r}{2} $$ --- ### 6. Recover δ3, factor remaining moduli, decrypt For `D3`: $$ D_3=\delta_3(S+\delta_3)\Rightarrow \delta_3^2+S\delta_3-D_3=0 $$ Discriminant: $$ \Delta_3=S^2+4D_3 $$ Positive root: $$ \delta_3=\frac{-S+\sqrt{\Delta_3}}{2} $$ Then factor each modulus: - `n0 = p*q` - `n1 = (p+δ1)(q+δ1)` - `n2 = (p+δ2)(q+δ2)` - `n3 = (p+δ3)(q+δ3)` Decrypt each chunk: - `phi = (p_i-1)(q_i-1)` - `d = e^{-1} mod phi` - `m = c^d mod n` - Convert `m` back to bytes and concatenate. Byte-join note: `bytes_to_long()` drops leading zero bytes. When converting back, pad the first three chunks to the same byte length. ### 7. Solver (Python) ```python from fractions import Fraction from math import isqrt def parse_output(path="output.txt"): vals = {} with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if "=" not in line: continue k, v = line.split("=", 1) vals[k.strip()] = int(v.strip()) ns = [vals[f"n{i}"] for i in range(4)] cs = [vals[f"c{i}"] for i in range(4)] return ns, cs def long_to_bytes(n: int) -> bytes: if n == 0: return b"\x00" out = bytearray() while n: out.append(n & 0xff) n >>= 8 return bytes(reversed(out)) def main(): ns, cs = parse_output("output.txt") e = 0x10001 n0 = ns[0] P = n0 D1, D2, D3 = ns[1] - P, ns[2] - P, ns[3] - P B = 2**192 - 1 frac = Fraction(D1, D2).limit_denominator(B) a, b = frac.numerator, frac.denominator # δ1/δ2 = a/b (reduced) # bD1 - aD2 = t^2 * a*b*(a-b) num = b * D1 - a * D2 den = a * b * (a - b) if den < 0: den = -den num = -num t2 = num // den t = isqrt(t2) delta1 = t * a delta2 = t * b S = D1 // delta1 - delta1 # p+q disc = S * S - 4 * P r = isqrt(disc) p = (S + r) // 2 q = (S - r) // 2 disc3 = S * S + 4 * D3 r3 = isqrt(disc3) delta3 = (-S + r3) // 2 deltas = [0, delta1, delta2, delta3] chunks = [] for i in range(4): pi = p + deltas[i] qi = q + deltas[i] phi = (pi - 1) * (qi - 1) d = pow(e, -1, phi) m = pow(cs[i], d, ns[i]) chunks.append(long_to_bytes(m)) L = max(len(chunks[0]), len(chunks[1]), len(chunks[2])) for i in range(3): chunks[i] = chunks[i].rjust(L, b"\x00") flag = b"".join(chunks) print(flag.decode(errors="replace")) if __name__ == "__main__": main() ``` --- ### 8. Flag `Alpaca{lattice_reductionNnnnnNnnNnnNnNNnNnNNnNn_solves_approximationNnnNNnnnNnnNNnnnNnnNNnn}` ## SchnorrLCG ### Overview This service implements **Schnorr signatures**. You are allowed to request signatures (`option 1`) for arbitrary messages **except**: - `b"give me flag"` If you provide a valid Schnorr signature for that exact message via `option 2 (verify)`, the server reveals the flag. The vulnerability: the signing nonce `k` is generated by an **LCG**, so nonces satisfy a linear recurrence. With enough signatures, we can recover the private key `x`, then forge the forbidden signature. --- ### Proof-of-Work (hashcash) Before you can access the menu, the service asks for a **hashcash** token (strength 27): - The server prints a `<challenge>` - You submit a hashcash token for that challenge - If valid, the service proceeds In practice, you can generate the token with: ```bash hashcash -mb27 <challenge> ``` --- ### Schnorr scheme used Parameters: - The server picks prime `q` and searches for a safe prime `p = 2q + 1`. - `g` is a fixed generator (in the source: `2**2`). - Secret key: `x` - Public key: `y = g^x mod p` Signing: 1. Pick nonce `k` 2. `r = g^k mod p` 3. `e = H(m || r) mod q` 4. `s = (k + x·e) mod q` Verification: - Compute `r' = g^s · y^{-e} mod p` - `e' = H(m || r') mod q` - Accept iff `e' == e` --- ### Core bug: LCG nonces Nonces `k` must be unpredictable in Schnorr. Here they come from an **LCG**: - `state = (a·state + b) mod q` So consecutive nonces satisfy: - `k_{i+1} = a·k_i + b (mod q)` From Schnorr: - `s_i = k_i + x·e_i (mod q)` - so `k_i = s_i − x·e_i (mod q)` Thus, the correct secret key `x` makes the recovered nonce sequence consistent with one LCG. --- ### Recovering the private key (math) Collect signatures `(e_i, s_i)`. From signing: - `k_i = s_i − x·e_i (mod q)` Define differences: - `Δk_i = k_{i+1} − k_i` - `Δs_i = s_{i+1} − s_i` - `Δe_i = e_{i+1} − e_i` Since `s_i = k_i + x·e_i`: - `Δs_i = Δk_i + x·Δe_i` - `Δk_i = Δs_i − x·Δe_i` LCG property implies: - `Δk_{i+1} = a · Δk_i (mod q)` Substitute and eliminate `a`: - `(Δs_2 − xΔe_2)(Δs_0 − xΔe_0) = (Δs_1 − xΔe_1)^2 (mod q)` This yields a **quadratic equation in x modulo prime q**. With 4 signatures you can build 3 difference pairs, derive `c2 x^2 + c1 x + c0 = 0 (mod q)`, then solve using a modular square root (Tonelli–Shanks). --- ### Candidate validation Quadratic solving may return up to two candidates. Validate each candidate `x` by: 1. Reconstruct all nonces: - `k_i = s_i − x·e_i (mod q)` 2. Derive `a,b` from any triple where `k1 != k0`: - `a = (k2 − k1) / (k1 − k0) (mod q)` - `b = k1 − a·k0 (mod q)` 3. Check the full recurrence: - `k_{i+1} == a·k_i + b (mod q)` for all i Only the correct `x` will satisfy the recurrence for the entire nonce sequence. --- ### Forging the flag signature Once `x` is known: 1. Pick random `k` 2. `r = g^k mod p` 3. `e = H("give me flag" || r) mod q` 4. `s = k + x·e mod q` Submit `(e,s)` to `option 2` for message `"give me flag"` to obtain the flag. --- ### End-to-end steps 1. Connect to the service. 2. Solve hashcash PoW. 3. Parse `p,g,pub_key`. 4. Request 8–12 signatures on harmless messages. 5. Solve and validate `x`. 6. Forge a signature for `"give me flag"`. 7. Verify and get the flag. --- ### Solver (Python) > Standalone solver with PoW + key recovery + forging. ```python #!/usr/bin/env python3 import argparse import hashlib import re import secrets import socket import subprocess import time from typing import List, Optional, Tuple GIVE_ME_FLAG = b"give me flag" Sig = Tuple[int, int] # (e, s) def long_to_bytes(n: int) -> bytes: return n.to_bytes((n.bit_length() + 7) // 8 or 1, "big") def H_int(msg: bytes) -> int: return int.from_bytes(hashlib.sha256(msg).digest(), "big") def modinv(a: int, m: int) -> int: a %= m return pow(a, -1, m) def legendre_symbol(a: int, p: int) -> int: return pow(a % p, (p - 1) // 2, p) def mod_sqrt(a: int, p: int) -> Optional[int]: a %= p if a == 0: return 0 if legendre_symbol(a, p) != 1: return None if p % 4 == 3: return pow(a, (p + 1) // 4, p) q = p - 1 s = 0 while q % 2 == 0: q //= 2 s += 1 z = 2 while legendre_symbol(z, p) != p - 1: z += 1 m = s c = pow(z, q, p) x = pow(a, (q + 1) // 2, p) t = pow(a, q, p) while t != 1: i = 1 t2 = pow(t, 2, p) while t2 != 1: t2 = pow(t2, 2, p) i += 1 if i >= m: return None b = pow(c, 1 << (m - i - 1), p) x = (x * b) % p t = (t * b * b) % p c = (b * b) % p m = i return x def schnorr_hash(q: int, msg: bytes, r: int) -> int: return H_int(msg + long_to_bytes(r)) % q class Remote: def __init__(self, host: str, port: int, debug: bool = False): self.s = socket.create_connection((host, port)) self.s.settimeout(1.0) self.buf = b"" self.debug = debug def close(self): try: self.s.close() except Exception: pass def _recv_some(self) -> bytes: return self.s.recv(4096) def recvline(self, deadline: float) -> bytes: while b"\n" not in self.buf: if time.time() > deadline: raise TimeoutError("recvline deadline exceeded") try: chunk = self._recv_some() if not chunk: raise EOFError("connection closed") self.buf += chunk except TimeoutError: continue i = self.buf.index(b"\n") + 1 out, self.buf = self.buf[:i], self.buf[i:] if self.debug: print(out.decode(errors="ignore"), end="") return out def recv_until(self, needle: bytes, deadline: float) -> bytes: while needle not in self.buf: if time.time() > deadline: raise TimeoutError("recv_until deadline exceeded") try: chunk = self._recv_some() if not chunk: raise EOFError("connection closed") self.buf += chunk except TimeoutError: continue j = self.buf.index(needle) + len(needle) out, self.buf = self.buf[:j], self.buf[j:] if self.debug: print(out.decode(errors="ignore"), end="") return out def sendline(self, data: bytes): if self.debug: print(f"[send] {data!r}") self.s.sendall(data + b"\n") def get_hashcash_token(bits: int, challenge: str) -> str: try: tok = subprocess.check_output(["hashcash", f"-mb{bits}", challenge], stderr=subprocess.DEVNULL) return tok.decode().strip() except FileNotFoundError: print("[!] 'hashcash' binary not found.") print(f" Run manually: hashcash -mb{bits} {challenge}") return input(" Paste hashcash token: ").strip() def try_candidates_from_4(q: int, sig4: List[Sig]) -> List[int]: es = [e for e, s in sig4] ss = [s for e, s in sig4] A = [(ss[i + 1] - ss[i]) % q for i in range(3)] B = [(es[i + 1] - es[i]) % q for i in range(3)] c2 = (B[1] * B[1] - B[0] * B[2]) % q c1 = (-2 * A[1] * B[1] + A[0] * B[2] + A[2] * B[0]) % q c0 = (A[1] * A[1] - A[0] * A[2]) % q if c2 == 0: if c1 == 0: return [] return [(-c0 * modinv(c1, q)) % q] D = (c1 * c1 - 4 * c2 * c0) % q r = mod_sqrt(D, q) if r is None: return [] inv_2c2 = modinv((2 * c2) % q, q) x1 = ((-c1 + r) * inv_2c2) % q x2 = ((-c1 - r) * inv_2c2) % q return [x1] if x1 == x2 else [x1, x2] def validate_x(q: int, sigs: List[Sig], x: int) -> bool: ks = [(s - (x * e) % q) % q for (e, s) in sigs] if all(k == ks[0] for k in ks): return True a = b = None for i in range(len(ks) - 2): d = (ks[i + 1] - ks[i]) % q if d != 0: a = ((ks[i + 2] - ks[i + 1]) * modinv(d, q)) % q b = (ks[i + 1] - a * ks[i]) % q break if a is None: return False for i in range(len(ks) - 1): if ks[i + 1] != (a * ks[i] + b) % q: return False return True def recover_x(q: int, sigs: List[Sig]) -> int: for i in range(len(sigs) - 3): for x in try_candidates_from_4(q, sigs[i:i + 4]): if validate_x(q, sigs, x): return x raise RuntimeError("Failed to recover private key x.") def forge(p: int, g: int, q: int, x: int, msg: bytes) -> Sig: k = secrets.randbelow(q - 1) + 1 r = pow(g, k, p) e = schnorr_hash(q, msg, r) s = (k + (x * e) % q) % q return e, s def main(): ap = argparse.ArgumentParser() ap.add_argument("host", nargs="?", default="34.170.146.252") ap.add_argument("port", nargs="?", type=int, default=26727) ap.add_argument("--debug", action="store_true") ap.add_argument("--sigs", type=int, default=10) args = ap.parse_args() io = Remote(args.host, args.port, debug=args.debug) deadline = time.time() + 60 bits = None challenge = None while time.time() < deadline: line = io.recvline(deadline).decode(errors="ignore").strip() m = re.search(r"hashcash\s+-mb(\d+)\s+([A-Za-z0-9_.]+)", line) if m: bits = int(m.group(1)) challenge = m.group(2) break if bits is None: raise RuntimeError("Could not parse PoW line.") io.recv_until(b"hashcash token", time.time() + 30) token = get_hashcash_token(bits, challenge) io.sendline(token.encode()) # Parse p,g,y (safe prime gen may be slow) deadline = time.time() + 240 p = None while time.time() < deadline: line = io.recvline(deadline).decode(errors="ignore").strip() if line.startswith("p="): p = int(line.split("=", 1)[1]) break if p is None: raise TimeoutError("Timed out waiting for p=") g = int(io.recvline(time.time() + 30).decode(errors="ignore").split("=", 1)[1]) y = int(io.recvline(time.time() + 30).decode(errors="ignore").split("=", 1)[1]) q = (p - 1) // 2 io.recv_until(b"option> ", time.time() + 30) sigs: List[Sig] = [] for i in range(args.sigs): io.sendline(b"1") io.recv_until(b"message(in hex)> ", time.time() + 30) msg = bytes([i + 1]) io.sendline(msg.hex().encode()) e_line = io.recvline(time.time() + 30).decode(errors="ignore").strip() s_line = io.recvline(time.time() + 30).decode(errors="ignore").strip() e = int(e_line.split("=", 1)[1]) s = int(s_line.split("=", 1)[1]) sigs.append((e, s)) io.recv_until(b"option> ", time.time() + 30) x = recover_x(q, sigs) print("[+] recovered priv_key =", x) e_f, s_f = forge(p, g, q, x, GIVE_ME_FLAG) io.sendline(b"2") io.recv_until(b"message(in hex)> ", time.time() + 30) io.sendline(GIVE_ME_FLAG.hex().encode()) io.recv_until(b"e> ", time.time() + 30) io.sendline(str(e_f).encode()) io.recv_until(b"s> ", time.time() + 30) io.sendline(str(s_f).encode()) out_deadline = time.time() + 30 out = b"" while time.time() < out_deadline: try: out += io.recvline(out_deadline) except Exception: break if b"flag" in out.lower(): break print(out.decode(errors="ignore")) io.close() if __name__ == "__main__": main() ``` ### Flag `Alpaca{5chn0rr_s1gn4tur3_1s_st1ll_s3cur3_t0_th15_d4y}` ## NTRU Broadcast Attack ### 1) Challenge Summary The challenge implements an NTRU-like scheme over: - Ring: $$ R = \mathbb{Z}[x]/(x^N - 1) $$ - Parameters: **N = 509**, **q = 2048 = 2¹¹**, **p = 3** - Message `msg` is a **binary** (0/1) polynomial of length N. - For **777 recipients**, it generates: - public key \(h_i\) - random binary blinding \(r_i\) - ciphertext: $$ c_i = h_i * r_i + m \pmod q $$ - **Core vulnerability:** the same message \(m\) is reused for all recipients. The flag is then encrypted with AES-CTR: ```python key = sha256(str(msg).encode()).digest()[:16] enc = nonce(8 bytes) || ciphertext ``` --- ### 2) Why This Breaks NTRU relies on the random blinding \(r_i\) to hide the message. But when the **same message** is sent to many recipients, we can build **many independent equations** involving the same unknown \(m\), and eliminate the randomness algebraically. This becomes especially convenient because: - \(q\) is a **power of two**, allowing fast polynomial inversion modulo \(2^k\) via Newton/Hensel lifting. --- ### 3) Notation For recipient \(i\): - public key \(h_i\) - ciphertext \(c_i\) - blinding $$(r_i \in \{0,1\}^N)$$ - message $$(m \in \{0,1\}^N)$$ Encryption: $$ c_i = h_i * r_i + m \pmod q $$ `*` denotes cyclic convolution modulo $$(x^N-1)$$. --- ### 4) Attack Idea (Broadcast Attack) #### Step A : Invert \(h_i\) modulo \(q = 2^{11}\) Compute: $$ u_i = h_i^{-1} \pmod q $$ Since \(q\) is a power of 2: 1) compute inverse modulo 2 in GF(2) 2) lift to \(2^{11}\) using Newton iteration: $$ u_{k+1} = u_k (2 - h u_k) \pmod {2^t} $$ until modulus 2048. #### Step B : Remove the \(h_i\) factor Multiply ciphertext by the inverse: $$ b_i = u_i * c_i = r_i + u_i*m \pmod q $$ Now `b_i` is “noise + linear transform(message)”. #### Step C : Linearization with correlation variables Broadcast attacks derive quadratic relations that eliminate \(r_i\) and yield expressions involving: - the message bits \(m_0,\dots,m_{N-1}\) - correlation variables: $$ x_j = \sum_k m_k m_{k+j} $$ Because of circulant/symmetric structure, only $$j = 1.. \lfloor (N-1)/2 \rfloor$$ are needed. Unknown count: - \(N = 509\) message variables - \(254\) correlation variables Total = **763** unknowns. We have **777 equations** from 777 ciphertexts, which is enough (typically full rank). #### Step D : Work modulo 1024 Due to symmetry, the derived equations are always even, so we divide by 2 and solve modulo: $$ 2^{10} = 1024 $$ --- ### 5) Solving Efficiently (GF(2) + Hensel Lifting) 1) Solve modulo 2 with bitset Gaussian elimination: $$ A_2Y_2 = S_2 \pmod 2 $$ 2) Lift the solution step-by-step to \(2^{10}\): - compute residual - solve a correction \(\Delta\) in GF(2) - update: $$ Y \leftarrow Y + 2^t\Delta $$ The recovered message coefficients \(m_k\) are exactly 0/1, with Hamming weight 260 for this instance. --- ### 6) AES Decryption (Key Pitfall) After recovering \(m\), you must rebuild **exactly** the same `str(msg)` format Sage produced. Then compute the AES key as **only the first 16 bytes** of SHA-256: ```python key = sha256(str(msg).encode()).digest()[:16] nonce = enc[:8] ct = enc[8:] pt = AES.new(key, AES.MODE_CTR, nonce=nonce).decrypt(ct) ``` Using the full 32-byte hash will produce garbage plaintext. ### 7) Solver (Python) ```python import re import numpy as np from hashlib import sha256 from Crypto.Cipher import AES from pathlib import Path # ===== Parameters (from chall.sage) ===== N = 509 q = 2048 qmask = q - 1 mod1024 = 1024 mmask = mod1024 - 1 inv3 = pow(3, -1, q) n_x = (N - 1) // 2 # 254 n_vars = n_x + N # 763 # ===== Helpers: parse polynomial strings into coefficient arrays mod q ===== def parse_poly(s): expr = s.replace(' ','') expr = expr.replace('-', '+-') if expr.startswith('+-'): expr = expr[1:] parts = [p for p in expr.split('+') if p!=''] coeffs = [0]*N for part in parts: if 'x^' in part: if '*x^' in part: coef_str, pow_str = part.split('*x^') else: coef_str, pow_str = part.split('x^') powv = int(pow_str) if coef_str in ('', '+'): coef = 1 elif coef_str == '-': coef = -1 else: coef = int(coef_str) coeffs[powv] = coef & qmask elif 'x' in part: if '*x' in part: coef_str = part.split('*x')[0] else: coef_str = part.split('x')[0] if coef_str in ('', '+'): coef = 1 elif coef_str == '-': coef = -1 else: coef = int(coef_str) coeffs[1] = coef & qmask else: coeffs[0] = int(part) & qmask return np.array(coeffs, dtype=np.int64) def poly_rev(a): # transpose circulant => reverse except index 0 return np.concatenate(([a[0]], a[:0:-1])) # ===== GF(2) polynomial inverse (bitset) ===== def gf2_deg(p: int) -> int: return p.bit_length() - 1 def gf2_divmod(a: int, b: int): if b == 0: raise ZeroDivisionError da = gf2_deg(a) db = gf2_deg(b) qv = 0 r = a while da >= db and r: shift = da - db qv ^= 1 << shift r ^= b << shift da = gf2_deg(r) if r else -1 return qv, r def gf2_mul(a: int, b: int) -> int: res = 0 while b: if b & 1: res ^= a b >>= 1 a <<= 1 return res def gf2_mod(a: int, mod: int) -> int: _, r = gf2_divmod(a, mod) return r def gf2_inv_mod(a: int, mod: int) -> int: r0, r1 = mod, a t0, t1 = 0, 1 while r1: qv, r2 = gf2_divmod(r0, r1) r0, r1 = r1, r2 t0, t1 = t1, t0 ^ gf2_mul(qv, t1) if r0 != 1: raise ValueError("no inverse") return gf2_mod(t0, mod) def arr_to_bits_mod2(a): bits = 0 for i, v in enumerate(a): if int(v) & 1: bits |= 1 << i return bits def bits_to_arr_mod2(bits): return np.array([(bits >> i) & 1 for i in range(N)], dtype=np.int64) # modulus poly in GF2: x^N - 1 == x^N + 1 in char2 mod_poly_bits = (1 << N) | 1 # ===== cyclic convolution using FFT (mod power-of-2 => use & mask) ===== def cyclic_conv_pow2(a, b, mod=q): fa = np.fft.fft(a) fb = np.fft.fft(b) c = np.fft.ifft(fa * fb).real c = np.rint(c).astype(np.int64) return c & (mod - 1) one = np.zeros(N, dtype=np.int64); one[0] = 1 two = np.zeros(N, dtype=np.int64); two[0] = 2 def invert_mod_power2_fast(f_arr, q=q, max_iter=20): f_bits = arr_to_bits_mod2(f_arr) inv_bits = gf2_inv_mod(f_bits, mod_poly_bits) h = bits_to_arr_mod2(inv_bits).astype(np.int64) & (q - 1) for _ in range(max_iter): r = cyclic_conv_pow2(h, f_arr, mod=q) if np.array_equal(r, one): return h h = cyclic_conv_pow2(h, (two - r) & (q - 1), mod=q) raise RuntimeError("inverse did not converge") # ===== GF(2) solver using bitrows ===== def gf2_prepare_rowbits(A2): row_bits = [] m, n = A2.shape for i in range(m): bits = 0 row = A2[i] for j in range(n): if row[j]: bits |= 1 << j row_bits.append(bits) return row_bits def gf2_solve_from_rowbits(row_bits, b_bits, n): m = len(row_bits) rows = [row_bits[i] | (int(b_bits[i]) << n) for i in range(m)] pivots = [] r = 0 for c in range(n): pivot_row = None for i in range(r, m): if (rows[i] >> c) & 1: pivot_row = i break if pivot_row is None: continue rows[r], rows[pivot_row] = rows[pivot_row], rows[r] piv = rows[r] for i in range(m): if i != r and ((rows[i] >> c) & 1): rows[i] ^= piv pivots.append(c) r += 1 if r == n: break sol = 0 for ridx, c in enumerate(pivots): rhs = (rows[ridx] >> n) & 1 if rhs: sol |= 1 << c y = np.fromiter(((sol >> j) & 1 for j in range(n)), dtype=np.uint8, count=n) return y # ===== Load output.txt ===== raw = Path("output.txt").read_text() raw_nl = raw.replace("\n", "") pk_start = raw_nl.find("public keys: [") ct_start = raw_nl.find("ciphertexts: [") enc_start = raw_nl.find("encrypted flag:") pk_list_str = raw_nl[len("public keys: "):ct_start] ct_list_str = raw_nl[ct_start+len("ciphertexts: "):enc_start].strip() enc_hex = raw_nl[enc_start+len("encrypted flag:"):].strip() def split_list(list_str): assert list_str[0] == "[" and list_str[-1] == "]" inner = list_str[1:-1] return [p.strip() for p in inner.split(",") if p.strip()] pks_parts = split_list(pk_list_str) cts_parts = split_list(ct_list_str) pks = np.stack([parse_poly(s) for s in pks_parts], axis=0) cts = np.stack([parse_poly(s) for s in cts_parts], axis=0) M = pks.shape[0] A_mat = np.empty((M, n_vars), dtype=np.uint16) b_vec = np.empty(M, dtype=np.uint16) # ===== Build linear system modulo 1024 ===== for i in range(M): h = pks[i] c = cts[i] hinv = invert_mod_power2_fast(h) hinv_rev = poly_rev(hinv) b = cyclic_conv_pow2(hinv, c, mod=q) w = cyclic_conv_pow2(hinv_rev, b, mod=q) a = cyclic_conv_pow2(hinv_rev, hinv, mod=q) a0 = int(a[0]) K = (a0 + inv3) & qmask # even K2 = (K >> 1) & mmask c1 = int(np.sum(c) & qmask) bTb = int(np.sum((b*b) & qmask) & qmask) t = ((inv3 * c1) - bTb) & qmask # even b_vec[i] = (t >> 1) & mmask A_mat[i, :n_x] = (a[1:n_x+1] & mmask).astype(np.uint16) A_mat[i, n_x:] = ((K2 - (w & mmask)) & mmask).astype(np.uint16) # ===== Solve via Hensel lifting (mod 2 -> mod 1024) ===== A2 = (A_mat & 1).astype(np.uint8) b2 = (b_vec & 1).astype(np.uint8) row_bits = gf2_prepare_rowbits(A2) y = gf2_solve_from_rowbits(row_bits, b2, n_vars).astype(np.int64) A_int = A_mat.astype(np.int64) b_int = b_vec.astype(np.int64) for tpow in range(1, 10): # lift to 2^10 mod_next = 1 << (tpow + 1) mask = mod_next - 1 Ay = (A_int @ y) & mask r = (b_int - Ay) & mask rhs_delta = (r >> tpow) & 1 delta = gf2_solve_from_rowbits(row_bits, rhs_delta, n_vars).astype(np.int64) y = y + (delta << tpow) y1024 = y & mmask m_bits = y1024[n_x:] # last 509 vars are m0..m508 assert set(np.unique(m_bits)).issubset({0,1}) print("Recovered m sum =", int(m_bits.sum())) # ===== Rebuild Sage-style polynomial string ===== def sage_poly_str_from_bits(bits): terms = [] for power in range(N-1, -1, -1): if bits[power] == 0: continue if power == 0: term = "1" elif power == 1: term = "x" else: term = f"x^{power}" terms.append(term) return "0" if not terms else " + ".join(terms) msg_str = sage_poly_str_from_bits(m_bits) # ===== Decrypt flag ===== key = sha256(msg_str.encode()).digest()[:16] # <-- IMPORTANT blob = bytes.fromhex(enc_hex) nonce = blob[:8] ct = blob[8:] pt = AES.new(key, AES.MODE_CTR, nonce=nonce).decrypt(ct) print("FLAG =", pt.decode(errors="replace")) ``` --- ### 8) Flag **`Alpaca{N7RU_ha5_m4ny_in73re571n6_vuln3r4bil1t13s^^}`** ---