#[derive(Debug, Clone, Copy)]
pub struct Generator {
pub name: &'static str,
pub category: &'static str,
pub description: &'static str,
}
const CATALOG: &[Generator] = &[
Generator {
name: "negative_numbers",
category: "boundary",
description: "negative values where a non-negative number is expected",
},
Generator {
name: "huge_values",
category: "overflow",
description: "very large integers that may overflow fixed-width types",
},
Generator {
name: "decimals",
category: "type-confusion",
description: "fractional values where an integer is expected",
},
Generator {
name: "zero",
category: "boundary",
description: "the zero value, a common off-by-one / divide-by-zero trigger",
},
Generator {
name: "empty",
category: "empty-input",
description: "an empty string, exercising missing-input handling",
},
Generator {
name: "sql_injection",
category: "injection",
description: "a classic tautology payload (' OR 1=1 --)",
},
Generator {
name: "xss",
category: "injection",
description: "a reflected-script payload",
},
Generator {
name: "null_bytes",
category: "injection",
description: "an embedded NUL byte that can truncate strings",
},
Generator {
name: "long_strings",
category: "resource",
description: "an oversized string to probe length limits and buffers",
},
Generator {
name: "unicode",
category: "encoding",
description: "multi-byte and astral-plane characters",
},
];
pub fn catalog() -> &'static [Generator] {
CATALOG
}
pub fn lookup(name: &str) -> Option<&'static Generator> {
let canonical = canonical_name(name);
CATALOG.iter().find(|g| g.name == canonical)
}
pub fn category_of(name: &str) -> &'static str {
lookup(name).map(|g| g.category).unwrap_or("custom")
}
pub fn generate(generator: &str) -> Vec<String> {
let s = |v: &str| v.to_string();
match generator.to_ascii_lowercase().as_str() {
"negative_numbers" | "negatives" => vec![s("-1"), s("-999999")],
"huge_values" | "huge" | "overflow" => {
vec![s("999999999999999"), s("99999999999999999999999999")]
}
"decimals" | "floats" => vec![s("0.0001"), s("3.14159")],
"zero" => vec![s("0")],
"empty" => vec![s("")],
"sql_injection" | "sqli" => vec![s("' OR 1=1 --")],
"xss" => vec![s("<script>alert(1)</script>")],
"null_bytes" => vec![s("a%00b")],
"long_strings" | "long" => vec!["A".repeat(5000)],
"unicode" => vec![s("𝕏𝕏𝕏"), s("🔥🔥🔥")],
other => vec![other.to_string()],
}
}
use std::time::Instant;
use crate::attacks::http::{HttpClient, HttpRequest};
use crate::klr::ast::{Attack, Value};
#[derive(Debug, Clone)]
pub struct FuzzOptions {
pub field: String,
pub method: String,
pub generators: Vec<String>,
pub target: Option<String>,
}
#[derive(Debug, Clone)]
pub struct GeneratorPreview {
pub generator: String,
pub category: String,
pub values: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum HitOutcome {
Handled(u16),
Fault(u16),
Unreachable(String),
}
#[derive(Debug, Clone)]
pub struct FuzzHit {
pub generator: String,
pub category: String,
pub value: String,
pub outcome: HitOutcome,
}
impl FuzzHit {
pub fn is_anomaly(&self) -> bool {
!matches!(self.outcome, HitOutcome::Handled(_))
}
}
#[derive(Debug, Clone)]
pub struct FuzzReport {
pub field: String,
pub method: String,
pub target: Option<String>,
pub previews: Vec<GeneratorPreview>,
pub hits: Vec<FuzzHit>,
pub elapsed_ms: u128,
}
impl FuzzReport {
pub fn total_inputs(&self) -> usize {
self.previews.iter().map(|p| p.values.len()).sum()
}
pub fn anomalies(&self) -> impl Iterator<Item = &FuzzHit> {
self.hits.iter().filter(|h| h.is_anomaly())
}
}
pub fn run<C: HttpClient>(client: &C, opts: &FuzzOptions) -> FuzzReport {
let started = Instant::now();
let previews: Vec<GeneratorPreview> = opts
.generators
.iter()
.map(|g| GeneratorPreview {
generator: g.clone(),
category: category_of(g).to_string(),
values: generate(g),
})
.collect();
let mut hits = Vec::new();
if let Some(target) = &opts.target {
for preview in &previews {
for value in &preview.values {
let outcome = fire(client, &opts.method, target, &opts.field, value);
hits.push(FuzzHit {
generator: preview.generator.clone(),
category: preview.category.clone(),
value: value.clone(),
outcome,
});
}
}
}
FuzzReport {
field: opts.field.clone(),
method: opts.method.clone(),
target: opts.target.clone(),
previews,
hits,
elapsed_ms: started.elapsed().as_millis(),
}
}
fn fire<C: HttpClient>(
client: &C,
method: &str,
url: &str,
field: &str,
value: &str,
) -> HitOutcome {
let mut attack = Attack::empty("fuzz".to_string(), 0);
attack.send = vec![(field.to_string(), Value::Str(value.to_string()))];
let (body, headers) = crate::klr::interpreter::build_body(&attack);
let req = HttpRequest {
method: method.to_string(),
url: url.to_string(),
headers,
body,
};
match client.execute(&req) {
Ok(resp) if resp.status >= 500 => HitOutcome::Fault(resp.status),
Ok(resp) => HitOutcome::Handled(resp.status),
Err(e) => HitOutcome::Unreachable(e.message),
}
}
fn canonical_name(name: &str) -> String {
match name.to_ascii_lowercase().as_str() {
"negatives" => "negative_numbers".to_string(),
"huge" | "overflow" => "huge_values".to_string(),
"floats" => "decimals".to_string(),
"sqli" => "sql_injection".to_string(),
"long" => "long_strings".to_string(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catalog_names_are_generatable_and_unique() {
let mut seen = std::collections::BTreeSet::new();
for g in catalog() {
assert!(seen.insert(g.name), "duplicate generator {}", g.name);
assert!(!generate(g.name).is_empty(), "{} produced nothing", g.name);
assert_eq!(lookup(g.name).unwrap().name, g.name);
}
}
#[test]
fn aliases_resolve_to_canonical_entries() {
assert_eq!(lookup("sqli").unwrap().name, "sql_injection");
assert_eq!(lookup("negatives").unwrap().name, "negative_numbers");
assert_eq!(category_of("huge"), "overflow");
}
#[test]
fn unknown_generator_is_echoed_as_literal() {
assert_eq!(generate("admin"), vec!["admin".to_string()]);
assert!(lookup("admin").is_none());
assert_eq!(category_of("admin"), "custom");
}
#[test]
fn known_generators_match_documented_counts() {
assert_eq!(generate("negative_numbers").len(), 2);
assert_eq!(generate("zero").len(), 1);
assert_eq!(generate("long_strings")[0].len(), 5000);
}
use crate::attacks::http::{HttpClient, HttpError, HttpRequest, HttpResponse};
struct FaultyClient {
trigger: &'static str,
}
impl HttpClient for FaultyClient {
fn execute(&self, req: &HttpRequest) -> Result<HttpResponse, HttpError> {
let body = req.body.clone().unwrap_or_default();
let status = if body.contains(self.trigger) {
500
} else {
200
};
Ok(HttpResponse {
status,
headers: Vec::new(),
body: String::new(),
})
}
}
#[test]
fn dry_run_generates_without_firing() {
let opts = FuzzOptions {
field: "amount".to_string(),
method: "POST".to_string(),
generators: vec!["negative_numbers".to_string(), "zero".to_string()],
target: None,
};
struct NoCall;
impl HttpClient for NoCall {
fn execute(&self, _: &HttpRequest) -> Result<HttpResponse, HttpError> {
panic!("dry run must not send requests");
}
}
let report = run(&NoCall, &opts);
assert_eq!(report.total_inputs(), 3); assert!(report.hits.is_empty());
assert_eq!(report.anomalies().count(), 0);
}
#[test]
fn fault_is_detected_and_flagged_as_anomaly() {
let opts = FuzzOptions {
field: "q".to_string(),
method: "POST".to_string(),
generators: vec!["sql_injection".to_string(), "zero".to_string()],
target: Some("http://127.0.0.1:9/x".to_string()),
};
let report = run(&FaultyClient { trigger: "OR 1=1" }, &opts);
assert_eq!(report.hits.len(), 2);
let anomalies: Vec<_> = report.anomalies().collect();
assert_eq!(anomalies.len(), 1);
assert_eq!(anomalies[0].generator, "sql_injection");
assert!(matches!(anomalies[0].outcome, HitOutcome::Fault(500)));
}
#[test]
fn unreachable_target_is_an_anomaly() {
struct DeadClient;
impl HttpClient for DeadClient {
fn execute(&self, _: &HttpRequest) -> Result<HttpResponse, HttpError> {
Err(HttpError {
message: "connection refused".to_string(),
})
}
}
let opts = FuzzOptions {
field: "x".to_string(),
method: "POST".to_string(),
generators: vec!["zero".to_string()],
target: Some("http://127.0.0.1:9/x".to_string()),
};
let report = run(&DeadClient, &opts);
assert_eq!(report.anomalies().count(), 1);
assert!(matches!(report.hits[0].outcome, HitOutcome::Unreachable(_)));
}
}