captchaforge 0.2.39

Captcha detection and solving for Firefox and BiDi-driven browsers. Detection, vendor solver scaffolding, trusted cross-origin click delivery into nested OOPIFs, and stealth personas are implemented and tested; broad live-vendor solve rates are not yet benchmarked.
Documentation

captchaforge

⚠️ DO NOT USE: UNDER ACTIVE DEVELOPMENT

This crate is not production-ready: broad live-vendor success across real anti-bot sites is not yet benchmarked or guaranteed. The architecture is in place (vendor solvers, retry-loop iframe walking, VLM provider abstraction, real-WAF bench harness) and the two historical blockers below have been resolved or disproven, but a measured success rate on the full real-site set is still pending.

The earlier "0%, cross-origin click never delivers / iframe never attaches" diagnosis was empirically DISPROVEN. A deterministic oracle (foxdriver/tests/cross_origin_click.rs, 3/3 live) proves a trusted top-context click routes into a doubly-nested cross-origin OOPIF (the Turnstile/hCaptcha/reCAPTCHA shape) and arrives isTrusted = true, and that BiDi input.performActions is accepted inside a child-iframe context. The real bug was synthetic JS clicks (el.click(), isTrusted = false) at scored controls, every solver path now locates-in-JS and clicks trusted in Rust.

Real-WAF fixture Status
Cloudflare Turnstile, managed (CF demo) ✅ auto-passes with reynard (token obtained, no click needed)
Cross-origin trusted click delivery (nested OOPIF) ✅ proven (cross_origin_click: trusted positives + an isTrusted=false negative)
Cloudflare Turnstile, always-block (2x…AB) ✅ INTEGRITY, refuses to fake-pass
Cloudflare Turnstile, force-interactive (3x…FF) ⚪ DEAD ORACLE, differentially proven that no Firefox (stock or reynard) builds the challenge iframe for this test key on a self-hosted page; there is nothing to click

The deeper wall was always fingerprint (TLS JA3/JA4 + automation coherence), not the click. That is now substantially addressed: the reynard stealth engine (navigator.webdriver = false natively, FF-150-exact TLS) is the default launch engine, and a persona coherence gate is enforced JS-to-TCP at every launch. Broad live-vendor proof across real sites is the remaining open work.

Do not depend on this for any real workload. The repo is private. Reach out to the Santh team if you have a use case that justifies early collaboration.

ci license

Automatic CAPTCHA detection and multi-strategy solving for Firefox/BiDi live pages (runtime_foxdriver::Page, re-exported as captchaforge::Page). Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio challenges, sliders, plus a community-extensible TOML rule pack for vendors like AWS WAF Captcha, DataDome, Akamai Bot Manager, PerimeterX/HUMAN, Arkose/FunCaptcha, Geetest v3+v4, Friendly Captcha, MTCaptcha, and WordPress math captchas.

Part of the Santh security research ecosystem. Beta, used in production by wafrift and golemn-browser but the public API may shift before 1.0. Pin the version in Cargo.toml.

Quick start

Add to Cargo.toml:

[dependencies]
captchaforge = { git = "https://github.com/santhreal/captchaforge", branch = "main" }
tokio = { version = "1", features = ["full"] }

CLI

A standalone binary ships with the workspace:

cargo install --path cli                       # builds `captchaforge`
captchaforge doctor                            # probe Ollama / Whisper / Tesseract
captchaforge setup --auto                      # auto-pull default vision model
captchaforge selftest                          # confirm the 11-solver chain wires up
captchaforge transcribe sample.wav             # smoke-test the STT backend
captchaforge solve https://example.com         # solve a captcha on a real URL
captchaforge serve --port 8080                 # HTTP API for non-Rust embeds

The serve mode exposes:

  • GET /doctor: capabilities JSON + install hints (works headless)
  • POST /solve: {"url": "https://...", "headless": true} → solve result
  • POST /transcribe: {"audio_b64": "..."}{"raw","normalized"}

