💵

SECCON CTF 13 Quals 脆弱Writeup

2024/11/28に公開

SECCON CTF 13 予選をチーム「full_weak_engineer | Please subscribe! -> https://asusn.online」で参加しました。

国内10位という結果でした。

https://twitter.com/secconctf/status/1860608031530393830

今年は、国内の上位8チームが決勝に進出できるのですが、あと1チーム差で惜しくも決勝進出ならずでした。(国内トップチームが国際決勝進出のため国内9位までが決勝進出)

悔しいです。
大会終了後「来年強くなってまた会おうな✋」と漫画みたいな解散をしました。

チームメンバーのWriteup

https://zenn.dev/tchen/articles/0efc8f9679a818

https://zenn.dev/shirano/articles/30fea5d47f530c

https://note.com/elbasable_81018/n/n12fac126fc0e

dual_summon (123pt, 63 Solved)

自分が解けた問題について、解法を書いていきます。

20時間ほどこの問題に費やして、大会終了まで1時間を切った13:10にflagを提出できしました。
諦めずに取り組んでよかったです。

作問者のkurenaifさんがwriteup streamで「GCM初めての人でも頑張れば大会中に解けるレベル」と言っていましたが、本当にその通りなんですがギリギリでした(笑)

問題

問題は至ってシンプルでした。
AESのGCMモードにおいて、keyが異なり、nanceは一致している場合に、認証用のタグを一致させる平文を送ることはできるか?という問題です。

from Crypto.Cipher import AES
import secrets
import os
import signal

signal.alarm(300)

flag = os.getenv('flag', "SECCON{sample}")

keys = [secrets.token_bytes(16) for _ in range(2)]
nonce = secrets.token_bytes(16)

def summon(number, plaintext):
    assert len(plaintext) == 16
    aes = AES.new(key=keys[number-1], mode=AES.MODE_GCM, nonce=nonce)
    ct, tag = aes.encrypt_and_digest(plaintext)
    return ct, tag

# When you can exec dual_summon, you will win
def dual_summon(plaintext):
    assert len(plaintext) == 16
    aes1 = AES.new(key=keys[0], mode=AES.MODE_GCM, nonce=nonce)
    aes2 = AES.new(key=keys[1], mode=AES.MODE_GCM, nonce=nonce)
    ct1, tag1 = aes1.encrypt_and_digest(plaintext)
    ct2, tag2 = aes2.encrypt_and_digest(plaintext)
    # When using dual_summon you have to match tags
    assert tag1 == tag2

print("Welcome to summoning circle. Can you dual summon?")
for _ in range(10):
    mode = int(input("[1] summon, [2] dual summon >"))
    if mode == 1:
        number = int(input("summon number (1 or 2) >"))
        name   = bytes.fromhex(input("name of sacrifice (hex) >"))
        ct, tag = summon(number, name)
        print(f"monster name = [---filtered---]")
        print(f"tag(hex) = {tag.hex()}")

    if mode == 2:
        name   = bytes.fromhex(input("name of sacrifice (hex) >"))
        dual_summon(name)
        print("Wow! you could exec dual_summon! you are master of summoner!")
        print(flag)

解法に至るまでの過程

AESのGCMモードは初めましてだったので、そこを調べるところから始まります。
英語のWikipediaを読むところからです。(英語版なの大事。情報量が違います。画像もivが無かったりと微妙に違いました)

調査

「AES GCM CTF」などで検索して出てきたwriteupなどを漁っていましたが、タグを一致させるようなものは見つからず。

作問者のkurenaifさんのGCMについての解説問題も出てきました。
https://youtu.be/nXi3Mpufe5M

これを見ることで、GCMについての理解度が高まりました。

動画の概要欄にはフルスクラッチの実装が貼られていて、これを動かしてみました。
https://github.com/kurenaif/ctf_lesson/tree/master/aes-gcm

動かしてみる

このフルスクラッチを動かしながら、内部のパラメータを覗いてみることで、理解度が爆上がりしました。
実装を見てもよくわからない部分は、GCMの論文とにらめっこしました。

