ふるつき

v(*'='*)v かに

TSG Live!8 CTF writeup - Two Keys

今年もまた世界で一番楽しいCTF、人生でもっとも頭を高速に回転させる必要のある100分間、TSGの作問力に舌を巻く儀式であるところのTSG Live! CTFが盛大に開催されました。 今回は全然解けなくて非常に悔しい思いをしたので精進します。 それはそれとしてCryptoのwriteupを一つくらい書いておくか、ということでTwo Keysという問題のwriteupです。

問題概要

要するに N_1 = pq, N_2 = (p + s)*(q + t)の2つのRSAです

from Crypto.Util.number import *
from flag import flag

def nextPrime(n):
    while True:
        n += 1
        if isPrime(n):
            return n

class RSA:
    def __init__(self, p, q):
        assert isPrime(p) and isPrime(q)
        self.p = p
        self.q = q
        self.e = 65537
        self.d = pow(self.e, -1, (self.p-1) * (self.q-1))
    def encrypt(self, x):
        return pow(x, self.e, self.p * self.q)
    def decrypt(self, y):
        return pow(y, self.d, self.p * self.q)
    def printPublicKey(self):
        print(f"N = {self.p * self.q}")
        print(f"e = {self.e}")

p = getPrime(512)
q = getPrime(512)
pp = nextPrime(p)
qq = nextPrime(q)
rsa1 = RSA(p, q)
rsa2 = RSA(pp, qq)

