初心者はどうやってTsukuCTF2025を解く？

# TsukuCTF 2025 Writeups
**Contents**
- 始めに
- 【OSINT】schnee
- 【OSINT】buildings
- 【Crypto】a8tsukuctf
- 【Web】len_len
- 【Web】flash
- 感想
## 始めに
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3952511/fa29f593-9868-4dcc-90b6-75e4e6d3866c.png)
僕は「NOCK」というチームに参加していました。
結果は97位、1100ptです！
開始1,2時間では2,3,4位とかでうぉぉぉ！とか言ってたんですけどね。
世の中甘くないですね笑

冗談はさておき、TsukuCTF解ける問題が多くて楽しかったです！！
今回はそんなTsukuCTFを、そこまで技術がない僕がどうやって解いているのかというWriteupを経緯などを説明しながらまとめていきます。
解くまでのストーリーと一緒にお楽しみください！

## 【OSINT】schnee
#### [終了時 100pt], [medium]
**問題文**
![schnee.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3952511/41b2ed5a-1d5d-44ff-9092-e89d3f30dafc.jpeg)
```
素敵な雪山に辿り着いた！スノーボードをレンタルをして、いざ滑走！
フラグフォーマットは写真の場所の座標の小数点第4位を四捨五入して、
小数第3位までをTsukuCTF25{緯度_経度}の形式で記載してください。
例: TsukuCTF25{12.345_123.456}
```
**経緯**
OSINT問題であるあるなのが特徴的な一部分にGoogleカメラを使ってみると上手くいくことなので最初は写真上部にある旗に焦点を当ててGoogleカメラを掛けてみました。

**第一章 - 広告の旗にGoogleカメラ**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3952511/1f3421e0-8846-4392-a30b-27f91b93a31c.png)
結果は空振り。ヒットしたのは日本の広告の旗ばかり。
さてどうしましょう。
旗の下部にURLがあったのでそれを検索することも考えましたが画質的に読み取れない...
ここで考えたのが全然関係ないけど一旦exiftool。

**第二章 - 申し訳程度のexiftool**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3952511/693a1fde-592a-4a53-897f-342ce1a8f5e0.png)
やはり特に何も情報は載ってなさそうですね...
(ちなみにちょっとUbuntuのカスタマイズしています。
僕は「Anonymous Pro」というフォントが好きです。)
次はやはりちゃんと写真から情報を集めて検索をかけることを試みます。

**第三章 - やはり旗...?**
店に書いてある情報は読み取りにくく、山で検索かけてもいくらでも似たような山なんて存在しますからやはり旗が今回の解決の糸口だと予測してよく見てみると旗の上部に「SKISET」という文字が見えていることに気がつきました。
なのでWebで検索してみると...
なんと検索に引っかかりました。
どうやらフランスに関連の深いショップのようだったので「SKISET」のフランスの店舗を調べることにしました。

**第四章 - 店舗はフランスだけじゃない**
ずっと「SKISET フランス」みたいな検索をして何店舗か見てみたものの写真のようなお店はなさそう...
そこでちょっとChatGPTと相談。(SKISET全国展開とかしてたらちょっとやばいかもな...とか思いながら)
そしたらこの返答。
![ChatGPT](https://media.discordapp.net/attachments/1368061895000719481/1368067159846027324/image.png?ex=681830d4&is=6816df54&hm=fee9f55d3b7e7c6cf1a2e22141ebfa3302e77a32e561a6dea35210efebc12964&=&format=webp&quality=lossless&width=1848&height=770)
左ChatGPT君の返答の中に「スイス」というワードを見つけてピンときました。
**そういや問題の写真の下の方にスイス国旗のTシャツ売ってる看板あったな！**
結果、スイスにあるSKISETを調べると数件引っかかってそのうちの一件がビンゴ。

**理想的な解き方の流れ(ネクストアクション)**
1.写真の中で特徴的な物を探したときに旗や店前のTシャツを売っている看板に気づく
2.それらの物にGoogleカメラを掛けたり、認識できる文字は検索
3.あとはその情報を武器にしてGoogleマップに挑戦
4.Flagゲット

## 【OSINT】buildings
#### [終了時 100pt], [easy]
**問題文**
![buildings.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3952511/1996e5e4-aa68-4252-85ec-8e9f2b327273.jpeg)
```
あの建物が建ったら、また空が狭くなるんだろうな。
フラグフォーマットはこの人が立っている場所のTsukuCTF25{緯度_経度}です。
ただし、緯度および経度は小数点以下五桁目を切り捨てたものとします。
```
**経緯**
先ほどの反省を活かしてできるだけ特徴的なものを探します。

**第一章 - 街灯が手がかりに該当？**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3952511/4ce0339a-45a8-4264-a9ba-50dde0e10d9d.png)

