😭

TRX CTF2026 Writeup

に公開

はじめに

これはチーム Vongole bianco、4/25 04:00 ~ 4/27 04:00開催のTRX CTF 2026に関するWriteupです。
チームメンバーは
・Wing2C1
・Lqd0f
になります。Lqd0fは解けた問題がありませんので今回彼のはありません。かくいう話、私も1問しか解けなかったです :(
Category, Quizの順に書いていきます。

Sanity

Sanity Check

Description

素直に入力します。

flag

TRX{DAJE_ROMA}

Crypto

HBKG

Description

HB key generator? I wonder what HB stands for...

Attachments
crypto_hbkg.zip

唯一解けたやつです。
まず、重要なファイルを提示します。

HBKG.sage
import random
import hashlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad

TIMEOUT = 60
FLAG = "TRX{fake_flag}"

def generate_rotation_matrix():
        theta = pi * random.randint(1, 2^16) / random.randint(1, 2^16)
        phi = pi * random.randint(1, 2^16) / random.randint(1, 2^16)
        v = vector([cos(theta)*sin(phi), sin(theta)*sin(phi), cos(phi)])

        theta = random.randint(1, 359)*pi/180

        u = v / v.norm()
        K = matrix([
                [0, -u[2], u[1]],
                [u[2], 0, -u[0]],
                [-u[1], u[0], 0]
        ])
        R = matrix.identity(3) + sin(theta)*K + (1 - cos(theta))*(K*K)

        return R, v

def get_tangent(point, axis):
        return point.cross_product(axis).simplify_full()

def check_on_sphere(point):
        return (point.norm()^2 - 1).simplify_full().n() == 0

def read_point():
        print("Insert a point")
        x, y, z = var('x y z')
        v = vector([x, y, z])

        values = [SR(input("x: ")), SR(input("y: ")), SR(input("z: "))]
        v = vector([x.subs({x: values[0]}), y.subs({y: values[1]}), z.subs({z: values[2]})])
        if not check_on_sphere(v):
                print("point not on the sphere")
                exit()
        return v

def rotate(v, R):
        return v*R

def cipher(v, axis):
        t = get_tangent(v, axis)
        k = t*random.randint(2^127, 2^128)

        data = ','.join([str(c) for c in k])
        h = hashlib.sha256(data.encode()).digest()
        aes = AES.new(h, AES.MODE_ECB)
        ciphertext = aes.encrypt(pad(FLAG.encode(), AES.block_size))

        return ciphertext.hex()

def print_menu():
        print("Welcome to HB Key Generator!")
        print("1) Rotate a point")
        print("2) Encrypt")

def main():
    R, axis = generate_rotation_matrix()

    can_rotate = 0
    print_menu()
    while True:
        choice = int(input("> "))
        if choice == 1:
            if can_rotate < 2:
                can_rotate += 1
                print(rotate(read_point(), R))
            else:
                print("can't rotate anymore")
                exit()
        elif choice == 2:
            print(cipher(read_point(), axis))
            exit()
        else:
            print("Invalid choice")
            exit()

if __name__ == "__main__":
    signal.alarm(TIMEOUT)
    main()

これが暗号化するためのファイルです。SageMath(以下Sage)というものがあります。
Pythonをベースとした数学の幅広い処理を扱うソフトウェアです。
これがサーバー上で動いているためそれに接続して挙動を見て解読します。
FLAG = "TRX{fake_flag}"
より、配布ファイルにあるフラグではないことは明らかです。
さて、このファイルの流れを解説します。

1.  R(回転行列)と axis(回転軸ベクトル)を生成
2.  ループ開始
2.1 ユーザーの点を R で回転させて表示(最大2回まで)
2.2 ユーザーの点と axis から暗号化したフラグを出力

ここから重要な関数を解説していく。

generate_rotation_matrix()

ランダムな角度 \theta, \phi でベクトル v を生成

\begin{align} v = (\cos\theta \sin\phi, \sin\theta \sin\phi, \cos\phi) \end{align}

次に v の単位ベクトル u を作成して以下の交代行列を作成。しかし、実は v のノルムが1である(単位ベクトルである)ため、 u = v となる。

\begin{align} K = \left[\begin{array}{cc} 0 & -u_2 & u_1 \\ u_2 & 0 & -u_0 \\ -u_1 & u_0 & 0 \end{array} \right] \end{align}

コード内で\thetaを使いまわしているというよろしくない書き方をしているため、区別するために \vartheta とすると、ランダムな角度 \vartheta を用いて回転行列 R を生成

\begin{align} R = I +\sin\vartheta\times K+(1-\cos\vartheta)K^2 \end{align}

最後に R, v を返す。そのうち、v が今回の暗号化に使われる。

read_point()

位置ベクトルを入力する。単位球上の点でなかったらエラーを返す。

rotate(v, R)

位置ベクトルを回転行列 R で回転させたものを返す。

cipher(v, axis)

入力された位置ベクトルと(1)のベクトル v を用いた暗号化。(引数がややこしい)
引数のv, \text{axis}の外積 t を計算し、2^{127}\verb|~|2^{128}までのランダムなスカラーを掛けた k を計算する。
k の文字列表現を SHA-256 ハッシュしたものをAES鍵としたもので、FLAG を暗号化して出力する。

このコードには暗号化する際の致命的な脆弱性がある。それは、cipher関数においてv, \text{axis}が同じならばその外積は 0 になるということである。これはベクトルとベクトルとの外積が平行四辺形になることを知っていると容易なはずだ。つまり、鍵が文字列"0,0,0"で固定できるということである!!
\text{axis}はもう固定化されているので、変更できるのは v のみ、ではどうやって v\text{axis}にするのか。\text{axis} は(1)のベクトルであるため、各成分の値を入力すればいい。その成分の求め方は容易である。
rotateする際に、例として1,0,0を順に入力すると

((cos(31/180pi) + 
1)cos(3058/57645pi)sin(2312/7501pi)^2sin(3058/57645pi)/(cos(3058/57645p
i)^2sin(2312/7501pi)^2 + sin(2312/7501pi)^2sin(3058/57645pi)^2 + 
cos(2312/7501pi)^2) + 
cos(2312/7501pi)sin(31/180pi)/sqrt(cos(3058/57645pi)^2sin(2312/7501pi)^
2 + sin(2312/7501pi)^2sin(3058/57645pi)^2 + cos(2312/7501pi)^2), -
(cos(3058/57645pi)^2sin(2312/7501pi)^2/(cos(3058/57645pi)^2sin(2312/750
1pi)^2 + sin(2312/7501pi)^2sin(3058/57645pi)^2 + cos(2312/7501pi)^2) + 
cos(2312/7501pi)^2/(cos(3058/57645pi)^2sin(2312/7501pi)^2 + 
sin(2312/7501pi)^2sin(3058/57645pi)^2 + cos(2312/7501pi)^2))
(cos(31/180pi) + 1) + 1, (cos(31/180pi) + 
1)cos(2312/7501pi)sin(2312/7501pi)sin(3058/57645pi)/(cos(3058/57645pi)^
2sin(2312/7501pi)^2 + sin(2312/7501pi)^2sin(3058/57645pi)^2 + 
cos(2312/7501pi)^2) - 
cos(3058/57645pi)sin(2312/7501pi)sin(31/180pi)/sqrt(cos(3058/57645pi)^2
sin(2312/7501pi)^2 + sin(2312/7501pi)^2sin(3058/57645pi)^2 + 
cos(2312/7501pi)^2))

と返ってくるとしよう。数式のままで返ってくることから角度が窃取できる。
角度がそのまま出てくる理由は、Rの一行目がそのまま出てくるためである。計算は各自で代入して確認されたい。
ここで、\cfrac{31\pi}{180}, \cfrac{2312\pi}{7501}, \cfrac{3058\pi}{57645}の3種類しかないことに気づくだろう。これらは順に\vartheta, \phi, \thetaである。なぜならば、生成方法と式に着目すると順に

theta = random.randint(1, 359)*pi/180
phi = pi * random.randint(1, 2^16) / random.randint(1, 2^16)
theta = pi * random.randint(1, 2^16) / random.randint(1, 2^16)

\varthetaは1から359のランダムな数を180で割ったものに\piを掛けたものであり、他と比べ分数が簡単になりやすい。
(1)の式から、\cos\phiの記述より、\cosで独立しているものは\phiのみであるからである。
よって、読み取った角度、例でいうならx,y,zを順に

x:cos(3058*pi/57645)sin(2312*pi/7501)
y:sin(3058*pi/57645)sin(2312*pi/7501)
z:cos(2312*pi/7501)

と入力すると

cb1cca360b02a47fe8f9a59b7331d491c3298f75420e91a775c43d2fc68f0619329e280
4293a2adb7188716be74601f2d14f6a59c3b6179bdb0c943421a770d0c71784c7f43c94
7e19ef4e00e9654919f612cdead1daf549fa5047b0b21032d3

が固定で返ってくる。あとはこれを"0,0,0"でデコードするとflagが返ってくる。

flag

TRX{y0u_c4n7_c0mb_TheHairyCoconut_https://en.wikipedia.org/wiki/Hairy_ball_theorem}

おわりに

決勝戦がイタリアで開催されるという情報しか知らずにでたらめに実力試しとして応募しました。
TRX CTFについて、公式Discordのアナウンスに以下の記述があります。

Welcome to the 2nd edition of TRX CTF!
TRX CTF is a Jeopardy-style competition featuring challenges in Web, Pwn, Rev, Crypto, Misc, and Blockchain.
The top 8 eligible teams will qualify for the on-site finals to be held in Italy this autumn.

Jeopardy-styleとは、一般的なCTFの形式で様々なカテゴリを好きな順に解いていくCTFです。
第二回目のCTFだそうで、新興のようです。
そんでもって、舐めてかかったらほぼ解けずに終わりました。伸びしろがあるってことですね!

V0ng0le b1anc0

Discussion