wafrift_evolution/differential/
binary_search.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ProbeFamily {
10 Sql,
11 Xss,
12 Command,
13}
14
15#[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#[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 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#[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#[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
115pub 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
243pub 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}