1use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
12 Regex::new(
13 &[
14 r#"O:\d+:"#,
15 r#"|C:\d+:"#,
16 r"|(?i:unserialize\s*\()",
17 r"|(?i:__wakeup\s*\()",
21 r"|(?i:__destruct\s*\()",
22 r"|(?i:__construct\s*\()",
23 r"|(?i:__toString\s*\()",
24 r"|(?i:__(?:get|set|call)\s*\()",
27 r"|a:\d+:\{",
28 ]
29 .concat(),
30 )
31 .unwrap()
32});
33
34static 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 #[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 #[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}