街灯があまり普段見ない形だったのでもしかしてとか思ったのですが結果は空振り。
かといって全体でGoogleカメラ掛けてもヒットするのは違う町ばかりでヒットしない。何か特徴的な建物は無いのか...？

...？
奥に何やら特徴的な建物が...

**第二章 - ギザギザの建物**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3952511/03f0c69d-614c-4dcb-96c2-60c53dfdc668.png)
写真の右上にちょっとギザギザした建物を発見。Googleカメラを掛けてみると見事ヒット。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3952511/f76b5aa7-c2dd-41ca-a380-b60bc1ff5b3d.png)

**第三章 - ギザギザの方向**
しかしGoogle mapで確認するとギザギザが向いている方向には川があるはず...
写真では角度的に見えてないだけか？それとも建物が違うのか？とかいろいろ考えました。なんやかんや写真の位置が見つからず苦戦していると、ある一つの動作で解決しました。

**第四章 - 出てきたもう一つのギザギザ**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3952511/db5e15ee-4645-4045-9144-6f5d7282692e.png)
なんと航空写真にしてみると反対側にもギザギザがあるではありませんか。
と言うことであとはストリートビューで付近を道路沿いに散歩して該当場所をゲット。

2Dだと建物の一番大きな形しか見えないので要注意ですね。とても面白い問題でした。

**理想的な解き方の流れ(ネクストアクション)**
1.いつも通り特徴的な建物などを探す
2.１問目と同じようにそれらの物にGoogleカメラを掛けたりする
3.**2Dマップだけで無く航空写真も確認する ← ここ大事**
4.Flagゲット

## 【Crypto】a8tsukuctf
#### [終了時 100pt], [easy]
**問題文**
```
適当な KEY を作って暗号化したはずが、 tsukuctf の部分が変わらないなぁ...
```
**output.txt**
```
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**
```python
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')

```
**経緯**
とりあえずChatGPTにこれは何をするプログラム？と聞いてみましょう。

**第一章 - 特殊な暗号化**
暗号化の形式が少し特殊らしい。
ChatGPTに言われたことを軽く自分の言葉でまとめてみると

１．Keyとなる文字列と平文の文字列(Flag)がある
２．a = 0,b = 1,c = 2,...,z = 25と番号を割り当てる
３．平文の各1文字に対してKeyの1文字を足す
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3952511/fddf1009-0925-4205-9cb1-a5cd43a0e2c1.png)

４．Keyが平文より短いので、足りない分はすでに暗号化した文字をKeyとして再利用する。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3952511/05c787ae-9301-413b-8ba0-ddf7d04a5ed0.png)

**第二章 - "tsukuctf"がそのままの文字列で残っているらしい**
ここでポイントとなるのが「tsukuctf」という文字列がそのまま残っていること。
言い換えれば**tsukuctfという8文字のkeyがaaaaaaaaであると言うこと。**
(aは数字に直すと0なので足してもアルファベットが変化しない。)


それを踏まえて問題文で配布されたoutput.txtを見たとき、ちょうどtsukuctfという文字列の前に8文字のaaaaaaaaを発見。

以下の図(第二章の例2)を見て思い出してほしいのがこの図だと**三文字前**の暗号文がそのままkeyに再利用されており、もともとのkeyの長さも**三文字**であること。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3952511/c6db6cf2-ae36-4d9d-b8f5-e8b3b555950a.png)

そう考えればtsukuctfという8文字が全て残るためには八文字前のaが再利用されていなければならないということになる！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3952511/797d61bb-c14d-4714-9291-c36b22eebbd2.png)
と言うことは最初の8文字以外は、問題文で配布された暗号化後の文字列で**8文字前**を見れば全て復号可能なことがわかると気づき、早速プログラムを作りにかかります。

**第三章 - プログラムを作る**
```python
import string

def f(c, k):
    c = ord(c) - ord('a')
    k = ord(k) - ord('a')
    ret = (c - k) % 26
    return chr(ord('a') + ret)


def decrypt(ciphertext):

    idx = 0
    plaintext = []
    plaintext_without_symbols = []

    ciphertext_without_symbols = "aybwpguujmzpwomjaaaaaaaatsukuctfhjvynjmmlogytreozbiymvrosfbfqnvjwsummbmmefntqgudwyfxdzyqycyehsfypfusyvnlimykcxbylecxvboapepaavbwxxwunyfnpzklrq"

    for p in ciphertext:
        # 空白や記号などは考慮しないよのif文
        if p in string.ascii_lowercase:
            # 8文字以下なら無視
            if len(plaintext_without_symbols) < 8:
                plaintext_without_symbols.append(p)
                plaintext.append(p)
                idx += 1
            # 8文字を超したタイミングから復号開始
            else:
                k = ciphertext_without_symbols[idx-8]
                plaintext_without_symbols.append(f(p, k))
                plaintext.append(f(p, k))
                idx += 1          
        else:
            plaintext.append(p)

    plaintext = ''.join(p for p in plaintext)

    return plaintext

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."

