Skip to main content

wafrift_evolution/differential/
binary_search.rs

1use super::probe::{
2    Probe, ProbeTarget, baseline_probe, command_path_probes, command_separator_probes,
3    sql_keyword_probes, sql_tautology_probes, xss_event_probes, xss_function_probes,
4    xss_tag_probes,
5};
6
7/// High-level probe family used to narrow the search space quickly.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ProbeFamily {
10    Sql,
11    Xss,
12    Command,
13}
14
15/// Generate a minimal first-pass probe set for quick analysis.
16#[must_use]
17pub fn generate_quick_probes() -> Vec<Probe> {
18    let mut probes = Vec::new();
19    probes.push(baseline_probe("test_benign", "baseline"));
20    probes.extend(generate_family_probes(ProbeFamily::Sql));
21    probes.extend(generate_family_probes(ProbeFamily::Xss));
22    probes.extend(generate_family_probes(ProbeFamily::Command));
23    probes
24}
25
26/// Generate a focused probe batch for one family during staged analysis.
27#[must_use]
28pub fn generate_family_probes(family: ProbeFamily) -> Vec<Probe> {
29    match family {
30        ProbeFamily::Sql => vec![
31            Probe {
32                payload: "' OR 1=1--".into(),
33                tests: ProbeTarget::SqlTautology("1=1".into()),
34                description: "classic SQLi".into(),
35                expected_blocked: true,
36            },
37            Probe {
38                payload: "test SELECT test".into(),
39                tests: ProbeTarget::SqlKeyword("SELECT".into()),
40                description: "SQL keyword".into(),
41                expected_blocked: true,
42            },
43            Probe {
44                payload: "test UNION test".into(),
45                tests: ProbeTarget::SqlKeyword("UNION".into()),
46                description: "SQL UNION".into(),
47                expected_blocked: true,
48            },
49            Probe {
50                payload: "'".into(),
51                tests: ProbeTarget::SqlQuote,
52                description: "single quote".into(),
53                expected_blocked: true,
54            },
55        ],
56        ProbeFamily::Xss => {
57            let tags = xss_tag_probes();
58            let events = xss_event_probes();
59            let funcs = xss_function_probes();
60            // Gracefully degrade if probe datasets are smaller than expected.
61            let mut out = Vec::with_capacity(6);
62            out.extend(tags.into_iter().take(3));
63            out.extend(events.into_iter().take(1));
64            out.extend(funcs.into_iter().take(1));
65            out
66        }
67        ProbeFamily::Command => {
68            let seps = command_separator_probes();
69            let paths = command_path_probes();
70            seps.into_iter()
71                .take(2)
72                .chain(paths.into_iter().take(1))
73                .collect()
74        }
75    }
76}
77
78/// Generate focused follow-up probes for families that blocked in the quick pass.
79#[must_use]
80pub fn generate_follow_up_probes(families: &[ProbeFamily]) -> Vec<Probe> {
81    let mut probes = Vec::new();
82    for family in families {
83        probes.extend(match family {
84            ProbeFamily::Sql => {
85                let mut sql = sql_keyword_probes();
86                sql.extend(sql_tautology_probes());
87                sql
88            }
89            ProbeFamily::Xss => {
90                let mut xss = xss_tag_probes();
91                xss.extend(xss_event_probes());
92                xss.extend(xss_function_probes());
93                xss
94            }
95            ProbeFamily::Command => {
96                let mut command = command_separator_probes();
97                command.extend(command_path_probes());
98                command
99            }
100        });
101    }
102    probes
103}
104
105/// Result of a binary search narrowing operation.
106#[derive(Debug, Clone)]
107pub struct NarrowingResult {
108    pub trigger: String,
109    pub start: usize,
110    pub end: usize,
111    pub probes_sent: usize,
112    pub description: String,
113}
114
115/// Binary search to find the minimum substring that triggers a WAF block.
116pub fn narrow_to_trigger(payload: &str, is_blocked: &dyn Fn(&str) -> bool) -> NarrowingResult {
117    let chars: Vec<char> = payload.chars().collect();
118    let len = chars.len();
119    let mut probes_sent = 0usize;
120    if len == 0 {
121        return NarrowingResult {
122            trigger: String::new(),
123            start: 0,
124            end: 0,
125            probes_sent,
126            description: "Empty payload cannot be narrowed".to_string(),
127        };
128    }
129
130    probes_sent += 1;
131    if !is_blocked(payload) {
132        return NarrowingResult {
133            trigger: payload.to_string(),
134            start: 0,
135            end: len,
136            probes_sent,
137            description: "Payload did not trigger a block during narrowing".to_string(),
138        };
139    }
140
141    let mut start = 0usize;
142    let mut end = len;
143
144    loop {
145        let removable_prefix =
146            max_removable_prefix(&chars, start, end, is_blocked, &mut probes_sent);
147        if removable_prefix > 0 {
148            start += removable_prefix;
149        }
150
151        let removable_suffix =
152            max_removable_suffix(&chars, start, end, is_blocked, &mut probes_sent);
153        if removable_suffix > 0 {
154            end -= removable_suffix;
155        }
156
157        if removable_prefix == 0 && removable_suffix == 0 {
158            break;
159        }
160    }
161
162    let trigger: String = chars[start..end].iter().collect();
163    probes_sent += 1;
164    let still_blocked = is_blocked(&trigger);
165
166    NarrowingResult {
167        trigger: trigger.clone(),
168        start,
169        end,
170        probes_sent,
171        description: if still_blocked {
172            format!(
173                "WAF trigger narrowed to '{}' ({} chars, positions {}-{} of {} char payload)",
174                if trigger.chars().count() > 50 {
175                    &trigger[..trigger
176                        .char_indices()
177                        .nth(50)
178                        .map_or(trigger.len(), |(i, _)| i)]
179                } else {
180                    &trigger
181                },
182                end - start,
183                start,
184                end,
185                len
186            )
187        } else {
188            "Could not narrow trigger (payload may use context-dependent matching)".to_string()
189        },
190    }
191}
192
193fn max_removable_prefix(
194    chars: &[char],
195    start: usize,
196    end: usize,
197    is_blocked: &dyn Fn(&str) -> bool,
198    probes_sent: &mut usize,
199) -> usize {
200    if end.saturating_sub(start) <= 1 {
201        return 0;
202    }
203    let mut lo = 0usize;
204    let mut hi = end - start - 1;
205    while lo < hi {
206        let mid = (lo + hi).div_ceil(2);
207        let candidate: String = chars[start + mid..end].iter().collect();
208        *probes_sent += 1;
209        if is_blocked(&candidate) {
210            lo = mid;
211        } else {
212            hi = mid - 1;
213        }
214    }
215    lo
216}
217
218fn max_removable_suffix(
219    chars: &[char],
220    start: usize,
221    end: usize,
222    is_blocked: &dyn Fn(&str) -> bool,
223    probes_sent: &mut usize,
224) -> usize {
225    if end.saturating_sub(start) <= 1 {
226        return 0;
227    }
228    let mut lo = 0usize;
229    let mut hi = end - start - 1;
230    while lo < hi {
231        let mid = (lo + hi).div_ceil(2);
232        let candidate: String = chars[start..end - mid].iter().collect();
233        *probes_sent += 1;
234        if is_blocked(&candidate) {
235            lo = mid;
236        } else {
237            hi = mid - 1;
238        }
239    }
240    lo
241}
242
243/// Find multiple independent triggers in a single payload.
244pub fn find_all_triggers(payload: &str, is_blocked: &dyn Fn(&str) -> bool) -> Vec<NarrowingResult> {
245    let mut triggers = Vec::new();
246    let mut remaining = payload.to_string();
247
248    for _ in 0..5 {
249        if !is_blocked(&remaining) {
250            break;
251        }
252        let result = narrow_to_trigger(&remaining, is_blocked);
253        if result.trigger.is_empty() || result.end <= result.start {
254            break;
255        }
256        let masked: String = remaining
257            .chars()
258            .enumerate()
259            .map(|(i, c)| {
260                if i >= result.start && i < result.end {
261                    'X'
262                } else {
263                    c
264                }
265            })
266            .collect();
267        triggers.push(result);
268        remaining = masked;
269    }
270
271    triggers
272}
273
274#[cfg(test)]
275mod tests {
276    use super::{
277        ProbeFamily, find_all_triggers, generate_family_probes, generate_follow_up_probes,
278        generate_quick_probes, narrow_to_trigger,
279    };
280
281    #[test]
282    fn quick_probes_smaller_set() {
283        let quick = generate_quick_probes();
284        assert!(quick.len() >= 10);
285    }
286
287    #[test]
288    fn family_probes_are_focused() {
289        assert_eq!(generate_family_probes(ProbeFamily::Sql).len(), 4);
290        assert_eq!(generate_family_probes(ProbeFamily::Command).len(), 3);
291    }
292
293    #[test]
294    fn follow_up_probes_expand_requested_families() {
295        let sql_only = generate_follow_up_probes(&[ProbeFamily::Sql]);
296        let both = generate_follow_up_probes(&[ProbeFamily::Sql, ProbeFamily::Xss]);
297        assert!(both.len() > sql_only.len());
298    }
299
300    #[test]
301    fn narrow_to_trigger_finds_minimal_substring() {
302        let payload = "prefixUNIONsuffix";
303        let result = narrow_to_trigger(payload, &|candidate| candidate.contains("UNION"));
304        assert_eq!(result.trigger, "UNION");
305        assert_eq!(result.start, 6);
306        assert_eq!(result.end, 11);
307    }
308
309    #[test]
310    fn find_all_triggers_masks_and_finds_multiple_regions() {
311        let payload = "aaaUNIONbbbSELECTccc";
312        let results = find_all_triggers(payload, &|candidate| {
313            candidate.contains("UNION") || candidate.contains("SELECT")
314        });
315        let triggers: Vec<_> = results
316            .iter()
317            .map(|result| result.trigger.as_str())
318            .collect();
319        assert!(triggers.contains(&"UNION"));
320        assert!(triggers.contains(&"SELECT"));
321    }
322}