pub mod adaptive;
pub mod cmd;
pub mod ldap;
pub mod log4shell;
pub mod nosql;
pub mod path;
pub mod sql;
pub mod ssrf;
pub mod ssti;
pub mod wafmodel;
pub mod xss;
pub mod xxe;
#[derive(Debug, Clone)]
pub struct Rng(u64);
impl Rng {
#[must_use]
pub fn new(seed: u64) -> Self {
Self(seed ^ 0x9E37_79B9_7F4A_7C15)
}
pub fn next_u64(&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)
}
pub fn below(&mut self, n: usize) -> usize {
if n == 0 {
return 0;
}
(self.next_u64() % n as u64) as usize
}
pub fn pick<'a, T>(&mut self, xs: &'a [T]) -> &'a T {
&xs[self.below(xs.len().max(1)).min(xs.len() - 1)]
}
pub fn chance(&mut self, num: u32, den: u32) -> bool {
den != 0 && (self.next_u64() % u64::from(den)) < u64::from(num)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Dialect {
Generic,
MySql,
Postgres,
MsSql,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeliveryShape {
Query { param: String },
FormBody { param: String },
JsonBody {
param: String,
content_type: Option<String>,
},
MultipartField { name: String },
MultipartFile {
name: String,
filename: String,
part_ct: String,
},
PathSegment,
HppSplit { param: String, parts: usize },
}
impl DeliveryShape {
#[must_use]
pub fn label(&self) -> &'static str {
match self {
Self::Query { .. } => "query",
Self::FormBody { .. } => "form_body",
Self::JsonBody { .. } => "json_body",
Self::MultipartField { .. } => "multipart_field",
Self::MultipartFile { .. } => "multipart_file",
Self::PathSegment => "path_segment",
Self::HppSplit { .. } => "hpp_split",
}
}
}
#[derive(Debug, Clone)]
pub struct EquivPayload {
pub payload: String,
pub delivery: DeliveryShape,
pub dialect: Dialect,
pub rules: Vec<&'static str>,
}
#[derive(Debug, Clone)]
pub struct EquivConfig {
pub seed: u64,
pub max: usize,
pub verify: bool,
pub vary_delivery: bool,
pub param: String,
pub force_delivery: Option<usize>,
}
pub const DEFAULT_SEED: u64 = 0x7761_6672_6966_7421;
impl Default for EquivConfig {
fn default() -> Self {
Self {
seed: DEFAULT_SEED,
max: 64,
verify: true,
vary_delivery: true,
param: "id".to_string(),
force_delivery: None,
}
}
}
#[must_use]
pub fn equiv_sql(payload: &str, cfg: &EquivConfig) -> Vec<EquivPayload> {
sql::generate(payload, cfg)
}
#[must_use]
pub fn supports_class(class: &str) -> bool {
matches!(
class,
"sql" | "xss" | "cmdi" | "path" | "ssti" | "ldap" | "ssrf" | "nosql" | "log4shell" | "xxe"
)
}
#[must_use]
pub fn equiv_for(class: &str, payload: &str, cfg: &EquivConfig) -> Vec<EquivPayload> {
match class {
"sql" => sql::generate(payload, cfg),
"xss" => xss::generate(payload, cfg),
"cmdi" => cmd::generate(payload, cfg),
"path" => path::generate(payload, cfg),
"ssti" => ssti::generate(payload, cfg),
"ldap" => ldap::generate(payload, cfg),
"ssrf" => ssrf::generate(payload, cfg),
"nosql" => nosql::generate(payload, cfg),
"log4shell" => log4shell::generate(payload, cfg),
"xxe" => xxe::generate(payload, cfg),
_ => Vec::new(),
}
}
pub const MP_BOUNDARY: &str = "----wafriftEQUIVb0undary";
fn json_escape(s: &str) -> String {
let mut o = String::with_capacity(s.len() + 2);
for c in s.chars() {
match c {
'"' => o.push_str("\\\""),
'\\' => o.push_str("\\\\"),
'\n' => o.push_str("\\n"),
'\r' => o.push_str("\\r"),
'\t' => o.push_str("\\t"),
c if (c as u32) < 0x20 => o.push_str(&format!("\\u{:04x}", c as u32)),
c => o.push(c),
}
}
o
}
fn effective_boundary(parts: &[&str]) -> String {
let mut bnd = MP_BOUNDARY.to_string();
let mut n: u64 = 0;
while parts.iter().any(|p| p.contains(bnd.as_str())) {
n = n.wrapping_add(1);
bnd = format!("{MP_BOUNDARY}{n:016x}");
}
bnd
}
fn url_with_pair(target: &str, param: &str, raw_value: &str) -> String {
let base = target.trim_end_matches('/');
let sep = if base.contains('?') { '&' } else { '?' };
format!(
"{base}{sep}{}={}",
urlencoding::encode(param),
urlencoding::encode(raw_value)
)
}
fn url_with_path_segment(target: &str, raw_seg: &str) -> String {
let (path, query) = target.split_once('?').map_or((target, ""), |(p, q)| (p, q));
let p = path.trim_end_matches('/');
let seg = urlencoding::encode(raw_seg);
if query.is_empty() {
format!("{p}/{seg}")
} else {
format!("{p}/{seg}?{query}")
}
}
impl DeliveryShape {
#[must_use]
pub fn to_request(&self, target: &str, payload: &str) -> wafrift_types::Request {
use wafrift_types::Request;
match self {
Self::Query { param } => Request::get(url_with_pair(target, param, payload)),
Self::FormBody { param } => {
let body = format!(
"{}={}",
urlencoding::encode(param),
urlencoding::encode(payload)
);
let mut r = Request::post(target.to_string(), body.into_bytes());
r.add_header("content-type", "application/x-www-form-urlencoded");
r
}
Self::JsonBody {
param,
content_type,
} => {
let body = format!("{{\"{}\":\"{}\"}}", json_escape(param), json_escape(payload));
let mut r = Request::post(target.to_string(), body.into_bytes());
if let Some(ct) = content_type {
r.add_header("content-type", ct.clone());
}
r
}
Self::MultipartField { name } => {
let bnd = effective_boundary(&[payload, name]);
let body = format!(
"--{bnd}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{payload}\r\n--{bnd}--\r\n"
);
let mut r = Request::post(target.to_string(), body.into_bytes());
r.add_header(
"content-type",
format!("multipart/form-data; boundary={bnd}"),
);
r
}
Self::MultipartFile {
name,
filename,
part_ct,
} => {
let bnd = effective_boundary(&[payload, name, filename, part_ct]);
let body = format!(
"--{bnd}\r\nContent-Disposition: form-data; name=\"{name}\"; filename=\"{filename}\"\r\nContent-Type: {part_ct}\r\n\r\n{payload}\r\n--{bnd}--\r\n"
);
let mut r = Request::post(target.to_string(), body.into_bytes());
r.add_header(
"content-type",
format!("multipart/form-data; boundary={bnd}"),
);
r
}
Self::PathSegment => Request::get(url_with_path_segment(target, payload)),
Self::HppSplit { param, parts } => {
let decoys = (*parts).max(1);
let mut u = target.to_string();
for k in 0..decoys {
u = url_with_pair(&u, param, &format!("v{k}"));
}
Request::get(url_with_pair(&u, param, payload))
}
}
}
}
#[must_use]
pub fn xss_delivered(payload: &str, max: usize) -> Vec<EquivPayload> {
let cfg = EquivConfig {
max,
vary_delivery: true,
param: "q".to_string(),
..EquivConfig::default()
};
xss::generate(payload, &cfg)
}
#[cfg(test)]
mod delivery_api_tests {
use super::*;
#[test]
fn xss_delivered_is_sound_diverse_and_deterministic() {
let atk = "<svg onload=alert(1)>";
let a = xss_delivered(atk, 40);
let b = xss_delivered(atk, 40);
assert_eq!(
a.iter().map(|m| &m.payload).collect::<Vec<_>>(),
b.iter().map(|m| &m.payload).collect::<Vec<_>>(),
"must be deterministic"
);
assert!(a.len() >= 8, "too few delivered xss members: {}", a.len());
for m in &a {
assert!(
xss::still_executes_xss(atk, &m.payload),
"UNSOUND delivered member {:?}",
m.payload
);
}
let shapes: std::collections::HashSet<_> =
a.iter().map(|m| m.delivery.label()).collect();
assert!(
shapes.len() >= 3,
"delivery axis not varied: {shapes:?}"
);
}
#[test]
fn to_request_renders_each_shape_faithfully() {
let t = "http://h/app";
let p = "<svg onload=alert(1)>";
let q = DeliveryShape::Query { param: "x".into() }.to_request(t, p);
assert!(q.url.contains("x=") && q.url.contains("%3Csvg"));
let mf = DeliveryShape::MultipartFile {
name: "f".into(),
filename: "a.txt".into(),
part_ct: "text/plain".into(),
}
.to_request(t, p);
let body = String::from_utf8_lossy(mf.body.as_deref().unwrap_or(&[]));
assert!(body.contains("filename=\"a.txt\"") && body.contains(p));
assert!(
mf.headers
.iter()
.any(|(k, v)| k == "content-type" && v.contains("multipart/form-data"))
);
let ps = DeliveryShape::PathSegment.to_request(t, p);
assert!(ps.url.starts_with("http://h/app/") && ps.url.contains("%3C"));
let jb = DeliveryShape::JsonBody {
param: "q".into(),
content_type: None,
}
.to_request(t, p);
assert!(!jb.headers.iter().any(|(k, _)| k == "content-type"));
let jbody = String::from_utf8_lossy(jb.body.as_deref().unwrap_or(&[])).into_owned();
assert!(jbody.starts_with("{\"q\":\"") && jbody.contains(p) && jbody.ends_with("\"}"));
}
}