print(decrypt(ciphertext))

```
こんな感じで作ってみました。あまりプログラムは作りたくないのでほとんどChatGPTに書かせてわかる部分だけ自分で修正しています。(解けるのが目的なので変なプログラム失礼します。)

これを実行すると
```
ayb wpg uujoy this problem or 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.
```
となり、最初の8文字以外は復元できました。
ちなみにChatGPTは最初の八文字はxqyybmqhというカギで暗号化されていて、完成した文章は
```
Did you enjoy this problem or 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.
```
となるのではないかと予測していました笑
文章の和訳は以下のようです。これでFlagゲットですね。
```
この問題、TsukuCTF、またはその両方を楽しみましたか？ フラグは、1文目の7番目の単語、2文目の3番目の単語、そして 'fun' をアンダースコアで連結したものです。
```

**理想的な解き方の流れ(ネクストアクション)**
1.ざっくりとした暗号化の仕組みを理解する
2.tsukuctfという文字列が残っているというヒントをもとに鍵を探す
3.プログラムを作って実行
4.全部じゃなくとも、ある程度複合してFlagゲット

## 【Web】len_len
#### [終了時 100pt], [easy]
**問題文**
```
"length".length is 6 ?

curl http://challs.tsukuctf.org:28888
```
```server.js
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}`);
});

```
**経緯**
まずは実行して様子見してみましょう。
server.jsの挙動はいつも通りChatGPTに聞いてみましょう。

**第一章 - 配列を送る、でも違う**
```bash
curl http://challs.tsukuctf.org:28888

How to use -> curl -X POST -d 'array=[1,2,3,4]' http://challs.tsukuctf.org:28888
```
実行してみるとこんな感じで使ってくださいねと言われるので言われたとおりにするも...
```bash
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
```
理不尽にすねて、Flagはくれない様子。
仕方なくserver.jsという菓子折りを持参してChatGPTに訪問、相談。
するとどうやら配列の長さがマイナスになるものを欲しがってるらしい...
これを聞いてぶちギレている僕を差し置いてChatGPTは大人な対応。
```bash
curl -X POST -d 'array={"length":-1}' http://challs.tsukuctf.org:28888
```
どうやら.lengthは**配列に対して**は配列の長さを返すものなのだが、**オブジェクト**(要素番号ではなくキーで値を管理するもの)に対してはただのlengthという名前のキーに紐づけられた値を参照するだけ。

つまりオブジェクトのlengthという名前のキーに負の数を紐づけていれば.lengthに対して負の数を実現できるわけだ。これは賢い。

今回はChatGPTが冷静だったので一章だけで終わっちゃいましたね笑

**理想的な解き方の流れ(ネクストアクション)**
1.server.jsファイルのうち、すねている原因はだいたい数行なので探す
2.その行を読める範囲でいいので自分で読む。
3.うまいこと条件を満たす方法を考える
4.ここでわからなければやっとChatGPTを使う。
4.Flagゲット

## 【Web】flash
#### [終了時 100pt], [medium]
**問題文**
```
3, 2, 1, pop!

http://challs.tsukuctf.org:50000/
```
```app.py
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)
```
```seed.txt
**REDACTED**
```
**経緯**
一旦実行しましょう。
その後、やはりapp.pyをChatGPTに回しましょう。
(聞きっぱなしでごめんなさい...)

**第一章 - 欠陥flash暗算**
startボタンがあったのでワクワクしながら押してみるとなんとflash暗算が！
しかし4,5枚くらい画面が真っ白で見えない...
いつも通りChatGPTに聞きに行くとどうやら4,5枚見えないのは仕様らしい...
しかし、ランダムな数字の生成方法が
1.セッションID(cookieにあるやつ)
2.seed値
の2つを使っているらしいので早速プログラムを作ってもらった。
```solver.py
import hmac
import hashlib

# ★ ここに直接入力してください ★
SEED_HEX = "???"
SESSION_ID = "???"

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

def main():
    seed = bytes.fromhex(SEED_HEX)
    session_id = SESSION_ID

    total = 0
    for i in range(TOTAL_ROUNDS):
        digits = generate_round_digits(seed, session_id, i)
        number = int(''.join(map(str, digits)))
        print(f"Round {i + 1:02}: Digits = {digits} → Number = {number}")
        total += number

    print(f"\n✅ Total Sum = {total}")

if __name__ == '__main__':
    main()

```
プログラムもできたことなので早速実行していきましょう！と思ったけどまずはセッションIDとseed値を捕まえに行かないと..

