- [web] len_len
- [web] flash
- [web] YAMLwaf
- [crypto] PQC0
- [crypto] a8tsukuctf
- [crypto] xortsukushift 解けず
- [crypto] PQC1 解けず
[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などでリクエストを保存しながらやる。
- 普通にフラッシュ暗算をスタートする
- 最終的に
GET /result
が開かれる - 適当な答えを
POST /result
で提出する - 回答に正解が出力されるので、それをコピーしておく -> 65134908
- 手順2の
GET /result
の記録しておいたリクエストを再送する - すると、Set-Cookieでsessionが、Bodyでresult_tokenが再度発行される
- 手順4の
POST /result
のCookieのsessionとBodyのtokenとanswerを手順4と手順6のものに入れ替えて送るとフラグが得られる
[web] YAMLwaf
ソースコード有り。サーバー部分は簡潔。./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分足りず全てが終わりました。解く方向性がそもそも思いつかず、精進不足。