TsukuCTF 2025 Writeup

に公開

2025年5月3日~4日に開催されたTsukuCTF2025にTeam syurenukoで同僚と2人で参戦していました。最終順位は46位/882チームでした。(1575pts)

CTFの参戦は2人とも今回のTsukuCTF2025が初めてになります。「何か面白いことを始めたい」の一言がきっかけとなり、チームでCTFを取り組むことになりました。
問題を解くにあたり、24時間ぶっ通しの作業をしてくれたチームメイトのsam氏に感謝です。

各分野の正答率はこんな感じでした。

分野 正答率
OSINT 7 / 8
Crypto 2 / 5
Web 3 / 3
Pwn 0 / 3

初めての割にはまぁまぁ解けたかな~と思います。pwnが全く歯が立たなかったので解けるようになりたいですね...(今後の課題)

余談ですが、序盤のOSINT問を解くスピードがかなり速かったらしく、7問目を解き終わった時点で全体2位の最大瞬間風速が出ていたのは興奮しました。(他分野がダメダメなのでこれ以降は順位が下がる一方でしたが...)

序盤元気マン

以下Writeupです。

[OSINT] Casca

海が綺麗なこの日本の街は、かつてポルトガルのリゾート地との交流がありました。
この写真のすぐ右側にはその記念碑が置かれています。記念碑に書かれている「式典の開催日」を答えてください。
Format: TsukuCTF25{YYYY/MM/DD}

与えられた画像を検索にかけると、写真と同じものが写っている2019年のブログが引っかかった。
https://gourmet-travelogue.doorblog.jp/archives/56226093.html

写真は静岡県熱海市で、ポルトガルのカスカイス市と姉妹都市を締結しているらしい。ブログの写真から記念碑の式典開催日を確認できないかと考えたが、残念ながら確認できず。

そこで「式典開催日は姉妹都市を締結した日では?」と読んで、姉妹都市締結日の1990年7月2日を入力するが不正解。

さらに調べていくと該当場所でセレモニーが開催された2014年のプレスリリースが見つかり、そこに日 時:平成26年6月6日(金) 午前11:00~正午の記述が見つかる。
https://prtimes.jp/main/html/rd/p/000000004.000008629.html

答えはTsukuCTF25{2014/06/06}

[OSINT] curve

これは日本の有名な場所の一部です。あなたはこの写真の違和感に気づけますか?
フラグはこの場所のWebサイトのドメインです。
例: TsukuCTF25{example.com}

回転式エスカレーターを見て三菱電機製のエスカレーターであることがわかる。(回転式エスカレーターは世界で三菱電機しか製造していないことは有名)

その希少性から設置場所は少ないはずなので、施設の写真を目視で全探索すれば正解に辿り着くと判断。回転式エスカレーターは国内で37台しかないとのことで多分行けそう。

「スパイラルエスカレーター 国内」と検索して、検索結果の画像を上から眺めていくと、与えられた写真と近い雰囲気のエスカレータの写真を掲載しているブログが引っかかる。撮影場所はランドマークタワーらしい。
https://eclat.hpplus.jp/article/133435

答えはTsukuCTF25{yokohama-landmark.jp}

[OSINT] destroyed

このTelegramの投稿の写真に写っている学校を特定してください。
フラグフォーマットはその場所の座標の小数点第4位を四捨五入して、小数第3位までをTsukuCTF25{緯度_経度}の形式で記載してください。
例: TsukuCTF25{12.345_123.456}

https://t.me/etozp/19319

書かれている外国語は読めないが、破壊された学校という写真の内容とTelegramの投稿が2022年のものであることからロシア・ウクライナ戦争のものであると予想。

衝撃的な写真であることから注目度が高く、海外のニュースサイト等で詳細が報じられている(≒明示的な答えがweb上に存在している)可能性が高いという予想と、自力で調べた時の翻訳にかかる労力と時間を考えて、ChatGPT o3のDeepResearchに推論させるのが良いと判断。問題文とTelegramのURLをChatGPTに投げる。10分ほど経つと座標が返ってきたので、それを入力すると正解。

答えはTsukuCTF25{47.854_35.195}

[OSINT] rider

遠くまで歩き、夕闇に消える足跡
煌めく街頭が、夜の街を飾る
傍らの道には、バイクの群れが過ぎ去り
風の音だけが残る

光と影の中、ふと立ち止まり思う
私は今、どこにいるんだろう

