ふるつき

v(*'='*)v かに

zer0pts秘伝のcrypto-writeupを公開します

タイトルは盛りました。zer0ptsのFurutsukiです。

2019年ごろから個人的なwriteupあるいはupsolveのためにcrypto-writeupという名前でscrapboxを作って、知見をためてチートシート的に使ったりしていました。あんまりちゃんとやっていたわけではないんですが、いまではだいたい800ページくらいに成長していて、問題を考えるときや解くときに重宝することもあります。

ある程度記事が溜まったあとはチームメイトにも公開して、zer0ptsの幾人かにもいくつかテクや知見を寄せてもらっていてなかなか価値のあるものに仕上がってきているのではないかと思います。

しかし昨年末くらいからはあまり時間が取れず、内容としては古くなってきてしまい価値が失われているなどの問題があり、秘匿することによってチームが得られるメリットも少なくなってしまいました。これ以上秘匿していてもだんだん滅びて価値を失うだけと考えて、この機会にcrypto-writeupを公開することにしました。もはやあんまり大した内容はないですが、cryptoを始めたばかりの人などに役立ててもらえればと思っています

以下免責事項です

  • 半分くらいのページは書きかけまたはタイトルだけのような状況です
  • 個人的に書いていたものなので間違いや不正確な理解があるかもしれません
  • 特定の問題をけなすような表現があることがあります

それでもよければこちらからどうぞ(もとのScrapboxからはてなブログに作り直しました)

crypto-writeup-public.hatenablog.com

crypto-writeup自体は今後もチームで記事を細々とかいて行こうと思います、内容は適宜publicにも反映します

この記事はCTF Advent Calendarの4日目の記事です。まだうまってないのでみんな書いてくれ!

HITCON CTF 2022 writeup

もう日が経ってしまったけど、HITCON CTF 2022のcrypto問題のwriteupです。難しい問題は解けなかったけど簡単な問題も楽しかったです。あらゆるところで言ってるけど、mapleとlyxがめちゃめちゃ信用できる。

Baby SSS

from random import SystemRandom
from Crypto.Cipher import AES
from hashlib import sha256
from secret import flag

rand = SystemRandom()


def polyeval(poly, x):
    return sum([a * x**i for i, a in enumerate(poly)])


DEGREE = 128
SHARES_FOR_YOU = 8  # I am really stingy :)

poly = [rand.getrandbits(64) for _ in range(DEGREE + 1)]
shares = []
for _ in range(SHARES_FOR_YOU):
    x = rand.getrandbits(16)
    y = polyeval(poly, x)
    shares.append((x, y))
print(shares)

secret = polyeval(poly, 0x48763)
key = sha256(str(secret).encode()).digest()[:16]
cipher = AES.new(key, AES.MODE_CTR)
print(cipher.encrypt(flag))
print(cipher.nonce)

とりあえずひと目見てわかることを整理します。

  • 一見Shamir's Secret Sharing
  • 多項式を復元して x = 0x48763を代入した値が得られればOK
  • 128次の多項式なのに対して、8個しかシェアが与えられていないので、通常は解けなさそう
  •  \mod pとっていないのが怪しい

とりあえず一式から多項式全部を復元できないかを考えますが、多項式の係数は64bitであるのに対して、各シェアの xは16bitなので、たとえばもらっている y_1に対して y_1 \mod x_1とかをとっても、0次の係数に関しては1/4しか復元できなさそうです。

しかし今回は8個のシェアが与えられているので、それぞれのシェアで y_i \mod x_i を取ると、係数の定数項を中国剰余で復元できそうです。定数項(仮に k_{i, 0}とします)がわかれば、 y_i = k_0 + \sum_{j=1}^{N} k_0x_i^jなので  (y_i - k_0) / x_i \equiv k_1 \mod x_iとなり、次々に係数が求まります。

実装するとこう

import ast
from hashlib import sha256
from Crypto.Cipher import AES