おかげで、ACM-GCMこの図の意味が完全に理解できました。(Hの多項式が具体的にどのような値かなど)

今回は1ブロックのみなので、もっとシンプルな図で表すことができます。
Auth Dataも指定がないので、省略できました。

ここまで理解しても、異なるkeyでタグを一致させる平文を求めるのは無理だろ!!!とずっと悩んでいました。

途中、いろいろいじっていたら、タグから平文を逆算できるようになりました。
とてなのよ〜。(シシガシラ風に)
今回の問題を解くには全く役に立たないのですが、こうやってCTFの他の問題って生まれていくんでしょうね。ストックしておきます。

着想

ここまでの理解度を持って、もう一度調べ上げた記事を読み漁っていると、やっぱりこれ近くね?というCTFの問題が見つかります。
https://meowmeowxw.gitlab.io/ctf/utctf-2020-crypto/

この問題は、好きな入力に対する暗号とタグのペアを知れるときに、特定の暗号に対するタグを作れという問題でした。
2組の暗号とタグが分かれば、Hなどのパラメータが求められるというものでした。

こういうのをForbidden Attackと呼ぶらしいです。

1周目の調査ではさっぱりわからなかった数式も、理解度を高めた上での2周目になると「読める!!読めるぞ!!」状態になりました。

Key1とKey2それぞれで、Hを求めたあとに、タグと平文を1つの変数にして、連立方程式を解けばいけるのでは???という発想になりました。
(実際、立式するのにかなり時間がかかりました。)

解法

Hを求める

まずは、Keyを1つに固定して考える。
暗号CとタグTの間では、以下の式が成り立つ。
GF(2^{128})の多項式演算)

C H^2 + L H + Ek_0 = T

これを平文Pを使って表すと以下のようになる。

(P + Ek_1) H^2 + L H + Ek_0 = T \\ P H^2 + Ek_1 H^2 + L H + Ek_0 = T

ここで、2つの平文P_1, P_2にして

P_1 H^2 + Ek_1 H^2 + L H + Ek_0 = T_1 \\ P_2 H^2 + Ek_1 H^2 + L H + Ek_0 = T_2

両辺足す。(加算はXORとなる)

P_1 H^2 + P_2 H^2 = T_1 + T_2 \\ (P_1 + P_2) H^2 + T_1 + T_2 = 0

これを解けばHが求まる。

Key1に対するH_1を求められ、同様にKey2に対するH_2も求められる。

タグと平文の連立方程式を解く

求めたい平文をP、そのとき一致するタグをTとすると、Key1、Key2に対して、以下のように式を作れる。

P H_1^2 + E_{1k_1} H_1^2 + L H_1 + E_{1k_0} = T \\ P H_2^2 + E_{2k_1} H_2^2 + L H_2 + E_{2k_0} = T

これをPについて解いていく。

P (H_1^2 + H_2^2) = E_{1k_1} H_1^2 + E_{1k_0} + E_{2k_1} H_2^2 + E_{2k_0} + L (H_1 + H_2) \\ P (H_1^2 + H_2^2) = S'_1 + S'_2 + L (H_1 + H_2)

ここで、S'_1 = E_{1k_1} H_1^2 + E_{1k_0}, S'_2 = E_{2k_1} H_2^2 + E_{2k_0} と置いた。
実は、これは求めることができる。(コードのコメントで該当箇所を記述する)

P = \frac{S'_1 + S'_2 + L (H_1 + H_2)}{(H_1^2 + H_2^2) }

よって、これがタグを一致させる平文となる。

Solver

根幹となるコードは以下の通り。
コードの一部はこちらのwriteupから拝借した。

#!/usr/bin/env sage
from sage.all import *
from Crypto.Util.number import long_to_bytes as lb
from Crypto.Util.number import bytes_to_long as bl
from binascii import unhexlify, hexlify
from sage.all import *
import struct

