2024/9/15に開催されたAlpacaHack Round 3 (Crypto)のwriteupです。全完して6位でした。
主に競技プログラミング界隈から来て最近CTFを始めたという方に向けて、CTF特有の考え方といった部分を丁寧めに書いてみます。
qrime
RSA暗号をベースにした問題ですが、 の素因数 の生成方法が特殊です。
として生成され、 に追加で が与えられています。
RSA暗号は または を求めることができてしまうと任意の暗号文を復号できます。
この式と の つの式を用いて の素因数 を求めることができないでしょうか。
ここで、 が数式として表しにくく邪魔なため、ある整数 を用いて と表すことを考えてみます。
すると、 に関する最初の式は となり、未知数が つ増えてしまう代わりに式として扱いやすくなります。*1
では、式に出てくる値それぞれの大きさ(bit数)を考えてみましょう。 はコードにある通り256ビットのランダムな値を取ります。 が ビットであることから は ビットです。では はどうでしょうか。
次のようなコードを実行すると、 がどれくらいの大きさの値になるかわかります。
x = getRandomNBitInteger(256) print(nextPrime(x) - x)
何度か実行してみると、高々1000とかなり小さい値をとっています。*2 両方を十分高速に全探索できそうです。*3
ここまでをまとめると、 を未知数として連立方程式
を解くことができればよいです。
これを変形していくと と、 に関する二次方程式が得られます。これを解くことで を求めることができました。
ソルバ
from Crypto.Util.number import long_to_bytes from math import isqrt n=200697881793620389197751143658858424075492240536004468937396825699483210280999214674828938407830171522000573896259413231953182108686782019862906633259090814783111593304404356927145683840948437835426703183742322171552269964159917779 e=65537 c=77163248552037496974551155836778067107086838375316358094323022740486805320709021643760063197513767812819672431278113945221579920669369599456818771428377647053211504958874209832487794913919451387978942636428157218259130156026601708 r=30736331670163278077316573297195977299089049174626053101058657011068283335270 def solve_rsa(p, q): phi = (p - 1) * (q - 1) d = pow(e, -1, phi) m = pow(c, d, n) return long_to_bytes(m) def solve(): for a in range(1000): for b in range(1000): aa = 2 * r + b bb = a * r cc = -n q = (-bb + isqrt(bb * bb - 4 * aa * cc)) // (aa * 2) if q > 0 and n % q == 0: p = n // q print(p, q) print(solve_rsa(p, q)) return solve()
Crypto.Util.number
は pycryptodome
のモジュールです。この問題ではバイト列と整数の間の変換に使われていましたが、pycryptodome
はこの問題に限らず多くの問題で使われます。
おまけ
ちなみに、sagemathをうまく使うと、二つの式を求めた後人力で式変形をする必要なく簡単に解くことができます。二つの式の終結式を求めてそれを解かせています。(私はこちらの方法で解きました)
from sage.all import * PR = PolynomialRing(ZZ, ["p", "q"]) p, q = PR.gens() for a in range(1000): for b in range(1000): f1 = n - p * q f2 = q * (r + b) + (q + a) * r - p rr = f1.resultant(f2).univariate_polynomial().roots() if len(rr) > 0: q = rr[0][0] p = n // q print(solve_rsa(p, q))
Rainbow Sweet Alchemist
RSA暗号をベースにした問題で、 の素因数 の生成方法が特殊です。
シードを固定した乱数生成器を用いていくつかの素数を生成した後、 個の値を使って のようにして を生成しています。
random.choice
はシードを固定した乱数生成器を使っていないため、同じコードを実行して を得るというのは不可能そうです。
ここで、 の素因数としてあり得るものが全て求まることに注目します。実験してみるとgetPrime
は多めに見積もっても 回程度のループで答えを返すため、deterministicGetPrime
が返す最初の 個の値を手元で計算しておくと、 の素因数は とこのリストの中の値のみを含みます。
つまり、 とこのリストすべての値の積を と置くと、これは の倍数となります。この性質の何が嬉しいかというと、オイラーの定理より、 を と互いに素な整数として が成り立ちます。
このリストから素数を一つずつ消していくと、 の素因数 が消された場合にのみ となり、各素数が の生成に使われたかを判定することができます。
逆に、リストに一つずつ素数を追加していき となったタイミングを調べていくという方法でも解くことができ、こちらのほうが高速です。
考え方としてはポラードのp-1因数分解法に近く、このアルゴリズムを知っている人であれば早く思いつけるのかなと思います。
ソルバ
from Crypto.Util.number import long_to_bytes import random r = random.Random(0) def deterministicGetPrime(): while True: if isPrime(p := r.getrandbits(64)): return p def solve_rsa(p, q): phi = (p - 1) * (q - 1) d = pow(e, -1, phi) m = pow(c, d, n) return long_to_bytes(m) assert deterministicGetPrime() == 2710959347947821323 ps = [deterministicGetPrime() for _ in range(2000)] factors = [] while len(factors) < 16: prd = 1 x = pow(2, 4, n) for p in factors: x = pow(x, p, n) if x == 1: break for p in ps: x = pow(x, p, n) if x == 1: factors.append(p) break print(factors) tmp = 1 for p in factors: tmp *= ps[i] p = tmp * 2 + 1 q = n // p print(solve_rsa(p, q))
A Dance of Add and Mul
楕円曲線の問題です。
いくつかの有名なパラメータの楕円曲線には名前がついており、問題コードに書いてあるBLS12-381
もその一つです*4。検索してみるとペアリングの計算をする際によく用いられるとわかります。
ペアリングについての説明は(私もあんまりよくわかっていないため)省略するので、このページ などを見てください。
さて、隣接する について から共通の値を使っていることから、各 に対して か か求めるよりも、隣接する二つの について同じ値を取るかを求めるほうが見通しがよさそうです。
と の間でペアリングの計算をすることを考えると、例として のとき のようになります。
更に、 のときについて考えると のようになり、先ほどの結果と一致します。
このことから、 とのペアリングを計算した結果が隣接する二つの で一致した場合、その二つの の値は異なることが分かります。 とのペアリングを計算した結果についても同様です。
これと一つ目の が であることから、すべての の値を求めることができます。
上述のサイトでは に拡大した体を用いていましたが、この問題では で十分なようです。
ソルバ
from pwn import * cs = eval(read("a-dance-of-add-and-mul/chall.txt").decode()) p = 0x1a0111ea397fe69a4b1ba7b6434bacd764774b84f38512bf6730d2a0f6b0f6241eabfffeb153ffffb9feffffffffaaab K = GF(p) E = EllipticCurve(K, (0, 4)) cs = [E(x, y) for x, y in cs] G1, G2 = E.gens() o1, o2 = G1.order(), G2.order() r = 0x5f19672fdf76ce50d28e776116d47d5841f8c5f1fba8d33881bfa40089fc5bffd1ffffff00010001 A1 = [c.weil_pairing(G1, r) for c in cs] A2 = [c.weil_pairing(G2, r) for c in cs] v = 0 vs = [] for i in range(len(A1) - 1): vs.append(v) if A1[i] == A1[i+1] or A2[i] == A2[i+1]: v = 1 - v vs.append(v) print(unbits(vs))
read
やunbits
はpwntools
にある関数で、それぞれバイト列としてファイルを読む関数とビットのリストをバイト列に変換する関数です。pwntools
には他にもいくつか便利な関数があるのでリファレンスに目を通しておくと便利かもしれません。
Hat Trick
三つのRollingHashについてハッシュが特定の値を取るような長さ54以下の英小文字列を求める問題です。
まずは与えられた条件から使えそうな式を立てていきます。
使う文字数については多いほうが考えられる状態が広く条件を満たすように値を取りやすくなるため、とりあえず最大の54文字を決め打ちで考えていきましょう。
は英小文字であることから、 です。
更にRollingHashの式を考えると、 というような式が つ立ちます。
一度ずつしか登場しない値 が法として使われているのはあまりうれしくないので、未知の整数 を用いて というように変形します。
ここで、 が未知の値で、他に出てくる値はすべて既知です。よって、すべての条件は線形な式で表されています。*5
未知数がとる値の範囲が他の値と比べて小さい、出てくる式が線形である、といった点から、LLLを使って解くことができそうだとあたりを付けることができます。
LLLはCTFにおいてかなり頻出のアルゴリズムで、競技プログラマであれば使うのはそれほど難しくないため、早めに慣れておくと便利かなと思います。*6
LLLについて非常に雑に説明すると、行列 に対し、行ベクトル を のノルムが"いい感じに小さく"なるようにとったときの の値を求めることができます。
(LLL自体の説明ではなく、LLLを使ってできることについての説明。詳しい説明については他の記事を参照してください。)
計算量は重めの多項式時間で、行列のサイズが くらいまでなら動くと思っておけばいいと思います。
問題に"いい感じに小さい"値が出てきたときは、その値が右辺に来るような線型方程式をうまく立てることによってLLLで解ける形に持っていくことができることが多いです。
それでは、LLLに使う式を立てていきます。
RollingHashの式はそのまま使って です。
更に、 は値域の中心である に近い値を取ってほしい、つまり が小さい値になってほしいです。次のような行列を立てます。
に左から を掛けるとノルムの小さいベクトル になるため、LLLの出力には が現れることが期待できます。LLLは実際には行ベクトルではなく行列を出力としていて、各行が としてありうるノルムの小さいベクトルとなっています。それぞれの行を見て の値域が条件を満たさないものなどを除くと解が得られます。
LLLではベクトルの各要素が同程度の大きさ(bit数)の値になるように重みを調節することが大切です。から列目にはが出てきてほしいため、他の列より小さくなるよう 倍しています(特にという数字に意味があるわけではありません)。
LLLはsagemathのライブラリを使うのが簡単です。
ソルバ
from sage.all import * from pwn import * import json so = remote("xxx.xxx.xxx.xxx", 0) params = json.loads(so.recvline().removeprefix(b"params: ")) for _ in range(3): target_hash = json.loads(so.recvline().removeprefix(b"target: ")) print(target_hash) MAX_LEN = 54 B = 128 M = Matrix(ZZ, MAX_LEN+4, MAX_LEN+4) for i in range(MAX_LEN): M[i, i] = 1 M[MAX_LEN+3, i] = 109 M[i, MAX_LEN+0] = pow(params[0]["base"], i, params[0]["p"]) * B M[i, MAX_LEN+1] = pow(params[1]["base"], i, params[1]["p"]) * B M[i, MAX_LEN+2] = pow(params[2]["base"], i, params[2]["p"]) * B M[MAX_LEN+0,MAX_LEN+0] = params[0]["p"] * B M[MAX_LEN+1,MAX_LEN+1] = params[1]["p"] * B M[MAX_LEN+2,MAX_LEN+2] = params[2]["p"] * B M[MAX_LEN+3,MAX_LEN+0] = target_hash[0] * B M[MAX_LEN+3,MAX_LEN+1] = target_hash[1] * B M[MAX_LEN+3,MAX_LEN+2] = target_hash[2] * B M[MAX_LEN+3,MAX_LEN+3] = 1 M = M.LLL() for row in M: if row[-1] != -1: continue s = [chr(ch + 109) for ch in row[:MAX_LEN]] if not ('a' <= min(s) and max(s) <= 'z'): continue if list(row[MAX_LEN:-1]) != [0, 0, 0]: continue s = "".join(s) print(s) so.sendlineafter(b"> ", s.encode()) break print(so.recvline())