Skip to main content

rust_web_server/waf/
detect.rs

1//! The built-in detections. Each inspects one normalized value
2//! ([`super::decode::normalize`]) and reports rule ids. Ids follow the OWASP
3//! Core Rule Set's families so they're familiar (942 SQL injection, 941
4//! XSS, 930 traversal, 931 remote inclusion, 932 command injection, 934
5//! SSRF, 944 Java, 913 scanners, 920 protocol), but the rules are rws's own.
6
7/// One built-in rule.
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct Rule {
10    pub id: u32,
11    /// Anomaly score added when it matches (5 critical, 3 warning, 2 notice).
12    pub score: u32,
13    /// The lowest paranoia level it runs at.
14    pub paranoia: u8,
15    pub message: &'static str,
16}
17
18pub const RULES: &[Rule] = &[
19    Rule { id: 942100, score: 5, paranoia: 1, message: "SQL injection: boolean logic after a quote" },
20    Rule { id: 942110, score: 5, paranoia: 1, message: "SQL injection: comment after a quote" },
21    Rule { id: 942120, score: 5, paranoia: 1, message: "SQL injection: UNION SELECT" },
22    Rule { id: 942130, score: 5, paranoia: 1, message: "SQL injection: stacked query" },
23    Rule { id: 942140, score: 5, paranoia: 1, message: "SQL injection: time-based probe" },
24    Rule { id: 942150, score: 5, paranoia: 1, message: "SQL injection: schema or file access" },
25    Rule { id: 942160, score: 5, paranoia: 1, message: "SQL injection: numeric tautology" },
26    Rule { id: 942200, score: 3, paranoia: 2, message: "SQL injection: SELECT … FROM" },
27    Rule { id: 942300, score: 2, paranoia: 3, message: "SQL comment sequence" },
28    Rule { id: 941100, score: 5, paranoia: 1, message: "XSS: script tag" },
29    Rule { id: 941110, score: 5, paranoia: 1, message: "XSS: event handler attribute" },
30    Rule { id: 941120, score: 5, paranoia: 1, message: "XSS: javascript:/vbscript:/data: URL" },
31    Rule { id: 941130, score: 5, paranoia: 1, message: "XSS: dangerous tag" },
32    Rule { id: 941140, score: 5, paranoia: 1, message: "XSS: CSS or srcdoc injection" },
33    Rule { id: 941300, score: 2, paranoia: 3, message: "HTML tag in a value" },
34    Rule { id: 930100, score: 5, paranoia: 1, message: "Path traversal" },
35    Rule { id: 930110, score: 5, paranoia: 1, message: "Access to a sensitive file" },
36    Rule { id: 931100, score: 5, paranoia: 1, message: "Remote or local file inclusion through a URL wrapper" },
37    Rule { id: 932100, score: 5, paranoia: 1, message: "Command injection: shell command after a separator" },
38    Rule { id: 932110, score: 5, paranoia: 1, message: "Command injection: $IFS evasion" },
39    Rule { id: 932200, score: 3, paranoia: 2, message: "Shell substitution syntax" },
40    Rule { id: 934100, score: 5, paranoia: 1, message: "SSRF: cloud metadata address" },
41    Rule { id: 944100, score: 5, paranoia: 1, message: "Java: JNDI lookup (Log4Shell)" },
42    Rule { id: 913100, score: 5, paranoia: 1, message: "Security scanner" },
43    Rule { id: 920100, score: 5, paranoia: 1, message: "Too many arguments" },
44    Rule { id: 920200, score: 3, paranoia: 2, message: "Argument value too long" },
45];
46
47pub fn rule(id: u32) -> Option<&'static Rule> {
48    RULES.iter().find(|r| r.id == id)
49}
50
51// ── SQL tokens ───────────────────────────────────────────────────────────────
52
53#[derive(Debug, Clone, PartialEq)]
54enum Tok {
55    Quote,
56    Word(String),
57    Num(String),
58    Op(String),
59    Comment,
60    Paren,
61    Semi,
62    Other,
63}
64
65/// A rough SQL tokenizer: enough to tell `O'Reilly` from `' or 1=1 --`.
66fn tokens(s: &str) -> Vec<Tok> {
67    let b: Vec<char> = s.chars().collect();
68    let mut out = Vec::new();
69    let mut i = 0;
70    while i < b.len() && out.len() < 4096 {
71        let c = b[i];
72        if c == ' ' {
73            i += 1;
74        } else if c == '\'' || c == '"' || c == '`' {
75            out.push(Tok::Quote);
76            i += 1;
77        } else if c.is_ascii_alphabetic() || c == '_' || c == '@' || c == '$' {
78            let start = i;
79            while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == '_' || b[i] == '@' || b[i] == '$' || b[i] == '.') {
80                i += 1;
81            }
82            out.push(Tok::Word(b[start..i].iter().collect()));
83        } else if c.is_ascii_digit() {
84            let start = i;
85            while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == '.') {
86                i += 1;
87            }
88            out.push(Tok::Num(b[start..i].iter().collect()));
89        } else if c == '-' && b.get(i + 1) == Some(&'-') || c == '#' {
90            out.push(Tok::Comment);
91            i += if c == '#' { 1 } else { 2 };
92        } else if c == '(' || c == ')' {
93            out.push(Tok::Paren);
94            i += 1;
95        } else if c == ';' {
96            out.push(Tok::Semi);
97            i += 1;
98        } else if "=<>!|&".contains(c) {
99            let start = i;
100            while i < b.len() && "=<>!|&".contains(b[i]) && i - start < 3 {
101                i += 1;
102            }
103            out.push(Tok::Op(b[start..i].iter().collect()));
104        } else {
105            out.push(Tok::Other);
106            i += 1;
107        }
108    }
109    out
110}
111
112fn word(t: Option<&Tok>, words: &[&str]) -> bool {
113    matches!(t, Some(Tok::Word(w)) if words.contains(&w.as_str()))
114}
115
116fn comparison(t: Option<&Tok>) -> bool {
117    matches!(t, Some(Tok::Op(o)) if ["=", "==", "<", ">", "<=", ">=", "<>", "!=", "<=>"].contains(&o.as_str())) || word(t, &["like", "rlike", "regexp", "is", "in", "between", "sounds"])
118}
119
120fn logic(t: Option<&Tok>) -> bool {
121    word(t, &["or", "and", "xor", "div"]) || matches!(t, Some(Tok::Op(o)) if o == "||" || o == "&&")
122}
123
124fn sqli(v: &str, hits: &mut Vec<u32>) {
125    if !v.bytes().any(|b| b"'\"`;=()-#".contains(&b) || b == b' ') {
126        return;
127    }
128    let t = tokens(v);
129    let at = |i: usize| t.get(i);
130    for i in 0..t.len() {
131        // 942100: ' or 1=1 / ") or "a"="a / ' || 'x' like 'x
132        if t[i] == Tok::Quote {
133            let mut j = i + 1;
134            while at(j) == Some(&Tok::Paren) {
135                j += 1;
136            }
137            if logic(at(j)) {
138                let window = (j + 1..(j + 6).min(t.len())).any(|k| comparison(at(k)) || word(at(k), &["true", "false", "null", "not"]));
139                if window || matches!(at(j + 1), Some(Tok::Num(_))) && at(j + 2).is_none() {
140                    hits.push(942100);
141                }
142            }
143            // 942110: admin'-- / x' # / ')--
144            if matches!(at(j), Some(Tok::Comment)) || (at(j) == Some(&Tok::Semi) && matches!(at(j + 1), Some(Tok::Comment))) {
145                hits.push(942110);
146            }
147        }
148        // 942120: union [all|distinct] select
149        if word(at(i), &["union"]) {
150            let mut j = i + 1;
151            while at(j) == Some(&Tok::Paren) {
152                j += 1;
153            }
154            if word(at(j), &["all", "distinct"]) {
155                j += 1;
156            }
157            while at(j) == Some(&Tok::Paren) {
158                j += 1;
159            }
160            if word(at(j), &["select"]) {
161                hits.push(942120);
162            }
163        }
164        // 942130: ; drop table / ; select …
165        if t[i] == Tok::Semi && word(at(i + 1), &["select", "insert", "update", "delete", "drop", "create", "alter", "truncate", "exec", "execute", "declare", "shutdown", "grant", "revoke", "merge", "call"]) {
166            let next = at(i + 2);
167            if next.is_some() && !matches!(next, Some(Tok::Other)) {
168                hits.push(942130);
169            }
170        }
171        // 942140: sleep(5), benchmark(…), pg_sleep(…), waitfor delay
172        if word(at(i), &["sleep", "benchmark", "pg_sleep", "dbms_lock.sleep", "dbms_pipe.receive_message"]) && at(i + 1) == Some(&Tok::Paren) && matches!(at(i + 2), Some(Tok::Num(_)) | Some(Tok::Word(_)) | Some(Tok::Quote)) {
173            hits.push(942140);
174        }
175        if word(at(i), &["waitfor"]) && word(at(i + 1), &["delay", "time"]) {
176            hits.push(942140);
177        }
178        // 942160: or 1=1 / and 2=2
179        if logic(at(i)) {
180            if let (Some(Tok::Num(a)), Some(Tok::Op(o)), Some(Tok::Num(b))) = (at(i + 1), at(i + 2), at(i + 3)) {
181                if a == b && (o == "=" || o == "==" || o == "<=>" || o == ">=" || o == "<=") {
182                    hits.push(942160);
183                }
184            }
185        }
186        // 942200 (PL2): select … from
187        if word(at(i), &["select"]) && (i + 1..(i + 12).min(t.len())).any(|k| word(at(k), &["from"])) {
188            hits.push(942200);
189        }
190    }
191    // 942150: schema and file probes
192    if ["information_schema", "load_file(", "into outfile", "into dumpfile", "@@version", "xp_cmdshell", "sysobjects", "pg_shadow", "mysql.user", "sqlite_master"].iter().any(|p| v.contains(p)) {
193        hits.push(942150);
194    }
195    if v.contains("--") || v.contains(" #") {
196        hits.push(942300);
197    }
198}
199
200// ── XSS ──────────────────────────────────────────────────────────────────────
201
202/// `v` with spaces and controls removed (`java script:` → `javascript:`).
203fn squeezed(v: &str) -> String {
204    v.chars().filter(|c| !c.is_whitespace()).collect()
205}
206
207fn xss(v: &str, hits: &mut Vec<u32>) {
208    if !v.bytes().any(|b| b == b'<' || b == b':' || b == b'=' || b == b'(') {
209        return;
210    }
211    if v.contains("<script") || v.contains("</script") {
212        hits.push(941100);
213    }
214    // 941110: <img src=x onerror=…> / " onmouseover="…
215    let bytes = v.as_bytes();
216    let mut i = 0;
217    while let Some(off) = v[i..].find("on") {
218        let at = i + off;
219        let before = if at == 0 { b' ' } else { bytes[at - 1] };
220        let name_end = at + 2 + v[at + 2..].bytes().take_while(|b| b.is_ascii_alphabetic()).count();
221        let name_len = name_end - at - 2;
222        let after = v[name_end..].trim_start();
223        if name_len >= 3 && after.starts_with('=') && matches!(before, b' ' | b'/' | b'"' | b'\'' | b'`' | b';') {
224            // Inside a tag, or right after breaking out of an attribute.
225            let tag = v[..at].rfind('<').is_some_and(|lt| !v[lt..at].contains('>'));
226            let breakout = v[..at].trim_end().ends_with(['"', '\'', '`']);
227            if tag || breakout {
228                hits.push(941110);
229                break;
230            }
231        }
232        i = at + 2;
233        if i >= v.len() {
234            break;
235        }
236    }
237    let tight = squeezed(v);
238    let url_start = |s: &str| ["javascript:", "vbscript:", "livescript:", "data:text/html", "data:image/svg+xml"].iter().any(|p| s.starts_with(p));
239    if url_start(tight.trim_start_matches(['"', '\'', '`'])) || ["=javascript:", "=\"javascript:", "='javascript:", "=vbscript:", "(javascript:", "url(javascript:"].iter().any(|p| tight.contains(p)) {
240        hits.push(941120);
241    }
242    if ["<iframe", "<object", "<embed", "<applet", "<base ", "<base/", "<meta ", "<frameset", "<svg/on", "<math", "<link rel=import", "<form action"].iter().any(|p| v.contains(p)) || (v.contains("<svg") && v.contains("onload")) {
243        hits.push(941130);
244    }
245    if tight.contains("expression(") || tight.contains("-moz-binding") || tight.contains("srcdoc=") || tight.contains("@import") && tight.contains("url(") {
246        hits.push(941140);
247    }
248    if v.find('<').is_some_and(|lt| v[lt + 1..].starts_with(|c: char| c.is_ascii_alphabetic() || c == '/' || c == '!')) {
249        hits.push(941300);
250    }
251}
252
253// ── Files, inclusion, commands, SSRF, Java ───────────────────────────────────
254
255fn files(v: &str, hits: &mut Vec<u32>) {
256    let traversals = v.matches("../").count() + v.matches("..\\").count() + v.matches("..;/").count();
257    // One `../` is a relative link; two climb out of where it points.
258    if traversals >= 2 {
259        hits.push(930100);
260    }
261    const SENSITIVE: [&str; 17] = [
262        "/etc/passwd", "/etc/shadow", "/etc/group", "/proc/self/", "/proc/1/", "c:\\windows\\", "c:/windows/", "boot.ini", "win.ini", "/web.config", "wp-config.php",
263        "/.htaccess", "/.htpasswd", "/.git/", "/.ssh/", "id_rsa", "/.aws/credentials",
264    ];
265    if SENSITIVE.iter().any(|p| v.contains(p)) || v == ".env" || v.ends_with("/.env") || v.contains("../.env") {
266        hits.push(930110);
267    }
268    if ["php://", "phar://", "expect://", "file://", "gopher://", "dict://", "zip://", "jar:file", "data://", "glob://", "ogg://", "ssh2."].iter().any(|p| v.contains(p)) {
269        hits.push(931100);
270    }
271}
272
273const COMMANDS: [&str; 46] = [
274    "cat", "ls", "id", "whoami", "uname", "pwd", "wget", "curl", "nc", "ncat", "netcat", "bash", "sh", "zsh", "ksh", "dash", "python", "python3", "perl", "ruby", "php", "ping", "nslookup",
275    "dig", "rm", "chmod", "chown", "telnet", "ssh", "scp", "powershell", "cmd", "certutil", "base64", "sudo", "kill", "ifconfig", "ps", "printenv", "systemctl", "tftp", "head", "tail",
276    "more", "busybox", "xterm",
277];
278
279fn commands(v: &str, hits: &mut Vec<u32>) {
280    if !v.bytes().any(|b| b";|&`$\n".contains(&b)) {
281        return;
282    }
283    for sep in [";", "|", "&&", "`", "$(", "\n"] {
284        let mut from = 0;
285        while let Some(off) = v[from..].find(sep) {
286            let after = v[from + off + sep.len()..].trim_start();
287            let w: String = after.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '/' || *c == '.').collect();
288            let name = w.rsplit('/').next().unwrap_or("");
289            let rest = &after[w.len()..];
290            if COMMANDS.contains(&name) && (rest.is_empty() || rest.starts_with([' ', ';', '|', '&', '`', ')', '<', '>', '$'])) {
291                hits.push(932100);
292                return;
293            }
294            from += off + sep.len();
295        }
296    }
297    if v.contains("${ifs}") || v.contains("$ifs") {
298        hits.push(932110);
299    }
300    if v.contains("$(") || v.matches('`').count() >= 2 {
301        hits.push(932200);
302    }
303}
304
305fn ssrf(v: &str, hits: &mut Vec<u32>) {
306    if ["169.254.169.254", "metadata.google.internal", "100.100.100.200", "fd00:ec2::254", "instance-data.ec2", "metadata.azure.com", "169.254.170.2"].iter().any(|p| v.contains(p)) {
307        hits.push(934100);
308    }
309}
310
311/// Resolve Log4j's lookup obfuscations (`${lower:j}`, `${::-n}`,
312/// `${env:X:-d}`) and look for a JNDI lookup.
313pub fn log4shell(v: &str) -> bool {
314    if !v.contains("${") {
315        return false;
316    }
317    let mut s = v.to_string();
318    for _ in 0..16 {
319        let Some(start) = s.rfind("${") else { break };
320        let Some(len) = s[start..].find('}') else { break };
321        let inner = &s[start + 2..start + len];
322        let resolved = if let Some((_, default)) = inner.split_once(":-") {
323            default.to_string()
324        } else if let Some(rest) = inner.strip_prefix("lower:").or_else(|| inner.strip_prefix("upper:")) {
325            rest.to_string()
326        } else if inner.starts_with("jndi:") {
327            return true;
328        } else {
329            format!("\u{0}{inner}\u{0}")
330        };
331        s.replace_range(start..start + len + 1, &resolved);
332    }
333    s.contains("jndi:ldap") || s.contains("jndi:rmi") || s.contains("jndi:dns") || s.contains("jndi:iiop") || s.contains("jndi:nis") || s.contains("jndi:corba") || s.contains("jndi:http")
334}
335
336/// The built-in rules matching one normalized value.
337pub fn value(v: &str, hits: &mut Vec<u32>) {
338    if v.is_empty() {
339        return;
340    }
341    sqli(v, hits);
342    xss(v, hits);
343    files(v, hits);
344    commands(v, hits);
345    ssrf(v, hits);
346    if log4shell(v) {
347        hits.push(944100);
348    }
349}
350
351/// Scanner signatures in a (normalized) User-Agent.
352pub fn scanner(user_agent: &str) -> bool {
353    ["sqlmap", "nikto", "nmap scripting engine", "masscan", "acunetix", "nessus", "openvas", "wpscan", "dirbuster", "gobuster", "fuzz faster u fool", "nuclei", "zgrab", "havij", "w3af", "netsparker", "arachni", "appscan", "jaeles", "zmeu", "morfeus", "paros", "webinspect", "commix"]
354        .iter()
355        .any(|p| user_agent.contains(p))
356}