# hCaptcha 视觉求解器 [← 回到 README](../README.md) `CTF-pay/hcaptcha_auto_solver.py` 是个 4000 行的独立 solver,被 `card.py` 通过 subprocess 拉起(ML 依赖在独立 venv 里,所以不能 import)。它对任何 hCaptcha bridge URL 都通用,不只是 Stripe 这一个场景。 --- ## 三层决策 ```mermaid flowchart TB Start([Bridge 页面加载]) --> Capture[截图当前 challenge] Capture --> VLM{VLM
可用?} VLM -->|是| VLMTry[试 VLM:
候选框 → 直出坐标] VLM -->|否| Heuristic[启发式 dispatcher] VLMTry -->|成| Execute[Playwright 执行
人类动作合成] VLMTry -->|败| Heuristic Heuristic --> Match{匹到已知
题型?} Match -->|是| Solver[跑专用 solver
CLIP / OpenCV / shape IoU] Match -->|否| Fail([抛 unknown_prompt]) Solver --> Execute Execute --> Verify{视觉反馈
是否落地?} Verify -->|否| Retry[偏移 ±10/16px
最多重试 9 次] Verify -->|是| Submit[提交 + 监听
checkcaptcha 响应] Retry --> Verify Submit --> Done([Pass / Fail]) ``` --- ## 第 1 层 —— VLM 决策(首选) 调任何兼容 OpenAI 协议的 `/v1/chat/completions` 端点,发 challenge 图、候选区域 overlay、结构化 JSON 指令。两种模式: ### 候选框模式 先用 OpenCV 提候选点击 / 拖拽目标,标号 `G1`、`G2`、`S1`、`T1` 写到 overlay 上,VLM 选 ID。 ```json // 提示给 VLM 的 message 大致这样 { "messages": [ {"role": "system", "content": "你是 hCaptcha 求解器..."}, {"role": "user", "content": [ {"type": "text", "text": "Prompt: please click on all the water travel"}, {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}, // 原图 {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}, // 标 ID 后的 overlay {"type": "text", "text": "{\"candidates\": [{\"id\": \"G1\", \"bbox\": [...]}, ...]}"} ]} ] } ``` VLM 返: ```json {"action": "click", "selected_ids": ["G1", "G3"]} {"action": "drag", "source_id": "S1", "target_id": "T2"} ``` ### 直出坐标模式 候选框提取失败时降级,VLM 直接返归一化坐标: ```json {"action": "click", "coords": [[0.31, 0.42], [0.73, 0.41]]} {"action": "drag", "from": [0.2, 0.5], "to": [0.7, 0.5]} ``` ### VLM 配置 通过环境变量: ```bash export CTF_VLM_BASE_URL="https://api.openai.com/v1" export CTF_VLM_API_KEY="sk-..." export CTF_VLM_MODEL="gpt-4o" ``` 或者命令行 `--vlm-base-url` / `--vlm-api-key` / `--vlm-model` 覆盖。 --- ## 第 2 层 —— CLIP / OpenCV 启发式 dispatcher VLM 不可用或失败时的回退路径。每种已知题型有专用 solver: | 题型 prompt 关键词 | Solver | 方法 | |---|---|---| | `water travel` / `vehicle...water` | `solve_water_travel()` | CLIP 二分类,3×3 grid 或 object 候选 | | `drag` / `complete the pair` | `solve_pair_drag()` | 颜色聚类定位 source + skeleton 匹配 | | `missing piece` / `complete the image` | `solve_missing_pieces_drag()` | HSV 槽位检测 + shape IoU | | `float on water` | `solve_float_on_water()` | CLIP 二分类 | | `served hot` | `solve_hot_food()` | CLIP 二分类 | | `hop or jump` / `hopping` | `solve_hop_animals()` | CLIP 滑窗 + 聚类 + 两阶段评分 | | `produce heat to work` | `solve_heat_work()` | CLIP 二分类 | | `shiny thing` | `solve_shiny_thing()` | CLIP 二分类(单选) | | `kept outside` | `solve_kept_outside()` | CLIP 二分类 | | `dissolve or melt` | `solve_dissolve_melt()` | CLIP 多标签分类 | | `hidden under the reference object` | `solve_hidden_under_reference()` | 边缘检测 + 连通组件 | | `complete the road` + `finish line` | `solve_road_completion()` | 边缘检测 + 连通组件 | --- ## 候选区域提取 两种互补策略,根据图像特征自动选择: ### Grid 模式(`detect_label_grid`) 针对标准 3×3 hCaptcha 网格。通过行 / 列像素方差或 non-white 统计检测 3 个等分 band。判定门槛: - 覆盖率 ≥ 72% - 均衡度 ≥ 55% 满足两条都过就走 grid 路径。 ### Object 模式(`_build_object_candidates_generic`) 非标准布局时回退。用 Canny 边缘 + 连通组件 + 形态学去噪提取独立物体。 --- ## 第 3 层 —— Playwright 执行器 ### 反检测 注入 `init_script` 覆盖: ```javascript Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); Object.defineProperty(navigator, 'platform', { get: () => 'Win32' }); Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 }); // + 其他十几个属性 ``` ### 人类动作合成 - **点击**:4 点 Bézier 曲线逼近 + 随机延迟(200–400ms) - **拖拽**:3 段插值路径,起 / 中 / 终各加 jitter - **停顿**:操作间均值 800ms ± 300ms 的 normal distribution ### 视觉反馈重试环 每次交互后抓前后帧,算两个指标: ```python changed_pixels = np.sum(np.any(frame_after != frame_before, axis=-1)) mean_diff = np.mean(np.abs(frame_after.astype(int) - frame_before.astype(int))) ``` 判定 "落地":`changed_pixels > THRESHOLD_PIXELS` 且 `mean_diff > THRESHOLD_DIFF`。 没落地就**自动偏移重试**: - 点击:`±10px / ±16px` 八方向 + 原点 = 9 次 - 拖拽:`5 starts × 5 ends = 25` 种 jitter 组合 ### 多 raster 源 优先 canvas `toDataURL()` 拿原始位图。canvas 空白(hCaptcha 有时把图绘到 SVG 里)时回退 `body.screenshot()`。 ### 网络监听 拦 `hcaptcha.com` 域的两个端点: ```python page.on("response", lambda r: ...) # 拦 /getcaptcha → 提取 prompt / ekey # 拦 /checkcaptcha → 提取 pass 状态 ``` 提取的元数据写到 `round_XX.json`。 --- ## Variation 重试体系 Solver 不返回单一答案,而是**有序候选序列**: ### Click 类 `candidate_click_sets` 按 CLIP 置信度排序: 1. **Strong set**:所有置信度 ≥ 0.5 的 tile 2. **Medium set**:所有置信度 ≥ 0.3 的 tile 3. **逐个单选**:每个候选 tile 单独提交一次 ### Drag 类 `build_drag_target_variations()` × `build_drag_start_variations()` 各生成 jitter 变体: - Source jitter:`±5px / ±10px` 五点 - Target jitter:同上五点 - 总组合:5 × 5 = 25 次 ### 图像哈希去重 ```python key = (prompt_text, hashlib.sha1(image_bytes).hexdigest()) exhausted_variations[key].add(variation_id) ``` 同题失败的方案自动跳过。 ### 耗尽 所有 variation 用完抛: ```python class drag_variations_exhausted(Exception): pass class click_set_variations_exhausted(Exception): pass ``` `card.py` 接住后会触发 daemon 的"重跑当前轮"分支。 --- ## 单跑 solver ```bash # 有头模式(看着它干活) ~/.venvs/ctfml/bin/python CTF-pay/hcaptcha_auto_solver.py \ http://127.0.0.1:PORT/index.html --headed --timeout 300 # 关 VLM,只跑启发式 ~/.venvs/ctfml/bin/python CTF-pay/hcaptcha_auto_solver.py \ http://127.0.0.1:PORT/index.html --no-vlm # 自定义 VLM ~/.venvs/ctfml/bin/python CTF-pay/hcaptcha_auto_solver.py \ http://127.0.0.1:PORT/index.html \ --vlm-base-url https://api.openai.com/v1 \ --vlm-api-key sk-xxx \ --vlm-model gpt-4o # 让 solver 直接提交 verify_challenge ~/.venvs/ctfml/bin/python CTF-pay/hcaptcha_auto_solver.py \ http://127.0.0.1:PORT/index.html \ --verify-url "https://api.stripe.com/v1/setup_intents/.../verify_challenge" \ --verify-client-secret "seti_xxx_secret_xxx" \ --verify-key "pk_live_xxx" ``` --- ## 调试产物 `--out-dir`(默认 `/tmp/hcaptcha_auto_solver`): | 文件 | 含义 | |---|---| | `round_XX.png` | 每轮截图 | | `round_XX.json` | 每轮完整决策元数据(prompt、候选框、VLM 响应、最终决策、视觉反馈值) | | `checkcaptcha_pass_*.json` | 过的那次的网络监听快照 | | `checkcaptcha_fail_*.json` | 失败那次的快照 | | `session_meta_*.json` | 整个会话元信息 | 调试一道失败的题: ```bash # 找最近一次失败 ls -lt /tmp/hcaptcha_auto_solver_live/checkcaptcha_fail_*.json | head -1 # 看决策过程 cat /tmp/hcaptcha_auto_solver_live/round_05.json | jq . ``` --- ## `card.py` 集成方式 `card.py` 通过 `subprocess` 调 solver,传 bridge URL 和 VLM 配置: ```json "browser_challenge": { "external_solver": { "enabled": true, "python": "~/.venvs/ctfml/bin/python", "script": "hcaptcha_auto_solver.py", "out_dir": "/tmp/hcaptcha_auto_solver_live", "timeout_s": 180, "headed": false, "vlm": { "enabled": true, "model": "gpt-4o", "base_url": "https://api.openai.com/v1", "api_key": "", "timeout_s": 45 } } } ``` `card.py::solve_stripe_hcaptcha_in_browser()` 检测到需要非 invisible challenge 且没显式配 external_solver 时会自动补齐上面这段。 solver 结果通过本地 bridge HTTP endpoint `/result` 回传给 `card.py`。 --- ## 扩展新题型 三步: 1. 写匹配函数: ```python def is_carry_things_prompt(prompt: str) -> bool: p = prompt.lower() return "carry" in p and "things" in p ``` 2. 写求解函数: ```python def solve_carry_things(image: np.ndarray, prompt: str, **kw) -> SolverResult: # ... CLIP / OpenCV / 你的方法 return SolverResult( action="click", candidate_click_sets=[ [tile_idx_1, tile_idx_2], # strong set [tile_idx_1], # fallback set ], ... ) ``` 3. 在 `solve_bridge()` 的 dispatcher 加分支: ```python elif is_carry_things_prompt(prompt): result = solve_carry_things(image, prompt, ...) ``` PR 欢迎,看 [CONTRIBUTING.md](../CONTRIBUTING.md)。 --- ## 已知题型覆盖范围 当前覆盖约 12 种常见 hCaptcha 题型(看上面表格)。遇到没见过的 prompt 时: - VLM 启用:仍尝试 VLM 直出坐标 / 候选框决策 - VLM 失败:抛 `unknown_prompt` 错误 - 调试信息保存到 `out_dir` 供后续分析 每加一种新题型大概需要: - 100–500 张该题型截图(用过去 daemon 跑出来的 `round_XX.png` 攒) - 读 prompt 文本找模式 - 写匹配函数 + solver - 集成测试 --- ## 性能调优建议 | 场景 | 建议 | |---|---| | **VLM 慢** | 调小 `vlm.timeout_s`,提前降级到启发式 | | **VLM 不准** | 换更强的模型(`gpt-4o` → `claude-opus-4-7`),或者改 system prompt | | **CLIP 慢** | 用 GPU venv(`pip install torch --index-url https://download.pytorch.org/whl/cu121`) | | **重试太多** | 调小 `max_click_retries` / `max_drag_retries`,让 daemon 重跑而不是 solver 内 retry | | **题型未覆盖** | 加新 solver(看上面) |