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
//! Adversarial decoy generator: mutate real-shape tokens into
//! decoys the engine MUST flag.
//!
//! Two related uses:
//!
//! 1. **Training-corpus synthesis**: generate adversarial inputs
//!    for offline ROC-AUC tuning of [`crate::solver::decoy_detector`].
//!    Real tokens are scarce (vendor-issued); decoy mutations
//!    multiply each real token into a family of N+ negative
//!    samples without needing additional vendor traffic.
//!
//! 2. **Regression-fixture generator**: every released oracle
//!    update runs the mutation set against every shipped real
//!    token (from cassettes / production capture) and asserts
//!    the verdict is Decoy. Catches the case where a feature-
//!    weight tweak accidentally accepts mutated tokens.
//!
//! The mutations below come from public WAF / bot-detection
//! research; each one corresponds to a documented bypass shape
//! catalogued in `tests/cve_replay_decoy.rs`. No public captcha-
//! solver crate ships this generator.

use serde::{Deserialize, Serialize};

/// One mutation that turns a real-shape token into a decoy.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Mutation {
    /// Truncate to `keep_chars`. Below `min_len` becomes Decoy.
    Truncate { keep_chars: usize },
    /// Pad the body with `len` repetitions of `fill_char`.
    PadWithFiller { len: usize, fill_char: u8 },
    /// Swap N characters to a non-base64url character set.
    CharSwap { swap_count: usize, replacement: u8 },
    /// Strip the vendor revision prefix (`0.`, `1.`, `P0_`, etc).
    StripPrefix,
    /// Strip all dots (for JWT-shape vendors only).
    StripDots,
    /// Repeat the first N chars of the body to make the token
    /// pattern-detectable.
    RepeatSegment { segment_len: usize, repeat: usize },
    /// Inject HTML tag in the middle of the body.
    InjectHtml,
    /// Inject ASCII newlines / tabs.
    InjectWhitespace,
    /// Swap base64url (`-`/`_`) characters for base64 (`+`/`/`).
    SwapBase64ToBase64url,
    /// Reduce to hex characters only (looks like a session ID).
    HexOnlyConvert,
    /// Replace the body with all-zero bytes.
    ZeroBody,
    /// Replace the body with a single repeated character.
    SingleCharBody { c: u8 },
    /// Replace with JSON-shaped string.
    JsonShape,
    /// Wrong prefix (e.g. `9.` for Turnstile).
    WrongPrefix { prefix: &'static str },
    /// Inject zero-width character (U+200B / U+FEFF).
    InjectZeroWidth,
    /// Inject Unicode RTL-override attack.
    InjectRtlOverride,
    /// Empty body (keep prefix only).
    PrefixOnly,
}

impl Mutation {
    /// Apply the mutation to `token`. Returns the resulting decoy
    /// string. Pure function (no allocation beyond the result).
    ///
    /// # Example
    ///
    /// ```rust
    /// use captchaforge::solver::adversarial_decoy::Mutation;
    ///
    /// let token = "0.aB3xY7zQ9mK2wL5jH8";
    /// // Strip the vendor revision prefix.
    /// assert_eq!(Mutation::StripPrefix.apply(token), "aB3xY7zQ9mK2wL5jH8");
    /// // Keep just the prefix.
    /// assert_eq!(Mutation::PrefixOnly.apply(token), "0.");
    /// // Truncate to a short length.
    /// assert_eq!(Mutation::Truncate { keep_chars: 5 }.apply(token).len(), 5);
    /// ```
    pub fn apply(&self, token: &str) -> String {
        match *self {
            Mutation::Truncate { keep_chars } => token.chars().take(keep_chars).collect(),
            Mutation::PadWithFiller { len, fill_char } => {
                let mut out = token.to_string();
                for _ in 0..len {
                    out.push(fill_char as char);
                }
                out
            }
            Mutation::CharSwap {
                swap_count,
                replacement,
            } => {
                let mut chars: Vec<char> = token.chars().collect();
                let r = replacement as char;
                for i in 0..swap_count.min(chars.len()) {
                    let pos = (i * 7) % chars.len();
                    chars[pos] = r;
                }
                chars.iter().collect()
            }
            Mutation::StripPrefix => strip_prefix(token).to_string(),
            Mutation::StripDots => token.replace('.', ""),
            Mutation::RepeatSegment {
                segment_len,
                repeat,
            } => {
                if token.is_empty() || segment_len == 0 {
                    return token.to_string();
                }
                let body = strip_prefix(token);
                let seg: String = body.chars().take(segment_len).collect();
                let mut out = vendor_prefix(token).to_string();
                for _ in 0..repeat {
                    out.push_str(&seg);
                }
                out
            }
            Mutation::InjectHtml => {
                let mid = utf8_safe_mid(token);
                let (left, right) = token.split_at(mid);
                format!("{left}<script>alert(1)</script>{right}")
            }
            Mutation::InjectWhitespace => {
                let mid = utf8_safe_mid(token);
                let (left, right) = token.split_at(mid);
                format!("{left}\n\t\n{right}")
            }
            Mutation::SwapBase64ToBase64url => token.replace('-', "+").replace('_', "/"),
            Mutation::HexOnlyConvert => token
                .chars()
                .filter(|c| c.is_ascii_hexdigit() || *c == '.')
                .collect(),
            Mutation::ZeroBody => {
                let prefix = vendor_prefix(token);
                let body_len = token.len().saturating_sub(prefix.len());
                let mut out = prefix.to_string();
                for _ in 0..body_len {
                    out.push('0');
                }
                out
            }
            Mutation::SingleCharBody { c } => {
                let prefix = vendor_prefix(token);
                let body_len = token.len().saturating_sub(prefix.len());
                let mut out = prefix.to_string();
                let cs = c as char;
                for _ in 0..body_len {
                    out.push(cs);
                }
                out
            }
            Mutation::JsonShape => {
                format!(
                    "{{\"verified\":true,\"score\":1.0,\"action\":\"submit\",\"token_len\":{}}}",
                    token.len()
                )
            }
            Mutation::WrongPrefix { prefix } => {
                let body = strip_prefix(token);
                format!("{prefix}{body}")
            }
            Mutation::InjectZeroWidth => {
                let mid = utf8_safe_mid(token);
                let (left, right) = token.split_at(mid);
                format!("{left}\u{200B}{right}")
            }
            Mutation::InjectRtlOverride => {
                let mid = utf8_safe_mid(token);
                let (left, right) = token.split_at(mid);
                format!("{left}\u{202E}{right}")
            }
            Mutation::PrefixOnly => vendor_prefix(token).to_string(),
        }
    }