フラグフォーマットはこの人が立っている場所のTsukuCTF25{緯度_経度}です。ただし、緯度および経度は小数点以下五桁目を切り捨てたものとします。

写真の雰囲気とバイクの多さ、左側車線であることからマレーシアかインドネシアあたりと予想を付ける。ChatGPTに聞くとインドネシアのヨグヤカルタ付近である可能性があるとのこと。(本当か?)

見切れているが、写真の右に写っている看板をよく見ると「Fried Chicken」の文字が見える。場所をインドネシアのジャワ島中部と決め打ちして、フライドチキン屋を探すと以下のお店がヒット。
https://maps.app.goo.gl/9xz6rYcmBnnZARqn6

眼鏡をかけた男性の特徴的な看板も同じだったのでここが正解かと思ったが、ストリートビューで店の前をみても写真と風景が一致しない。どうやら「OTI Fried Chicken」はチェーン店らしい。

地図の範囲を広げて、OTI Fried Chickenと検索するとたくさんのお店がヒットしたが、写真と同じ2車線道路沿いにある店はそこまで多くないので、一件一件調べていけば正解に辿り着きそう。最終的に大きな湖の近くにあるお店前の風景と写真が一致した。

答えはTsukuCTF25{-7.3190_110.4971}

[OSINT] sctohnee

素敵な雪山に辿り着いた!スノーボードをレンタルをして、いざ滑走!
フラグフォーマットは写真の場所の座標の小数点第4位を四捨五入して、小数第3位までをTsukuCTF25{緯度_経度}の形式で記載してください。
例: TsukuCTF25{12.345_123.456}

スキーレンタル店の垂れ幕に「SKISET – SKI RENTAL Grindelwald」と書かれていることと、背景にアルプスらしき山が見えることから、スイス・ベルン州グリンデルワルト村、Dorfstrasse 160(Buri Sport Main Shop)付近と特定。

答えはTsukuCTF25{46.624_8.041}

[OSINT] buildings

あの建物が建ったら、また空が狭くなるんだろうな。
フラグフォーマットはこの人が立っている場所のTsukuCTF25{緯度_経度}です。ただし、緯度および経度は小数点以下五桁目を切り捨てたものとします。

ビルの雰囲気と通りの無機質感から霞が関や虎ノ門っぽいと一瞬感じたが、写真の左側のようにビルが全く無い空が広がっていることは考えにくいので東京・横浜の湾岸エリアであると予想。

  • 豊洲
  • 有明
  • 晴海
  • 勝どき
  • みなとみらい

を候補に出してGoogle Mapの3D表示で様々な方向から風景を見てみるが、一致しそうな場所が見つからない。

写真の奥に見える高層マンションと思われる建物の側面が道路と並行かつ隣接しているが、候補のエリアにある高層マンションでこの条件に当てはまるものが少なく、出した候補のエリアに正解の座標がないのではと思い始める。

なかなか見つからないので心が折れかけていた時に、左に写っているビルだけをトリミングしてGoogle画像検索をすると品川にあるビル(品川シーズンテラス)がヒット。ストリートビューで調べると写真と風景が一致。そういえば品川も湾岸に面したエリアだった。

答えはTsukuCTF25{35.6318_139.7430}

[OSINT] power

力を感じてきた。

フラグフォーマットはこの人が立っている場所のTsukuCTF25{緯度_経度}です。ただし、緯度および経度は小数点以下五桁目を切り捨てたものとします。

点字で書かれた案内板と思われる写真。
平面図らしき図面が枯山水で有名な京都の龍安寺っぽいと思って調べてみるが違う。

「史蹟」という漢字が見えることと、背景の道路が東京のオフィス街感が強いことから都内一等地の史跡と当たりを付けると「首塚(将軍塚)」が候補に浮上。図面は首塚を上から見た構図で間違いなさそう。

「パワーってそっちの意味か...」と思いながら首塚の座標を入力。

答えはTsukuCTF25{35.6872_139.7627}

[OSINT] hidden_wpath

新しいブログサイトを作成したので、ぜひ見に来てください!
ちなみに隠されたページがあります😎

注意点: ツールの使用は許可されていますが、短期間で大量にリクエストを送信しないでください。隠されたページのリンクは100文字以上あり、かつ推測不可能です。

http://challs.tsukuctf.org:9000

解答に至らず

難しかった...OSINTだけではなくWebの知識も必要な問題。
開発者ツールでページを開いて網羅的にソースコード見たり、WordpressのREST APIを確認したり、他にもリダイレクトされている可能性など色々考えて隠しページを探しましたが、結局発見できず。

