AlpacaHack Round 12 (Crypto)の作問を担当しました。
実はこれがほとんど初めてのCTF作問でしたがいかがでしたでしょうか。楽しんでいただけていれば幸いです。
各問題の解説を書いていこうと思います。
RSARSARSARSARSARSA (39 solves)
次のスクリプトとその実行結果が与えられます。
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 = }")
フラグを1337回繰り返した文字列がRSAによって暗号化されており、公開鍵,
と暗号文
が与えられています。
フラグの代わりに"flag"という文字列で実験してみましょう。"flag"をbytes_to_long関数で整数に変換するととなります。では2回結合した"flagflag"はどうなるでしょうか。実験してみると
となり、"flag"の場合の
倍となります。
同様に3回結合した"flagflagflag"はで"flag"の
倍、1337回結合した場合
倍となります。
フラグは26文字であることが分かっているので、得られる暗号文はフラグを倍した値を暗号化したものとなります。
よって暗号化はをフラグとして、
と表すことができます。式変形すると、
となり、フラグを直接暗号化した場合の暗号文が計算できました。
更に、が19と小さいことに注目します。一般的に
としては65537が選ばれることが多いです。
が小さいことは直接的にはRSAの安全性には影響を与えませんが、
CTF crypto 逆引きなどにもある通り、を満たす場合には、単純に
の
乗根を計算することによって平文が復元できてしまい脆弱であることが知られています。
実際、フラグは26文字であるため から
は3952bitしかなく、これは
の4096bitを下回るため条件を満たします。
ソルバは以下の通りです。sympyのroot関数を使って乗根を計算しています。
import ast from sympy import root from Crypto.Util.number import long_to_bytes with open("./output.txt", "r") as f: n = ast.literal_eval(f.readline().removeprefix("n = ")) e = ast.literal_eval(f.readline().removeprefix("e = ")) c = ast.literal_eval(f.readline().removeprefix("c = ")) t = 0 for _ in range(1337): t = t * 256 ** 26 + 1 c_flag = c * pow(t, -e, n) % n eth_root = root(c_flag, e) print(long_to_bytes(eth_root).decode())
一問目に置ける難易度の問題を作るのは正直ボス問を作るより難しかったです。典型問題そのままではなくちょっとひねった感じであまり見たことがない問題となっておりお気に入りの一問です。
OTEC (21 solves)
次のスクリプトが与えられます。
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() # Oblivious Transfer using Elliptic Curves 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())
楕円曲線を使った1-out-of-2 Oblivious Transferが実装されています。
1-out-of-2 OTは送信者が持つ2つのメッセージのうち、受信者がどちらか一方を選んで受け取ることができ、
- 送信者は受信者がどちらを受け取ったかはわからない
- 選ばなかったほうのメッセージに関する情報は一切得られない
という性質を満たすプロトコルです。今回の問題においては1つ目の性質は特に関係なく、"選ばなかったほうのメッセージに関する情報は一切得られない"という点が重要になります。
サーバーと複数回通信することができるため、flag[0::3]とflag[1::3]はそれぞれで通常のOblivious Transferのプロトコルに沿って通信することによって求めることができます。
を求めたい場合、
を送ると、
となり、の場合には
を、
の場合には
を
A.xとして求めることができます。*1
もう一方の値を求めるためにはを求める必要がありますが、これには楕円曲線上の離散対数問題を解く必要があり、
secp256k1のような安全な楕円曲線のパラメータに対しては現実的な時間では困難であることが知られています。
では、k0とk1のxorされた値を求めることができるでしょうか?というのがこの問題の本質的な部分です。
前述した通り、k0とk1を両方同時に求めることは困難です。よって、それぞれの値を知ることなくk0^k1を求める必要があります。
これを満たす方法として、k0^k1 == 0、つまりk0 == k1となるようにBの値を決めることができないか考えてみましょう。
を
として与えると、
,
となり、互いに逆数の関係になります。ここで、楕円曲線ではある点
とその逆元
は共通のx座標を持ち、y座標のみが異なる値となることから、x座標のみを鍵として用いるこのプロトコルにおいては
k0 == k1となります。
よってflag[2::3]は0000...0000で暗号化されることとなり、フラグのすべての部分が復元できます。
を求める際、代わりに
secp256k1.qを法とした2の逆元pow(2, -1, secp256k1.q)を掛けることによって計算する必要があることに注意してください。
ソルバは以下の通りです。
from pwn import * from fastecdsa.curve import secp256k1 from fastecdsa.point import Point from Crypto.Cipher import AES from Crypto.Util.Padding import unpad HOST = os.getenv("HOST", "localhost") PORT = int(os.getenv("PORT", "9999")) LOCAL = os.getenv("LOCAL") G = secp256k1.G def oracle(b_func): if LOCAL: sc = process(["python", "../rawdistfiles/server.py"]) else: sc = remote(HOST, PORT) sc.recvuntil(b"A.x = ") x = int(sc.recvline()) sc.recvuntil(b"A.y = ") y = int(sc.recvline()) A = Point(x, y, secp256k1) B = b_func(A) sc.sendlineafter(b"B> ", f"{B.x},{B.y}".encode()) ct0 = bytes.fromhex(sc.recvline().decode()) ct1 = bytes.fromhex(sc.recvline().decode()) ct2 = bytes.fromhex(sc.recvline().decode()) return ct0, ct1, ct2, A ct0, _, _, A0 = oracle(lambda A: G) _, ct1, _, A1 = oracle(lambda A: A + G) _, _, ct2, A2 = oracle(lambda A: A * pow(2, -1, secp256k1.q)) k0 = long_to_bytes(A0.x, 32) k1 = long_to_bytes(A1.x, 32) k2 = b"\0" * 32 pt0 = unpad(AES.new(k0, AES.MODE_ECB).decrypt(ct0), 16) pt1 = unpad(AES.new(k1, AES.MODE_ECB).decrypt(ct1), 16) pt2 = unpad(AES.new(k2, AES.MODE_ECB).decrypt(ct2), 16) print(bytes([[pt0, pt1, pt2][i%3][i//3] for i in range(len(pt0) + len(pt1) + len(pt2))]).decode())
よくわからない暗号プロトコルと楕円曲線と、どちらも難しいがちで初心者には忌避されるように思いますが、解法はかなりシンプルで試行錯誤の中でたどり着ける範囲ではあるのかなと思います。
見た目はすごく安全そうでも実は脆弱というのはCryptoジャンルの問題の面白いところの一つで、そんなところを感じていただけたら嬉しいです。
Flag Sharing (6 solves)
次のスクリプトとその実行結果が与えられます。
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個の
とその
で評価した値
の上位bitが得られています。
これはShamir's secret sharingという秘密分散のアルゴリズムに似ています。
通常のShamir's secret sharingのプロトコルでは多項式の次数+1個のシェア(の組)を集めると共有した値
が復元できますが、この際どうやって復元するのかを考えると、ラグランジュ補間を使って以下のように計算されています。
はすべて得られていることから、
に関する線形和でフラグを表すことができます。また、シェアのうち未知の部分が小さいことから、LLLを使ってシェアとフラグを復元できそうと考えることができます。では、LLLの格子の基底行列の構成方法について考えましょう。
まず、を既知の部分
と
に分割して
と表します。
フラグのうちAlpaca{}の部分は既知なので、
とおきます。
これで、未知の値はと
だけになりました。
先程の式に代入すると、
となります。
( とおきました。)
ここで、未知数の大きさを計算すると、は
bit、
は
byte(最上位bitは0のため
bit)であり、合計で
bit となります。
の大きさは512bitなので、507bitの自由度であれば解が一意に特定できそうです。
と
からなるベクトルを含むように格子を構成すると、例えば以下のようにできます。
更に、縮約されたベクトルの各要素の大きさを揃えるため、次のようにスケーリングします。
この行列に対してLLLなどの格子基底簡約を適用したいですが、残念ながらこの行列では目的のベクトルは得られません(この問題はかなりboundが厳しめになるようにしています)。
スケーリング後の目的のベクトルは各値が]に分布します。この値から
を引いて
]の分布に移動させると目的のベクトルのノルムを平均的に小さくすることができ、より簡約後の格子の基底ベクトルとして現れやすくできます。
この行列にLLLを適用することにより、目的のベクトル、そしてフラグが得られます。
ソルバは以下の通りです。
import ast import string from sage.all import * from Crypto.Util.number import long_to_bytes, bytes_to_long with open("../distfiles/output.txt", "r") as f: p = ast.literal_eval(f.readline().removeprefix("p = ")) xs = ast.literal_eval(f.readline().removeprefix("xs = ")) share = [] for line in f.readlines(): share.append(int(line.rstrip("?\n"), 16)) N = 10 c = bytes_to_long(b"Alpaca{".ljust(44 - 1, b"\0") + b"}") lam = [product(-xs[j] * pow(xs[i]-xs[j], -1, p) % p for j in range(N) if i != j) for i in range(N)] inv256 = pow(256, -1, p) const = (sum(share[i]*2**(4*(i+1)) * lam[i] for i in range(N)) - c) % p M = block_matrix([ [Matrix([lam[i] * inv256 % p for i in range(N)]).T, diagonal_matrix([2**(287-4*(i+1)) for i in range(N)]), zero_matrix(N, 1)], [(const*inv256-2**286) % p, Matrix([-2**286]*N), 2**286], [p, zero_matrix(1, N), 0] ]) for row in M.LLL(): f = long_to_bytes(int(row[0]) + 2**286) if all(ch in string.printable.encode() for ch in f): print(f"Alpaca{{{f.decode()}}}") break
を引く方法以外に、フラグの一文字目を全探索することでも目的のベクトルの大きさを小さくすることができ、同様にフラグが得られます。
LLLの問題はboundに余裕を持たせてあることも多いですが、もう少しのところで目的のベクトルが得られないというときにどうしますか?というところを考えるのもなかなかおもしろいと思います。
Zero-sum game (3 solves)
次のスクリプトが与えられます。
import os import random import signal from functools import lru_cache @lru_cache def _nimber_mul(x, y, p=512): assert x.bit_length() <= p and y.bit_length() <= p if x == 0 or y == 0: return 0 if x == 1: return y if y == 1: return x p >>= 1 lx, rx = x >> p, x & (1 << p) - 1 ly, ry = y >> p, y & (1 << p) - 1 rr = _nimber_mul(rx, ry, p) ll = _nimber_mul(lx, ly, p) lz = _nimber_mul(lx ^ rx, ly ^ ry, p) ^ rr rz = _nimber_mul(1 << (p - 1), ll, p) ^ rr return lz << p | rz class Nimber: def __init__(self, value): self.value = value def __add__(self, other): return Nimber(self.value ^ other.value) def __sub__(self, other): return Nimber(self.value ^ other.value) def __mul__(self, other): return Nimber(_nimber_mul(self.value, other.value)) def __pow__(self, e): assert e >= 0 a = self r = Nimber(1) while e > 0: if e % 2 == 1: r = r * a a *= a e //= 2 return r def __repr__(self): return str(self.value) signal.alarm(90) flag = os.environ.get("FLAG", "Alpaca{**** REDACTED ****}") atk = Nimber(random.getrandbits(512)) hp = Nimber(random.getrandbits(512)) print(f"Your ATK: {atk}") print("Show your power!") for turn in range(4): print(f"Turn #{turn+1}") print(f"Enemy HP: {hp}") power = int(input("> ")) assert 0 <= power < 2**512 hp -= atk ** power if hp.value == 0: print("You win!") print(f"Reward: {flag}") exit() print("You lose...")
atk,hpがランダムな512bitのNimberとして与えられており、
となるような
を求める問題です。
ただし和、積はNimberの和、積演算です。
ほとんどの人はNimberというものに初めて触れたと思いますが、Nimberは組み合わせゲーム理論で出てくる謎の体です。競技プログラミングの世界では高度典型として一部で知られて(?)います。
Nimberの世界では、和はビットごとのXORで求まります。一方、積はと定義されています。これは意味が分からないので、謎の演算で定義されているんだなあだとブラックボックス的に理解してもこの問題では特に問題ありません。
この問題で使うNimberの性質は主に以下の5つです。
1. bit以下のNimberは和と積について閉じており、有限体
と同型
2. とすると、
は通常の積
に等しい
3. 任意のNimber は
を用いて
と一意に表せる
4. bit以下任意のNimber
に対して、
は
bit以下となる
5. と逆元が計算できる
まず、直接DLPを解くことによってとなるような
が求まるか考えたいです。しかし、これができてしまうと
から同型写像で行き来することによって
上のDLPも解けてしまうことから、おそらく不可能だと結論付けることができます。逆に、
上のDLPが解けるのであれば、同型写像で行き来して
bitのNimber上のDLPも解くことができそうです。
のDLPは関数体篩法などの高速なアルゴリズムの実装がすでに存在し、
であればSageMathの
log関数などを使って10秒程度で解くことができます*2。
よって、
1. 128bit Nimberからへの同型写像を構成する
2. 128bit NimberのDLP oracleが呼び出せる状況で となるような
を構成する
の2つができればこの問題を解くことができます。
1. 同型写像の構成
、つまり
から128bit Nimberへの同型写像を
とします。
と分解できるとしたとき、
が成り立ちます。つまり、各項
があるNimberに対応しており、その和で写像が表せます。
ここで、適当な128bitのNimber が
と対応付けられると仮定します。
このとき、に対応するNimberは
となります。よって、
と計算できます。
128bit Nimberからへの同型写像はこの逆写像です。
の各ビットを要素とする128要素のベクトルを並べた
上の
行列を考え、その逆行列を計算すると構成できます。
このとき、の生成多項式は
となります。
2. の構成
まず256bitのNimber に対してDLPオラクル2回の呼び出しによって
となるような
を求めます。
と分解します(
は128bitのNimber)。この分解は存在する場合一意で、
と計算できます。
あとは となるような
(
についても同様)を求めることができればよいですが、
が128bitであることから
は必ず
の倍数となります。よって、
と
の間のDLPをオラクルに問い合わせて解けば
が求まります。
さて、この議論は512bitのNimberに対しても同様に適用できます。つまり、512bitのNimber に対して
と分解し、
,
となるような
〜
を求めることで
と表すことができます。
ソルバは以下の通りです。
from functools import lru_cache from sage.all import * from pwn import * HOST = os.getenv("HOST", "localhost") PORT = int(os.getenv("PORT", "9999")) LOCAL = os.getenv("LOCAL") @lru_cache def nimber_mul(x, y, p=512): assert x.bit_length() <= p and y.bit_length() <= p if x == 0 or y == 0: return 0 if x == 1: return y if y == 1: return x p >>= 1 lx, rx = x >> p, x & (1 << p) - 1 ly, ry = y >> p, y & (1 << p) - 1 rr = nimber_mul(rx, ry, p) ll = nimber_mul(lx, ly, p) lz = nimber_mul(lx ^ rx, ly ^ ry, p) ^ rr rz = nimber_mul(1 << (p - 1), ll, p) ^ rr return lz << p | rz def nimber_div(x, y, p=256): return nimber_mul(x, nimber_power(y, 2**p-2)) def nimber_power(a, e): r = 1 while e > 0: if e % 2 == 1: r = nimber_mul(r, a) a = nimber_mul(a, a) e //= 2 return r m = 128 a = random.randrange(1, 1<<m) mat = [] for i in range(m): x = nimber_power(a, i) mat.append([x >> i & 1 for i in range(m)]) mat = Matrix(GF(2), mat) x = nimber_power(a, m) x = vector(GF(2), [x >> i & 1 for i in range(m)]) u = x * mat ** -1 F = GF(2**m, "a", modulus=list(u) + [1]) def to_field(x): x = vector(GF(2), [x >> i & 1 for i in range(m)]) u = x * mat ** -1 return F(u) def to_nimber(x): x = x.to_integer() x = vector(GF(2), [x >> i & 1 for i in range(m)]) u = x * mat return sum(int(u[i]) << i for i in range(m)) def make(P, Q, p): if p == 64: return [to_field(Q).log(to_field(P))] ph, pl = P>>p, P&(2**p-1) qh, ql = Q>>p, Q&(2**p-1) n = 2**p P2 = nimber_power(P, n+1) t = make(P2, nimber_div(qh, ph, p), p>>1) a = 0 for v in t: a ^= nimber_power(P, (n+1)*v+1) u = make(P2, ql ^ (a % n), p>>1) return [(n+1)*v+1 for v in t] + [(n+1)*v for v in u] while True: start = time.time() if LOCAL: sc = process(["python", "../rawdistfiles/server.py"]) else: sc = remote(HOST, PORT) sc.recvuntil(b": ") P = int(sc.recvline()) sc.recvuntil(b": ") Q = int(sc.recvline()) try: s = make(P, Q, 256) except ValueError: print("failed") sc.close() continue print(time.time() - start) for v in s: sc.sendlineafter(b"> ", str(v).encode()) sc.recvline() print(sc.recvline()) print(time.time() - start) break
ところで、この問題はNimberの話を一切出さずにの問題として出題することも可能でした。
その場合からの二次拡大を考えると概ね同様の解法で解くことができるのですが、この問題ではNimberの性質を使うことでより解法の方針が立ちやすくなっています。
最初は「CTFでNimberが出たら面白いやろ!w」みたいなノリで作った問題でしたが、First Bloodのsoon-haariさんにもone of the most memorizable chall to me this yearと言っていただき、結果的にかなり面白い問題に昇華できて良かったと思います。
総評
CTFtime運営の対応が遅くCTFtimeに掲載されなかったようで、参加者数が想定の1/3くらいでした。許せねえ。
最終的に解かれた数は前から39-21-6-3でした。開始前の予想は100-65-20-3だったので全体を3倍したら大体当たってますね。AlpacaHackのRoundは4問しかないのもあって毎回崖ができがちですが、かなり理想的な傾斜になったのではないかと思います。
もともと5問出題予定だったのですが、セット的に5問だとさすがに6時間では重すぎるということで4問目が消え、今の4問になりました。結果的に4問に減らして正解だったように思います。消えた問題はまたいつかどこかで出そうと思います。
また、筑波大学のCTFチームTPCでtkbctfというCTFを冬ごろ開催予定です。そちらにもCryptoなどの問題をいくつか出す予定ですので是非ご参加ください。6時間CTFでは重くて出せない難易度の問題もあり、それなりに骨のあるセットになるかなと思います。よろしくお願いします。
*1:通常はとしますが、この問題では"送信者は受信者がどちらを受け取ったかはわからない"というのを満たす必要はないため単純に
としてよいです
*2:古いバージョンだと遅い実装が使われておりもう少し時間がかかるようです https://github.com/sagemath/sage/issues/32842