Use serve to embed solving in any language that speaks HTTP. Meridian should not use the standalone solve/serve browser path for auth-preserving hunting; it calls CaptchaForge inside the existing Guise Firefox/BiDi page through guise-bridge.

Library

The shortest path from "fresh page" to "solved captcha" is one call [captchaforge::solve_url] wires stealth + named profile before navigation, warms the loaded page, then runs auto_solve:

use captchaforge::prelude::*;

async fn open(page: &captchaforge::Page) -> anyhow::Result<()> {
    match solve_url(page, "https://example.com",
                    Some(StealthProfile::FirefoxLinux)).await? {
        None                          => println!("no captcha"),
        Some(r) if r.success          => println!("solved via {:?} ({}ms)", r.method, r.time_ms),
        Some(r)                       => println!("failed: {:?}", r.method),
    }
    Ok(())
}

If you already navigated and just want the detect-then-solve loop on the current page, use [captchaforge::auto_solve]:

async fn handle_captcha(page: &captchaforge::Page) -> anyhow::Result<()> {
    // Returns Ok(None) for no-captcha pages.
    if let Some(outcome) = captchaforge::auto_solve(page).await? {
        if outcome.success {
            tracing::info!(method = ?outcome.method, "captcha solved");
        } else if let Some(screenshot) = outcome.screenshot {
            tracing::warn!(bytes = screenshot.len(), "human review needed");
        }
    }
    Ok(())
}

For high-stakes flows (checkout, account creation, bot-blocked public APIs) wrap the call in [captchaforge::auto_solve_with_retries], it dismisses cookie-consent banners between attempts and retries on transient failure. Pair with [captchaforge::dismiss_chat_widget] when an Intercom / Drift / HubSpot launcher overlaps the captcha widget.

For full control, telemetry, token cache, custom solvers, drop down to the explicit chain:

use std::sync::Arc;
use captchaforge::{detect, solver::{CaptchaSolverChain, TokenCache}};

let cache = Arc::new(TokenCache::new());     // 60s TTL by default
let chain = CaptchaSolverChain::default_chain()
    .with_token_cache(cache);                 // hit cache before solving

let info = detect::detect(page).await?;
if detect::is_captcha(&info) {
    let outcome = chain.solve(page, &info).await;
    // ...
}

Capability map

Each capability has its own module + at least one production wiring point. Re-export paths are at the crate root; the internal home is one of captchaforge::*.

Capability Module Wired into
One-call solve / prepare / dismiss helpers sdk captchaforge::{solve_url, …} re-exports
158-vendor TOML rule pack detect::rules ProviderRegistry::with_built_in_rules
15 named browser stealth profiles stealth_profiles prepare_page(_, Some(profile))
Real-human mouse-trace sampler (8-trace corpus + jitter) mouse_sampler opt-in; behavior::mouse_move_human ships the legacy path
Bezier mouse / hover-dwell / touch-swipe / pointer-jitter / triple-click behavior bench-suite + production solvers
Audio captcha DSP (denoise + bandpass + normalise) audio_dsp AudioCaptchaSolver::transcribe
DOM/frame graph (BFS / deepest-captcha / ancestors) frame_graph opt-in for solver authors
Multi-hop captcha planner solver::planner opt-in via MultiHopPlanner
Read-only artifact pre-flight handle solver::speculation opt-in via PreflightHandle
Per-vendor token shape oracle solver::token_shapes chain post-solve check (auto)
Adversarial training corpus training_corpus chain.with_training_corpus(...)
VLM training dataset (content-addressed) vlm_dataset training pipeline (training/)
Mouse-trace ingest store trace_ingest server side of B1
Vendor JS scraper + probe-surface diff vendor_scraper captchaforge scrape-vendors CLI
TOML rule hot-reload (mtime-poll, atomic swap) rule_watcher opt-in via HotReloadRegistry::start
Patched-Chromium sidecar detector chromium_sidecar opt-in legacy diagnostic via PatchedChromiumDetector
Per-solver telemetry + Prometheus exposition telemetry chain.with_telemetry(...) + serve /metrics
Network-layer WAF challenge replay (no browser) crate captchaforge-challenge direct dep
Pure-data rule schema (no browser dep) crate captchaforge-rules direct dep for static-analysis tools