x1 = int.from_bytes(str.encode(flag[:len(flag)//2]), "big")
x2 = int.from_bytes(str.encode(flag[len(flag)//2:]), "big")
y1 = rsa1.encrypt(x1)
y2 = rsa2.encrypt(x2)

assert x1 == rsa1.decrypt(y1)
assert x2 == rsa2.decrypt(y2)

print("First half:")
rsa1.printPublicKey()
print(f"y = {y1}")
print()
print("Second half:")
rsa2.printPublicKey()
print(f"y = {y2}")

解法

とりあえず s, tは小さいことを期待して全探索することにして、 N_1, N_2から p, qに関する式を建てます。  N_2を開いて

 N_2 = (p + s)(q +t) = pq + pt + qs + st = N_1 + pt + qs + st です。さらに q = \frac{N_1}{p}なので

 N_2 = N_1 + pt + \frac{N_1}{p}s + st。両辺に pをかけて整理すると -tp^2 + (N_2 - N_1 - st)p - sN_1 = 0という二次方程式になります。

正しい s, tが求められていればこの式の解 pが整数になるはずであることを利用して探索中の s, tが求めている値かどうかをチェックし、最終的に整数 pが得られれば勝ちです

exploit

時間が少ないCTFだと雑なコードを書いてしまい、結果的にタイムをロスします。丁寧にassertとか入れて仮説とその実装の正しさを保ちながらきれいに書きましょう(反省)

from gmpy2 import iroot
from tqdm import tqdm

N1 = 568...
e = 65537
c1 = 541...

N2 = 568...
e = 65537
c2 = 549...

for s in tqdm(range(10,10000)):
    for t in range(10,10000):
        a = -t
        b = N2 - N1 - s*t
        c = -s*N1

        if b**2 - 4*a*c <= 0:
            continue

        D, ok = iroot(b**2 - 4*a*c,2 )
        if not ok:
            continue
        if (-b + D) % (2*a) == 0:
            p = (-b + D) // (2*a)
            print(p)
            q = N1 // p

            pp = (p + s)
            qq = N2 // pp
            assert pp*qq == N2

            d1 = pow(e, -1, (p-1)*(q-1))
            m1 = pow(c1, d1, N1)

            d2 = pow(e, -1, (pp-1)*(qq-1))
            m2 = pow(c2, d2, N2)

            print(bytes.fromhex(hex(m1)[2:]) + bytes.fromhex(hex(m2)[2:]))

        if (-b - D) % (2*a) == 0:
            p = (-b - D) // (2*a)
            print(p)
            q = N1 // p

            pp = (p + s)
            qq = N2 // pp
            assert pp*qq == N2

            d1 = pow(e, -1, (p-1)*(q-1))
            m1 = pow(c1, d1, N1)

            d2 = pow(e, -1, (pp-1)*(qq-1))
            m2 = pow(c2, d2, N2)

            print(bytes.fromhex(hex(m1)[2:]) + bytes.fromhex(hex(m2)[2:]))

感想

この問題も他の問題も楽しかったです

シェル芸botで急にtextimgが動かなくなったやつ

なんか急にシェル芸botでtextimgが動かなくなったというお便りが寄せられた

心当たりはあって、最近シェル芸botで使っているDocker Imageのベースイメージをubuntu 22.04にアップグレードした。タイミングをみるとこれが疑わしい。なんかシェル芸botを実行するときはコンテナランタイムとしてgVisorを使っていた気がするからそのあたりの噛み合わせかな、と思った。

TLのシェル芸人の皆様方が調査してくださる

webshというのは@jiro_saburomaruさん作のシェル芸botのWebインタフェースで、これはシェル芸botと同じDocker Imageを使ってるけど、実行しているホストとかは別物なので、やっぱりDocker Imageが悪いっぽい。webshのリポジトリはよくできていて、同バックエンドが構築されているかとかも書いてあるので見に行くとgVisorとかはつかってなさそう。じゃあgVisorは関係ないか

一方ローカル環境にdocker pull theoldmoon0602/shellgeibot して実験してみるとこちらは問題なく成功する。なるほど……。

エラーメッセージでぐぐってみると どうもOSの対応していないシステムコールを呼ぼうとしたっぽい? ということがわかる。

ホストOSの更新が必要かな〜と思いながら、一応gVisor外してみるかとシェル芸botの実行環境に潜ったら、記憶とは裏腹にgVisor入ってなかった。そうなんだ……

なんとなくgVisorいれて、コンテナランタイムを指定して実行したらtextimg動いた。やった〜 じゃあこれでいっかということで、シェル芸botの設定ファイルをちょっといじって再起動したら動いた

なんか知らないけどちゃんと設定ファイルからコンテナランタイムを指定できるようになっていて偉い

gVisor、一回入れた記憶はあるんだけど、なんで外したんだっけ……? ということを思い出せません。 次は思い出せないまでも記録には残しておこうと思ってこのエントリを書いてる


どうもこういう感じなのかな……。なんとなく問題は解消したので深堀していないけど、なにがおきていたのかは知れたら楽しいので教えてください

*1:🐑++

slide attack - Plaid CTF 2022 choreography upsolve

こういう典型っぽいのでも意外と知らないのはいくらでもあるんだなと言うことでupsolveです。こういうのってどこで学ぶんですかね。知識を体系的に獲得していないとこういうところでボロが出る……。

問題概要

  • 2種類のブロック暗号のロジックencrypt1, encrypt2 があります。ブロック長は32bitで鍵の長さも32bit。2つの暗号化ロジックは共通のSBoxを用いています
  • ある秘密の鍵key を使って任意の平文をencrypt1encrypt2 で暗号化できるオラクルがそれぞれ500回まで利用できます
  • key を当てるのが今回の目的です

暗号化ロジックは以下の通り。それぞれの暗号化ロジックは非常に単純ですが、ラウンド数が尋常じゃない回数あって入力を工夫したりして直接暗号鍵を求めることは難しそうです。 また、ロジックをよく見るとラウンド鍵が奇数ラウンドと偶数ラウンドで別れていることがわかります。

ROUNDS = 2**22 + 2

def encrypt1(k, plaintext):
    a,b,c,d = plaintext
    for i in range(ROUNDS):
        a ^= sbox[b ^ k[(2*i)&3]]
        c ^= sbox[d ^ k[(2*i+1)&3]]
        a,b,c,d = b,c,d,a
    return bytes([a,b,c,d])

def encrypt2(k, plaintext):
    a,b,c,d = plaintext
    for i in range(ROUNDS)[::-1]:
        b,c,d,a = a,b,c,d
        c ^= sbox[d ^ k[(2*i)&3]]
        a ^= sbox[b ^ k[(2*i+1)&3]]
    return bytes([a,b,c,d])

スライド攻撃(slide attack)

今回の問題設定のように複数ラウンドで同じラウンド鍵が使われている時、スライド攻撃によってラウンド鍵を求めることができるかもしれません。

スライド攻撃のアイデアは非常に単純です。

まず、今回の問題設定は一旦忘れて、より単純なブロック暗号のモデルを考えます。今回考える暗号方式では、全てのラウンドで同じラウンド関数 f_kを用い、暗号化 E_k = f_k^lであるとします(lはラウンド数)。つまり、メッセージ mをラウンド関数にかける処理を f_k(m)と書いて、暗号化は c = E_k(m) = f_k(f_k( \dots f_k(m) \dots )) です。

この時、ラウンド関数を一回だけ適用したメッセージを  m' = f_k(m)と書くとして (m, m')slid pair*1と呼びますが、このようなペアをもし知っていれば m' = f_k(m)という式からラウンド鍵を求められます。

当然そのようなペアを意図的に手に入れることは多くの場合難しいでしょうが、もし暗号化オラクルがあるなどの状況でknown-plaintextとそれに対応するciphertextのペアをいくつも手に入れられる場合、偶然その集合の中にはslid pairになるような組み合わせが存在するかもしれません。ブロック長を nbitとしてbirthday boundを考えると、 2^{n/2}個のペアが存在すれば1つはslid pairが存在することが期待できます。そして、任意のペア (m_i, c_i) (m_j, c_j)がslid pairかどうかは、 m_j = f_k(m_i)と仮定して算出した鍵と c_j = f_k(c_i)と仮定して算出した鍵が一致しているかどうかで確かめられます(一致していればslid pair)。

したがってスライド攻撃は(最もナイーブには)

  • 各ラウンドで同じラウンド鍵が用いられるブロック暗号において
  • 暗号化オラクルを有している時
  • ブロック長 nに対しておよそ 2^{n/2}の探索でラウンド鍵を求めることができる攻撃

と言えそうです。もし鍵長がブロック長と同じなら、鍵を全探索すると 2^{n}の空間を探索することになりますからそれに比べると遥かに効率の良い*2攻撃と言えます。

2k-DES構造のブロック暗号に対するスライド攻撃

本題からは少しずれますがこちらも合わせて書いておきます。これまでは非常に単純な、各ラウンドで同じラウンド関数が用いられるようなケースを考えていましたが、例えば(今回の問題のように)奇数ラウンドと偶数ラウンドで異なる鍵が用いられるようなブロック暗号に対してはどのようにアプローチすると良いのでしょうか。一つの方針としては、 (m, m'')のようなslid pairを探すことが考えられます。2ラウンド程度の処理であればそれぞれのラウンド鍵を求めることは造作もない、という場合であればこの攻撃は非常に単純明快で同様に効果的と言えそうです。一方、それが難しい場合にも(ブロック暗号の構造次第ではあるものの)工夫すれば効率的にスライド攻撃が可能であるということだったので紹介します。

今回取り上げるのは2k-DESと呼ばれるラウンド鍵が2種類のDESの亜種です。DESはfeistel構造と呼ばれる構成になっていて、これはブロックを半分に分けて左半分を L、右半分を Rと置き、 fをラウンド関数として

 L_{i+1}, R_{i+1} = R_{i}, L_{i} \oplus f(k_{i}, R_{i})

と表せます。DESの場合は特に L_{i+1}, R_{i+1} = R_{i}, L_{i} \oplus f(k_{i} \oplus R_{i}) です。

この時 ( (L, R), (L' \oplus \Delta, R' \oplus \Delta))というslid pairを考えることができます。記法としてはこれまで通り L' = R, R' = L \oplus f(k_0, \oplus R)で、 \Deltaは適当な差分です*3

 (L' \oplus \Delta, R' \oplus \Delta)を暗号化するとどうなるかを考えてみると、 \Deltaがなければ L'' = R'となりますがいまは \Deltaがあるので L'' = R' \oplus \Deltaとなり、同様に R'' = L' \oplus f(k_0 \oplus R') *4となるところが R'' = L' \oplus \Delta \oplus f(k_0 \oplus R' \oplus \Delta) となり、どんどん差分 \Deltaが伝搬していきます。最終的に得られた (L, R)に対応する暗号文を (M, N)とすると、 (L' \oplus \Delta, R' \oplus \Delta) に対応する暗号文は (M', N') = (M \oplus \Delta, M \oplus \Delta f(k_1 \oplus N \oplus \Delta) *5となります。

この時、 (L' \oplus \Delta) \oplus M' = (R \oplus \Delta) \oplus (N \oplus \Delta) = R \oplus N が成立することからslid pairかどうかを判定でき、 \Delta = R \oplus (L' \oplus \Delta から \Deltaの具体的な値がわかるので、ラウンド鍵を求めることができます。

今回の問題の場合

さてでは早速スライド攻撃をやってみましょう! 今回はブロック長が32bitなので、およそ 2^{16} = 65536個程度の平文と暗号文のペアがあればスライド攻撃が適用できそうです! それでは65535回クエリを……

……今回の問題設定では500回までしかクエリを投げられないということでした。500個のペアではslid pairが存在する確率は65536個の場合に比べて著しく低いので、これで問題が解けることは期待できそうにありません。更に工夫が必要になりそうです。

この工夫は天才的なひらめきをするか、全ての考えられる組み合わせをやってみるか、問題設定から感じ取る必要があるのですが、 x, y, z, w = encrypt1(a, b, c, d), x', y', z', w' = encrypt2(A = c' \oplus \Delta , B = d', C = a' \oplus \Delta, D = b') としてみます。ここで a' = a \oplus f(k_0 \oplus b), b' = b, c = c \oplus f(k_1 \oplus d), d = dです。

この時次の関係が成り立ちます。なぜこうなるかまで逐一説明したかったのですが(私が)却って混乱するので気になる場合は自分で追いかけてみてください。

 \begin{cases} a' =  a \oplus f(b \oplus k_0) \\ b' = b \\ c' = c  \oplus f(d \oplus k_1) \\ d' = d \\ x' = z \oplus f(w \oplus k_1) \oplus \Delta \\ y' = w \\ z' = x \oplus f(y \oplus k_0) \oplus \Delta \\ w' = y \end{cases}

この場合は y' = w, w' = yが成り立っていればslid pairと判断でき、slid pairが得られれば \Deltaを全探索しながらうまく一致する鍵を探せば k_0, k_1 が求まり、 \Delta = k_2 \oplus k_3より k_2を全探索してkey全体を求めることができます。

今回は (B, D)の値は (b, d)によって決まっているので、slid pairが存在するには (a, c), (A, C)の組み合わせが偶然いい感じになればよく、birthday boundは 2^{8} ということになりそうです。これなら500回もクエリできれば十分そうに見えます。

exploit

でき上がったものがこちら

from ptrlib import Socket, chunks
from tqdm import tqdm
import secrets

ROUNDS = 2**22 + 2

sbox = [109, 86, 136, 240, 199, 237, 30, 94, 134, 162, 49, 78, 111, 172, 214, 117, 90, 226, 171, 105, 248, 216, 48, 196, 130, 203, 179, 223, 12, 123, 228, 96, 225, 113, 168, 5, 208, 124, 146, 184, 206, 77, 72, 155, 191, 83, 142, 197, 144, 218, 255, 39, 236, 221, 251, 102, 207, 57, 15, 159, 98, 80, 145, 22, 235, 63, 125, 120, 245, 198, 10, 233, 56, 92, 99, 55, 187, 43, 25, 210, 153, 101, 44, 252, 93, 82, 182, 9, 36, 247, 129, 3, 84, 74, 128, 69, 20, 246, 141, 2, 41, 169, 59, 217, 137, 95, 189, 138, 116, 7, 180, 60, 18, 238, 73, 133, 121, 62, 87, 40, 213, 37, 33, 122, 200, 192, 118, 205, 135, 53, 58, 89, 201, 21, 193, 149, 8, 112, 81, 243, 131, 158, 188, 154, 211, 147, 164, 195, 181, 222, 178, 67, 76, 115, 150, 127, 103, 254, 1, 249, 186, 88, 177, 61, 14, 152, 106, 161, 229, 70, 160, 175, 29, 224, 66, 38, 91, 79, 185, 114, 190, 6, 110, 194, 250, 119, 0, 230, 176, 51, 104, 219, 215, 151, 75, 13, 23, 165, 11, 139, 42, 167, 52, 85, 156, 253, 163, 19, 35, 140, 107, 31, 143, 166, 32, 47, 132, 239, 234, 71, 241, 157, 170, 64, 100, 16, 97, 227, 204, 34, 4, 50, 126, 209, 174, 46, 45, 28, 232, 24, 212, 244, 220, 173, 17, 54, 231, 108, 65, 202, 27, 68, 26, 183, 148, 242]
inv = {v:i for i,v in enumerate(sbox)}


def encrypt1(k, plaintext):
    a,b,c,d = plaintext
    for i in range(ROUNDS):
        a ^= sbox[b ^ k[(2*i)&3]]
        c ^= sbox[d ^ k[(2*i+1)&3]]
        a,b,c,d = b,c,d,a
    return bytes([a,b,c,d])

def encrypt1_reduced(k, plaintext):
    a,b,c,d = plaintext
    order = 0
    for i in range(ROUNDS):
        a ^= sbox[b ^ k[(2*i)&3]]
        c ^= sbox[d ^ k[(2*i+1)&3]]
        a,b,c,d = b,c,d,a
        key = bytes([a,b,c,d])
        if key == plaintext:
            order = i + 1
            break
    else:
        raise ValueError("ha?")

    for i in range(ROUNDS % order):
        a ^= sbox[b ^ k[(2*i)&3]]
        c ^= sbox[d ^ k[(2*i+1)&3]]
        a,b,c,d = b,c,d,a

    return bytes([a,b,c,d])

def encrypt2(k, plaintext):
    a,b,c,d = plaintext
    for i in range(ROUNDS)[::-1]:
        b,c,d,a = a,b,c,d
        c ^= sbox[d ^ k[(2*i)&3]]
        a ^= sbox[b ^ k[(2*i+1)&3]]
    return bytes([a,b,c,d])

def encrypt2_reduced(k, plaintext):
    a,b,c,d = plaintext
    order = 0
    for i in range(ROUNDS)[::-1]:
        b,c,d,a = a,b,c,d
        c ^= sbox[d ^ k[(2*i)&3]]
        a ^= sbox[b ^ k[(2*i+1)&3]]
        key = bytes([a,b,c,d])
        order += 1
        if key == plaintext:
            break
    else:
        raise ValueError("ha?")

    for i in range(ROUNDS % order)[::-1]:
        b,c,d,a = a,b,c,d
        c ^= sbox[d ^ k[(2*i)&3]]
        a ^= sbox[b ^ k[(2*i+1)&3]]

    return bytes([a,b,c,d])

N = 500
def main():
    sock = Socket("localhost", 9999)

    # fix b, d
    B, D = secrets.token_bytes(2)

    query1 = set()
    while len(query1) != N:
        a, c = secrets.token_bytes(2)
        query1.add(bytes([a, B, c, D]))
    query1 = list(query1)

    query2 = set()
    while len(query2) != N:
        a, c = secrets.token_bytes(2)
        query2.add(bytes([c, D, a, B]))  # twist
    query2 = list(query2)

    sock.recvuntil("ENCRYPT 1")
    sock.sendlineafter("hex): ", b"".join(query1).hex())

    sock.recvuntil("ENCRYPT 2")
    sock.sendlineafter("hex): ", b"".join(query2).hex())

    res = bytes.fromhex(sock.recvlineafter("result:").decode().strip())
    res = chunks(res, 4)
    res1 = res[:N]
    res2 = res[N:]

    # search slid pair
    for i in tqdm(range(len(res1))):
        a, b, c, d = query1[i]
        x, y, z, w = res1[i]
        for j in range(len(res2)):
            a_, b_, c_, d_ = query2[j]
            x_, y_, z_, w_ = res2[j]

            if y_ == w and w_ == y:
                print("[+] slid pair is found")

                for t in tqdm(range(256)):
                    k0 = inv[c_ ^ t ^ a] ^ b
                    k1 = inv[a_ ^ t ^ c] ^ d

                    k0_ = inv[x ^ t ^ z_] ^ y
                    k1_ = inv[z ^ t ^ x_] ^ w
                    if k0 == k0_ and k1 == k1_:
                        print("[+] delta is found")

                        for k2 in tqdm(range(256)):
                            k3 = t ^ k2
                            key = bytes([k0, k1, k2, k3])

                            if encrypt1_reduced(key, query1[i]) == res1[i]:
                                print("k2, k3 are found")
                                print("key is: {}".format(key.hex()))

                                sock.sendlineafter("hex): ", key.hex())
                                sock.interactive()
                                quit()


if __name__ == '__main__':
    main()

まとめ

ブロック暗号でラウンド鍵が使い回されている場合に誕生日攻撃によって任意のラウンド数だけずれた平文と暗号文のペア、slid pairを発見することによって任意のラウンドだけが適用されたペアを発見し攻撃するというスライド攻撃の原理を紹介し、応用としてPlaid CTF 2022で出題されてchoreographyという問題を解いてきました。いかがでしたか? 私はこんなの解けるわけないだろと思いましたが、本番で意外と14solves もあるんですよね。嘘だろ! それでpressureが18solvesということは、CTF界隈の暗号屋は大別して何でも解ける層と何も解けない層に分類されることがわかりますね。そこそこの問題だけ解けるという存在は意外とレアなのか……?

スライド攻撃についてここがわからなかった / スライド攻撃のことをお前は何もわかっとらん。教えたるから正座せんかい / choreographyはこう考えるとわかりやすいです / 〇〇についても知りたいですなどの声をお待ちしております

参考文献

*1:slideのtypoではなく"slid pair"で正しいです。slidはslideの過去分詞形

*2:なんかもうちょっと言い方あるだろという気がします。なんていうべきなんですか

*3:文章中に明に登場しないので適当なと言っていますが実際には \Delta k_0とも k_1ともxorを取られる都合上 \Delta = k_0 \oplus k_1 です

*4:ここで使われる鍵は k_0であることに注意

*5:ブロック暗号の総ラウンド数が偶数の場合