Skip to main content

security_rust/file/
upload.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3use regex::Regex;
4use std::sync::LazyLock;
5
6use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
7
8/// webshell 特征:**文件本身就是检出项**,不是一个碰巧出现的 token。`<?php`/`<%@` 的
9/// 唯一读法就是「这是可被服务端执行的代码」——与 `data_leak` 的 PAN 同类,出现即泄露。
10///
11/// 故这一档不设强弱分层:JSP 页面与 JSP webshell 的前导字节逐字节相同
12/// (`<%@ page language="java" … %>` 与 `<%@ page import="java.io.*" %>` 同一形态),
13/// 把 `<%@`/`<%=` 降档等于让 webshell 落到拒绝线以下 —— 那是换个方式删检测。
14/// 代价是扫描**正在对外提供的**页面(而不是上传的文件)也会命中;那属于输入域不符,
15/// 消息文案 `Malicious file upload detected` 已点明域。
16/// 十五条分支合成一条 alternation。flags **完全一致**(都是 `(?i)`),
17/// 提到最前面即可,无需逐条包裹。
18static PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
19    Regex::new(
20        &[
21            r"(?i)<\?php",
22            r"|<\?=",
23            r"|<%\s*@",
24            r"|<%\s*=",
25            r#"|<script\s+language\s*=\s*["']?(?:php|vbscript|jscript)["']?"#,
26            r"|eval\s*\(\s*\$",
27            r"|system\s*\(\s*\$",
28            r"|exec\s*\(\s*\$",
29            r"|passthru\s*\(\s*\$",
30            r"|shell_exec\s*\(\s*\$",
31            r"|\$_GET\[",
32            r"|\$_POST\[",
33            r"|\$_REQUEST\[",
34            r"|\$_SERVER\[",
35            r"|base64_decode\s*\(",
36        ]
37        .concat(),
38    )
39    .unwrap()
40});
41
42/// 守卫探针:15 条分支各要求 `<`(5 条)、`$`(9 条)、`base64_decode`(1 条)。
43/// 第三个条件用它而不是小写 `contains` —— `(?i)` 顺延到 base64_decode 分支,
44/// `BASE64_DECODE(` 今天命中,小写 contains 会漏。ASCII 的 windows 扫描同样不行:
45/// 主模式的 `(?i)` 是 Unicode 折叠(`baſe64_decode(` 命中),ASCII 扫描漏。
46/// 探针与主模式同为 `(?i)`,折叠语义一致。
47static B64_NEEDLE: LazyLock<Regex> = LazyLock::new(|| Regex::new("(?i)base64_decode").unwrap());
48
49pub struct UploadDetector;
50
51impl Detector for UploadDetector {
52    fn name(&self) -> &'static str {
53        "upload"
54    }
55
56    fn detect(&self, input: &str) -> Option<DetectionResult> {
57        // 三种触发字符全无 → 任何分支都不可能命中,直接返回。1MB 干净文本实测
58        // 1.8ms → 0.4ms(release,min-of-5)。前提只对**当前分支集**成立:新增
59        // 分支若不含这三种触发字符,这里会变成漏报,必须同步放宽守卫。
60        if !input.contains('<') && !input.contains('$') && !B64_NEEDLE.is_match(input) {
61            return None;
62        }
63        regex_detect(
64            std::slice::from_ref(&*PATTERNS),
65            self.name(),
66            AttackCategory::File,
67            Severity::Critical,
68            "Malicious file upload detected",
69            input,
70        )
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn name_returns_attack_type() {
80        assert_eq!(UploadDetector.name(), "upload");
81    }
82
83    #[test]
84    fn detects_php_tags() {
85        for payload in [
86            "<?php system($_GET['cmd']); ?>",
87            "<?= shell_exec($_POST['cmd']) ?>",
88            "<?php echo 'hello';",
89        ] {
90            let r = UploadDetector
91                .detect(payload)
92                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
93            assert_eq!(r.attack_type, "upload");
94            assert_eq!(r.category, AttackCategory::File);
95            assert_eq!(r.severity, Severity::Critical);
96            assert!(
97                !r.matched_pattern.is_empty(),
98                "matched_pattern empty for {:?}",
99                payload
100            );
101            assert!(
102                r.offset <= payload.len(),
103                "offset out of range for {:?}",
104                payload
105            );
106        }
107    }
108
109    #[test]
110    fn detects_asp_and_script_language_tags() {
111        for payload in [
112            "<% @ Page Language=\"C#\" %>",
113            "<% = response.write(1) %>",
114            "<script language='vbscript'>MsgBox 1</script>",
115            "<script language=\"jscript\">x()</script>",
116            "<script language=php>echo 1;</script>",
117        ] {
118            let r = UploadDetector
119                .detect(payload)
120                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
121            assert!(
122                !r.matched_pattern.is_empty(),
123                "matched_pattern empty for {:?}",
124                payload
125            );
126            assert!(
127                r.offset <= payload.len(),
128                "offset out of range for {:?}",
129                payload
130            );
131        }
132    }
133
134    #[test]
135    fn detects_exec_functions_and_superglobals() {
136        for payload in [
137            "eval($code);",
138            "system($cmd);",
139            "exec($cmd);",
140            "passthru($cmd);",
141            "shell_exec($cmd);",
142            "$_GET['cmd']",
143            "$_POST['cmd']",
144            "$_REQUEST['cmd']",
145            "$_SERVER['REQUEST_URI']",
146            "base64_decode('aGVsbG8=')",
147        ] {
148            let r = UploadDetector
149                .detect(payload)
150                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
151            assert!(
152                !r.matched_pattern.is_empty(),
153                "matched_pattern empty for {:?}",
154                payload
155            );
156            assert!(
157                r.offset <= payload.len(),
158                "offset out of range for {:?}",
159                payload
160            );
161        }
162    }
163
164    #[test]
165    fn ignores_benign_inputs() {
166        for input in [
167            "Hello, this is a normal text input.",
168            "php is a popular language",
169            "<script>alert(1)</script>",
170            "system('id')",
171            "exec('ls')",
172            "base64_decode",
173            "$_GET",
174            "eval()",
175        ] {
176            assert!(
177                UploadDetector.detect(input).is_none(),
178                "false positive: {:?}",
179                input
180            );
181        }
182    }
183
184    /// `(?i)` 顺延到 base64_decode 分支:大小写变体今天命中。任何"小写 contains"
185    /// 式守卫都会把它变成漏报——这条固化现状,防止守卫写歪。
186    #[test]
187    fn detects_case_insensitive_base64_decode() {
188        for payload in [
189            "BASE64_DECODE('aGVsbG8=')",
190            "BaSe64_DeCoDe('x')",
191            "ba\u{17f}e64_decode('x')", // Unicode 折叠:'ſ' 折叠到 's',ASCII 扫描会漏
192        ] {
193            assert!(
194                UploadDetector.detect(payload).is_some(),
195                "missed {:?}",
196                payload
197            );
198        }
199    }
200
201    #[test]
202    fn trigger_free_long_input_stays_clean() {
203        // 无 `<`/`$`/base64_decode 的输入在 detect 顶部走快速路径返回。
204        // 长文本:守卫若吞掉命中(或误报),这条先炸。
205        let clean = "The quick brown fox jumps over the lazy dog and then walks back home again "
206            .repeat(2000);
207        assert!(UploadDetector.detect(&clean).is_none());
208    }
209
210    #[test]
211    fn edge_cases() {
212        assert!(UploadDetector.detect("").is_none());
213        assert!(UploadDetector.detect("   ").is_none());
214        assert!(UploadDetector.detect("<?php echo 1;").is_none()); // fullwidth angle bracket
215        assert!(UploadDetector.detect("<? phpx echo 1;").is_none()); // space breaks the tag
216    }
217}