Architecture surface (post-extraction)

  • detect::Detector, single per-vendor detector trait. Eleven built-ins (TurnstileDetector, RecaptchaDetector, …) live one per file under src/detect/. Add a new detector by writing src/detect/<vendor>.rs and registering it.
  • detect::rules, community-extensible TOML rule layer. Drop a [[provider]] entry in rules/community.toml (or your own file) and the vendor is detected without writing any Rust. Bundled pack covers DataDome, Akamai, PerimeterX/HUMAN, Arkose/FunCaptcha, Geetest v3+v4, AWS WAF Captcha, Friendly Captcha, MTCaptcha, WordPress math captchas.
  • provider::CaptchaProvider, bundles a detector + the recommended solver methods for that vendor in one trait. Built-in providers wired via macro; new providers implement both traits in one file.
  • provider::ProviderRegistry::with_built_in_rules(), single call returning the built-in detectors PLUS the bundled community rule pack, all priority-sorted.
  • solver::CaptchaSolverChain, multi-strategy chain with cache
    • telemetry + pattern-store routing. See "Strategies" below.
  • solver::TokenCache, per-(domain, captcha_type) solved token cache with configurable TTL. Cache hits skip the solver run entirely and report through telemetry as solver: "TokenCache".
  • telemetry::SolverTelemetry, implement to capture every solver attempt as a SolveEvent. Default is NoopTelemetry. Production deployments plug in Prometheus / OTel / SQLite / Sentry.
  • solver::PatternStore (per-domain learned routing). save_to_path / load_from_path / merge for cross-restart and cross-instance sharing.
  • captchaforge::auto_solve(), top-level one-call API for the 90% case. Auto-detects local backends (Ollama VLM / Whisper / Tesseract) on first call and logs the capability summary so operators see what's wired without reading the source.
  • captchaforge::backends::probe(), non-blocking parallel probe of Ollama (/api/tags), whisper / whisper-cli on PATH, OPENAI_API_KEY env, tesseract on PATH. Returns a Capabilities struct with install_hints() for any missing backend so consumers can tell their users exactly what to install.
  • captchaforge::backends::Capabilities::ensure_vlm_model() spawns ollama pull llama3.2-vision:11b when Ollama is reachable but no vision model is present. Auto-install for the SDK consumer.

Strategies

For each detected captcha the chain runs through a layered short circuit + strategy stack. First match wins.

  1. Token cache: if a TokenCache is installed and holds a fresh (domain, captcha_type) token (default 60s TTL), return it immediately tagged as CrowdSourced. Telemetry sees this as a Success with solver: "TokenCache". Skip the rest of the chain.
  2. Provider routing: for DetectedCaptcha::Custom (TOML rule layer), only solvers whose SolveMethod appears in the bundled provider's recommended_solver_methods are eligible, in declared order. Built-in detectors fall back to the standard supports() filter.
  3. Pattern store ordering: for built-in kinds, solvers eligible under (1) get re-ordered so the historically-best method for this domain runs first. PatternStore can be persisted across restarts (save_to_path / load_from_path) and merged across worker instances (merge).
  4. Behavioural: realistic mouse movement + timing for Turnstile and reCAPTCHA v3. Solvers verify the response token is actually present before reporting success. On success, captures session cookies into CaptchaSolveResult::cookies for replay.
  5. Vision-LLM: screenshot the challenge, hand it to a multimodal model (Ollama qwen3-vl:30b out of the box, configurable), parse the model's grid / click answer, simulate the mouse. Captures session cookies on success.
  6. Audio bypass: click the accessibility audio challenge, run STT, type the transcribed answer, then verify the reCAPTCHA token was accepted. Captures session cookies on success.
  7. Telemetry: every attempt at every layer fires a SolveEvent to the installed SolverTelemetry impl (default NoopTelemetry). Outcomes: Success, Failure, Error, Timeout. Cache hits surface as Success with solver: "TokenCache" so dashboards distinguish solved-from-scratch from cache-served.
  8. Human fallback: when every solver fails, return unsolved with a base64 JPEG screenshot in CaptchaSolveResult::screenshot so a downstream human-in-the-loop can finish.

