こんにちは。今日はAlpacaHack Round 12 (Crypto)でした。
AlpacaHackは6時間4問の個人戦CTFを不定期で開催しているCTFプラットフォームです。程よいボリュームであること、毎回良質な問題が提供されることから今一番贔屓にしています。
今回はyu212 さんによるCrypto回で、私はCTFではCryptoが特に好きなので参加して、4問中3問を解いて7位でした。目標は4時間*1で4問のつもりだったのに実際は3問と及ばず残念ですが、せっかくなのでwriteupです。今回は個人的にははじめて問題に取り組む中でAIサービスをうまく使うことができたので、何をAIに助けてもらったのか、なども記録します。
RSARSARSARSARSARSA
問題は非常にシンプルです。
from math import gcd import os from Crypto.Util.number import getPrime, bytes_to_long e = 19 while True: p = getPrime(2048) q = getPrime(2048) if gcd((p - 1) * (q - 1), e) == 1: break n = p * q flag = os.environ.get("FLAG", "Alpaca{**** REDACTED ****}") assert len(flag) == 26 and flag.startswith("Alpaca{") and flag.endswith("}") m = bytes_to_long((flag * 1337).encode()) c = pow(m, e, n) print(f"{n = }") print(f"{e = }") print(f"{c = }")
RSAの問題で、以下の要素があります。
が小さいことで素朴に
乗根をとるアプローチや、
乗を含む多項式の根を求めるアプローチを想像します。今回は平文が大きいので前者のアプローチは棄却しました。
一方、後者のアプローチは平文の構造がわかっていることもあり有力と感じました。 RSAで一部の値がわかっている時 - crypto-writeup-publicの「eが小さく、mの大部分がわかっている時」のアプローチですね。
素朴に平文をエミュレートしてやればよく、flagは未知部分をと置いて
prefix*256^19 + x*256 + suffix
と書けますから、あとはこれを1337回繰り返すようにして乗すれば
相当の式になります。結局
に関する19次の式に落ち着くので、Coppersmith法で解けます。
こういう感じですね
f = open("output.txt") n = int(f.readline().strip().split(" = ")[1]) e = int(f.readline().strip().split(" = ")[1]) c = int(f.readline().strip().split(" = ")[1]) prefix = int(b"Alpaca{".hex(), 16) suffix = int(b"}".hex(), 16) PR.<x> = PolynomialRing(Zmod(n)) f = 0 for _ in range(1337): f = f*256**26 + (prefix*256**19 + x*256 + suffix) f = (f**e - c).monic() roots = f.small_roots(X=256**18, beta=0.5) flag = b"Alpaca{" + int(roots[0]).to_bytes(18, "big") + b"}" print(flag)
……というのはCTF終了後だからいえることで、なんか競技中はバグらせまくって大苦戦しました。素朴に筋肉が衰えていたのだと思いますが……。
↑の方針を立てたは良いのですが、なんか実装をミスってが
次になって困り果て、AIに聞きまくったところ、3回目くらいで以下のようなコードを出してくれました。式をmonicにして、Coppersmith法のパラメータを調整するところだけ人間がやったら解けるところまでできていたので、本番はそれで解きました。
long_flag_poly = prefix_val_part * (R**(unknown_len + len(suffix_bytes))) + \ x * (R**len(suffix_bytes)) + \ suffix_val_part S = (pow(R, flag_len * 1337) - 1) // (pow(R, flag_len) - 1) m_poly = long_flag_poly * S f_poly = m_poly^e - c
まだ微妙にこの立式のことを理解してないのですが、やってることは同じなはず。なんかは等比数列の和らしいです。私が
for
でやってることを数学的にまとめるとこうなるんでしょうね。
OTEC
こちらもシンプルな問題です
import os import signal import secrets from fastecdsa.curve import secp256k1 from fastecdsa.point import Point from Crypto.Cipher import AES from Crypto.Util.Padding import pad from Crypto.Util.number import long_to_bytes signal.alarm(60) flag = os.environ.get("FLAG", "Alpaca{**** REDACTED ****}").encode() G = secp256k1.G a = secrets.randbelow(secp256k1.q) A = a * G print(f"{A.x = }") print(f"{A.y = }") x, y = map(int, input("B> ").split(",")) B = Point(x, y, secp256k1) k0 = long_to_bytes((a * B).x, 32) k1 = long_to_bytes((a * (B - A)).x, 32) def encrypt(message, key): return AES.new(key, AES.MODE_ECB).encrypt(pad(message, 16)) print(encrypt(flag[0::3], k0).hex()) print(encrypt(flag[1::3], k1).hex()) print(encrypt(flag[2::3], bytes(c0 ^ c1 for c0, c1 in zip(k0, k1))).hex())
楕円曲線を用いた紛失通信(Obilivious Transfer)が実装されています。紛失通信というのは送信者がn種類の値を暗号化して送り、受信者がそのうちのどれか1つのみを復号可能であって、送信者や他者からはどちらを復号できたかは不明、という性質をもつプロトコルです。そして、CTFでは大体受信者となって、なんとかしてすべての値を復号せよ、という問題が出題されます。
今回の問題では以下のような流れになっています。
- 送信者が
を計算して
を渡してくれるので
- 受信者は適当に
を決めて
を送ると
- 送信者が
,
を計算し、それぞれのx座標を
として
および
を鍵として3種類の暗号文を送る
とりあえずこういう問題がでたら、とりあえず素朴にやって値1つを復号できることを確認します。
今回の場合は本当に適当なを選ぶと、
の導出に使われる
は
と変形できるので受信者側で計算できて、
で暗号化されている値が復号できます。一方このとき
は
から算出されますが、
がわからないので
は計算できず求められません。
を求めたい場合は
ということにすると
となって、
は受信者側わかります。このとき
で、やはり
が計算できず
は求められません。
こうしてみるとなんとなく紛失通信っぽくなっていて、を同時に求めることは不可能そうに見えます。フラグは通信を超えて固定なので2回通信すればそれぞれのケースで
または
がわかり、フラグ全体の2/3までは求められそうですが、困りました。
ここでXORの性質に思いを馳せると*2、の値がわかるのは
および
の両方がわかっているときと、
が満たされて
となっているときです。
同時にはわからないので、わからないにせよ
となるようなケースを作ることを目指します。
素朴に立式して です。これを整理すると
から
となって、これはこまりました。受信者が操作可能なのは
だけなのに、式を整理すると
は消えてしまいます。つまり
となるケースは作れません。
ここでめげずに、 は楕円曲線上の点のx座標から計算されていることを思い出し、さらに
であることまで思い出すと*3、もう一つの式
が立式できます。こちらは整理すると
となり、両辺を比較すると
とすれば
のx座標が等しくなって、
が実現できそうということがわかります。
ということで実装するとこうです。この問題ではAIに頼ることはありませんでした。自分で解けたので。
p = 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f K = GF(p) a = K(0x0000000000000000000000000000000000000000000000000000000000000000) b = K(0x0000000000000000000000000000000000000000000000000000000000000007) E = EllipticCurve(K, (a, b)) G = E(0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798, 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8) q = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141 * 0x1 E.set_order(q) from ptrlib import Socket from Crypto.Util.number import long_to_bytes from Crypto.Util.Padding import unpad from Crypto.Cipher import AES def decrypt(message, key): return unpad(AES.new(key, AES.MODE_ECB).decrypt(message), 16) HOST = "34.170.146.252" PORT = 62340 sock = Socket(HOST, PORT) Ax = int(sock.recvlineafter(" = ").strip().decode()) Ay = int(sock.recvlineafter(" = ").strip().decode()) A = E((Ax, Ay)) sock.sendlineafter("B> ", "{},{}".format(G.x(), G.y())) k0 = long_to_bytes(int(A.x()), 32) print("k0 = {}".format(k0.hex())) c0 = bytes.fromhex(sock.recvline().strip().decode()) m0 = decrypt(c0, k0) sock.close() sock = Socket(HOST, PORT) Ax = int(sock.recvlineafter(" = ").strip().decode()) Ay = int(sock.recvlineafter(" = ").strip().decode()) A = E((Ax, Ay)) B = A+G sock.sendlineafter("B> ", "{},{}".format(B.x(), B.y())) k1 = long_to_bytes(int(A.x()), 32) print("k1 = {}".format(k1.hex())) _ = sock.recvline() c1 = bytes.fromhex(sock.recvline().strip().decode()) m1 = decrypt(c1, k1) sock.close() sock = Socket(HOST, PORT) Ax = int(sock.recvlineafter(" = ").strip().decode()) Ay = int(sock.recvlineafter(" = ").strip().decode()) A = E((Ax, Ay)) r = (q+1) / K(2) B = r*A assert 2*B == A sock.sendlineafter("B> ", "{},{}".format(B.x(), B.y())) k2 = long_to_bytes(0, 32) _ = sock.recvline() _ = sock.recvline() c2 = bytes.fromhex(sock.recvline().strip().decode()) m2 = decrypt(c2, k2) sock.close() print(b"".join(bytes([a, b, c]) for a, b, c in zip(m0, m1 + b"\0", m2 + b"\0")))
Flag Sharing
こちらも非常にシンプルな問題です。
import os import secrets from Crypto.Util.number import getPrime, bytes_to_long N = 10 def random_poly(t): return [secret] + [secrets.randbelow(p) for _ in range(t - 1)] def evaluate(f, x): return sum(c * pow(x, i, p) for i, c in enumerate(f)) % p p = getPrime(512) flag = os.environ.get("FLAG", "Alpaca{************* REDACTED *************}") assert len(flag) == 44 and flag.startswith("Alpaca{") and flag.endswith("}") secret = bytes_to_long(flag.encode()) xs = [secrets.randbelow(p) for _ in range(N)] f = random_poly(N) shares = [evaluate(f, x) for x in xs] print(f"{p = }") print(f"{xs = }") for i, share in enumerate(shares, 1): print(hex(share)[:-i] + "?" * i)
シャミアの秘密分散が(10, 10)で実装されていますが、シェアの一部が欠けています。どういう欠け方をしているかについては、こう↓です。末尾の4, 8, 12, 16, ..., 40ビットが欠けてます。
一見してなかなか大変そうな問題だと思ったのですが、問題のスクリプトをAIに投げてありえそうなアプローチを思いつくだけ列挙してと依頼したところ、LLLを利用する方法だけが提案されました。そこに表示されている式がなんとなくあってそうかつ取り組めそうだったので、この方針を採用しました。
LLLでやるときは、何らか既存の名前がついている問題((extended) Hidden Number ProblemやApproximate GCD)に当てはめてその問題用の格子を作るケースと、とにかく(不)等式を立式してInequality Solving with CVP - crypto-writeup-publicに解いてもらうケースがありますが、今回は後者でできそうでした。
Inequality Solving with CVPの使い方については上記のリンクや kurenaif さんによる以下の投稿を参考にしてください。
SECCON CTFの Tidal waveの author's writeupでも使用している Inequality Solving with CVPというライブラリの紹介です!
— kurenaif🪄🗝 (@fwarashi) 2024年12月2日
一見すごく難しそうに見えるのですが意外と簡単に使えるのでおすすめです!
upsolveでお試しください!https://t.co/mLnN0LnsLP pic.twitter.com/0hMY6vaqgq
さて、立てる不等式を考えていきます。今回のケースでは上のある秘密の多項式
があって、ここに適当な値
を次々に当てはめてシェア
を計算しています。
未知数は及び、
を
上に収めるための適当な整数
です。
というイメージですね。計算結果は
となってほしいですが、先述の通りシェア
は最大40bit欠けていますから、ここを不等式として
の値がありうる
の最小値と最大値の間に収まるようバウンドを設定します。
また、については
という不等式も立ちますのでこれも入れておきます。特にフラグである
は制約によって上限と下限がもっと狭められるので、それも設定します。
ということでこういう行列が作れました。
min_flag = int(("Alpaca{" + " "*(44 - 8) + "}").encode().hex(), 16) max_flag = int(("Alpaca{" + "~"*(44 - 8) + "}").encode().hex(), 16) M = matrix(ZZ, [ [1 if i == j else 0 for i in range(N)] + [0] * N for j in range(N) ] + [ [pow(xs[j], i, p) for i in range(N)] + [-p if i == j else 0 for i in range(N)] for j in range(N) ]).T lb = [ min_flag ] + [0] * (N - 1) + ys ub = [ max_flag ] + [p] * (N - 1) + [y + ((1 << 4*i) - 1) for i, y in enumerate(ys, 1)]
伝わってるか心配なので画像でも……
ソリューションスクリプト全体は以下のようになりました。コメントにあるように求まったフラグは微妙に壊れてたんですが、きれいに求める方法はわからなかったので手で推測して当てました。
import ast N = 10 f = open("./output.txt") p = ast.literal_eval(f.readline().strip().split(" = ")[1]) xs = ast.literal_eval(f.readline().strip().split(" = ")[1]) ys = [] for _ in range(N): y = ast.literal_eval(f.readline().strip().replace("?", "0")) ys.append(y) min_flag = int(("Alpaca{" + " "*(44 - 8) + "}").encode().hex(), 16) max_flag = int(("Alpaca{" + "~"*(44 - 8) + "}").encode().hex(), 16) M = matrix(ZZ, [ [1 if i == j else 0 for i in range(N)] + [0] * N for j in range(N) ] + [ [pow(xs[j], i, p) for i in range(N)] + [-p if i == j else 0 for i in range(N)] for j in range(N) ]) lb = [ min_flag ] + [0] * (N - 1) + ys ub = [ max_flag ] + [p] * (N - 1) + [y + ((1 << 4*i) - 1) for i, y in enumerate(ys, 1)] for row in M.T: for x in row: print(Integer(x).nbits(), end="\t") print() load("./rkm.sage") _, _, sol = solve(M.T, lb, ub) print(bytes.fromhex(hex(sol[0])[2:]))
AIに方針を教えてもらって自分で解法実装していい感じで解けたと思います。
Zero-sum game
解けませんでした。いくら「離散対数問題を解かないといけないからBSGSとか実装してもらえませんか」と言ってもAIがMeet-in-the-Middleの方針に固執したり、「Nimberで逆数を求める方法を教えて下さい」と言ったらでinverseしたら良いと言われたのですが実験してみたらうまくいかずで、進み方がわからなかった。
そもそも計算量を落とすためにはもっと工夫する必要がありそうでしたが、私の知識もたりなかったし、AIもうまく方針をいろんな方面から立てるということができず歯が立たなかった印象です。素朴に離散対数問題が解けたら4回も入力する必要がないので、4回入力することでどういうことができると考えられるか、などの方面から考察をすすめる(あるいは考えさせる)などが必要だったなと思います。
全体を通しての感想
どの問題もとても好みでよかったです。良いセットだと思いました。Authorのyu212さんとAdminの皆様にあらためて感謝を。
個人的にはCTF関連の勘や筋力が衰えていて1問あたりにかける時間が長くなってしまって、自分と同じ3問を通したなか同点の群では最下位になってしまったので反省です。AIを利用して解けるようになったというところは良かったので、実装をバグらせたりしないようにするというのが頑張れるポイントだと思う。
一方、このくらいの難易度ならもっと他の人もバンバンといて自分の順位はもっと低いかなと思っていたところ、ゆっくりでもちゃんと解いたらある程度上位に入れたことは嬉しいです。 再来週もAlpacaHackではRound 13(Crypto) が開催されるようなので、また自分を満足させる順位を取れるように頑張りたいところです。