ksjsb/tools/extract_captcha_flow.py
2026-07-30 20:25:56 +08:00

211 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""解析验证码 Frida 日志,提取 705 验证页与后续请求链。
默认终端只打印脱敏摘要;完整事件写入本地 JSON便于后续对接 CLI。
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from collections import Counter
from pathlib import Path
from typing import Any
from urllib.parse import parse_qsl, urlsplit
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
SECRET_RE = re.compile(
r"(sig|token|ticket|key|captcha|verify|session|security|cookie|egid|did|odid|rdid|mobile|phone|visitor_st|(?:^|[_-])st(?:$|[_-]))",
re.I,
)
def _latest_log() -> Path:
patterns = ["probe_captcha_*.log", "probe_*captcha*.log"]
seen: list[Path] = []
for pat in patterns:
seen.extend((ROOT / "out").glob(pat))
logs = sorted(set(seen), key=lambda p: p.stat().st_mtime, reverse=True)
if not logs:
raise FileNotFoundError("out 下没有 probe_captcha_*.log")
return logs[0]
def _read_text(path: Path) -> str:
raw = path.read_bytes()
if raw.startswith(b"\xff\xfe") or raw.startswith(b"\xfe\xff"):
return raw.decode("utf-16", errors="replace")
return raw.decode("utf-8", errors="replace")
def _mask_scalar(key: str, value: Any) -> str:
s = "" if value is None else str(value)
s = re.sub(r"\b1\d{10}\b", "<MOBILE>", s)
s = re.sub(r"((?:code|verifyCode|验证码)[=:\"]?)(\d{4,8})", r"\1<CODE>", s, flags=re.I)
if SECRET_RE.search(key) and s:
return f"<MASK len={len(s)}>"
if len(s) > 180:
return f"{s[:80]}...<len={len(s)}>...{s[-30:]}"
return s
def _mask_obj(obj: Any) -> Any:
if isinstance(obj, dict):
return {str(k): _mask_obj_by_key(str(k), v) for k, v in obj.items()}
if isinstance(obj, list):
return [_mask_obj(v) for v in obj]
return _mask_scalar("", obj)
def _mask_obj_by_key(key: str, value: Any) -> Any:
if isinstance(value, dict):
return {str(k): _mask_obj_by_key(str(k), v) for k, v in value.items()}
if isinstance(value, list):
return [_mask_obj_by_key(key, v) for v in value]
return _mask_scalar(key, value)
def _extract_urls(text: str) -> list[dict[str, Any]]:
urls: list[dict[str, Any]] = []
for m in re.finditer(r"https?://[^\s\"'<>\\]+", text):
raw_url = m.group(0).rstrip("),;]")
try:
u = urlsplit(raw_url)
query = dict(parse_qsl(u.query, keep_blank_values=True))
urls.append(
{
"url": raw_url,
"host": u.netloc,
"path": u.path,
"query": query,
"query_keys": list(query.keys()),
}
)
except Exception:
urls.append({"url": raw_url})
return urls
def _interesting_event(ev: dict[str, Any]) -> bool:
text = json.dumps(ev, ensure_ascii=False)
return bool(
re.search(
r"captcha|verify|705|challenge|slider|captchaToken|verifyToken|captchaTicket|verifyTicket|quickLoginToken|verifyCode|mobileVerifyCode|requestMobileCode|defaultLogin|@@CAPTCHA_JS",
text,
re.I,
)
)
def parse_log(log_path: Path) -> dict[str, Any]:
events: list[dict[str, Any]] = []
tag_counts: Counter[str] = Counter()
url_counts: Counter[str] = Counter()
parse_errors = 0
for line_no, line in enumerate(_read_text(log_path).splitlines(), 1):
if not line.startswith("@@CAPTCHA "):
continue
try:
ev = json.loads(line[len("@@CAPTCHA ") :])
except json.JSONDecodeError:
parse_errors += 1
continue
tag_counts[str(ev.get("tag", ""))] += 1
if not _interesting_event(ev):
continue
text = json.dumps(ev, ensure_ascii=False)
urls = _extract_urls(text)
for u in urls:
if "path" in u:
url_counts[u["path"]] += 1
events.append(
{
"line": line_no,
"tag": ev.get("tag", ""),
"seq": ev.get("seq"),
"ts": ev.get("ts"),
"event": ev,
"urls": urls,
}
)
return {
"source_log": str(log_path),
"event_count": len(events),
"parse_errors": parse_errors,
"tag_counts": dict(tag_counts),
"url_counts": dict(url_counts),
"events": events,
}
def masked_summary(parsed: dict[str, Any], limit: int = 80) -> dict[str, Any]:
events = parsed.get("events", [])
compact = []
for item in events[:limit]:
ev = item.get("event", {})
compact.append(
{
"line": item.get("line"),
"seq": item.get("seq"),
"tag": item.get("tag"),
"ts": item.get("ts"),
"urls": _mask_obj(item.get("urls", [])),
"event": _mask_obj(ev),
}
)
return {
"source_log": parsed.get("source_log"),
"event_count": parsed.get("event_count"),
"parse_errors": parsed.get("parse_errors"),
"top_tags": Counter(parsed.get("tag_counts", {})).most_common(40),
"top_paths": Counter(parsed.get("url_counts", {})).most_common(40),
"events_head": compact,
}
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="解析 Frida 验证码链日志")
p.add_argument("--log", default="", help="日志路径;不传则取 out/probe_captcha_*.log 最新")
p.add_argument("--out", default="out/captcha_flow_latest.json", help="完整事件 JSON 输出路径")
p.add_argument("--summary-out", default="out/captcha_flow_latest.summary.json", help="脱敏摘要输出路径")
p.add_argument("--show", action="store_true", help="终端打印完整事件;默认打印脱敏摘要")
return p
def main() -> int:
args = build_parser().parse_args()
log_path = Path(args.log) if args.log else _latest_log()
if not log_path.is_absolute():
log_path = ROOT / log_path
parsed = parse_log(log_path)
out_path = Path(args.out)
if not out_path.is_absolute():
out_path = ROOT / out_path
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(parsed, ensure_ascii=False, indent=2), encoding="utf-8")
summary = masked_summary(parsed)
summary_path = Path(args.summary_out)
if not summary_path.is_absolute():
summary_path = ROOT / summary_path
summary_path.parent.mkdir(parents=True, exist_ok=True)
summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"[OK] full={out_path}")
print(f"[OK] summary={summary_path}")
print(json.dumps(parsed if args.show else summary, ensure_ascii=False, indent=2)[:12000])
return 0
if __name__ == "__main__":
raise SystemExit(main())