    /// Stable name for the mutation, used by the regression
    /// reporter so a failing mutation is named in the error.
    pub fn name(&self) -> &'static str {
        match self {
            Mutation::Truncate { .. } => "truncate",
            Mutation::PadWithFiller { .. } => "pad_with_filler",
            Mutation::CharSwap { .. } => "char_swap",
            Mutation::StripPrefix => "strip_prefix",
            Mutation::StripDots => "strip_dots",
            Mutation::RepeatSegment { .. } => "repeat_segment",
            Mutation::InjectHtml => "inject_html",
            Mutation::InjectWhitespace => "inject_whitespace",
            Mutation::SwapBase64ToBase64url => "swap_base64_to_base64url",
            Mutation::HexOnlyConvert => "hex_only",
            Mutation::ZeroBody => "zero_body",
            Mutation::SingleCharBody { .. } => "single_char_body",
            Mutation::JsonShape => "json_shape",
            Mutation::WrongPrefix { .. } => "wrong_prefix",
            Mutation::InjectZeroWidth => "inject_zero_width",
            Mutation::InjectRtlOverride => "inject_rtl_override",
            Mutation::PrefixOnly => "prefix_only",
        }
    }
}

/// Vendor-prefix recogniser. Returns the prefix substring (e.g.
/// `"0."`, `"1."`, `"P0_"`, `"P1_"`, `"03AGdBq25_"`, etc.) or
/// empty when no known prefix matches.
fn vendor_prefix(token: &str) -> &str {
    for p in &[
        "03AGdBq25_",
        "03AGdBq27_",
        "P0_",
        "P1_",
        "0.",
        "1.",
        "9.",
        "datadome_cookie=",
        "aws-waf-token=",
        "_abck=",
        "_px3=",
    ] {
        if token.starts_with(p) {
            return &token[..p.len()];
        }
    }
    ""
}

fn strip_prefix(token: &str) -> &str {
    let prefix = vendor_prefix(token);
    &token[prefix.len()..]
}

/// Return the byte offset closest to `token.len() / 2` that's on a
/// UTF-8 character boundary. `String::split_at` panics if called
/// with a byte offset inside a multibyte sequence, this helper
/// keeps every injection mutation safe under UTF-8 input.
fn utf8_safe_mid(token: &str) -> usize {
    let target = token.len() / 2;
    // Walk forward to the first char boundary >= target.
    for i in target..=token.len() {
        if token.is_char_boundary(i) {
            return i;
        }
    }
    token.len()
}

/// All mutations that produce a token guaranteed to be Decoy.
/// Some mutations need parameters; we pre-instantiate sensible
/// defaults so callers don't have to. Caller can also build their
/// own list of `Mutation` enum values.
pub fn standard_mutations() -> Vec<Mutation> {
    vec![
        Mutation::Truncate { keep_chars: 5 },
        Mutation::Truncate { keep_chars: 20 },
        Mutation::PadWithFiller {
            len: 200,
            fill_char: b'a',
        },
        Mutation::PadWithFiller {
            len: 400,
            fill_char: b'0',
        },
        Mutation::CharSwap {
            swap_count: 50,
            replacement: b' ',
        },
        Mutation::CharSwap {
            swap_count: 50,
            replacement: b'<',
        },
        Mutation::StripPrefix,
        Mutation::StripDots,
        Mutation::RepeatSegment {
            segment_len: 3,
            repeat: 100,
        },
        Mutation::InjectHtml,
        Mutation::InjectWhitespace,
        Mutation::SwapBase64ToBase64url,
        Mutation::HexOnlyConvert,
        Mutation::ZeroBody,
        Mutation::SingleCharBody { c: b'a' },
        Mutation::SingleCharBody { c: b'X' },
        Mutation::JsonShape,
        Mutation::WrongPrefix { prefix: "9." },
        Mutation::WrongPrefix { prefix: "ZZZ_" },
        Mutation::InjectZeroWidth,
        Mutation::InjectRtlOverride,
        Mutation::PrefixOnly,
    ]
}

/// Apply every mutation in `mutations` to `seed`. Returns
/// `(mutation_name, decoy_token)` pairs.
pub fn generate(seed: &str, mutations: &[Mutation]) -> Vec<(&'static str, String)> {
    mutations
        .iter()
        .map(|m| (m.name(), m.apply(seed)))
        .collect()
}

#[cfg(test)]
#[path = "adversarial_decoy/tests.rs"]
mod tests;