Apply captured cookies to subsequent navigations via captchaforge::cookies::apply_to_page(&page, &result.cookies) so the WAF/vendor's trusted session is preserved and the next request does not re-trigger the captcha.

You can plug in your own solver by implementing the CaptchaSolver trait and adding it to the chain via chain.add_solver(...) before default_chain() strategies.

Detection coverage

Built-in detectors (one per file under src/detect/):

  • Cloudflare Turnstile (.cf-turnstile, [data-turnstile-sitekey], script[src*="challenges.cloudflare.com"])
  • Cloudflare bot interstitial ("Just a moment", #challenge-form)
  • reCAPTCHA v2 + v3 (.g-recaptcha, [data-sitekey], script[src*="google.com/recaptcha"])
  • hCaptcha (.h-captcha, [data-hcaptcha-sitekey], script[src*="hcaptcha.com"])
  • Generic image / audio CAPTCHAs
  • PoW CAPTCHAs (ALTCHA, mCaptcha, Friendly Captcha, Cap.dev)
  • Slider / swipe / rotate / drag CAPTCHAs
  • Canvas / SVG / math / icon / color CAPTCHAs
  • Multi-step / wizard CAPTCHAs
  • Shadow-DOM-hidden CAPTCHAs

Bundled community rule pack (rules/community.toml, registered via ProviderRegistry::with_built_in_rules()):

  • AWS WAF Captcha
  • DataDome
  • Akamai Bot Manager
  • PerimeterX / HUMAN
  • Arkose Labs / FunCaptcha
  • Geetest v3 + v4
  • Friendly Captcha
  • MTCaptcha
  • WordPress math captchas (Captcha by BestWebSoft, generic forms)

Adding a new family is two lines of TOML, see rules/community.toml for the schema. Per-rule positive + negative HTML fixtures live under tests/rule_fixtures/<vendor>/; the community_rules integration test asserts every rule fires on its positive and rejects its negative AND doesn't cross-fire on any other vendor's negative.

Configuration

Two layers, lightest to heaviest:

Env vars (zero-config)

  • CAPTCHAFORGE_VLM_ENDPOINT: Ollama endpoint URL.
  • CAPTCHAFORGE_VLM_MODEL: VLM model name.
  • CAPTCHAFORGE_THIRDPARTY_API_KEY: 2captcha-compatible service key.
  • CAPTCHAFORGE_THIRDPARTY_ENDPOINT: override the service base URL.
  • CAPTCHAFORGE_CONFIG: explicit Tier-A config file path.

Tier-A operational config (.captchaforge.toml)

Drop .captchaforge.toml in your project (or use the discovery search path: $CAPTCHAFORGE_CONFIG, ./.captchaforge.toml, ./captchaforge.toml, $XDG_CONFIG_HOME/captchaforge/config.toml). The schema covers chain timeouts, cache TTL, VLM endpoint/model, third-party service selection, and every SolveConfig field. See .captchaforge.toml.example for the reference. Unknown keys are a parse error so typos surface instead of silently being ignored.

per_solver_timeout_ms = 60000

[cache]
ttl_seconds = 90

[vlm]
endpoint = "http://gpu-host:11434"

[third_party]
service = "cap_solver"
api_key = "..."

Builder methods (fine-grained)

For fine-grained control inside one process, use the builder methods they always beat env + TOML.

use captchaforge::solver::{VlmCaptchaSolver, CaptchaSolverChain};

let mut chain = CaptchaSolverChain::empty();
chain.add_solver(
    VlmCaptchaSolver::new()
        .with_endpoint("http://my-gpu-host:11434")
        .with_model("qwen3-vl:30b"),
);

Audio STT endpoint is similarly configurable on AudioCaptchaSolver.

Third-party solver service

ThirdPartyCaptchaSolver talks the 2captcha-compatible HTTP protocol used by 2captcha, CapMonster, and CapSolver, every TOML-bundled vendor that recommends ThirdPartyService now actually has a solver to land on. Without an API key configured, supports() returns false across the board so the solver is skipped instead of issuing 401s.

use captchaforge::solver::{ThirdPartyCaptchaSolver, CaptchaSolverChain};

let mut chain = CaptchaSolverChain::empty();
chain.add_solver(
    // Pick the service flavour, then pass the key:
    ThirdPartyCaptchaSolver::cap_solver().with_api_key("..."),
);

CLI

cargo install --path cli
captchaforge inspect-rules                         # list bundled + extra rules
captchaforge detect https://example.com/login      # navigate, run detector
captchaforge solve  https://example.com/login      # navigate, run full chain
captchaforge inspect-rules --rules my-pack.toml    # additive extra rules
captchaforge rules                                 # 158-vendor coverage table
captchaforge profiles                              # 15 named StealthProfiles
captchaforge corpus stats /var/lib/cf/corpus       # adversarial-training samples
captchaforge dataset stats /var/lib/cf/datasets    # VlmDataset shape
captchaforge scrape-vendors --out report.txt       # diff vendor JS probe surface
captchaforge validate-rules                        # static-check every TOML rule
captchaforge serve --port 8080                     # HTTP API (incl. /metrics)

Telemetry

Two batteries-included impls plus the trait for custom sinks.

use std::sync::Arc;
use captchaforge::solver::CaptchaSolverChain;
use captchaforge::telemetry::{JsonTelemetry, MetricsTelemetry};

// Option 1: route every event through `tracing::info!` with structured
// fields. Pairs with `tracing-subscriber`'s JSON layer for ELK / Loki
// / Datadog without any extra integration code.
let chain = CaptchaSolverChain::default_chain()
    .with_telemetry(Arc::new(JsonTelemetry));

// Option 2: in-process Prometheus-shaped aggregator. `serve` wires
// this into `/metrics` and `/metrics.json` automatically.
let metrics = Arc::new(MetricsTelemetry::new());
let chain = CaptchaSolverChain::default_chain()
    .with_telemetry(metrics.clone());
println!("{}", metrics.snapshot().to_prometheus());

The chain fires one event per solver attempt at every outcome (Success / Failure / Error / Timeout). Cache hits also fire as Success with solver: "TokenCache" so dashboards distinguish solved-from-scratch from cache-served.

Adversarial training loop

Every failed solve auto-appends to a TrainingCorpus when one is configured. After ~1 week of production traffic you have a labelled- by-failure dataset ready to feed back into a fine-tune.

use std::sync::Arc;
use captchaforge::solver::CaptchaSolverChain;
use captchaforge::training_corpus::TrainingCorpus;

let corpus = Arc::new(TrainingCorpus::open("/var/lib/captchaforge/corpus")?);
let chain = CaptchaSolverChain::default_chain()
    .with_training_corpus(corpus.clone());

The Python compute side lives in training/: Dockerfile + train_lora.py (LoRA fine-tune of Qwen2-VL-7B on a captchaforge VlmDataset), distill.py (7B teacher → 2B student distillation), auto_label.py (GPT-4V / Claude vision teacher labelling), bench_compare.py (CI gate for model promotion based on per-vendor regression bound), promote_to_ollama.sh (HF → GGUF → Ollama registration).

Hot-reload TOML rules

Operators drop a new vendor rule into the watched dir; the registry atomically swaps in <2s without a process restart.

use std::time::Duration;
use captchaforge::rule_watcher::HotReloadRegistry;

let watcher = HotReloadRegistry::start("/etc/captchaforge/rules.d", Duration::from_secs(2))?;
let registry = watcher.current(); // Arc<ProviderRegistry>, atomically swapped on TOML change

Bad TOML keeps the previous registry active and logs at warn (no restart spam). The validate-rules CLI subcommand pre-flights every rule before promotion.

Network-layer challenge replay

For WAF challenges that are JS-only (no visual captcha), the captchaforge-challenge workspace crate solves them in pure HTTP typically 50-150ms instead of the 8-12s a browser round-trip takes, no GPU, no browser binary. Cloudflare interstitial reference impl ships today; DataDome / Akamai / Imperva / Kasada slot into the same ChallengeSolver trait as their JS is reverse-engineered.

Origin and licensing

Extracted from golemn-browser (originally GPL-3.0). The original author (Santh Project) re-licensed the extracted slice as MIT OR Apache-2.0 so it can be embedded in MIT/Apache-licensed downstream tools (notably wafrift for managed-WAF challenge handling and any future Santh tool that needs a captcha layer).

Testing

cargo test --workspace        # unit + integration (no chromium required)
cargo test --doc              # doctest in lib.rs
cargo test --test community_rules    # asserts every TOML rule fires on its
                              # positive HTML fixture, rejects its negative,
                              # and doesn't cross-fire on other vendors' negatives
cargo test --test integration_chain_wires  # cache + telemetry + chain wires
cargo test --test wirings    # production wirings: H-series modules
                             # actually reachable from the chain (oracle,
                             # corpus, hot-reload, telemetry, planner)
cargo test --test property_token_shapes   # ~10k randomised cases per
                             # token-oracle invariant (decoy never
                             # Plausible, classify deterministic, etc.)

Offline module micro-bench

cd bench
cargo run --release -- --suite modules

In-process measurements (no browser) of the H-series hot paths:

  • token-oracle classify (1k iters)
  • token-oracle resolve (1k)
  • frame-graph construction (1k)
  • mouse-sampler trace synthesis (1k)
  • training-corpus append (100)

Total runtime <1s. Output uses the same regression-delta tooling as the live-browser suites, so per-module slowdowns surface in the bench-baseline.json diff. CI runs this on every PR via the --suite quick aggregate.

Local proof (benches)

cargo run --example architecture_proof --release

Prints a markdown report covering the latency-critical paths added by the architecture sweep:

  • TokenCache get hit / miss
  • ProviderRegistry::find_by_kind for Custom kinds
  • RuleDetector probe generation (one-shot at registration)
  • Per-rule HTML fixture match latency (one row per bundled vendor)

Browser-free, runs in well under a second after the release build. Use to sanity-check that no future change regresses the hot path.

The test suite does not require a real browser binary or a real Ollama server, browser-bound paths are pure Rust unit tests against the JavaScript / Rust glue layer. Tests that exercise the real headless browser live in wafrift-captchaforge-bridge where a live browser runtime is available.

License

MIT OR Apache-2.0 at your option. Copyright 2026 CORUM COLLECTIVE LLC.

Scorecard badges

These measure structural coverage, that the solver chain, the community rule set, the challenge-fingerprint catalogue, and the test/property suites are complete and wired. They are not a live-vendor success rate: a measured pass rate against real anti-bot sites is still open work (see the banner at the top). A 100% here means "every stage/fixture we model is exercised", not "we beat every real CAPTCHA".

real_waf_solver_chain_completeness real_waf_community_rule_coverage real_waf_bench_fixture_coverage real_waf_challenge_fingerprint_coverage real_waf_test_suite_coverage real_waf_property_test_coverage