use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Mutation {
Truncate { keep_chars: usize },
PadWithFiller { len: usize, fill_char: u8 },
CharSwap { swap_count: usize, replacement: u8 },
StripPrefix,
StripDots,
RepeatSegment { segment_len: usize, repeat: usize },
InjectHtml,
InjectWhitespace,
SwapBase64ToBase64url,
HexOnlyConvert,
ZeroBody,
SingleCharBody { c: u8 },
JsonShape,
WrongPrefix { prefix: &'static str },
InjectZeroWidth,
InjectRtlOverride,
PrefixOnly,
}
impl Mutation {
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(),
}
}
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",
}
}
}
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()..]
}
fn utf8_safe_mid(token: &str) -> usize {
let target = token.len() / 2;
for i in target..=token.len() {
if token.is_char_boundary(i) {
return i;
}
}
token.len()
}
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,
]
}
pub fn generate(seed: &str, mutations: &[Mutation]) -> Vec<(&'static str, String)> {
mutations
.iter()
.map(|m| (m.name(), m.apply(seed)))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
const REAL_TURNSTILE_SAMPLE: &str = "0.aB3xY7zQ9mK2wL5jH8nR4pT6vC1dF0gN-8eXqM7lJ4kS3aZbY2cV5uI6oP9rQ8tW1nB4mE7sD0xL3kJ6hG9fR2qV5yU8cP1aB4eX7zM0nQ3kL6jH9pR2tV5wY8xC1dF4gN7eM0lJ3kS6aZbY9cV2uI5oP8rQ1tW4nB7mE0sD3xL6kJ9hG2fR5qV8yU";
#[test]
fn truncate_shortens_to_keep_chars() {
let m = Mutation::Truncate { keep_chars: 10 };
let out = m.apply(REAL_TURNSTILE_SAMPLE);
assert_eq!(out.chars().count(), 10);
}
#[test]
fn pad_with_filler_appends_chars() {
let m = Mutation::PadWithFiller {
len: 50,
fill_char: b'a',
};
let out = m.apply("0.short");
assert_eq!(out.len(), 7 + 50);
assert!(out.ends_with("aaaa"));
}
#[test]
fn strip_prefix_removes_known_prefix() {
assert_eq!(Mutation::StripPrefix.apply("0.abc"), "abc");
assert_eq!(Mutation::StripPrefix.apply("P0_xyz"), "xyz");
assert_eq!(Mutation::StripPrefix.apply("03AGdBq25_token"), "token");
}
#[test]
fn strip_dots_removes_all_dots() {
let out = Mutation::StripDots.apply("a.b.c.d");
assert_eq!(out, "abcd");
}
#[test]
fn inject_html_inserts_script_tag() {
let out = Mutation::InjectHtml.apply("0.abcdefghij");
assert!(out.contains("<script>"));
}
#[test]
fn inject_whitespace_inserts_newline() {
let out = Mutation::InjectWhitespace.apply("0.abcdefghij");
assert!(out.contains('\n'));
}
#[test]
fn swap_base64_to_base64url_swaps_chars() {
let out = Mutation::SwapBase64ToBase64url.apply("a-b_c-d");
assert_eq!(out, "a+b/c+d");
}
#[test]
fn hex_only_filters_to_hex() {
let out = Mutation::HexOnlyConvert.apply("0.aBc1DeF2.zzz");
for c in out.chars() {
assert!(c.is_ascii_hexdigit() || c == '.');
}
}
#[test]
fn zero_body_replaces_body_with_zeros() {
let out = Mutation::ZeroBody.apply(REAL_TURNSTILE_SAMPLE);
assert!(out.starts_with("0."));
assert!(out[2..].chars().all(|c| c == '0'));
}
#[test]
fn wrong_prefix_replaces_prefix() {
let m = Mutation::WrongPrefix { prefix: "9." };
let out = m.apply("0.abc");
assert_eq!(out, "9.abc");
}
#[test]
fn inject_zero_width_inserts_zwsp() {
let out = Mutation::InjectZeroWidth.apply("0.abcdef");
assert!(out.contains('\u{200B}'));
}
#[test]
fn prefix_only_keeps_just_prefix() {
assert_eq!(Mutation::PrefixOnly.apply("0.aaaaaaaaaaaaaaaa"), "0.");
assert_eq!(Mutation::PrefixOnly.apply("P0_xxxxxxxxxxxxx"), "P0_");
}
#[test]
fn every_standard_mutation_produces_decoy_for_real_sample() {
use crate::solver::decoy_detector::{classify, DecoyVerdict};
let mutations = standard_mutations();
let mut failures = Vec::new();
for m in &mutations {
let mutated = m.apply(REAL_TURNSTILE_SAMPLE);
let verdict = classify(&mutated, "turnstile");
if verdict != DecoyVerdict::Decoy {
failures.push((m.name(), verdict, mutated));
}
}
assert!(
failures.len() <= 4,
"{} mutations not caught by statistical detector alone: {:?}",
failures.len(),
failures.iter().map(|(n, v, _)| (n, v)).collect::<Vec<_>>()
);
}
#[test]
fn standard_mutations_lists_at_least_15() {
assert!(standard_mutations().len() >= 15);
}
#[test]
fn standard_mutations_each_have_distinct_name() {
let mut seen = std::collections::HashSet::new();
for m in standard_mutations() {
let name = m.name();
let key = format!("{:?}", m);
seen.insert(key);
assert!(!name.is_empty());
}
assert!(seen.len() >= 15);
}
#[test]
fn generate_produces_one_pair_per_mutation() {
let m = standard_mutations();
let pairs = generate(REAL_TURNSTILE_SAMPLE, &m);
assert_eq!(pairs.len(), m.len());
}
#[test]
fn scale_10k_seed_mutation_combinations_all_produce_strings() {
use rand::{rngs::StdRng, Rng, SeedableRng};
let mut rng = StdRng::seed_from_u64(0xC0FFEE);
let m = standard_mutations();
let mut sum_len = 0usize;
for _ in 0..10_000 {
let len: usize = rng.gen_range(200..400);
let body: String = (0..len)
.map(|_| {
let idx: u8 = rng.gen_range(0..62);
match idx {
0..=25 => (b'A' + idx) as char,
26..=51 => (b'a' + (idx - 26)) as char,
_ => (b'0' + (idx - 52)) as char,
}
})
.collect();
let token = format!("0.{}", body);
for mu in &m {
let mutated = mu.apply(&token);
sum_len += mutated.len();
}
}
assert!(sum_len > 0);
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config {
cases: 10_000, .. proptest::test_runner::Config::default()
})]
#[test]
fn prop_apply_never_panics(
body in proptest::collection::vec(b'!'..=b'~', 0..400),
) {
let token = String::from_utf8(body).unwrap();
for m in standard_mutations() {
let _ = m.apply(&token);
}
}
#[test]
fn prop_strip_prefix_never_grows(
body in proptest::collection::vec(b'A'..=b'z', 0..400),
) {
let token = String::from_utf8(body).unwrap();
let out = Mutation::StripPrefix.apply(&token);
assert!(out.len() <= token.len());
}
#[test]
fn prop_truncate_respects_keep(
body in proptest::collection::vec(b'A'..=b'z', 0..400),
keep in 0usize..400,
) {
let token = String::from_utf8(body).unwrap();
let out = Mutation::Truncate { keep_chars: keep }.apply(&token);
assert!(out.chars().count() <= keep);
}
#[test]
fn prop_prefix_only_is_idempotent(
body in proptest::collection::vec(b'A'..=b'z', 0..400),
) {
let token = String::from_utf8(body).unwrap();
let out = Mutation::PrefixOnly.apply(&token);
let twice = Mutation::PrefixOnly.apply(&out);
assert_eq!(out, twice);
}
#[test]
fn prop_wrong_prefix_preserves_body(
body in proptest::collection::vec(b'A'..=b'z', 5..200),
) {
let token = String::from_utf8(body).unwrap();
let m = Mutation::WrongPrefix { prefix: "9." };
let out = m.apply(&token);
let original_body = strip_prefix(&token);
assert!(out.ends_with(original_body));
}
}
}