zer0pts CTF writeup(2)
はじめに
はじめも何もないですけどね。続きです。
前回はこちら
OT or NOT OT (crypto, 116pt, 71solces)
OT or NOT OT?
# server.py import os import signal import random from base64 import b64encode from Crypto.Util.number import getStrongPrime, bytes_to_long from Crypto.Util.Padding import pad from Crypto.Cipher import AES from flag import flag p = getStrongPrime(1024) key = os.urandom(32) iv = os.urandom(AES.block_size) aes = AES.new(key=key, mode=AES.MODE_CBC, iv=iv) c = aes.encrypt(pad(flag, AES.block_size)) key = bytes_to_long(key) print("Encrypted flag: {}".format(b64encode(iv + c).decode())) print("p = {}".format(p)) print("key.bit_length() = {}".format(key.bit_length())) signal.alarm(600) while key > 0: r = random.randint(2, p-1) s = random.randint(2, p-1) t = random.randint(2, p-1) print("t = {}".format(t)) a = int(input("a = ")) % p b = int(input("b = ")) % p c = int(input("c = ")) % p d = int(input("d = ")) % p assert all([a > 1 , b > 1 , c > 1 , d > 1]) assert len(set([a,b,c,d])) == 4 u = pow(a, r, p) * pow(c, s, p) % p v = pow(b, r, p) * pow(c, s, p) % p x = u ^ (key & 1) y = v ^ ((key >> 1) & 1) z = pow(d, r, p) * pow(t, s, p) % p key = key >> 2 print("x = {}".format(x)) print("y = {}".format(y)) print("z = {}".format(z))
nc
コマンドで接続するタイプの問題です。サーバを再現したければ
from flag import flag
の箇所をflag = b'zer0pts{hogehoge}'
などに置き換えて
$ socat tcp-listen:4000,reuseaddr,fork exec:"python3 /path/to/server.py"
などとすると$ nc localhost 4000
でローカルで再現できます。
さて、内容を要約すると
(256bit)を使ったAES-CBCでflagを暗号化
素数が与えられる
が0になるまで以下を繰り返す
以下のランダム値が選ばれる
ユーザ(我々)は以上以下の値を自由に設定可能。ただし4つは全て異なる
以下の規則に従ってが出力される
の下1bit目が0なら mod 、1なら mod
の下2bit目が0なら mod 、1なら mod
mod
をに更新。つまり右に2bitシフト
大文字は既知(および自由に設定可能)で、小文字は未知です。
アプローチ1
war(sa)mupの時にも言いましたが、乗法準同型性が成り立ちます。なので、例えば mod を代入すると
の下1bit目が0なら mod 、1なら mod
mod
となり、を計算することで
これがに等しければ の下1bit目は0、そうでなければ下1bit目は1と確定します。
これで半分クリアしました。
…ですが、をどのように設定してもうまく下2bit目を特定できません1。
一旦この方針は保留にします。他のアプローチが失敗したら戻ってこようと思います。
アプローチ2
nc
コマンドで接続するタイプのcryptoの問題で一度は試したいのが「 (あるいは)の設定」です。
例えばですが、とすると、 mod となり、
というどんな値をとるのか見当もつかない状態からの2択にまで絞れます。
先ほどはに注目しましたが、以下ではとしてに注目します。
アプローチ1と同様に、乗法準同型性を生かすために、として、いったん状況をまとめます。
の下1bit目が0なら mod 、1なら mod
の下2bit目が0なら mod 、1なら mod
もう少し場合分けを細分化します。
下1bit | 下2bit | の偶奇 | → | の関係 | ||
---|---|---|---|---|---|---|
0 | 0 | 偶数 | ||||
0 | 0 | 奇数 | ||||
0 | 1 | 偶数 | ||||
0 | 1 | 奇数 | ||||
1 | 0 | 偶数 | ||||
1 | 0 | 奇数 | ||||
1 | 1 | 偶数 | ||||
1 | 1 | 奇数 |
ごく稀にですが重複は発生します。例えば、の時、であるとすると、
で、上の表でいう1番目と4番目の判別がつきません。
厳密な議論はしませんが、少なくとも上から1~4番目内での重複発生確率はせいぜいと言ったところです。は1024bitなのでさすがに重複が発生しないと信じてソルバを書きます2。
# find_key.py from pwn import * # ローカルで動かしていると仮定してます io = remote('127.0.0.1', 4000) # 上から順に「AESで暗号化したflag」「素数P」「keyのbit長」です io.recvline() p = int(io.recvline()[:-1].decode().split(' = ')[1]) io.recvline() # T取得 t = int(io.recvline()[:-1].decode().split(' = ')[1]) # A = 2, B = 4, C = P-1 # Dは何でもOK。今回は3にしています。 a, b, c, d = [2, 4, p-1, 3] io.recv(100) io.sendline(str(a)) io.recv(100) io.sendline(str(b)) io.recv(100) io.sendline(str(c)) io.recv(100) io.sendline(str(d)) # X, Y, Z x = int(io.recvline()[:-1].decode().split(' = ')[1]) y = int(io.recvline()[:-1].decode().split(' = ')[1]) z = int(io.recvline()[:-1].decode().split(' = ')[1]) if pow(x,2,p) == y : key1, key2 = 0, 0 elif pow(x,2,p) == -y % p: key1, key2 = 0, 0 elif pow(x, 2, p) == (y^1) : key1, key2 = 0, 1 elif pow(x, 2, p) == -(y^1) % p : key1, key2 = 0, 1 elif pow((x^1), 2, p) == y: key1, key2 = 1, 0 elif pow((x^1), 2, p) == -y % p: key1, key2 = 1, 0 elif pow((x^1), 2, p) == (y^1): key1, key2 = 1, 1 elif pow((x^1), 2, p) == -(y^1) % p: key1, key2 = 1, 1 else: print('coding error') key1, key2 = 2, 2 print(key1, key2) io.close()
とりあえずこんな感じで上の表を愚直にコーディングしました。綺麗にまとめたければ適宜いじってもらって大丈夫です。
これを、が0になるまでループさせ、復元したをもとにAESで復号します。
以上をまとめたソルバは次のようになります。長いので、上のfind_key.py
と一致する部分は省略します。
# solver.py from pwn import * from Crypto.Util.number import long_to_bytes from base64 import b64decode from Crypto.Cipher import AES io = remote('127.0.0.1', 4000) cipher = io.recvline()[:-1].decode().split(': ')[1] p = int(io.recvline()[:-1].decode().split(' = ')[1]) io.recvline() _key = '' while True: # keyをビットシフトしていき、最終的に0になるとrecvlineが正常に動作しないので # それをwhile文を抜ける条件としています。 try: t = int(io.recvline()[:-1].decode().split(' = ')[1]) except EOFError: break # ここは同じ。最後のelseまで省略 a, b, c, d = [2, 4, p-1, 3] ... else: print('coding error') key1, key2 = 2, 2 break _key = str(key2) + str(key1) + _key # デバッグ用。結構時間かかるのでkeyの復元の様子を表示 print(hex(int(_key, 2))) io.close() # AES-CBCで復元 _key = long_to_bytes(int(_key, 2)) c = b64decode(cipher) iv, c = c[:16], c[16:] aes = AES.new(key=_key, mode=AES.MODE_CBC, iv=iv) m = aes.decrypt(c) print(m)
感想ですが、絶対に想定解ではないでしょう。
だってとか使ってないですからね…
アプローチ3
(これはrkm0959氏のwriteupを引用しています。自分が考えた方法ではありません。)
まず、 mod になるまで粘ります。
getStrongPrime
はが大きな素因数を持つことを「Strong Prime」と定義しているのであって、
が素数になるいわゆる「安全素数」の生成ではありません。なので、 mod は簡単に達成できます。
さて、が得られたら、ランダムな数を自分で選んで
mod
とします。ただしはいずれもmod で1にならないとします。
この計算も高々1/2の確率くらいで達成可能です3。このは、Fermatの小定理から
mod
です。では、としてみましょう。
の下1bit目が0なら mod 、1なら mod
の下2bit目が0なら mod 、1なら mod
キーワードは mod です。上をさらに変形すると
の下1bit目が0なら mod 、1なら mod
の下2bit目が0なら mod 、1なら mod
これで解けます。こちらもは使いませんね。コーディング的にもこちらがずっとシンプルです。お見事。
jankenの問題もあとで記事にします。