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
42pub struct UploadDetector;
43
44impl Detector for UploadDetector {
45    fn name(&self) -> &'static str {
46        "upload"
47    }
48
49    fn detect(&self, input: &str) -> Option<DetectionResult> {
50        regex_detect(
51            std::slice::from_ref(&*PATTERNS),
52            self.name(),
53            AttackCategory::File,
54            Severity::Critical,
55            "Malicious file upload detected",
56            input,
57        )
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    #[test]
66    fn name_returns_attack_type() {
67        assert_eq!(UploadDetector.name(), "upload");
68    }
69
70    #[test]
71    fn detects_php_tags() {
72        for payload in [
73            "<?php system($_GET['cmd']); ?>",
74            "<?= shell_exec($_POST['cmd']) ?>",
75            "<?php echo 'hello';",
76        ] {
77            let r = UploadDetector
78                .detect(payload)
79                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
80            assert_eq!(r.attack_type, "upload");
81            assert_eq!(r.category, AttackCategory::File);
82            assert_eq!(r.severity, Severity::Critical);
83            assert!(
84                !r.matched_pattern.is_empty(),
85                "matched_pattern empty for {:?}",
86                payload
87            );
88            assert!(
89                r.offset <= payload.len(),
90                "offset out of range for {:?}",
91                payload
92            );
93        }
94    }
95
96    #[test]
97    fn detects_asp_and_script_language_tags() {
98        for payload in [
99            "<% @ Page Language=\"C#\" %>",
100            "<% = response.write(1) %>",
101            "<script language='vbscript'>MsgBox 1</script>",
102            "<script language=\"jscript\">x()</script>",
103            "<script language=php>echo 1;</script>",
104        ] {
105            let r = UploadDetector
106                .detect(payload)
107                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
108            assert!(
109                !r.matched_pattern.is_empty(),
110                "matched_pattern empty for {:?}",
111                payload
112            );
113            assert!(
114                r.offset <= payload.len(),
115                "offset out of range for {:?}",
116                payload
117            );
118        }
119    }
120
121    #[test]
122    fn detects_exec_functions_and_superglobals() {
123        for payload in [
124            "eval($code);",
125            "system($cmd);",
126            "exec($cmd);",
127            "passthru($cmd);",
128            "shell_exec($cmd);",
129            "$_GET['cmd']",
130            "$_POST['cmd']",
131            "$_REQUEST['cmd']",
132            "$_SERVER['REQUEST_URI']",
133            "base64_decode('aGVsbG8=')",
134        ] {
135            let r = UploadDetector
136                .detect(payload)
137                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
138            assert!(
139                !r.matched_pattern.is_empty(),
140                "matched_pattern empty for {:?}",
141                payload
142            );
143            assert!(
144                r.offset <= payload.len(),
145                "offset out of range for {:?}",
146                payload
147            );
148        }
149    }
150
151    #[test]
152    fn ignores_benign_inputs() {
153        for input in [
154            "Hello, this is a normal text input.",
155            "php is a popular language",
156            "<script>alert(1)</script>",
157            "system('id')",
158            "exec('ls')",
159            "base64_decode",
160            "$_GET",
161            "eval()",
162        ] {
163            assert!(
164                UploadDetector.detect(input).is_none(),
165                "false positive: {:?}",
166                input
167            );
168        }
169    }
170
171    #[test]
172    fn edge_cases() {
173        assert!(UploadDetector.detect("").is_none());
174        assert!(UploadDetector.detect("   ").is_none());
175        assert!(UploadDetector.detect("<?php echo 1;").is_none()); // fullwidth angle bracket
176        assert!(UploadDetector.detect("<? phpx echo 1;").is_none()); // space breaks the tag
177    }
178}