感想
FCCPC 国内13位(全体40位)
途中までいい感じだったんですけど僕がdual_summonでドハマりしたので失速しました(数弱)
赤ちゃんの世話しながらだったんですが、割と本戦に行きたかったので本格的にベビーシッター雇ったり、少し妻に多めに赤ちゃんの世話をさせてしまったり、いい年して24時間連続で起きていましたが普通に敗退したのでなんか俺何してんだろ...感すごいです。来年はちゃんと寝て本戦いきます。
実はSECCONに出るメンバーがそんなに集まっていなくて、ブーブーの本戦でお会いしたkusanoさんにダメ元で声をかけさせていただいたところいいよ!とのことだったので一緒に参加させてもらいました。
以下 Writeup
packed
UPX適用するやつね〜と適用したらカスのバイナリが現れ、わかんないからkusanoさんにお願いしたらUPXは罠だったらしい。ええ〜
reiwa_rot13
eが小さいから普通に累乗根求めるやつだと思って、チームメイトにsagemathのコード(ただし法が素数のときだけ動く)を投げ、迷惑をかける。 法が素数じゃなかったら普通にeはそんな小さくないね..となる。 rot13だけど、文字毎に折り返しが起きてるか、つまり桁ごとに+13するか-13を決める。みたいなことをすると、「xe%nと(x+a)e%nからxを求める」みたいな問題になるねという話をチームメイトとする。
RSAの一般的なテクはインターネットによくまとめられているので、一つ一つ使えそうなやつはないか精査していく。
Franklin-Reiter Related Message Attack とかいうやつがまさにそれじゃんとなり、実行するとdnjqygbmorがキーであることがわかったので、あとはチームメイトにフラグ復元してもらう。
https://project-euphoria.dev/blog/27-rsa-attacks/#franklin-reiter-related-message-attack
flag: SECCON{Vim_has_a_command_to_do_rot13.g?is_possible_to_do_so!!}
Jump
ARMいやすぎ〜。心を無にしてClaudeに逆アセンブリ結果をぶんなげ、すべてを解かせる。一回もgdbを実行することなくフラグをゲット。こんなんでいいのか?
flag: SECCON{5h4k3_1t_up_5h-5h-5h5hk3}
dual_summon
簡単枠なのに全然わからないとチームで話題に。皆さんが少し考察を進めるもののあまり進捗はなさそうなので、自分も取り組んで見ることに(コンテスト開始から7時間後くらいのこと)。結果としてそこから12時間以上ハマることになりました。
そもそもガロア体ってなんだよ。
まず全然わからないのだけれどチームメイトがこれ関連だろうな〜ということでほん怖AES-GCMという記事を共有してくれた。
Wikipediaを読んでも細かいところの処理が全然わからないのでpycryptodomeの実装を読みまくることに。
コメントに出てくる色々もよくわからないので
https://nvlpubs.nist.gov/nistpubs/legacy/sp/nistspecialpublication800-38d.pdf
も読むことに。上記PDFは死ぬほど読みやすく、細かいところもしっかり把握できるようになっていて仕様書って最高となる。
とりあえずタグの計算を平文Mを含む数式に落とし込むことができたので、あとは色々頑張って内部のキーとかを特定してごにょごにょすることに。
紆余曲折、徹夜でふやけた頭にはきついバグで数時間溶かすなど、いろいろあり、コンテスト開始から22時間経ったくらいに
SECCON{Congratulation!_you are_master_of_summonor!_you_can_summon_2_monsters_in_one_turn}
コード(Sage)
import socket import secrets from Crypto.Cipher import AES import time classSummoningAPI: def__init__(self, host=None, port=None): if host and port: self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.connect((host, port)) self.sock_file = self.sock.makefile('rwb', buffering=0) else: # ローカルテスト用 self.keys = [secrets.token_bytes(16) for _ inrange(2)] self.nonce = secrets.token_bytes(16) defrecv_until(self, delim=b'>'): """ delimまでの文字列を受信する"""ifnothasattr(self, 'sock'): return b'' data = b''whilenot data.endswith(delim): data += self.sock_file.read(1) return data defsummon(self, number, plaintext): """ Args: number (int): 1 or 2 (key number) plaintext (bytes): 16 bytes data Returns: tuple: (ciphertext, tag)"""ifhasattr(self, 'sock'): # リモート接続時 self.recv_until(b'>') self.sock_file.write(b'1\n') self.recv_until(b'>') self.sock_file.write(f"{number}\n".encode()) self.recv_until(b'>') self.sock_file.write(plaintext.hex().encode() + b'\n') # monster nameの行をスキップwhile b'tag(hex)'notin (line := self.sock_file.readline()): continue# tag値を取得 tag_hex = line.decode().split('=')[1].strip() tag = bytes.fromhex(tag_hex) returnNone, tag else: # ローカルテスト時 aes = AES.new(key=self.keys[number - 1], mode=AES.MODE_GCM, nonce=self.nonce) x, y = aes.encrypt_and_digest(plaintext) print(x, y) return x, y defdual_summon(self, plaintext): """ Args: plaintext (bytes): 16 bytes data that should produce same tag with both keys"""ifhasattr(self, 'sock'): self.recv_until(b'>') self.sock_file.write(b'2\n') self.recv_until(b'>') self.sock_file.write(plaintext.hex().encode() + b'\n') # フラグを含む応答を読む response = self.sock_file.readline().decode() if"master of summoner"in response: flag = self.sock_file.readline().decode().strip() print("Flag:", flag) return flag else: aes1 = AES.new(key=self.keys[0], mode=AES.MODE_GCM, nonce=self.nonce) aes2 = AES.new(key=self.keys[1], mode=AES.MODE_GCM, nonce=self.nonce) ct1, tag1 = aes1.encrypt_and_digest(plaintext) ct2, tag2 = aes2.encrypt_and_digest(plaintext) assert tag1 == tag2 print("Congrats") defclose(self): ifhasattr(self, 'sock'): self.sock.close() defexploit(api): """ GCMのtagが衝突する入力を総当たりで探す"""try: # ランダムな16バイトの入力を生成して試行 start_time = time.time() attempts = 0for _ inrange(2 ** 16): # 適当な試行回数 attempts += 1if attempts % 100 == 0: elapsed = time.time() - start_time print(f"Attempts: {attempts}, Time elapsed: {elapsed:.2f}s") test_input = secrets.token_bytes(16) _, tag1 = api.summon(1, test_input) _, tag2 = api.summon(2, test_input) if tag1 == tag2: print(f"Found collision after {attempts} attempts!") print(f"Input (hex): {test_input.hex()}") print(f"Tag (hex): {tag1.hex()}") return api.dual_summon(test_input) exceptExceptionas e: print(f"Error occurred: {e}") finally: api.close() returnFalsedefexploit(api): """ GCMのtagが衝突する入力を総当たりで探す"""# ランダムな16バイトの入力を生成して試行for _ inrange(2 ** 16): # 適当な試行回数 test_input = secrets.token_bytes(16) _, tag1 = api.summon(1, test_input) _, tag2 = api.summon(2, test_input) if tag1 == tag2: print(f"Found collision! Input: {test_input.hex()}") api.dual_summon(test_input) returnTruereturnFalse# SageMath# Define GF(2^128) with the GCM reduction polynomialfrom Crypto.Util.strxor import strxor X = GF(2).polynomial_ring().gen() poly = X ** 128 + X ** 7 + X ** 2 + X ** 1 + 1 F.<a> = GF(2 ** 128, name='a', modulus=poly) R.<H, E, Q, E_2, H_2, W_2, Q_2, M> = PolynomialRing(F) defto_F(n): """Convert integer to field element"""return F([int(b) for b informat(n, '0128b')]) deffrom_F(fe): """Convert field element to integer"""returnint(''.join(['1'if b else'0'for b in (list(fe.polynomial()) + [0] * 128)[:128]]), 2) defsolve_for_x(tag1_0, tag2_0, tag1_1, tag2_1): """ Solve for x given tags for PLAINTEXT=0 and PLAINTEXT=1 Arguments should be integers representing the 128-bit values"""# Convert all inputs to field elements tag1_0 = to_F(tag1_0) tag2_0 = to_F(tag2_0) tag1_1 = to_F(tag1_1) tag2_1 = to_F(tag2_1) # Using the fact that PLAINTEXT is either 0 or 1, we can try linear combinations# plaintext_x = α•0 + β•1 where α,β ∈ GF(2)# Due to AES-GCM's linearity in GF(2^128), if plaintext_x is a linear combination,# the corresponding tag will be the same linear combination of the tagsfor alpha_int inrange(2): for beta_int inrange(2): alpha = F(alpha_int) beta = F(beta_int) # Calculate expected tags for this combination tag1_x = alpha * tag1_0 + beta * tag1_1 tag2_x = alpha * tag2_0 + beta * tag2_1 if tag1_x == tag2_x: print(tag1_x) # Found a collision!# The plaintext will be the same linear combination of 0 and 1 plaintext = beta_int # since alpha•0 + beta•1 = beta•1 = betareturn plaintext, from_F(tag1_x) returnNone, None# Example usage:"""# Convert your tag values to integers firsttag1_0 = bytes_to_int(tag1_0_bytes)tag2_0 = bytes_to_int(tag2_0_bytes)tag1_1 = bytes_to_int(tag1_1_bytes)tag2_1 = bytes_to_int(tag2_1_bytes)plaintext, tag = solve_for_x(tag1_0, tag2_0, tag1_1, tag2_1)if plaintext is not None: print(f"Found solution: plaintext = {plaintext}") print(f"Tag = {hex(tag)}")"""from Crypto.Cipher import AES import secrets import signal from Crypto.Util.number import bytes_to_long, long_to_bytes signal.alarm(300) api = SummoningAPI(host="dual-summon.seccon.games", port=2222) _, tag0_2 = api.summon(2, long_to_bytes(0, 16)) _, tag1_2 = api.summon(2, long_to_bytes(1, 16)) _, tag0 = api.summon(1, long_to_bytes(0, 16)) _, tag1 = api.summon(1, long_to_bytes(1, 16)) W = to_F(bytes_to_long(b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x80')) T_0 = to_F(bytes_to_long(tag0)) T_1 = to_F(bytes_to_long(tag1)) T_0_2 = to_F(bytes_to_long(tag0_2)) T_1_2 = to_F(bytes_to_long(tag1_2)) M_0 = to_F(0) M_1 = to_F(1) M_2 = to_F(2) defsolve_H(f): # GF(2^128)上の多項式環を設定 R.<a> = PolynomialRing(GF(2)) # AES-GCMで使用される既約多項式 modulus = a ^ 128 + a ^ 7 + a ^ 2 + a + 1# 有限体を構築 F.<a> = GF(2 ^ 128, modulus=modulus) # 2次方程式の係数を設定# H^2の係数 coeff_h2 = a ^ 127# Hの係数(この場合は0) coeff_h = 0# 定数項 constant = f.constant_coefficient() # 2次方程式を解く# GF(2^n)では、x^2 + ax + b = 0 の解は# x = √(b/a^2) + √(b/a^2) * √(a^2/a) if a ≠ 0# この場合、H^2の係数が非ゼロで、Hの係数が0なので、# H = √(constant/coeff_h2)# 定数項を係数で割る ratio = constant / coeff_h2 # 平方根を計算# GF(2^n)では、平方根は Frobenius 自己準同型を使用defsqrt_gf2n(x): # GF(2^n)では、平方根は x^(2^(n-1)) n = F.degree() return x ^ (2 ^ (n - 1)) solution = sqrt_gf2n(ratio) # GF(2)では、√x の解は ±√x だが、特性2の体では + と - が同じなので、解は1つ# 検証 verification = solution ^ 2 * coeff_h2 + constant assert verification == 0return solution F_0 = (M_0 + E) * H ^ 2 + W * H + Q - T_0 F_1 = (M_1 + E) * H ^ 2 + W * H + Q - T_1 F_0_2 = (M_0 + E) * H_2 ^ 2 + W * H_2 + Q_2 - T_0_2 F_1_2 = (M_1 + E) * H_2 ^ 2 + W * H_2 + Q_2 - T_1_2 H1 = solve_H(F_0 + F_1) H2 = solve_H(F_0_2 + F_1_2) COND1 = (M + E) * H ^ 2 + W * H + Q - ((M + E_2) * H_2 ^ 2 + W * H_2 + Q_2) COND2 = (E) * H ^ 2 + W * H + Q - T_0 + ((E_2) * H_2 ^ 2 + W * H_2 + Q_2 - T_0_2) M_formula = ((COND1 - COND2).subs({H: H1, H_2: H2})) coef_M = M_formula.coefficient(M) # Mの係数を取得 const_term = M_formula.constant_coefficient() # 定数項を取得print(const_term) M_sol = (const_term / coef_M).constant_coefficient() M_bytes = long_to_bytes(from_F(M_sol), 16) print(api.dual_summon(M_bytes))
JavaScrypto
ホントぽにょごめん (参考:
) 。まさかこれが決め手で落ちるとは