with open("output.txt") as f:
    shares = ast.literal_eval(f.readline())
    ciphertext = ast.literal_eval(f.readline())
    nonce = ast.literal_eval(f.readline())


def polyeval(poly, x):
    return sum([a * x**i for i, a in enumerate(poly)])


DEGREE = 128
SHARES_FOR_YOU = 8  # I am really stingy :)


coeffs = []
for d in range(DEGREE + 1):
    pairs = []
    values = []
    mods = []
    for i in range(len(shares)):
        x, y = shares[i]
        s = y - polyeval(coeffs, x)
        values.append(s // x**d)
        mods.append(x)

    c = CRT(values, mods)
    coeffs.append(c)


secret = polyeval(coeffs, int(0x48763))
key = sha256(str(secret).encode()).digest()[:16]
cipher = AES.new(key, AES.MODE_CTR, nonce=nonce)
print(cipher.decrypt(ciphertext))

ptrlibのCRTメソッドは各 x_iが厳密に互いに素であることを要求してきたので今回はSagemathを使いました。そのうち治したい。

Secret

問題はこう

import random, os
from Crypto.Util.number import getPrime, bytes_to_long

p = getPrime(1024)
q = getPrime(1024)
n = p * q

flag = open('flag','rb').read()
pad_length = 256 - len(flag)
m = bytes_to_long(os.urandom(pad_length) + flag)
assert(m < n)
es = [random.randint(1, 2**512) for _ in range(64)]
cs = [pow(m, p + e, n) for e in es]
print(es)
print(cs)

唯一の nのもとで、64個の { e_i }を用いて c_i = m^{e_i + p} \mod nという暗号化をしているRSAです。一見Common Moudulus Attack典型のような形をしていますが、 nが不明なので、まず nを求める必要がありそうです。

これについては最近N1CTF 2022で出題されたBrand new checkinという問題で似たような要求があったことを思い出し、出題者であるmapleさんのwriteupを見に行ったところ、さらに過去にmapleさんが出題した問題の解説がありました。

github.com

これによれば、以下のような理屈で c eの複数の組から nが求まります。

複数の数の組 {a_i}を仮定して、 a_0e_0 + a_1e_1 + \dots = 0 \mod \phi(n)となるとき、 \prod m^{a_ie_i} \equiv m^{1} \equiv 1 \mod nなので、 \prod c_i^{a_i} -1 = knとなり、このような {a_i}と同様の {b_i}を用意して、 \gcd(\prod c_i^{a_i} - 1, \prod c_i^{b_i} - 1) = nとなることが期待できます*1

さて、 {e_i}からそのような {a_i}を求めるのはLLLの得意とするところで、

\begin{pmatrix} e_0 & 1 \\ e_1 & & 1 \\ \vdots & & & \ddots \end{pmatrix}

という行列のLLLでそのような係数が複数求まります*2

今回は少し発展して \sum a_i (p + e_i) \equiv 1 \mod \phi(n) の場合について考える必要があるので一筋縄ではいかない、というかこれをLLLで解くのは無理じゃないか……? と思っていましたが、なんか実験していたら適当なスケーリングでなんとかなりました……。なんで……? LLLのこと何もわかりません*3

下のプログラムで nが求まります。あとはCommon Modulus Attackをやるだけなので省略。ptrlibでできます。

import ast

f = open("output.txt")
es = ast.literal_eval(f.readline())
cs = ast.literal_eval(f.readline())


K = 2**1024
M = matrix(QQ, len(es), len(es)+1)
M.set_block(0, 0, matrix(QQ, len(es), 1, [e + K for e in es]) * K)
M.set_block(0, 1, matrix.identity(len(es)))

L = M.LLL()
S = product(int(c)**r for c, r in zip(cs, L[0][1:]))
T = product(int(c)**r for c, r in zip(cs, L[1][1:]))
t2 = cputime()

r = gcd(S.numerator() - S.denominator(), T.numerator() - T.denominator()) 
print(r)

Superprime

from Crypto.Util.number import getPrime, isPrime, bytes_to_long


def getSuperPrime(nbits):
    while True:
        p = getPrime(nbits)
        pp = bytes_to_long(str(p).encode())
        if isPrime(pp):
            return p, pp


p1, q1 = getSuperPrime(512)
p2, q2 = getSuperPrime(512)
p3, q3 = getSuperPrime(512)
p4, q4 = getSuperPrime(512)
p5, q5 = getSuperPrime(512)

n1 = p1 * q1
n2 = p2 * p3
n3 = q2 * q3
n4 = p4 * q5
n5 = p5 * q4

e = 65537
c = bytes_to_long(open("flag.txt", "rb").read().strip())
for n in sorted([n1, n2, n3, n4, n5]):
    c = pow(c, e, n)

print(f"{n1 = }")
print(f"{n2 = }")
print(f"{n3 = }")
print(f"{n4 = }")
print(f"{n5 = }")
print(f"{e = }")
print(f"{c = }")

5重のRSAですが素数の生成が特殊で、素数 p_iと対になる q_iは「 p_iの文字列をバイト列だと思って解釈し直したときの数値」です。つまり、 p_i 123\dotsなら q_i = 0x313233\dotsです。これらの p_i, q_iが様々に組み合わされて n_1 \dots n_5ができています。

結論から言えば、これらの合成数はその成り立ちが特異なので全てDFSやBFSで枝刈りをしながら素数の候補を狭めていく方法で素因数分解できますが、今回はケースによって少しずつ違う解き方をしたのでそれらを紹介します。勘が良ければ一気呵成に全部を素因数分解する方法も思いつけるのかも。

まず第一に n_3は、以前SECCON 2020 Qualsで出題されたThis is RSAという問題と同じネタなので、この方法で解けます。素因数のどちらも 0x3a3b3c\dotsという形になっているので4bitを全探索しながら、上位の 0x30と合わせつつ、 p, qの下位bitの探索結果の積と素因数分解の対象の nの下位bitとが一致するかを確認します。

こういう感じ

def dfs(N, p, q, depth):
    for x in range(10):
        for y in range(10):
            if depth == 0 and y < x:
                continue
            ps = str(x).encode().hex() + p
            qs = str(y).encode().hex() + q
            pp = int(ps, 16)
            qq = int(qs, 16)

            if (pp*qq) % (2**((depth+1) * 8)) == N % (2**((depth + 1) * 8)):
                if N % pp == 0:
                    return [pp, N // pp]
                r = dfs(N, ps, qs, depth + 1)
                if r is not None:
                    return r

q2, q3 = dfs(n3, "", "", 0)
assert n3 == q2 * q3

続いて n_1 p qの間に関連性があるので、 pのある桁を決めると自動的に対応する qの桁が決まります。これを使って、今度は上位から探索する方法をとります。だいたい pと同じ大きさの p'を用意すれば n / p' \approx qで、上位の桁は一致することが期待できるので、 pの上位を決めながら n / pの結果の上位の桁が qと一致しているかを確認しました。

こう

def dfs2(N, s, l):
    if l == 0 and N % int(s, 16) == 0:
        return int(s, 16), N // int(s, 16)

    for x in range(10):
        prefix = s + str(x).encode().hex()
        d = bytes.fromhex(prefix).decode()
        q_ = int(prefix + "0" * (l-2), 16)
        p_ = N // q_

        if str(p_).startswith(d):
            r = dfs2(N, prefix, l-2)
            if r:
                return r

L = ((n1.bit_length() - 512) + 3) // 4
p1, q1 = dfs2(n1, "", L)
assert n1 == p1 * q1

そして最後に n_4, n_5ですが、今度は n_4の素因数である p_4を決めると n_5の素因数である q_4が決まる、といった具合で2つの合成数にまたがった関係があります。とはいえ n_1の場合と同様にやって、 p_4 p_5を同時に探索しながら、対応する n_4 / p_4, n_5 / p_5がそれぞれ q_5, q_4と上位の桁で一致するかを確認しながら決めていけばなんとかなりました。今回は上位から決めていった関係で桁数の見積もりをミスっていると素因数分解できないため、桁数は探索してます。

def dfs3(N1, N2, s1, s2, l1, l2):
    if l1 == 0:
        q1 = int(s1, 16)
        if N1 % q1 == 0:
            return (q1, N1 // q1)
        if N2 % q1 == 0:
            return (q1, N2 // q1)
    if l2 == 0:
        q2 = int(s2, 16)
        if N1 % q2 == 0:
            return (q2, N1 // q2)
        if N2 % q2 == 0:
            return (q2, N2 // q2)
    for x in range(10):
        for y in range(10):
            s1_ = s1 + str(x).encode().hex()
            s2_ = s2 + str(y).encode().hex()

            d1 = bytes.fromhex(s1).decode()
            d2 = bytes.fromhex(s2).decode()

            q1_ = int(s1_ + "0" * (l1-2), 16)
            q2_ = int(s2_ + "0" * (l2-2), 16)

            p1_ = N1 // q2_
            p2_ = N2 // q1_

            if str(p1_).startswith(d1) and str(p2_).startswith(d2):
                r = dfs3(N1, N2, s1_, s2_, l1-2, l2-2)
                if r:
                    return r

found = False
for d1 in range(0, 5, 2):
    for d2 in range(0, 5, 2):
        r = dfs3(n4, n5, "", "", L + d1, L+d2)
        if r:
            q4, p5 = r
            p4 = int(bytes.fromhex(hex(q4)[2:]))
            q5 = n4 // p4
            found = True
            break
    if found:
        break
assert n4 == p4 * q5
assert n5 == p5 * q4

素因数分解できたらあとはRSAを復号しておわりです。This is RSAを見たときには n_1 n_4, n_5を思いつかなかったのが悔しいですね*4

*1:実際にはLLLで整数解ではなく有理数解が得られるので分母を払った形のgcdをとることになるとおもいます。詳しくはmapleさんの解説にあります

*2:実践的にはスケーリングをすることになります

*3:まあなんか a_ip + a_jp = 0となるようにうまく選んで打ち消してるんだと思う

*4:それどころか当時は n_3すら解けてませんでしたが…

SECCON CTF 2022作問感想:janken vs kurenaif

こんにちは。今年もSECCON CTF 2022 (Qualification)が無事終了しました。私はCTF WGの一員として運営に参加していて、今回はCryptoの問題を一問提供しているのでその感想です。

提供した問題はjanken vs kureanifというやつで、これのauthorがtheoremoonでいいのかって感じの名前のやつです。zer0pts CTF 2021で出題したjanken vs yoshikingの流れを汲む正統派(?)jankenシリーズですが、問題としては全く異なるので当然以前の問題について知らなくても解けます。この問題をみてjanken vs yoshikingのことを思い出した人はいないと思う。このカシオミニを賭けても良いです

問題としてはかなりシンプルで、名前の通りあのcrypto witch, kurenaifとじゃんけん勝負をすることになりますが、互いの手は乱数によって決まります。kurenaifの手を決める乱数のシードは事前に教えてもらえているので、kurenaifにじゃんけんで666連勝できるようなシードを入力できれば良いです。

import os
import signal
import random
import secrets

FLAG = os.getenv("FLAG", "fake{cast a special spell}")


def janken(a, b):
    return (a - b + 3) % 3


signal.alarm(1000)
print("kurenaif: Hi, I'm a crypto witch. Let's a spell battle with me.")

witch_spell = secrets.token_hex(16)
witch_rand = random.Random()
witch_rand.seed(int(witch_spell, 16))
print(f"kurenaif: My spell is {witch_spell}. What about your spell?")

your_spell = input("your spell: ")
your_random = random.Random()
your_random.seed(int(your_spell, 16))

for _ in range(666):
    witch_hand = witch_rand.randint(0, 2)
    your_hand = your_random.randint(0, 2)

    if janken(your_hand, witch_hand) != 1:
        print("kurenaif: Could you come here the day before yesterday?")
        quit()

print("kurenaif: Amazing! Your spell is very powerful!!")
print(f"kurenaif: OK. The flag is here. {FLAG}")

この問題では次のような知識を要求しています。

  • pythonのrandomモジュールで使われている乱数生成アルゴリズムについて(メルセンヌツイスタ法)
  • メルセンヌツイスタ法自体について(32bit × 624個の内部状態からなることなど)
  • pythonのrandomモジュールのrandintの実装(32bit未満の乱数を生成するときは32bit未満の乱数を一個生成して、その値を切り詰めている)
  • pythonのrandomモジュールのseedの実装(整数を渡したときにどのようにそれが内部状態に影響するか)

前半の3つは暗号問題を解く上では頻出の知識なので、この問題に取り組む上では知っておいてほしいと思っていたことで、今回はseedの実装を読んでそれを逆算するような計算を組めますかというところを問う問題になりました。想定解法としてはz3のようなSMTソルバを用いて、メルセンヌツイスタの内部状態とその内部状態を生成するような乱数のseedを求めることになります。私の解法では内部状態を求めるパートとseedを求めるパートは別に分けましたが、多分一気通貫にも計算できると思います。

この問題はもともと以前開催したCakeCTF 2022に出題するつもりで作ったのですが、問題を一見したときに「これができるのか」という驚きがありそうなのと実装がそれなりに大変そうということで、もしかしたらSECCON CTFにも出せるかもと思ってptr-yudaiに相談して、こちらに回すことにしました。そういう経緯があって以前はjanken vs yoshiking2という名前でyoshikingとじゃんけんしていたのですが、SECCON CTFに出すということでじゃんけんのプレイヤーをkurenaifさんに交代してもらいました。以前は挑戦者の名前を入力してもらってそれを数値にして*1シードにするという不自然さの残る問題設定だったところが、kurenaifさんは魔女ということで、魔女と呪文で勝負! という感じの問題設定にしてそこそこ自然にできて問題としても良くなりました。

さらにkurenaifさんに無茶を言ってこの問題のために動画を一本とってもらって、その動画の限定公開のURLをこの問題のフラグとしています。kurenaifさんは国内外のCTFプレイヤーによく知られている人気タレントなので、この仕組みも好評でした。思いの外solve数が伸びなかったこともあり、特に競技期間中に解けたチームはなかなか特別感が得られたのではないかと思います。まだその動画を見れていないという人は、ぜひ誰かのwriteupを読んだりして問題が稼働しているうちにupsolveしてURLを発見してください*2。そしてぜひコメントなどを残していってください。

それにしても、思ったよりこの問題が解かれなかったので驚いています。個人的には同じSECCON CTFに出ていたCrypto問題ではinsufficientと同じかもうちょっと解かれるかなと思っていましたが、結果としては一番solve数の少ない問題になりましたし、解いたチームから推測すると日本のプレイヤーでこの問題を解いたのは2人しかいなさそうです。この問題を解いたり、取り組んだけど解けなかった人はどのあたりが大変だったり難しかったのか今度教えてください。

それでは、次はSECCON CTF 2022 (Final)でお会いしましょう

*1:pythonのrandom.seedは文字列も受け付けますが、その場合途中で入力値をハッシュ関数にかける処理があるので望んだ乱数を生成するようなシードを生成するのは不可能そうでした

*2:まあ出題した問題や解法などのファイルは今後GitHubで公開するでしょうからそのタイミングをまてば問題を解かずに動画にアクセスすることはできるでしょうが、それはかなり味気ないと思います