seed.txt配られてたので見に行くと**中身なし！！**
そこから地獄の雰囲気と時間だけがひたすらに流れていきましたとさ。

**第二章 - 調べたろ、知らんけど**
もう心身共に疲れ切って何も考えずに
`http://challs.tsukuctf.org:50000/static/seed.txt`
で検索したところ...
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3952511/2267bc64-586b-4216-bc45-03a564aa9d12.png)
**いや、普通に出て来るやないかい！！**
ということであとはセッションIDを取得して実行するだけ。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3952511/c13a4b31-87ec-4fd9-b371-73f0d6c970ba.png)
開発者ツール>applicationからcookie確認してセッションIDの部分を取得する。(実はこの前にcookie丸ごとセッションIDだと思ってずっと入力してたけどうまくいかず、ChatGPTが見かねて教えてくれました...)
今回はこれ。
```
eyJyb3VuZCI6MCwic2Vzc2lvbl9pZCI6IjNmYzA0MDI3YWM4NjdiMGYxMjI5ZmFkOTc4OGE5ODRlIn0
```
これはbase64でエンコードされているらしいのでdencodeというツールでデコードすると...
```
{"round":0,"session_id":"3fc04027ac867b0f1229fad9788a984e"}
```
これでセッションIDも取得することができました！
材料がそろったので早速実行していきましょう！

**第三章 - 決戦の時**
```solver.py
import hmac
import hashlib

# ★ ここに直接入力してください ★
SEED_HEX = "b7c4c422a93fdc991075b22b79aa12bb19770b1c9b741dd44acbafd4bc6d1aabc1b9378f3b68ac345535673fcf07f089a8492dc1b05343a80b3d002f070771c6"
SESSION_ID = "3fc04027ac867b0f1229fad9788a984e"

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

def main():
    seed = bytes.fromhex(SEED_HEX)
    session_id = SESSION_ID

    total = 0
    for i in range(TOTAL_ROUNDS):
        digits = generate_round_digits(seed, session_id, i)
        number = int(''.join(map(str, digits)))
        print(f"Round {i + 1:02}: Digits = {digits} → Number = {number}")
        total += number

    print(f"\n✅ Total Sum = {total}")

if __name__ == '__main__':
    main()

```
これを実行すると...
```
Round 01: Digits = [6, 9, 6, 8, 3, 2, 5] → Number = 6968325
Round 02: Digits = [4, 3, 4, 2, 7, 1, 3] → Number = 4342713
Round 03: Digits = [6, 1, 0, 5, 4, 8, 8] → Number = 6105488
Round 04: Digits = [5, 2, 4, 7, 6, 3, 5] → Number = 5247635
Round 05: Digits = [0, 5, 7, 1, 1, 7, 4] → Number = 571174
Round 06: Digits = [5, 9, 1, 7, 8, 0, 3] → Number = 5917803
Round 07: Digits = [7, 8, 4, 6, 2, 9, 3] → Number = 7846293
Round 08: Digits = [4, 1, 2, 5, 5, 4, 3] → Number = 4125543
Round 09: Digits = [1, 5, 0, 8, 4, 8, 3] → Number = 1508483
Round 10: Digits = [0, 4, 8, 2, 9, 3, 0] → Number = 482930

✅ Total Sum = 43116387
```
とこういう結果になったので入力！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3952511/c613905e-2c76-4b7a-aed2-c062620624aa.png)

これでFlagが出てきました！
検索でseed値を見つけたり、デコードでセッションIDを見つけたりしたときの感動は今でも忘れられません！とてもワクワクする問題でした。

**理想的な解き方の流れ(ネクストアクション)**
1.app.pyの概要をChatGPTで理解する
2.ファイルの中身がなくてもURL検索は一応してみる癖をつける
3.セッションIDはcookieそのものではないことに注意してセッションIDを取得する
4.ChatGPTにプログラムを書かせて実行
4.Flagゲット

## 感想
OSINTやCrypto、Webなどたくさんの問題に触れ、気づき、解けたのでとても充実したCTFになって面白かったです！！
解けるまでの時間がそれぞれちょうどいいですね。
また、プログラムの細かな仕様がわからなくてもざっくりとした仕組みが分かれば考えられるのがとても楽しかったです。

OSINTでは細かい、位置特定の手がかりになりそうな"鍵"に気づき、徹底的に調べて位置を特定する。
Cryotoでは暗号化において不可解な部分を見つけ、複合の鍵を求める方法を考え、暗号化されたFlagを平文に戻す。
Webでは仕様とセキュリティ的な部分を見つけ、それを突破する一手を考える。
今回のCTFでこれらのことを学んだので次から活かしていきたいです！