TsukuCTF 2025 WriteUp(OSINT, Crypto, Web)

本記事は2025/05/03 12:00 ~ 2025/05/04 11:59に開催されたTsukuCTF 2025のWriteUpです。

CTFページ: https://tsukuctf.org/
# OSINT
## Casca
>海が綺麗なこの日本の街は、かつてポルトガルのリゾート地との交流がありました。
>この写真のすぐ右側にはその記念碑が置かれています。記念碑に書かれている「式典の開催日」を答えてください。
>![casca.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4079345/659de93a-c2cb-4bc6-973f-541b7b9ebf53.jpeg)



Googleレンズで画像を検索すると静岡県熱海市のジャカランダ遊歩道という場所がヒットします。

「ジャカランダ遊歩道　式典」でGoogle検索したところ、熱海市の公式サイトにて**平成26年6月6日**にジャカランダ遊歩道の完成式典を開催したという記述を発見しました。

## curve
> これは日本の有名な場所の一部です。あなたはこの写真の違和感に気づけますか？
> フラグはこの場所のWebサイトのドメインです。
> ![curve.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4079345/bb5d7387-ea2f-4e04-85c1-b5c14c861f07.jpeg)

画像をGoogleレンズで検索したところ、横浜ランドマークタワーがヒットしました。
曲がったエスカレーターって結構レアなんですよね。こういった特徴的なものが写っていると場所の特定がしやすくて助かります。

あとは横浜ランドマークタワーの公式サイトでドメインを確認するだけ。

## schnee
>素敵な雪山に辿り着いた！スノーボードをレンタルをして、いざ滑走！
>フラグフォーマットは写真の場所の座標の小数点第4位を四捨五入して、小数第3位までをTsukuCTF25{緯度_経度}の形式で記載してください。
>例: TsukuCTF25{12.345_123.456}
>![schnee.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4079345/4568e936-b33a-4af1-98b1-d672f2af1f37.jpeg)

画像をGoogleレンズで調べるとスイスのグリンデルワルトという土地がヒットしました。
画像に移っている「SKISET」という単語で検索すると、グリンデルワルトにSkisetという店舗があることが分かります。
ストリートビューで確認すると問題の画像と同じ特徴的な建物を見つけることができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4079345/07a64c32-ca10-4ba4-ab23-f7c1f5364a2a.png)

## buildings
> あの建物が建ったら、また空が狭くなるんだろうな。
> フラグフォーマットはこの人が立っている場所のTsukuCTF25{緯度_経度}です。ただし、緯度および経度は小数点以下五桁目を切り捨てたものとします。
> ![buildings.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4079345/3cc74ec1-db95-41ca-a4dd-be05b536e09c.jpeg)

リアル知識で突破しました。
品川シーズンテラスとソニーシティの辺りを何度か通ったことがあったので「あれ、ここ見たことあるぞ...?」みたいな感じで。

ビルの画像は画像検索で場所を絞りづらそうなので問題の場所を知らなかったらちょっと苦戦したかも。

## power
> 力を感じてきた。
> フラグフォーマットはこの人が立っている場所のTsukuCTF25{緯度_経度}です。ただし、緯度および経度は小数点以下五桁目を切り捨てたものとします。
> ![power.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4079345/fc788734-934b-4476-bb96-f3b1a9efd401.jpeg)

画像に点字が写っていたためこれを解読して場所の特定を試みました。

点字の解読にはこのサイトを使用。
https://rannking.com/Tenbord.html

解読した内容は以下の通り。
地図の上の部分「ちどりい?」
地図の茶色い部分「じゅもく」
上の特徴的な部分「せきひ　いしどーろー」
下の文章の頭「とーきょーと　してい　きゅーせき　まさかどづか」

点字の内容から、これが将門塚で撮られた写真であるとわかります。

看板の場所を直接特定できる材料を見つけられなかったので、後ろの道路、植え込み、街路樹から写真を撮った場所と角度を推測して座標を特定しました。

## rider
> (ポエムは省略)
> フラグフォーマットはこの人が立っている場所のTsukuCTF25{緯度_経度}です。ただし、緯度および経度は小数点以下五桁目を切り捨てたものとします。
> ![rider.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4079345/ccfa5d33-287b-4526-81d2-45d5b6df490d.png)

