Skip to main content

security_rust/data/
deserialization.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7/// 强信号:序列化载荷本身(`O:8:"` / `a:1:{`)与魔术方法的**调用形态**。
8/// 各分支合成一条 alternation:原来 N 条正则在干净输入上要各扫一遍,合成后一次扫完。
9/// 分支的 flags 收在 `(?i:…)` 里——裸 `(?i)` 会顺延到后面的分支,
10/// 让 `O:\d+:"` 也变成大小写不敏感(`o:8:` 本不该命中)。
11static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
12    Regex::new(
13        &[
14            r#"O:\d+:"#,
15            r#"|C:\d+:"#,
16            r"|(?i:unserialize\s*\()",
17            // PHP 魔术方法必须是调用形态:后面跟 `(`。裸名字在文档里满地都是
18            // (本库 README 的检测器表格就写着 `__wakeup`/`__destruct`/`__toString`),
19            // 那是「在讨论它们」,不是攻击。`function __construct()` 照报。
20            r"|(?i:__wakeup\s*\()",
21            r"|(?i:__destruct\s*\()",
22            r"|(?i:__construct\s*\()",
23            r"|(?i:__toString\s*\()",
24            // `__get`/`__set`/`__call` 同样:裸名字会误杀 Python 的 `__getitem__` /
25            // `__set_name__` / `__getattr__`(`\b` 挡不住,`_` 是词字符)。
26            r"|(?i:__(?:get|set|call)\s*\()",
27            r"|a:\d+:\{",
28        ]
29        .concat(),
30    )
31    .unwrap()
32});
33
34/// 弱信号:裸魔术方法名。它确实是反序列化 gadget 的标识,但单看只是一个词——
35/// 报 Low,单条不足以拒绝。四条分支的 flags 完全一致,提到最前面即可。
36static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
37    Regex::new(r"(?i)__wakeup|__destruct|__construct|__toString").unwrap()
38});
39
40pub struct DeserializationDetector;
41
42impl Detector for DeserializationDetector {
43    fn name(&self) -> &'static str {
44        "deserialization"
45    }
46
47    fn detect(&self, input: &str) -> Option<DetectionResult> {
48        regex_detect(
49            std::slice::from_ref(&*STRONG_PATTERNS),
50            self.name(),
51            AttackCategory::Data,
52            Severity::Critical,
53            "PHP deserialization attack detected",
54            input,
55        )
56        .or_else(|| {
57            regex_detect(
58                std::slice::from_ref(&*WEAK_PATTERNS),
59                self.name(),
60                AttackCategory::Data,
61                Severity::Low,
62                "PHP magic method name present (weak signal)",
63                input,
64            )
65        })
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn name_returns_attack_type() {
75        assert_eq!(DeserializationDetector.name(), "deserialization");
76    }
77
78    #[test]
79    fn detects_serialized_php_object() {
80        let input = r#"O:8:"stdClass":1:{s:4:"test";s:5:"value";}"#;
81        let r = DeserializationDetector
82            .detect(input)
83            .expect("serialized PHP object should be detected");
84        assert_eq!(r.attack_type, "deserialization");
85        assert_eq!(r.category, AttackCategory::Data);
86        assert_eq!(r.severity, Severity::Critical);
87        assert_eq!(r.offset, 0);
88    }
89
90    #[test]
91    fn detects_serialized_arrays_and_magic_methods() {
92        for payload in [
93            r#"a:1:{s:4:"key";s:5:"value";}"#,
94            r#"C:5:"Foo":0:{}"#,
95            "unserialize($_POST['data'])",
96            "trigger __wakeup magic method",
97            "call __destruct on shutdown",
98            "override __toString()",
99        ] {
100            let r = DeserializationDetector
101                .detect(payload)
102                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
103            assert!(
104                !r.matched_pattern.is_empty(),
105                "matched_pattern empty for {:?}",
106                payload
107            );
108            assert!(
109                r.offset <= payload.len(),
110                "offset out of range for {:?}",
111                payload
112            );
113        }
114    }
115
116    #[test]
117    fn detects_php_magic_method_calls_only() {
118        for payload in [
119            "function __get($name) { return $this->data[$name]; }",
120            "$obj->__call('method', $args)",
121            "$this->__set('key', $value)",
122            "public function __get ($name)",
123        ] {
124            assert!(
125                DeserializationDetector.detect(payload).is_some(),
126                "expected detection for {:?}",
127                payload
128            );
129        }
130    }
131
132    /// 魔术方法名分档:调用形态(`function __construct()`)是 PHP 源码,Critical;
133    /// 裸名字只是词——文档表格里满是这样写的(本库 README 就是),Low。
134    #[test]
135    fn magic_method_call_is_critical_bare_name_is_low() {
136        for payload in [
137            "function __construct($config) { $this->config = $config; }",
138            "public function __destruct() { $this->close(); }",
139            "override __toString()",
140            "function __wakeup() { $this->init(); }",
141        ] {
142            let r = DeserializationDetector
143                .detect(payload)
144                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
145            assert_eq!(r.severity, Severity::Critical, "payload {:?}", payload);
146        }
147        for input in [
148            "| **deserialization** | `__wakeup`/`__destruct`/`__toString` 等魔术方法 | Critical |",
149            "trigger __wakeup magic method",
150            "call __destruct on shutdown",
151        ] {
152            let r = DeserializationDetector
153                .detect(input)
154                .unwrap_or_else(|| panic!("expected weak detection for {:?}", input));
155            assert_eq!(r.severity, Severity::Low, "input {:?}", input);
156        }
157    }
158
159    #[test]
160    fn ignores_benign_inputs() {
161        for input in [
162            "Hello, this is a normal text input.",
163            "Order: 8 items please",
164            "O:8",
165            "unserialize_data is not a call",
166            "constructor and destructor in C++",
167            "a:b:{not a serialized array}",
168        ] {
169            assert!(
170                DeserializationDetector.detect(input).is_none(),
171                "false positive: {:?}",
172                input
173            );
174        }
175    }
176
177    /// 真实语料:Python 的 dunder 与 `__get`/`__set`/`__call` 同前缀,`\b` 挡不住(`_` 是
178    /// 词字符),裸名字版本会把任何 Python 文件判成 Critical 反序列化攻击。
179    #[test]
180    fn ignores_realistic_python_source() {
181        for input in [
182            "def __getitem__(self, key): return self.data[key]",
183            "def __getattr__(self, n)",
184            "def __getattr__(self, name):\n    raise AttributeError(name)\n",
185            "def __setattr__(self, name, value):\n    self.__dict__[name] = value\n",
186            "__set_name__ is called by the descriptor protocol.",
187            "class Config:\n    def __init__(self):\n        self.__get = 1\n",
188            "Python docs mention __getattribute__, __set_name__ and __call__ in prose.",
189        ] {
190            assert!(
191                DeserializationDetector.detect(input).is_none(),
192                "false positive: {:?}",
193                input
194            );
195        }
196    }
197
198    #[test]
199    fn edge_cases() {
200        assert!(DeserializationDetector.detect("").is_none());
201        assert!(DeserializationDetector.detect("   ").is_none());
202        assert!(
203            DeserializationDetector
204                .detect("日本語のテキストです")
205                .is_none()
206        );
207    }
208}