waf_detection/rce.rs
1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4use regex::RegexSet;
5use tracing::warn;
6use waf_core::{Config, Decision, Phase, RequestContext, ScoreItem, Severity, WafModule};
7
8use crate::{all_matches, body_str_values, inspectable_header_values, Rule};
9
10// ── rules ─────────────────────────────────────────────────────────────────────
11//
12// Command injection is the most false-positive-prone category (shell
13// metacharacters are common in legit text), so the paranoia tiers are deliberate:
14// - PL1 (Critical): only high-confidence signals — command substitution,
15// a metacharacter *followed by a known command*, explicit shell binaries,
16// reverse-shell idioms.
17// - PL2 (Warning): backticks (common in markdown), ${IFS}, remote fetch,
18// Windows shell invocation.
19// - PL3 (Notice): bare logical operators `&&` / `||`, noisy on their own.
20//
21// All patterns target the normalizer's OUTPUT form (Fase 2): query/body values
22// are double percent-decoded + NFKC-normalized. Every token here is ASCII, which
23// NFKC leaves unchanged, so shell metacharacters and `${IFS}` are matched as-is.
24
25pub static RCE_RULES: &[Rule] = &[
26 Rule {
27 id: "rce-cmd-substitution",
28 // $( ... ) command substitution.
29 pattern: r"\$\([^)]+\)",
30 severity: Severity::Critical,
31 paranoia: 1,
32 },
33 Rule {
34 id: "rce-chained-command",
35 // A shell separator immediately followed by a known command name.
36 pattern: r"(?i)[;&|]\s*(?:cat|ls|id|whoami|uname|pwd|wget|curl|nc|ncat|netcat|ping|bash|sh|zsh|python|perl|ruby|nslookup|dig|getent|host|chmod|rm|cp|mv|kill|telnet|powershell|cmd)\b",
37 severity: Severity::Critical,
38 paranoia: 1,
39 },
40 Rule {
41 id: "rce-shell-path",
42 // Explicit shell binary path / executable.
43 pattern: r"(?i)(?:/bin/(?:sh|bash|zsh|dash|ksh)\b|cmd\.exe|powershell\.exe)",
44 severity: Severity::Critical,
45 paranoia: 1,
46 },
47 Rule {
48 id: "rce-reverse-shell",
49 // Common reverse-shell idioms.
50 pattern: r"(?i)(?:/dev/tcp/|bash\s+-i|nc\s+-[a-z]*e|mkfifo)",
51 severity: Severity::Critical,
52 paranoia: 1,
53 },
54 Rule {
55 id: "rce-yaml-deserialization",
56 // Unsafe YAML deserialization gadgets (PyYAML `!!python/object/...`,
57 // `!!python/object/apply`, etc.) — gotestwaf rce-urlparam. The `!!python/`
58 // tag never appears in benign input → unequivocal RCE.
59 pattern: r"(?i)!!python/(?:object|module|name|apply)",
60 severity: Severity::Critical,
61 paranoia: 1,
62 },
63 Rule {
64 id: "rce-backtick",
65 // Backtick command substitution; also common in markdown inline code,
66 // hence Warning/PL2 (won't block on its own).
67 pattern: r"`[^`]+`",
68 severity: Severity::Warning,
69 paranoia: 2,
70 },
71 Rule {
72 id: "rce-ifs-evasion",
73 // ${IFS} / $IFS used to smuggle spaces.
74 pattern: r"(?i)\$(?:\{ifs\}|ifs\b)",
75 severity: Severity::Warning,
76 paranoia: 2,
77 },
78 Rule {
79 id: "rce-download-exec",
80 // Remote payload fetch via wget/curl.
81 pattern: r"(?i)\b(?:wget|curl)\s+(?:-\S+\s+)*https?://",
82 severity: Severity::Warning,
83 paranoia: 2,
84 },
85 Rule {
86 id: "rce-windows-shell",
87 // Windows shell invocation: cmd /c …, powershell -…, or the built-in
88 // `set /a`|`set /p` (arithmetic/prompt — gotestwaf shell-injection `| set /a`).
89 // `/a\b`/`/p\b` anchors avoid matching `set /address` etc.
90 pattern: r"(?i)(?:cmd(?:\.exe)?\s*/c|powershell(?:\.exe)?\s+-|\bset\s+/[ap]\b)",
91 severity: Severity::Warning,
92 paranoia: 2,
93 },
94 Rule {
95 id: "rce-logical-operator",
96 // Bare shell logical operators. NB: `&&|\|\|` — NOT `&&|||` (which would
97 // contain an empty alternative and match everything).
98 pattern: r"&&|\|\|",
99 severity: Severity::Notice,
100 paranoia: 3,
101 },
102 Rule {
103 id: "rce-expression-language",
104 // Server-side expression-language / template code execution: a `${…}` or `#{…}`
105 // block that CALLS a dangerous function — PHP `${@print(…)}` (gotestwaf
106 // community-rce-rawrequests), SpEL/EL `#{…Runtime.exec(…)}`, etc. Scoped to a
107 // dangerous-function CALL inside the braces so it stays distinct from SSTI
108 // (`{{…}}` / `${n*n}` arithmetic) and benign interpolation (`${base_url}`,
109 // `#{user.name}`) which have no such call. `[^}]*` keeps it within one block.
110 pattern: r"(?i)[#$]\{[^}]*\b(?:print|eval|exec|system|passthru|assert|shell_exec|popen|phpinfo|file_get_contents|getruntime|runtime|processbuilder)\s*\(",
111 severity: Severity::Critical,
112 paranoia: 1,
113 },
114 // ── VBScript / Classic-ASP RCE (Fase 10c §6-D3) ────────────────────────────────
115 // gotestwaf rce-urlparam ships a VBScript webshell obfuscated with string-concat
116 // (`"Ex"&"e"&"cute` → `Execute`) and whitespace-split (`Ev al`). On the wire the
117 // concat `&` is a literal query separator → the payload SHATTERS across params, but
118 // several VBScript/ASP intrinsics survive a fragment INTACT and are unambiguous on
119 // their own. (The `"&"`-concat de-obf channel additionally reconstructs `Execute(`
120 // for the well-formed `%26` variant — see waf-normalizer `strip_vbscript_concat`.)
121 Rule {
122 id: "rce-vbscript-on-error",
123 // `On Error Resume Next` — a VBScript-only statement; in a request parameter it
124 // is a webshell/script-injection tell, never benign app input.
125 pattern: r"(?i)\bon\s+error\s+resume\s+next\b",
126 severity: Severity::Critical,
127 paranoia: 1,
128 },
129 Rule {
130 id: "rce-asp-server-intrinsic",
131 // Classic-ASP `Server.` intrinsics used by webshells to spawn COM / read files /
132 // tune the runtime. ASP-only method names → high confidence.
133 pattern: r"(?i)\bserver\.(?:scripttimeout|createobject|mappath|execute|transfer)\b",
134 severity: Severity::Critical,
135 paranoia: 1,
136 },
137 Rule {
138 id: "rce-vbscript-createobject",
139 // `CreateObject("WScript.Shell" | "MSXML2…" | "ADODB…" | FileSystemObject | …)` —
140 // the canonical VBScript/COM webshell sink. Anchored to the dangerous progIDs so
141 // a bare `CreateObject(` (also a .NET API) does not match on its own.
142 pattern: r#"(?i)\bcreateobject\s*\(\s*["']?(?:wscript\.|msxml|adodb\.|scripting\.filesystemobject|shell\.application|microsoft\.xmlhttp)"#,
143 severity: Severity::Critical,
144 paranoia: 1,
145 },
146 Rule {
147 id: "rce-asp-response-write",
148 // `Response.Write(` — ASP output sink; common in webshell scaffolding. More
149 // tutorial-prone than the above, so Warning (accumulates, sub-threshold alone).
150 pattern: r"(?i)\bresponse\.write\s*\(",
151 severity: Severity::Warning,
152 paranoia: 2,
153 },
154];
155
156// ── module ────────────────────────────────────────────────────────────────────
157
158#[derive(Default)]
159pub struct RceModule {
160 rule_set: Option<RegexSet>,
161 /// Rules active at the configured paranoia level, index-aligned with `rule_set`.
162 active_rules: Vec<&'static Rule>,
163}
164
165impl RceModule {
166 pub fn new() -> Self {
167 Self::default()
168 }
169}
170
171impl WafModule for RceModule {
172 fn id(&self) -> &str {
173 "rce"
174 }
175
176 fn phase(&self) -> Phase {
177 Phase::Body
178 }
179
180 fn init(&mut self, cfg: &Config) {
181 let pl = cfg.waf.paranoia_level;
182 self.active_rules = RCE_RULES.iter().filter(|r| r.paranoia <= pl).collect();
183 self.rule_set = Some(
184 RegexSet::new(self.active_rules.iter().map(|r| r.pattern))
185 .expect("RCE rule compilation failed — check patterns at startup"),
186 );
187 }
188
189 fn inspect(&self, ctx: &RequestContext) -> Decision {
190 let Some(rule_set) = &self.rule_set else {
191 return Decision::Allow;
192 };
193
194 // `normalized.path` is inspected too: command injection in the URL PATH
195 // (`/; cat /etc/passwd`, `/cmd=127.0.0.1 && ls /etc`) — gotestwaf rce-urlpath
196 // — bypasses a query/body-only scan. The path is the resolved, decoded form
197 // (Fase 2), so shell metacharacters appear as-is.
198 let path = std::iter::once(ctx.normalized.path.as_str());
199 let query = ctx.normalized.query_params.iter().map(|(_, v)| v.as_str());
200 let cookies = ctx.normalized.cookies.iter().map(|(_, v)| v.as_str());
201 let body_vals = body_str_values(&ctx.normalized.body);
202 let body = body_vals.iter().map(String::as_str);
203 let derived = ctx.normalized.derived_decoded.iter().map(String::as_str);
204
205 // P1-B: also scan the allowlisted request headers (Referer / X-Forwarded-* / custom x-*).
206 let headers = inspectable_header_values(ctx);
207 let matched = all_matches(rule_set, path.chain(query).chain(cookies).chain(body).chain(derived).chain(headers));
208 if matched.is_empty() {
209 return Decision::Allow;
210 }
211
212 let items: Vec<ScoreItem> = matched
213 .iter()
214 .map(|&idx| {
215 let rule = self.active_rules[idx];
216 warn!(
217 request_id = %ctx.request_id,
218 rule_id = %rule.id,
219 severity = ?rule.severity,
220 "rce detection"
221 );
222 ScoreItem {
223 rule_id: rule.id.to_string(),
224 severity: rule.severity,
225 }
226 })
227 .collect();
228
229 Decision::Scores(items)
230 }
231}