写真にOTI Chickenという看板が写っていたのでこれをGoogle検索したところ、OTI FRIED CHICKENがインドネシアに展開するお店であることが分かりました。
画像検索ではそれ以上の情報は得られなかったため、Google Mapでヒットした店舗の前の景色をストリートビューで一つずつ確認していきました。

手前のパンダの写真と奥のKINGと書かれた看板、道路脇の大木を目印に探していったところ、Salatigaの店舗の前で撮られた写真であることが分かりました。
あとは写真から細かい座標を推測して解答。

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

※戦争に関係する若干ショッキングな画像であるためこちらの画像は載せておりません。

Telegramの投稿を翻訳したところ、ザポリージャ州のStepneという地域のギムナジウム(中等学校)の写真であることを確認できました。
Google MapでStepneの学校を検索したが、Stepneと呼ばれる地域にて学校や教育機関はヒットせず。

Stepneがあまり広くなかったため、画像に写った建物の形とGoogle Mapの航空写真を照らし合わせて問題の建物を探しました。
写真からわかる次の特徴を持つ建物を探したところ、それっぽいL字の建物を発見できました。
- 二階建て
- L字の部分の内側に遊具を置いている
- 塀で囲まれている
- 三角屋根

あとは学校の緯度と経度を解答。

# crypto
## a8tsukuctf
>適当な KEY を作って暗号化したはずが、 tsukuctf の部分が変わらないなぁ...

次のような暗号化後のテキストと暗号化用のコードが与えられます。
また、enc.pyには[wikipediaのリンク](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)が貼られており、今回使用している暗号化アルゴリズムがヴィジュネル暗号の亜種であることが予想できます。

```
ciphertext="ayb wpg uujmz pwom jaaaaaa aa tsukuctf, hj vynj? mml ogyt re ozbiymvrosf bfq nvjwsum mbmm ef ntq gudwy fxdzyqyc, yeh sfypf usyv nl imy kcxbyl ecxvboap, epa 'avb' wxxw unyfnpzklrq."
```
```enc.py
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
```

与えられた情報を整理します。
- 最初の`len(key)`文字はkey(こちらは分からない)を用いて暗号化されている
    - これを暗号化1と呼びます
- keyを使い終わったあとは暗号化された部分を鍵として暗号化する
    - これを暗号化2と呼びます
- 暗号文の中に怪しい部分がある
(**aが8回続いたあとにtsukuctfという暗号化されていない文字列が入る**)

最初の部分の暗号化に使用されているkeyがわからない以上はどの部分で暗号化1と暗号化2が切り替わっているか分からないので解読は困難なはずでした。

しかし、aが8回続いたあとにtsukuctfという暗号化されていない文字列が入っていることからtsukuctfの部分の暗号化には直前の**aaaaaaaa**が使用されている(拡張鍵部分で暗号化を行っている)と考えることができます。

また、tsukuctf以降の部分は次のような手順で復号していくことができます。
1. tsukuctfの次の8文字をkey:tsukuctfとして解く。
    鍵部分を*Italic*、復号される部分を**Bold**にしてあります。
    
   > *tsukuctf*, **hj vynj? mm**l ogyt re ozbiymvrosf
   > ↓
   > tsukuctf, **or both? th**l ogyt re ozbiymvrosf
   
2. さらに次の8文字は直前の8文字の暗号文(hrvynjmm)をkeyとして解く
   > tsukuctf, *hj vynj? mm***l ogyt re o**zbiymvrosf
   > ↓
   > tsukuctf, or both? th**e flag is c**zbiymvrosf
3. 以下略

こうして"tsukuctf"以降を復号してやると次のような文章が出てきます。
> tsukuctf or both the flag is concatenate the seventh word in the first sentence the third word in the second sentence and fun with underscores

あとはこの文章に従ってフラグを組み立ててあげればOKです。

復号にあたってdcodeを使用しました。
https://www.dcode.fr/vigenere-cipher


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

