SECCON CTF 2021 Writeup
Sun Dec 12 2021
12/11-12 で開催していた SECCON CTF 2021 にチーム WreckTheLine で参加しました。結果は 14th/506 (得点のあるチームのみカウント) でした。
自分は Crypto に集中して取り組みました。どの Crypto 問もシンプルだけど非自明な感じがあって好きでした。 ところで終了後の solves 数を改めて見ると、自分の解いた感覚の難易度と solves 数が結構違くて🤔 なんかシンプルな解法の見落としがあるのかな… (個人の感想としての難易度は難しいほうから oOoOoO > CCC > cerberus > Sign Wars > XXX > pppp でした)
以下、 Crypto の問題について writeup をまとめます。
Crypto
Sign Wars
8 solves
challenge.sagefrom Crypto.Util.number import bytes_to_long, long_to_bytes from Crypto.Util.Padding import pad import random from secret import msg1, msg2, flag flag = pad(flag, 96) flag1 = flag[:48] flag2 = flag[48:] # P-384 Curve p = 39402006196394479212279040100143613805079739270465446667948293404245721771496870329047266088258938001861606973112319 a = -3 b = 27580193559959705877849011840389048093056905856361568521428707301988689241309860865136260764883745107765439761230575 curve = EllipticCurve(GF(p), [a, b]) order = 39402006196394479212279040100143613805079739270465446667946905279627659399113263569398956308152294913554433653942643 Z_n = GF(order) gx = 26247035095799689268623156744566981891852923491109213387815615900925518854738050089022388053975719786650872476732087 gy = 8325710961489029985546751289520108179287853048861315594709205902480503199884419224438643760392947333078086511627871 G = curve(gx, gy) for b in msg1: assert b >= 0x20 and b <= 0x7f z1 = bytes_to_long(msg1) assert z1 < 2^128 for b in msg2: assert b >= 0x20 and b <= 0x7f z2 = bytes_to_long(msg2) assert z2 < 2^384 # prequel trilogy def sign_prequel(): d = bytes_to_long(flag1) sigs = [] for _ in range(80): # normal ECDSA. all bits of k are unknown. k1 = random.getrandbits(128) k2 = z1 k3 = random.getrandbits(128) k = (k3 << 256) + (k2 << 128) + k1 kG = k*G r, _ = kG.xy() r = Z_n(r) k = Z_n(k) s = (z1 + r*d) / k sigs.append((r,s)) return sigs # original trilogy def sign_original(): d = bytes_to_long(flag2) sigs = [] for _ in range(3): # normal ECDSA k = random.getrandbits(384) kG = k*G r, _ = kG.xy() r = Z_n(r) k = Z_n(k) s = (z2 + r*d) / k sigs.append((r,s)) return sigs def sign(): sigs1 = sign_prequel() print(sigs1) sigs2 = sign_original() print(sigs2) if __name__ == "__main__": sign()
ECDSA の鍵としてフラグの前半部 (sign_prequel) と後半部 (sign_original) が使われています。前半部→後半部とフラグを求めていきます。
sign_prequel 番目の署名について、 という式が成り立ちます。 は128 bitの乱数です。署名は80個得られています。
この式の両辺にある定数をかけることで左辺を小さい値にすることができれば、 Hidden Number Problem (HNP) として LLL で を求めることができます。まずはこのような定数を探しました。ソルバーとして rkm-san の実装 を使いました。
# [x, k1, k2] mat mat = matrix(ZZ, 3, 3) mat[0, 0] = 2**256 mat[1, 0] = -order mat[0, 1] mat[2, 1] = -order mat[0, 2] = 1 lb = [0, 0, 0] ub = [2**190] * 3 res = solve(mat, lb, ub) x = int(res[2][0])
↑で求めた を両辺にかけることで、左辺は 程度の大きさになります。あとは HNP を解きます。
# [z1, d, l1, l2, ...] mat mat = matrix(ZZ, 82, 82) for i in range(80): r, s = sigs1[i] mat[0, i] = (pow(s, -1, order) - 2**128) * x mat[1, i] = r * pow(s, -1, order) * x mat[i+2, i] = -order mat[0, 80] = 1 mat[1, 81] = 1 lb = [0] * 82 ub = [2**(190+128)] * 80 + [2**128, 2**(48*8)] res = solve(mat, lb, ub) z1 = int(res[2][0]) d1 = int(res[2][1])
これで前半部のフラグ (d1) が求まりました。
sign_original ECDSA の部分を見ると署名は3個しかなく、の乱数も384 bitで生成しているので、前半の手法を使うことができません。普通だったらこの条件で を求めることはできません。
というわけで前半→後半と別れている意味を考えます。乱数生成に注目すると、前半の時点で bits 分が getrandbits で生成されています。これは32 bit整数640個分に相当します。 getrandbits は Mersenne Twister を使用していますが、この乱数生成方法は32 bit整数が624個がわかっているとき後続の乱数が再現されます。つまり今回の問題のケースでは、後半部分の の値を乱数生成の観点からリークさせることができます。 がわかれば当然 も求まります。
rands = [] for i in range(80): r, s = sigs1[i] k = int((z1 * pow(s, -1, order) + r * pow(s, -1, order) * d1) % order) rands.append(k % 2**128) k = k // 2**256 assert k < 2**128 rands.append(k) rands32 = [] for rand in rands: for i in range(4): rands32.append(int(rand % 2**32)) rand = rand // 2**32 # https://inaz2.hatenablog.com/entry/2016/03/07/194147 def untemper(x): x = unBitshiftRightXor(x, 18) x = unBitshiftLeftXor(x, 15, 0xefc60000) x = unBitshiftLeftXor(x, 7, 0x9d2c5680) x = unBitshiftRightXor(x, 11) return x def unBitshiftRightXor(x, shift): i = 1 y = x while i * shift < 32: z = y >> shift y = x ^^ z i += 1 return y def unBitshiftLeftXor(x, shift, mask): i = 1 y = x while i * shift < 32: z = y << shift y = x ^^ (z & mask) i += 1 return y mt_state = tuple([int(untemper(x)) for x in rands32[:624]] + [int(624)]) random.setstate((int(3), mt_state, None)) for _ in range(640 - 624): random.getrandbits(32) k1 = random.getrandbits(384) k2 = random.getrandbits(384) kG1 = k1*G kG2 = k2*G r1, s1 = sigs2[0] r2, s2 = sigs2[1] assert kG1.xy()[0] == r1 assert kG2.xy()[0] == r2 d2 = int((s1 * k1 - s2 * k2) * pow(r1 - r2, -1, order)) long_to_bytes(d1) + long_to_bytes(d2)
SECCON{New_STARWARS_Spin-Off_The_Book_Of_Boba_Fett_Will_Premiere_On_December_29-107c360aab}
XXX
14 solves
task.sageimport os flag = os.getenv("FLAG", "fake{fakeflag_blahblah}") x = int.from_bytes(flag.encode(), "big") p = random_prime(1 << int(x.bit_length() * 2.5)) Fp = GF(p) params = [] while len(params) != 6: try: y = randint(2, x) a = randint(2, p-1) b = (y^2 - (x^3 + a*x)) % p EC = EllipticCurve(Fp, [a, b]) EC(x, y) params.append([a, b]) except ValueError: pass print(p) print(params)
フラグの値を をしたとき、ある をランダムに生成し、 が楕円曲線 上に乗るような がランダムに生成されます。 が6種類与えられています。
番目の楕円曲線を とおきます。 番目と 番目の式の差を考えると、 となります。ここで各項のbit数を見てみると、左辺が右辺の各項に対して小さいことがわかります。そのため、 LLL を使って が求められそうです。
solve.pyfrom itertools import combinations from Crypto.Util.number import long_to_bytes mat = matrix(ZZ, 17, 17) for k, (i, j) in enumerate(combinations(range(6), 2)): ai, bi = params[i] aj, bj = params[j] mat[0, k] = ai - aj mat[1, k] = bi - bj mat[k+2, k] = -p mat[0, 15] = 1 mat[1, 16] = p res = mat.LLL() C = mat.solve_left(res) for c in C: print(long_to_bytes(abs(round(c[0]))))
SECCON{9dd4e461268c8034f5c8564e155c67a6}
cerberus
16 solves
problem.pyimport base64 from Crypto.Cipher import AES from Crypto.Random import get_random_bytes from Crypto.Util.Padding import pad, unpad from Crypto.Util.strxor import strxor from flag import flag import signal key = get_random_bytes(16) block_size = 16 # encrypt by AES-PCBC # https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Propagating_cipher_block_chaining_(PCBC) def encrypt(m): cipher = AES.new(key, AES.MODE_ECB) # MODE_PCBC is not FOUND :sob: :sob: m = pad(m, 16) m = [m[i : i + block_size] for i in range(0, len(m), block_size)] iv = get_random_bytes(16) c = [] prev = iv for m_block in m: c.append(cipher.encrypt(strxor(m_block, prev))) prev = strxor(c[-1], m_block) return iv, b"".join(c) # decrypt by AES-PCBC # https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Propagating_cipher_block_chaining_(PCBC) def decrypt(iv, c): cipher = AES.new(key, AES.MODE_ECB) # MODE_PCBC is not FOUND :sob: :sob: c = [c[i : i + block_size] for i in range(0, len(c), block_size)] m = [] prev = iv for c_block in c: m.append(strxor(prev, cipher.decrypt(c_block))) prev = strxor(m[-1], c_block) return b"".join(m) # The flag is padded with 16 bytes prefix # flag = padding (16 bytes) + "SECCON{..." signal.alarm(3600) ref_iv, ref_c = encrypt(flag) print("I teach you a spell! repeat after me!") print(base64.b64encode(ref_iv + ref_c).decode("utf-8")) while True: c = base64.b64decode(input("spell:")) iv = c[:16] c = c[16:] if not c.startswith(ref_c): print("Grrrrrrr!!!!") continue m = decrypt(iv, c) try: unpad(m, block_size) except: print("little different :(") continue print("Great :)")
AES の PCBC モード を使った Padding Oracle Attack 問題。 PCBC モード初めて知った… 少々困る制約として、復号してもらう暗号の前半部分は、フラグを暗号化したものである必要があります。 フラグの暗号文が64bytesなことから4ブロックあることがわかります。
上記リンクの復号スキームの画像とにらめっこをしているとすぐわかることですが、 IV に対して何らかの値で xor をとると、復号結果の各ブロックそれぞれに対してその値の xor が加えられます。 この特徴を使うことで暗号文の最後のブロックに対して Padding Oracle Attack が適用でき、復号することができます。
よくある Padding Oracle Attack の問題では、最後のブロックを一つ求めては削っていくことで全ブロックの復号ができます。しかしこの問題では送信する暗号の前半部がフラグの暗号と一致している必要があり、最後のブロックを削るということができません。ひと工夫必要です。
今、 を ref_iv を使って復号してもらうことを考えます。最後のブロックの復号結果は となります。 であることを使うと となります。ここから最後のブロックの復号結果が と同じになるための iv を求めることができ、その状態から Padding Oracle Attack を使って を求められます。 これを繰り返すことで全てのブロックの復号結果をリークできます。
solve.pyfrom pwn import remote io = remote("cerberus.quals.seccon.jp", 8080) _ = io.recvline() c = b64decode(io.recvline().strip()) ref_iv = c[:16] ref_c = c[16:] def try_decrypt(iv, c): io.sendlineafter(b"spell:", b64encode(iv + c)) ret = io.recvline().strip().decode() if "little" in ret: return False elif "Great" in ret: return True else: io.close() raise ValueError dec = b"" iv = ref_iv for idx in range(16)[::-1]: print(idx) for i in range(256): tmp_iv = iv[:idx] + long_to_bytes(iv[idx] ^ i) + iv[idx+1:] if (idx == 15 and i == 0) or not try_decrypt(tmp_iv, ref_c): continue dec = long_to_bytes((16 - idx) ^ i) + dec print(dec) iv = iv[:idx] + long_to_bytes(iv[idx] ^ i) + iv[idx+1:] iv = iv[:idx] + xor(iv[idx:], long_to_bytes(16-idx), long_to_bytes(16-idx+1)) break print(dec) dec3 = dec iv = xor(iv, b"\x11", ref_c[-16:], ref_iv) dec = b"" for idx in range(16)[::-1]: print(idx) for i in range(256): tmp_iv = iv[:idx] + long_to_bytes(iv[idx] ^ i) + iv[idx+1:] if (idx == 15 and i == 0) or not try_decrypt(tmp_iv, ref_c+ref_c[:16]): continue dec = long_to_bytes((16 - idx) ^ i) + dec print(dec) iv = iv[:idx] + long_to_bytes(iv[idx] ^ i) + iv[idx+1:] iv = iv[:idx] + xor(iv[idx:], long_to_bytes(16-idx), long_to_bytes(16-idx+1)) break print(dec) dec0 = dec iv = xor(iv, b"\x11", ref_c[:16], ref_c[:16], dec) dec = b"" for idx in range(16)[::-1]: print(idx) for i in range(256): tmp_iv = iv[:idx] + long_to_bytes(iv[idx] ^ i) + iv[idx+1:] if (idx == 15 and i == 0) or not try_decrypt(tmp_iv, ref_c+ref_c[:32]): continue dec = long_to_bytes((16 - idx) ^ i) + dec print(dec) iv = iv[:idx] + long_to_bytes(iv[idx] ^ i) + iv[idx+1:] iv = iv[:idx] + xor(iv[idx:], long_to_bytes(16-idx), long_to_bytes(16-idx+1)) break print(dec) dec1 = dec iv = xor(iv, b"\x11", ref_c[16:32], ref_c[16:32], dec) dec = b"" for idx in range(16)[::-1]: print(idx) for i in range(256): tmp_iv = iv[:idx] + long_to_bytes(iv[idx] ^ i) + iv[idx+1:] if (idx == 15 and i == 0) or not try_decrypt(tmp_iv, ref_c+ref_c[:48]): continue dec = long_to_bytes((16 - idx) ^ i) + dec print(dec) iv = iv[:idx] + long_to_bytes(iv[idx] ^ i) + iv[idx+1:] iv = iv[:idx] + xor(iv[idx:], long_to_bytes(16-idx), long_to_bytes(16-idx+1)) break print(dec) dec2 = dec flag = dec0 + dec1 + dec2 + dec3 print(flag)
SECCON{v._.^v-_-v^._.^_S0und_oF_0rpHeUs_Aha~~}
日本のサーバーだからなのか、 Padding Oracle Attack 問なのに oracle が高速だったので待ち時間があまりなくストレスフリーでした。
CCC
17 solves
challenge.pyfrom Crypto.Util.number import bytes_to_long, getPrime, getRandomInteger, isPrime from secret import flag def create_prime(p_bit_len, add_bit_len, a): p = getPrime(p_bit_len) p_bit_len2 = 2*p_bit_len // 3 + add_bit_len while True: b = getRandomInteger(p_bit_len2) _p = a * p q = _p**2 + 3*_p*b + 3*b**2 if isPrime(q): return p, q def encrypt(p_bit_len, add_bit_len, a, plain_text): p, q = create_prime(p_bit_len, add_bit_len, a) n = p*q e = 65537 c = pow(plain_text, e, n) print(f"{n=}") print(f"{e=}") print(f"{c=}") print(f"{a=}") if __name__ == "__main__": encrypt(1024, 9, 23, bytes_to_long(flag))
RSA の公開鍵 について、 が成り立ちます。で は乱数で生成されています。 は1024bitで は691bitです。
手計算をすると と変形できます。 は十分小さいため無視して考えると と近似できます。 試しに自分で生成した について以上の近似がどれだけ成り立つか確認してみると、bit程度の誤差になりました。これは全探索するのに十分小さい値なので の正確な値を探索させました。
solve.pyfrom gmpy2 import iroot ap_b_approx = int(iroot(a * n, 3)[0]) for i in range(10000): tmp = ap_b_approx + i if tmp ** 3 - a * n < 0: continue root, exact = iroot(tmp ** 3 - a * n, 3) if exact: b = int(root) ap_b = tmp print(root) p = (ap_b - b) // a assert n % p == 0 q = n // p phi = (p - 1) * (q - 1) d = int(pow(e, -1, phi)) print(long_to_bytes(int(pow(c, d, n))))
SECCON{CCC_means_Cubic_root_and_the_CTF_I_learnt_a_lot_about_fermat's_factorization_method}
oOoOoO
26 solves
problem.pyimport signal from Crypto.Util.number import long_to_bytes, bytes_to_long, getPrime import random from flag import flag message = b"" for _ in range(128): message += b"o" if random.getrandbits(1) == 1 else b"O" M = getPrime(len(message) * 5) S = bytes_to_long(message) % M print("M =", M) print('S =', S) print('MESSAGE =', message.upper().decode("utf-8")) signal.alarm(600) ans = input('message =').strip().encode() if ans == message: print(flag) else: print("🧙")
o か O で構成された128文字の文字列の数値に対して、ある素数 で mod を計算したもの が与えられます。元の文字列を当てられればフラグが手に入ります。
o は 0x6f、O は 0x4f です。この文字列を数値で表すと となります。ここで は末尾から数えて番目が O のとき0, o のとき1となります。 と の関係性は と記せます。 問題の見方を変えると、集合 のどの部分集合の和を取ると になるかを求める問題となります。これは 部分和問題 です。
自分はこの問題を となる を LLL のような格子基底簡約アルゴリズムを使って解くことにしました。 mod のぶんは全探索しました (最大でも20-30種類程度しかない)。しかし LLL だと答えが出ず、 BKZ を使ってみると答えが得られました。 BKZ よく知らないんだよな…
solve.pyfrom pwn import remote io = remote("oooooo.quals.seccon.jp", 8000) io.recvuntil(b"M = ") M = int(io.recvline()) io.recvuntil(b"S = ") S = int(io.recvline()) res = 0 for i in range(128): res += 0x4f * 256**i res -= S res %= M target = -res % M cands = [] for i in range(128): cands.append(int(0x20 * 256**i % M)) def check(res): for r in res[0]: if abs(r) != 0 and abs(r) != 1: return False return True for j in range(20): print(j) mat = matrix(ZZ, 129, 129) for i in range(128): mat[i, i] = 1 for i in range(128): mat[i, 128] = cands[i] mat[128, 128] = -(j*M+target) weights = diagonal_matrix([1] * 128 + [12]) mat *= weights res = mat.BKZ() res /= weights if check(res): break dec = b"" for i in range(128): if res[0][i] == 0: dec = b"O" + dec elif abs(res[0][i]) == 1: dec = b"o" + dec print(dec) io.sendlineafter(b"message =", dec) print(io.recvline())
SECCON{Here_is_Huge-Huge_Island_yahhoOoOoOoOoOoO}
この問題がここまで解かれてるのすごい、 SECCON beginners でナップサック暗号が出たからなのだろうか。
pppp
70 solves
problem.sagefrom Crypto.Util.number import * from flag import flag p = getStrongPrime(512) q = getStrongPrime(512) n = p*q mid = len(flag) // 2 e = 65537 m1 = int.from_bytes(flag[:mid], byteorder='big') m2 = int.from_bytes(flag[mid:], byteorder='big') assert m1 < 2**256 assert m2 < 2**256 m = [[p,p,p,p], [0,m1,m1,m1], [0,0,m2,m2],[0,0,0,1]] # add padding for i in range(4): for j in range(4): m[i][j] *= getPrime(768) m = matrix(Zmod(p*q), m) c = m^e print("n =", n) print("e =", e) print("c =", list(c))
上三角行列を RSA で暗号化した問題。
上三角行列なので対角項の暗号文 は となります。 ( は未知の素数) であることを考慮すると、 で が求まります。 が求まったので RSA の復号ができるようになり、もともとの行列の対角にある も求まります。 これらの が素因数分解できればフラグは求まるのですが、流石にできないようになっていました ( のほうはできたけど はできなかった)。
次に や の値が の値を使ってどう表されるかを見てみると、 となります。 は上記の方法で求まるので も求まります。 という形なので、 で が求まります。 に注目すると同様の方法で も求まります。
solve.pyp = gcd(c[0][0], n) q = n // p c1 = c[1][1] c2 = c[2][2] phi = (p - 1) * (q - 1) d = int(pow(e, -1, phi)) m00 = int(pow(c[0][0], d, n)) m11 = int(pow(c1, d, n)) m22 = int(pow(c2, d, n)) m33 = int(pow(c[3][3], d, n)) tmp = 0 for i in range(e): tmp += pow(m22, i, n) * pow(m11, e-1-i, n) m12 = int(c[1][2] / tmp) tmp = 0 for i in range(e): tmp += pow(m33, i, n) * pow(m22, e-1-i, n) m23 = int(c[2][3] / tmp) print(long_to_bytes(gcd(m11, m12)) + long_to_bytes(gcd(m22, m23)))
SECCON{C4n_y0u_prove_why_decryptable?}