/wp-adminでエラーメッセージが表示されることは途中気付いていたんですが、そのメッセージ内容の404 Solutionに着目する発想は無かったですね...

[crypto] PQC0

PQC(ポスト量子暗号)を使ってみました!
添付ファイル:prob.py output.txt

CTFに出るのにも関わらず、OpenSSLがダウンロードされたノートPCを2人とも持ち寄っていない状態からスタート。(Linux環境すら用意していない有様)

macOSのノートPCに、いそいそとHomebrewでOpenSSLをインストール。

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew install openssl@3
pip3 install pycryptodome

あとは、秘密鍵→暗号文→フラグの順に取り出してML‑KEM 768の共有鍵を取得、得られた共有鍵でAES-ECB/PKCS#7パディングを解除し、flagを取得。

# 秘密鍵 PEM
awk '/-----BEGIN PRIVATE KEY-----/,/-----END PRIVATE KEY-----/ {print}' \
    output.txt > priv-ml-kem-768.pem

# 暗号文 ciphertext.dat
grep -A1 "==== ciphertext(hex) ====" output.txt | tail -n1 | tr -d '\n' \
  | xxd -r -p > ciphertext.dat

# 暗号化フラグ encflag.bin
grep -A1 "==== encrypted_flag(hex) ====" output.txt | tail -n1 | tr -d '\n' \
  | xxd -r -p > encflag.bin

ML‑KEM 768 をデキャップして共有鍵取得
openssl pkeyutl -decap -inkey priv-ml-kem-768.pem \
                -in ciphertext.dat \
                -secret shared.bin
# フラグ復号
python3 - <<'PY'
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
key = open('shared.bin','rb').read()      # 32 byte
ct  = open('encflag.bin','rb').read()
flag = unpad(AES.new(key, AES.MODE_ECB).decrypt(ct), 16)
print(flag.decode())
PY

>>TsukuCTF25{W3lc0me_t0_PQC_w0r1d!!!}

答えはTsukuCTF25{W3lc0me_t0_PQC_w0r1d!!!}

実はこれが初めてのcryptoだったのでflagのメッセージはなかなかに心が震えた。

[crypto] a8tsukuctf

適当な KEY を作って暗号化したはずが、tsukuctfの部分が変わらないなぁ...
添付ファイル:enc.py output.txt

enc.pyの実装を調べてみると、tsukuctfが残ってしまうのはキー文字'a'が連鎖していることが原因らしい(aがキーに回ってきた区間では、加算が0となり、平文がそのまま暗号文に写り込む)

python
import string

plaintext = '[REDACTED]'
key = '[REDACTED]'

#    <plaintext>               <ciphertext>
# ...?? tsukuctf, ??... ->  ...aa tsukuctf, hj...
assert plaintext[30:38] == 'tsukuctf'


# https://ja.wikipedia.org/wiki/ヴィジュネル暗号#数式でみる暗号化と復号
def f(p, k):
    p = ord(p) - ord('a')
    k = ord(k) - ord('a')
    ret = (p + k) % 26
    return chr(ord('a') + ret)


def encrypt(plaintext, key):
    assert len(key) <= len(plaintext)

    idx = 0
    ciphertext = []
    cipher_without_symbols = []

    for c in plaintext:
        if c in string.ascii_lowercase:
            if idx < len(key):
                k = key[idx]                          # 初期キー
            else:
                k = cipher_without_symbols[idx - len(key)]  # 直前に出力した暗号文をキーに再利用
            cipher_without_symbols.append(f(c, k))
            ciphertext.append(f(c, k))
            idx += 1
        else:
            ciphertext.append(c)                      # 記号・大文字などはそのまま

    ciphertext = ''.join(ciphertext)
    return ciphertext


ciphertext = encrypt(plaintext=plaintext, key=key)

with open('output.txt', 'w') as f:
    f.write(f'{ciphertext=}\n')

scriptを回し、暗号文にそのまま混入していた平文を拾ってみると、tsukuctf_is_funの文字列が出現した。

python
import re

with open('output.txt', encoding='utf-8') as f:
    whole = f.read()

ciphertext = re.search(r'ciphertext\s*=\s*"([^"]+)"', whole).group(1)
leak = re.search(r'tsukuctf[a-z_]*', ciphertext)

print(leak.group(0))

>>> tsukuctf_is_fun

答えはTsukuCTF25{tsukuctf_is_fun}

[crypto] xortsukushift