耐量子暗号アルゴリズムのKEMに触ってみる問題です。
問題では以下の情報が与えられています。
- shared keyの暗号化に使用された秘密鍵(private key)
- private keyを用いて暗号化されたshared key(cipher text)
- shared keyを用いて暗号化されたflag(encrypted flag)
- 暗号化に使用するスクリプト(prob.py)

```prob.py
# REQUIRED: OpenSSL 3.5.0

import os
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from flag import flag

# 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")
# REQUIRED: OpenSSL 3.5.0

import os
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from flag import flag

# 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())
```
この問題では次のような暗号化が行われているようです。
なお、問題にて直接与えられていない情報を太字にしてあります。
- **shared_secret**を使用し、**flag**をAESのECBモードで暗号化する
- private keyを使用し、**shared_secret**を暗号化する(これをciphertext.datとする)

よって、flagを得るためにすべきことは以下の2つです。
- 与えられたprivate keyでciphertextを復号して**shared_secret**を得る
- 得られた**shared_secret**を用いてencrypted_flagを復号し**flag**を得る

private keyでciphertextを複合する際にKEMというアルゴリズムを使用しているのですが、これをOpenSSLから使用するためにOpenSSL 3.5.0が必要とされています。
こちらは2025/4/8リリースということで私が使用しているパッケージマネージャーに入っていなかったのでソースコードからビルドして使用しました。

OpenSSLを用いてshared_secretを得るやり方
```bash 
openssl pkeyutl -decap -inkey priv-ml-kem-768.pem -in ciphertext.dat -secret recovered_shared.dat
```
shared_secretを得た後はAES ECBモードの復号を行いました。

特にひねりのない操作なのでツールを使用しました。
https://flatsystems.net/encrypt_aes_ecb.php

# Web
## len_len
> "length".length is 6 ?
> curl http[:]//challs.tsukuctf.org:28888

curlでPOSTリクエストを投げてフィルタを回避すればフラグが得られるパターンの問題です。

フラグ出力部は以下のようになっています。
`array.length < 0`という**通常ではありえない**チェックをしており、たいへん怪しいですね。
```javascript
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}`;
}
```
sanitizedが文字列である限り、sanitized.length<10となることはありえません。
ただし型のチェック等を一切行っていないので、strにオブジェクトとして解釈されるような文字列をいれることができます。
例えばsasanitizedの中身が`array={"length":-1}`であれば`sanitized.length=-1`が成り立ちます。

ということで次のようなリクエストを送ってあげればOKです。
```bash
$ curl -X POST -d 'array={"length":-1}' http://challs.tsukuctf.org:28888
```

## flash
> 3, 2, 1, pop!
> http[:]//challs.tsukuctf.org:50000/

seed値から生成される10個の数字の和を計算する問題となっています。
フラッシュ暗算みたいな感じで画面に数字が出てきますが、途中で数字が表示されなくなるので正攻法では解けません。

問題のソースコードを確認すると、サーバに保存されたseed値とsession idの値からLCGという疑似乱数生成アルゴリズムを用いて10個の数字を作っていることが分かります。

```python

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
```

seedとsession idの値が決まれば生成される値は一つに定まることから、次の方針で問題を解きました。
1. seed, session idを手に入れる
2. seed, session idから10個の数字を手元で生成
3. 10個の数字の和を答えてフラグを取る

**seedの入手**
/static/seed.txtを読み込んでいるようだったので、http[:]//challs.tsukuctf.org:50000/static/seed.txtにリクエストを送ってみたところ簡単に入手することが出来ました。

**session idの入手**
何度かサイトの挙動を確かめたところ、スタートボタンを押すたびにcookieがセットし直されていることが分かりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4079345/6b9ccb03-cdb7-48d2-8764-60e91db843ef.png)

cookieにはsession idの値をBASE64でエンコードした値が入っていたため、これを取り出して計算に利用しました。

seedとsession idを入手したあとは問題のソースコードを参考にLCGで10個の数字を生成し、解答を入力することでフラグをゲットしました。


# 感想
- OSINT多めのCTFだったため地力が問われる他分野の問題の比率が少なくなっており、普段より良い順位が取れたように思います
- OSINTは根性とアイデアで解ける問題が多い気がするので好き
- 出来ればWeb問題のYAMLwafまで解きたかった......!!!