def bytes_to_polynomial(block, a):
    poly = 0 
    # pad to 128
    bin_block = bin(bl(block))[2 :].zfill(128)
    # reverse it to count correctly, wrong don't reverse it lol
    # bin_block = bin_block[::-1]
    for i in range(len(bin_block)):
        poly += a^i * int(bin_block[i])
    return poly

def polynomial_to_bytes(poly):
    return lb(int(bin(poly.to_integer())[2:].zfill(128)[::-1], 2))

def convert_to_blocks(ciphertext):
    return [ciphertext[i:i + 16] for i in range(0 , len(ciphertext), 16)]

def xor(s1, s2):
    if(len(s1) == 1 and len(s1) == 1):
        return bytes([ord(s1) ^^ ord(s2)])
    else:
        return bytes(x ^^ y for x, y in zip(s1, s2))

F, a = GF(2^128, name="a", modulus=x^128 + x^7 + x^2 + x + 1).objgen()
R, x = PolynomialRing(F, name="x").objgen()

# Key1の平文とタグ
P1_1 = b'0000000000000000'
T1_1 = bytes.fromhex('b4a62b2a590d64c24e739fcc21063396')
P1_2 = b'1111111111111111'
T1_2 = bytes.fromhex('4a437f6da2f84bcea8fda42b2608c458')
# Key2の平文とタグ
P2_1 = b'2222222222222222'
T2_1 = bytes.fromhex('05a6507f3cc4d284b091f5f5b0de4692')
P2_2 = b'3333333333333333'
T2_2 = bytes.fromhex('1696769e2e30b30f9d88faba59cf4911')

# Key1についてのパラメータを求める
P1_1_p = bytes_to_polynomial(P1_1, a)
P1_2_p = bytes_to_polynomial(P1_2, a)
T1_1_p = bytes_to_polynomial(T1_1, a)
T1_2_p = bytes_to_polynomial(T1_2, a)
L = struct.pack(">QQ", 0 * 8, 1 * 8)
L_p = bytes_to_polynomial(L, a)

G_1 = (P1_1_p * x^2) + (L_p * x) + T1_1_p
G_2 = (P1_2_p * x^2) + (L_p * x) + T1_2_p

P1 = G_1 + G_2
H1 = P1.roots()[0][0]
S1 = G_1(H1) # S'_1 = E_{1k_1} H_1^2 + E_{1k_0} の値

# Key2についてのパラメータを求める
P2_1_p = bytes_to_polynomial(P2_1, a)
P2_2_p = bytes_to_polynomial(P2_2, a)
T2_1_p = bytes_to_polynomial(T2_1, a)
T2_2_p = bytes_to_polynomial(T2_2, a)

F_1 = (P2_1_p * x^2) + (L_p * x) + T2_1_p
F_2 = (P2_2_p * x^2) + (L_p * x) + T2_2_p

P2 = F_1 + F_2
H2 = P2.roots()[0][0]
S2 = F_1(H2) # S'_2 = E_{2k_1} H_2^2 + E_{2k_0} の値

# タグが一致する平文を求める
plaintext_with_same_tag_p = (L_p * (H2 + H1) + (S2 + S1)) / (H2^2 + H1^2)
plaintext_with_same_tag = polynomial_to_bytes(plaintext_with_same_tag_p)
print(f'{plaintext_with_same_tag = }')

問題のサーバに接続して、Key1に対するパラメータを推定するために、「0000000000000000」と「1111111111111111」のタグを取得する。
同様に、Key2でも「2222222222222222」「3333333333333333」のタグを取得する。

これらの値からタグが一致する平文を計算して、それをサーバに送ってあげればflagゲット!

SECCON{Congratulation!_you are_master_of_summonor!_you_can_summon_2_monsters_in_one_turn}

おわりに

来年強くなってまた会おう✋

https://www.youtube.com/@full-weak-engineer/community

チーム「脆弱エンジニア(full_weak_engineer)」では、SECCON CTF決勝進出を目指してCTFに取り組んでいます。

チームでCTFをやりたいメンバーを常に募集していますので、YouTubeの概要欄にあるDiscordリンクから、気軽にご参加ください!

Discussion