つくし君とじゃんけんしよう。負けてもチャンスはいっぱいあるよ! フラグフォーマットは TsukuCTF25{} です。
nc challs.tsukuctf.org 30057
添付ファイル:server.py

解答に至らず

まず、じゃんけんで294連勝?がどういうことなのかわからなかった。

crypto初学者すぎて機械学習的な推測なのかと初めは思ったが、どうやら3の剰余が関係しているとのことで、seed値がわかると次に出す手が一意に定まることだと理解した。しかし、xorshiftと剰余の組み合わせ方がわからずChatGPT頼みに。

ChatGPTが吐き出した理論値64という説明をまんまと飲み込み、無限計算編に突入。その後、z3やガウスの消去法、格子基底削減などの実装を試み、パリティ遷移で乱数の癖なども確認したが敢えなく断念。どうやら300近くまで増やさないといけなかったらしい。

睡眠時間と引き換えに基礎分析の大切さを思い知った。

[web] len_len

"length".length is 6 ?
curl http://challs.tsukuctf.org:28888
添付ファイル:len_len.zip

lengthが負になるリクエストを送信すればいい。具体的には以下のコマンドのようになる。

bash
curl -X POST -d 'array={"length":-1}' http://challs.tsukuctf.org:28888

この問題を解いていた時、Windows機で作業をしていたのでpowershell上での実行でなんとかならないかとChatGPTに相談したら以下のコマンドで行けるらしい。横着に成功。(WSLを入れろ)

powershell
# 送信するリクエストボディを用意
$body = 'array={"length":-1}'

# POST リクエストを送信(フラグ文字列は .Content で取得)
$response = Invoke-WebRequest `
    -Uri 'http://challs.tsukuctf.org:28888' `
    -Method POST `
    -Body $body `
    -ContentType 'application/x-www-form-urlencoded'

# 画面にフラグを表示
$response.Content

> TsukuCTF25{l4n_l1n_lun_l4n_l0n}

答えはTsukuCTF25{l4n_l1n_lun_l4n_l0n}

[web] flash

3, 2, 1, pop!
http://challs.tsukuctf.org:50000/
添付ファイル:flash.zip

フラッシュ暗算のように7桁の数字が順番に10回現れるのでその合計値を入力するというもの。
しかしRound4~7は数字が表示されず真っ白な画面なので合計を計算できない。

http://challs.tsukuctf.org:50000/resultに遷移し、解答を入力する直前の状態のCookieに答えとなる値が保持されているのではないか?と予想を立てる。
そこで、Developer ToolのCookieからsessionの値を取得し、decodeしてみるがそれっぽい数字は見当たらない。

「まさかRound1~3、Round8~10の数字の観測情報からRound4~7を逆算できたりするのか?」と訳の分からないことを一瞬考えるが、乱数生成の仕組み上その可能性は限りなく低いので一から考え直す。

  1. サーバーが公開している乱数生成のシード(seed.txt)を取得
  2. クライアントに渡されるFlaskのセッションCookieからsession_idを復号
  3. サーバー側と同じ線形合同法(LCG)のパラメータa, cをHMAC-SHA256で導出
  4. 各ラウンドで生成される7桁の数字を特定し、合計値を計算

という方針を立て実装し、pythonを無理やり回していく。Cookieのsession_idをconsoleに入れると、見えなかったRound4〜7も浮かび上がり合計値を計算することができた。

答えはTsukuCTF25{Tr4d1on4l_P4th_Trav3rs4l}

[web] YAMLwaf

YAML is awesome!!
curl -X POST "http://challs.tsukuctf.org:50001" -H "Content-Type: text/plain" -d "file: flag.txt"
(mirror)
curl -X POST "http://20.2.250.108:50001" -H "Content-Type: text/plain" -d "file: flag.txt"
添付ファイル:YAMLwaf.zip

Node.js最高!ということで、この問題はYAMLがNode.jsのBufferを生成してしまう穴を突き、任意のファイルの読み込みを通すという問題であると理解した。

tag:yaml.org,2002:をBase64→BufferにdecodeしてPOST、flag.txtの中身がそのまま返る。

echo '%YAML 1.2
%TAG !b! tag:yaml.org,2002:
---
file: !b!binary ZmxhZy50eHQ='

| curl -v -X POST http://challs.tsukuctf.org:50001/ \
    -H "Content-Type: text/plain" \
    --data-binary @-

答えはTsukuCTF25{YAML_1s_d33p!}

Discussion