Skip to main content

security_rust/injection/
ssti.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// `${...}` / `{{...}}` 本身**不是**信号:shell、Spring `@Value("${x}")`、
8// `@Value` 占位符、JS 模板串、Thymeleaf、Vue/Handlebars 变量全是这个语法,
9// 拿定界符当特征等于把正常业务流量全拦下。SSTI 的真实信号是**表达式被求值**:
10//   1. 定界符里出现字面量之间的算术 —— `{{7*7}}`、`${7*7}`,最经典的求值探针;
11//   2. 表达式开头的对象/运行时访问 —— `{{config}}`、`${T(java.lang.Runtime)}`;
12//   3. Python 魔术属性 —— 没有正常业务用法。
13static PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
14    vec![
15        // 求值探针:数字 + 运算符 + 数字。要求定界符成对闭合(`{{7*7` 不报)。
16        // 间隙用 `[^{}]` 而不是 `.`,避免跨多个定界符乱吞。
17        Regex::new(r"\{\{[^{}]{0,120}?\d+[ \t]*[-+*/%][ \t]*\d+[^{}]{0,120}?\}\}").unwrap(),
18        Regex::new(r"\$\{[^{}]{0,120}?\d+[ \t]*[-+*/%][ \t]*\d+[^{}]{0,120}?\}").unwrap(),
19        // `config` 只在 `{{...}}` 里算信号(Jinja/Flask 的内置对象);
20        // `${config}` 是 shell/SpEL 的普通属性占位符,不算。
21        // 必须紧跟在定界符之后:`{{ app_config }}` 是普通变量名。
22        Regex::new(r"\{\{[ \t]*config\b").unwrap(),
23        // SpEL 的类型访问 `T(...)` 与静态成员访问 `@Type@method`。
24        Regex::new(r"\$\{[ \t]*(?:T[ \t]*\(|@[\w.]+@)").unwrap(),
25        // 以下形状本身即信号,不依赖里面写了什么
26        Regex::new(r"\{%\s*.*?\s*%\}").unwrap(),
27        Regex::new(r"<%=").unwrap(),
28        Regex::new(r"<%@").unwrap(),
29        Regex::new(r"#set\s*\(").unwrap(),
30        Regex::new(r"__mro__").unwrap(),
31        Regex::new(r"__subclasses__").unwrap(),
32        Regex::new(r"__globals__").unwrap(),
33        Regex::new(r"__builtins__").unwrap(),
34        Regex::new(r"__class__").unwrap(),
35        Regex::new(r"__dict__").unwrap(),
36    ]
37});
38
39pub struct SstiDetector;
40
41impl Detector for SstiDetector {
42    fn name(&self) -> &'static str {
43        "ssti"
44    }
45
46    fn detect(&self, input: &str) -> Option<DetectionResult> {
47        regex_detect(
48            &PATTERNS,
49            self.name(),
50            AttackCategory::Injection,
51            Severity::Critical,
52            "Server-Side Template Injection detected",
53            input,
54        )
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    fn det() -> SstiDetector {
63        SstiDetector
64    }
65
66    fn assert_hit(input: &str) {
67        crate::test_helpers::assert_detected(
68            &det(),
69            input,
70            AttackCategory::Injection,
71            Severity::Critical,
72        );
73    }
74
75    #[test]
76    fn name_is_ssti() {
77        assert_eq!(det().name(), "ssti");
78    }
79
80    #[test]
81    fn detects_common_payloads() {
82        for input in [
83            "{{7*7}}",
84            "{{ ''.__class__.__mro__[1].__subclasses__() }}",
85            "${7*7}",
86            "{% include '/etc/passwd' %}",
87            "<%= params[:x] %>",
88            r#"<%@ page import="java.util.*" %>"#,
89            "#set($x = 5)",
90            "{{config.__init__.__globals__}}",
91        ] {
92            assert_hit(input);
93        }
94    }
95
96    #[test]
97    fn benign_inputs_not_detected() {
98        for input in [
99            "Hello, this is a normal text input. Nothing suspicious here.",
100            "The total is $5.00 plus tax",
101            "Please enter your name below",
102            "The class of 2026 graduates in May",
103            "100% of users agree with this",
104        ] {
105            assert!(det().detect(input).is_none(), "false positive: {input}");
106        }
107    }
108
109    /// 变量插值到处都是,定界符本身不是信号
110    #[test]
111    fn plain_interpolation_not_detected() {
112        for input in [
113            "the price is ${amount}",
114            "${user}${pass}",
115            "@Value(\"${x.y.z}\")",
116            "${env:JAVA_HOME}",
117            "const x = `${name}`",
118            "${#strings.toUpperCase(name)}",
119            "{{ name }}",
120            "{{ app_config }}",
121            "${timeout:30s}",
122            "${PATH:-/usr/bin}",
123        ] {
124            assert!(det().detect(input).is_none(), "false positive: {input}");
125        }
126    }
127
128    /// 求值探针与运行时访问才是信号
129    #[test]
130    fn evaluation_probes_and_runtime_access_detected() {
131        for input in [
132            "${7*7}",
133            "{{7*7}}",
134            "{{ 7 * 7 }}",
135            "${{7*7}}",
136            "<%= 7*7 %>",
137            "{{config}}",
138            "{{ config.items }}",
139            "${T(java.lang.Runtime)}",
140            "${@java.lang.Runtime@getRuntime()}",
141            "{{ ''.__class__.__mro__ }}",
142            "{{ self.__dict__ }}",
143        ] {
144            assert_hit(input);
145        }
146    }
147
148    #[test]
149    fn edge_cases() {
150        assert!(det().detect("").is_none());
151        assert!(det().detect(" \t\n ").is_none());
152        assert!(det().detect("你好世界 こんにちは").is_none());
153        // near misses: delimiters incomplete, or magic names uppercase (case-sensitive)
154        assert!(det().detect("{7*7}").is_none());
155        assert!(det().detect("{{7*7").is_none());
156        assert!(det().detect("__CLASS__").is_none());
157        assert!(det().detect("{$x=7}").is_none());
158    }
159
160    #[test]
161    fn obfuscated_variants_detected() {
162        for input in [
163            "{{ ''.__class__.__MRO__[1] }}",
164            "{{ self.__dict__ }}",
165            "{{request.application.__globals__}}",
166        ] {
167            assert_hit(input);
168        }
169    }
170}