TsukuCTF 2025

chizuchizu chizuchizu
,
naotiki naotiki
,
NXVZBGBFBEN NXVZBGBFBEN
,
Nimono Nimono
開催期間(JST) 2025/05/03 12:00 - 2025/05/04 12:00 結果 11th (Japanese Students) CTF Website https://tsukuctf.org/ CTFTime https://ctftime.org/event/2769

Writeup

osint

web

crypto


curve

category: osint
author:
naotiki naotiki

問題

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

curve

Google Lensで調べると、どうやらスパイラルエスカレーターという珍しいエスカレーターらしい

スパイラルエスカレーターで検索して画像を漁っていると・・・

google screenshot

エスカレーターの警告表示(?)が似ている

https://ameblo.jp/umaleeno/entry-12876405935.html

「ランドマークタワー」という場所らしい

https://www.yokohama-landmark.jp/

TsukuCTF25{www.yokohama-landmark.jp}


destroyed

category: osint
author:
chizuchizu chizuchizu

問題

このTelegramの投稿の写真に写っている学校を特定してください。
フラグフォーマットはその場所の座標の小数点第4位を四捨五入して、小数第3位までをTsukuCTF25{緯度_経度}の形式で記載してください。
例: TsukuCTF25{12.345_123.456}
注意: この問題を解く過程で、戦争に関わる直接的な画像が表示される場合があります。

23:14 GMT+9 追記: フラグを追加しました

解法

Telegramの投稿にある画像を使って画像検索をすると2022年12月24日にウクライナのザポリージャ郊外にミサイル攻撃を受けた記事が出てくる。
2022年12月24日、ザポリジジでロシア軍がステプネンコミュニティの体育館に発砲しました

記事をみると

  • ステプネンコミュニティ (Степненська громада)
  • ギムナジウム (гімназія)
  • レジネ村 (село Лежине)

レジネ村のギムナジウムを調べれば良いことがわかったのでGoogle検索すると、次のような学校のデータベースが得られた。

Лежинська гімназія Степненської сільської ради Запорізького району Запорізької області | Реєстр суб’єктів освітньої діяльності

住所は с. Лежине, Запорізький р-н, Запорізька обл., вулиця Центральна, 19 らしい。この住所は通りの名前しか示していないそうなので、衛星画像から学校っぽい建物を調べる。
Alt text

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

Alt text

全く通らないと思って試行錯誤していたが、どうやら問題に誤ったフラグが設定されていたらしく再提出で正解した。

検索によく出てくるようなニュースだったため、サクサク情報が出てきた印象がある。

TsukuCTF25{47.807_35.358}


rider

category: osint
author:
chizuchizu chizuchizu

問題

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

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

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

解法

雰囲気が東南アジアで左側通行なのでマレーシアとかインドネシア?っぽいなと思った。

画像検索したりChatGPTに聞いてもわからなかったのでランドマークを探してみた。右側にある OTI FRIED CHIKEN がきになる。
調べると、インドネシアにあるOTI Fried Chikenというチェーン店で30店舗くらいあることがわかった。

Kunjungi Kami - OTI Fried Chicken

全店舗のストリートビューを見て OTI Fried Chicken Salatiga だとわかった。見覚えのある街灯と歩道もあった。

Alt text

TsukuCTF25{-7.3188_110.4970}

OSINTの良い入門no
mondai
だと思う。


schnee

category: osint
author:
naotiki naotiki

問題

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

 {width=30vmax;margin=auto}

解法

とりあえず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}


Casca

category: osint
author:
chizuchizu chizuchizu

問題

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

解法

画像検索したら
熱海市とポルトガルのカスカイス市との国際交流を記念したことに関連があることがわかった。

姉妹都市の提携が1990年7月2日だったが、違った。
他の式典を探したら次の広報誌が見つかりました。

広報あたみ No.689 7月 10日発行

新たな熱海の花の名所としてお宮緑地を中心に平成26年6月6日に「ジャカランダ遊歩道」がオープンしました。
完成記念式典はあいにくの雨だったものの、篤志家である(株)大塚商会の大塚実名誉会長、
ジョゼ・デ・フレイタス・フェラース駐日ポルトガル大使をはじめとして、多くの関係者が一堂に介し、新たな花の名所の誕生を祝いました。

TsukuCTF25{2014/06/06}


YAMLwaf

category: web
author:
naotiki naotiki

問題

YAML is awesome!!

解法

server.js
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

ほぼ同じ状況だ・・・

alt text

TsukuCTF25{YAML_1s_d33p!}


len_len

category: web
author:
naotiki naotiki

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になる何かを入れれば良い。

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

TsukuCTF25{l4n_l1n_lun_l4n_l0n}


flash

category: web
author:
naotiki naotiki

問題

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が入っているので、デコードしてみる。
alt text

セッションIDがわかる。

seed.txt/static/seed.txtにあるが、普通に取得できそう。

location /images {
alias /var/www/flash/static/images/;
}

alt text
b7c4c422a93fdc991075b22b79aa12bb19770b1c9b741dd44acbafd4bc6d1aabc1b9378f3b68ac345535673fcf07f089a8492dc1b05343a80b3d002f070771c6
あとは手元で生成するだけ

solver.py
import hmac, hashlib, secrets
with open('./static/seed.txt', 'r') as f:
SEED = bytes.fromhex(f.read().strip())
TOTAL_ROUNDS = 10
DIGITS_PER_ROUND = 7
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
session_id = input("session_id:")
correct = 0
for 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)

実行
alt text
答えを入力

正解!
alt text

TsukuCTF25{Tr4d1on4l_P4th_Trav3rs4l}


a8tsukuctf

category: crypto
author:
chizuchizu chizuchizu

問題

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%B7
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(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に投げました。


xortsukuctf

category: crypto
author:
chizuchizu chizuchizu

問題

つくし君とじゃんけんしよう。負けてもチャンスはいっぱいあるよ! フラグフォーマットは TsukuCTF25{} です。

import os
import secrets
import 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", 30057
PROMPT = 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()

ほえーーーというのと、実験することは大事なんだなぁと思った。


PQC0

category: crypto
author:
chizuchizu chizuchizu

問題

PQC(ポスト量子暗号)を使ってみました!

# REQUIRED: OpenSSL 3.5.0
import os
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
flag = "TsukuCTF25{hoge}"
# generate private key
os.system("openssl genpkey -algorithm ML-KEM-768 -out priv-ml-kem-768.pem")
# generate public key
os.system("openssl pkey -in priv-ml-kem-768.pem -pubout -out pub-ml-kem-768.pem")
# generate shared secret
os.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をビルドした。

Downloads | OpenSSL Library

本当は./Configureの時点でprefixを設定して/usr/localにバイナリを置かないようにするべきだと思う。

Terminal window
wget https://github.com/openssl/openssl/releases/download/openssl-3.5.0/openssl-3.5.0.tar.gz
tar zxvf openssl-3.5.0.tar.gz
cd openssl-3.5.0
./Configure
make
make install

あとはxxdを使ってhexをバイナリになおしてopensslで復号する。

  • priv.pem
  • ct.bin
Terminal window
xxd -r -p flag.hex > flag.enc
Terminal window
/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をビルドして良い経験になった。