Skip to main content

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}