TPCとしてSECCON 2023 Finalsに参加し、domestic 5位でした。writeupってヤツを書こうと思います。
全体的にかなり雰囲気で数式を用いているため、雰囲気でお読みください。
muck-a-mac
問題概要
ChaCha20-Poly1305によって暗号化された値を100回連続で復元するとフラグがもらえます。
それぞれの値は次のように生成されています
self.key = secrets.token_bytes(32) self.nonce = secrets.token_bytes(12) self.plaintext = b"" for i in range(4): self.plaintext += b"\x00"*13 + secrets.token_bytes(3) self.aad = b"" for i in range(16): self.aad += bytes([random.choice(bytes(string.printable, 'ascii'))])
また、次の六種類のクエリを各一回ずつまで好きな順番で質問することができます。
def get_ciphertext(self, plaintext): ciphertext, _ = encrypt(self.key, self.aad, plaintext, self.nonce) return ciphertext def default(self): _, mac = encrypt(self.key, self.aad, self.plaintext, self.nonce) return mac def change_aad(self, aad): _, mac = encrypt(self.key, aad, self.plaintext, self.nonce) return mac def xor_plaintext(self, target): _, mac = encrypt(self.key, self.aad, strxor(self.plaintext, target), self.nonce) return mac def change_length(self,l,r): _, mac = encrypt(self.key, self.aad, self.plaintext[l:r], self.nonce) return mac
解法
コンテスト開始直後にすべての問題を流し読みした結果、この問題が一番競プロer向きに見えたので突撃しました。
とりあえずChaCha20-Poly1305について調べてみます。
https://tex2e.github.io/blog/crypto/chacha20poly1305
- ChaCha20 で を作る
- に謎の処理をして を作る
のようにして ciphertext と mac を生成しているらしいです。
key や nonce に関する情報は得られないので、ChaCha20パートは無視して (R, S, K) をただの乱数として扱うことにします。
また、cについては、aad が16ビットで plaintext が64ビットであることから、
c = (aad||01, ciphertext[0:16]||01, ciphertext[16:32]||01, ciphertext[32:48]||01, ciphertext[48:64]||01, len(aad)||len(ciphertext)||01)
のようになっています(ここで、a||b はバイト列の結合)
各クエリの使い方について考えます。
- get_ciphertextに00..00を投げると ciphertext = xor(K, 00..00) = K から K が得られます。 K より有益な情報は特に得られないので最初にこれをしてよさそうです。
- change_add に 00..00 を投げてdefaultで得られたmacと差分をとるとが得られます。
- change_lengthに[0,63]を投げるとcの後ろ2チャンクのみが変わるため、defaultで得られたmacと差分をとるとrについての二次式ができます。二次の係数は判明していませんが、変化するのは一バイトのみのため全探索すると256通りrの候補が得られることになります。
ここで、 を用いてaadを復元することができます。間違ったrに対してaadがrintableな文字列になる確率は十分低いため、これによってrを特定することができます。
後はxor_plaintextを使ってうまくplaintextを特定します。plaintextの各チャンクが小さいためlattice attackがしたいです。
また、sが特定できていないので、defaultとの差分でうまくplaintextのみの多項式を作ります。
plaintextで0でない部分を1で埋めた次のようなバイト列をxor_plaintextに投げることを考えます。
00000000000000000000000000ffffff00000000000000000000000000ffffff00000000000000000000000000ffffff00000000000000000000000000ffffff
すると、差分 に対応する は次のような値になっていると考えられます。( から も同様)
これは上13ビットが相殺され 程度になるため、次のような格子を構成して LLL をすることによって復元できます。(係数はかなり適当です)
(ここで、 )
また、c'_iが分かると、 であることから、
と復元できます。
これでplaintextが復元できました。
順番に実装してみるとうまく動きます。(たまに失敗します。)
また、なぜかいろんなところで各値がほしい値より とかズレたりするようなので、強引にズレる分を探索してカバーしました。
すべて実装できた時点で一日目の競技が残り10分程度でした。
急いで実行したものの、数十ラウンドまでいったところで失敗してしまいます。最終的に91ラウンド目まで成功したところでサーバーが閉じてしまいました。
二日目、最初に実行して提出したもののFirst Bloodはとられてしまいました。かなしい…
コード
def b2i(b): return int.from_bytes(b, "little") def solve(chal): key = chal.get_ciphertext(b"\0" * 64) print(f"{key = }") d_mac = chal.default() print(f"{d_mac = }") p = (1<<130) - 5 cadd = chal.change_aad(b"\0" * 16) print(f"{cadd = }") hint = (b2i(d_mac) - b2i(cadd)) % p # aad * r^6 print(f"{hint = }") h2 = chal.change_length(0, 63) h3 = b2i(d_mac) - b2i(h2) print(f"{h3 = }") crs = set() for i in range(1, 256): for k in range(-10, 10): aa = i << 120 bb = 0x10000000000000000 cc = -(h3+k*(1<<128)) a1 = (-bb+pow(bb*bb-4*aa*cc, (p+1)//4, p))*pow(2*aa, -1, p)%p a2 = (-bb-pow(bb*bb-4*aa*cc, (p+1)//4, p))*pow(2*aa, -1, p)%p if (aa*a1*a1+bb*a1+cc)%p == 0: crs.add(a1) if (aa*a2*a2+bb*a2+cc)%p == 0: crs.add(a2) found = False bcr = None for cr1 in crs: for k in range(-10, 10): x = pow(cr1, -6, p) * (hint+k*0x100000000000000000000000000000000) % p if sum(ch in string.printable.encode() for ch in long_to_bytes(int(x))[1:]) >= 15: print(sum(ch in string.printable.encode() for ch in long_to_bytes(int(x))) >= 15, long_to_bytes(int(x))) if all(ch in string.printable.encode() for ch in long_to_bytes(int(x))): print(long_to_bytes(int(x)), hex(x)) bcr = cr1 found = True b = (b2i(d_mac) - b2i(chal.xor_plaintext(b"\0\0\0\0\0\0\0\0\0\0\0\0\0\xff\xff\xff" * 4))) % p ms = 1000 bans = [] def fix(x, i): v = (0xffffff-((x^^0xffffff)-x))//4 if x > 0: return v ^^ b2i(key[i*16+13:i*16+16]) ^^ 0x800000 else: return v ^^ b2i(key[i*16+13:i*16+16]) for k in range(5): for kk in range(-5,5): nb = int(b) nb =nb-0x100000000000000000000000000000000*k-kk nb *= pow(0x100000000000000000000000000, -1, p) nb %= p M = Matrix([ [2^300*int(p), 0, 0, 0, 0, 0], [2^300*int(nb), 2^128, 0, 0, 0, 0], [2^300*int(bcr**5%p), 0, 2^100, 0, 0, 0], [2^300*int(bcr**4%p), 0, 0, 2^100, 0, 0], [2^300*int(bcr**3%p), 0, 0, 0, 2^100, 0], [2^300*int(bcr**2%p), 0, 0, 0, 0, 2^100], ]) M = M.LLL() for row in M: if row[0] != 0 or abs(row[1]) != 0x100000000000000000000000000000000: continue ans = [row[2]//2^100, row[3]//2^100, row[4]//2^100, row[5]//2^100] siz = sum([int(ans[0]).bit_length(), int(ans[1]).bit_length(), int(ans[2]).bit_length(), int(ans[3]).bit_length()]) ans2 = b"" for i in range(4): if row[1] == 0x100000000000000000000000000000000: ans[i] *= -1 fv = int(fix(ans[i], i)) ans2 += b"\0" * 13 + int.to_bytes(fv, 3, "little") if ms > siz: ms = siz bans = ans2 return bans ss = remote("example.com", int(0)) for i in range(100): chal = Challenge() res = solve(chal) print(res) chal.answer(res) print(ss.recvline()) print(ss.recvline()) print(ss.recvline())
まとめ
去年もKOSENセキュコン枠で参加したんですが、その時は最下位でした。それと比べると今年はかなり成長したのではないかなと思います。
来年はもうちょっとCryptoを解きたいです。がんばります。