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}`、`<%= 7*7 %>`;
11//      已知上限,**不要「修」**:操作数两侧都是字面量数字时,形状与模板源码里
12//      `{{ 10 % 2 }}` 完全相同,所以它报 Critical。要求操作数是标识符会同时放过
13//      `{{7*7}}`,而后者正是 SSTI 的规范判据;且模板文件与请求体是不同的输入域。
14//      `{{ count % 2 }}`、`{{ n % 10 }}`、`{{ a % b }}` 都 clean(标识符操作数)。
15//   2. 表达式开头的对象/运行时访问 —— `{{config}}`、`${T(java.lang.Runtime)}`;
16//   3. Python 魔术属性**在定界符内** —— 裸的 `__class__`/`__dict__` 是普通 Python
17//      (`self.__class__.__name__`、`obj.__dict__`、`Plugin.__subclasses__()`)。
18// 模板指令本身(`{% %}`、`<%=`、`#set(`)在模板源码里就是功能,形状与攻击完全相同,
19// 单独出现不足以拒绝,放 WEAK_PATTERNS。
20/// 8 条分支合并成 1 条 alternation —— `regex_detect` 对列表里每条 `Regex` 各跑一次
21/// `find`,干净输入下 8 次全文扫描变 1 次。分支顺序 = 原 vec 顺序(同一位置上取最左
22/// 分支;与「按列表顺序取第一条命中的模式」相比偏移量可能不同,档位不变)。
23/// 内联 flag 一律裹进 `(?i:…)` —— 裸 `(?i)` 的作用域会蔓延到它后面拼进来的分支。
24static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
25    Regex::new(concat!(
26        // 求值探针:数字 + 运算符 + 数字。要求定界符成对闭合(`{{7*7` 不报)。
27        // 间隙用 `[^{}]` 而不是 `.`,避免跨多个定界符乱吞。
28        // 右操作数允许带引号:Twig/Jinja 的 `{{7*'7'}}`(字符串重复求值)与不加引号
29        // 的 `{{7*7}}` 是同一个探针,引号只是骗过按数字匹配的 WAF。
30        r#"\{\{[^{}]{0,120}?\d+[ \t]*[-+*/%][ \t]*['"]?\d+[^{}]{0,120}?\}\}"#,
31        r#"|\$\{[^{}]{0,120}?\d+[ \t]*[-+*/%][ \t]*['"]?\d+[^{}]{0,120}?\}"#,
32        // ERB/ASP 同理:裸 `<%=` 是模板源码的常规写法(见 WEAK),带算术求值的才是探针
33        r#"|<%=[^{}]{0,120}?\d+[ \t]*[-+*/%][ \t]*['"]?\d+[^{}]{0,120}?%>"#,
34        // `config` 只在 `{{...}}` 里算信号(Jinja/Flask 的内置对象);
35        // `${config}` 是 shell/SpEL 的普通属性占位符,不算。
36        // 必须紧跟在定界符之后:`{{ app_config }}` 是普通变量名。
37        r"|\{\{[ \t]*config\b",
38        // SpEL 的类型访问 `T(...)` 与静态成员访问 `@Type@method`。
39        r"|\$\{[ \t]*(?:T[ \t]*\(|@[\w.]+@)",
40        // Jinja/Twig 的 include/extends 只有带绝对路径或 `..` 才是 LFI:
41        // `{% include 'header.html' %}` 是模板的正常写法。
42        r#"|\{%\s*(?:include|extends|import|from)\s+['"]\s*(?:/|\.\.)"#,
43        // 魔术属性只有在**定界符内**才是逃逸链 —— 裸的 `__class__` 是普通 Python
44        // (`self.__class__.__name__`、`dict(obj.__dict__)`、`Plugin.__subclasses__()`)。
45        // 间隙用 `[^{}%]` 避免跨定界符乱吞。
46        r"|\{\{[^{}%]{0,160}?__(?:class|dict|mro|subclasses|globals|builtins)__|\{%[^{}%]{0,160}?__(?:class|dict|mro|subclasses|globals|builtins)__|\$\{[^{}%]{0,160}?__(?:class|dict|mro|subclasses|globals|builtins)__",
47        // FreeMarker 的反射内建。`?new(` 是 FreeMarker 的 builtin
48        // (`"freemarker.template.utility.Execute"?new()`),别处没有这个写法。
49        r"|(?i:\?new\s*\()",
50    ))
51    .unwrap()
52});
53
54/// 弱信号:模板指令「出现」本身就是模板语言的功能 —— `{% if %}`、`<%= %>`、`#set(`、
55/// `<#assign>` 在正常模板源码里满地都是,与攻击形状完全相同;裸魔术属性则是普通
56/// Python 代码。都保留检出,但单条不拒绝。
57/// 6 条分支合并成 1 条 alternation —— `regex_detect` 对列表里每条 `Regex` 各跑一次
58/// `find`,干净输入下 6 次全文扫描变 1 次。分支顺序 = 原 vec 顺序(同一位置上取最左
59/// 分支;与「按列表顺序取第一条命中的模式」相比偏移量可能不同,档位不变)。
60/// 内联 flag 一律裹进 `(?i:…)` —— 裸 `(?i)` 的作用域会蔓延到它后面拼进来的分支。
61static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
62    Regex::new(concat!(
63        r"\{%\s*.*?\s*%\}",
64        r"|<%=",
65        r"|<%@",
66        r"|#set\s*\(",
67        // `<` 必须紧贴在 `#` 前:`href="#assign x"` 这类锚点里 `#` 前面是引号,不命中
68        r"|(?i:<#assign\s)",
69        r"|__(?:class|dict|mro|subclasses|globals|builtins)__",
70    ))
71    .unwrap()
72});
73
74pub struct SstiDetector;
75
76impl Detector for SstiDetector {
77    fn name(&self) -> &'static str {
78        "ssti"
79    }
80
81    fn detect(&self, input: &str) -> Option<DetectionResult> {
82        regex_detect(
83            std::slice::from_ref(&*STRONG_PATTERNS),
84            self.name(),
85            AttackCategory::Injection,
86            Severity::Critical,
87            "Server-Side Template Injection detected",
88            input,
89        )
90        .or_else(|| {
91            regex_detect(
92                std::slice::from_ref(&*WEAK_PATTERNS),
93                self.name(),
94                AttackCategory::Injection,
95                Severity::Low,
96                "Template directive / magic attribute present (weak signal)",
97                input,
98            )
99        })
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    fn det() -> SstiDetector {
108        SstiDetector
109    }
110
111    fn assert_hit(input: &str) {
112        assert_hit_at(input, Severity::Critical);
113    }
114
115    fn assert_hit_at(input: &str, severity: Severity) {
116        crate::test_helpers::assert_detected(&det(), input, AttackCategory::Injection, severity);
117    }
118
119    #[test]
120    fn name_is_ssti() {
121        assert_eq!(det().name(), "ssti");
122    }
123
124    #[test]
125    fn detects_common_payloads() {
126        for (input, severity) in [
127            // 强信号:求值探针 / 定界符内的逃逸链,Critical
128            ("{{7*7}}", Severity::Critical),
129            ("{{ ''.__class__.__mro__[1].__subclasses__() }}", Severity::Critical),
130            ("${7*7}", Severity::Critical),
131            ("{% include '/etc/passwd' %}", Severity::Critical),
132            ("{{config.__init__.__globals__}}", Severity::Critical),
133            // 弱信号:模板指令出现本身,Low
134            ("<%= params[:x] %>", Severity::Low),
135            (r#"<%@ page import="java.util.*" %>"#, Severity::Low),
136            ("#set($x = 5)", Severity::Low),
137        ] {
138            assert_hit_at(input, severity);
139        }
140    }
141
142    /// 正常模板源码。`{% if %}`、`<%= @user.name %>`、`#set(`、`<#assign>` 是各自模板
143    /// 语言的功能写法,每个模板文件都长这样;它们曾按 Critical 被判 SSTI,整份文件被拒。
144    /// 下面是**本检测器局部**的档位,不是全管线保证:`<%=` 在完整管线里仍是 upload
145    /// 检测器的 Critical(webshell 特征),只扫这一条输入时 `Hello <%= @user.name %>`
146    /// 照样越过拒收线 —— 那是输入域(上传的文件 vs 渲染后的页面)问题,不归 ssti 管。
147    #[test]
148    fn template_source_is_low_not_critical() {
149        for input in [
150            "{% if user.is_admin %}\n  <p>Admin</p>\n{% endif %}",
151            "{% for item in items %}\n  <li>{{ item.name }}</li>\n{% endfor %}",
152            "<h1>Hello <%= @user.name %></h1>",
153            r#"<%@ page language="java" contentType="text/html; charset=UTF-8" %>"#,
154            "#set($x = 5)",
155            "<#assign title=\"Home\">",
156        ] {
157            assert_hit_at(input, Severity::Low);
158        }
159    }
160
161    /// 裸魔术属性是普通 Python:`self.__class__.__name__` / `obj.__dict__` /
162    /// 插件注册表的 `Plugin.__subclasses__()`。定界符内的同名字段仍是 Critical。
163    #[test]
164    fn bare_python_dunders_are_low_but_delimited_ones_stay_critical() {
165        for input in [
166            "return f\"<Store {self.__class__.__name__}>\"",
167            "return dict(self.__dict__)",
168            "return [c for c in Plugin.__subclasses__()]",
169        ] {
170            assert_hit_at(input, Severity::Low);
171        }
172        for input in [
173            "{{ self.__dict__ }}",
174            "{{ ''.__class__.__mro__ }}",
175            "${config.__class__}",
176        ] {
177            assert_hit(input);
178        }
179    }
180
181    /// 正常 Python 里 `__getitem__` / `__set_name__` 这类协议方法不是逃逸链 ——
182    /// 白名单只含 6 个逃逸用的魔术属性名,别的一个都不碰。
183    #[test]
184    fn python_protocol_methods_not_detected() {
185        for input in [
186            "def __getitem__(self, key):\n    return self._data[key]",
187            "__set_name__ is called by the descriptor protocol.",
188            "def __getattr__(self, name):\n    raise AttributeError(name)",
189        ] {
190            crate::test_helpers::assert_clean(&det(), input);
191        }
192    }
193
194    #[test]
195    fn benign_inputs_not_detected() {
196        for input in [
197            "Hello, this is a normal text input. Nothing suspicious here.",
198            "The total is $5.00 plus tax",
199            "Please enter your name below",
200            "The class of 2026 graduates in May",
201            "100% of users agree with this",
202        ] {
203            assert!(det().detect(input).is_none(), "false positive: {input}");
204        }
205    }
206
207    /// 变量插值到处都是,定界符本身不是信号
208    #[test]
209    fn plain_interpolation_not_detected() {
210        for input in [
211            "the price is ${amount}",
212            "${user}${pass}",
213            "@Value(\"${x.y.z}\")",
214            "${env:JAVA_HOME}",
215            "const x = `${name}`",
216            "${#strings.toUpperCase(name)}",
217            "{{ name }}",
218            "{{ app_config }}",
219            "${timeout:30s}",
220            "${PATH:-/usr/bin}",
221        ] {
222            assert!(det().detect(input).is_none(), "false positive: {input}");
223        }
224    }
225
226    /// 求值探针与运行时访问才是信号
227    #[test]
228    fn evaluation_probes_and_runtime_access_detected() {
229        for input in [
230            "${7*7}",
231            "{{7*7}}",
232            "{{ 7 * 7 }}",
233            // 字面量数字两侧 —— 与模板源码同形的已知上限,钉住它以免被「修掉」
234            "{{ 10 % 2 }}",
235            "total = {{ 10 % 2 }}",
236            "${{7*7}}",
237            "<%= 7*7 %>",
238            "{{config}}",
239            "{{ config.items }}",
240            "${T(java.lang.Runtime)}",
241            "${@java.lang.Runtime@getRuntime()}",
242            "{{ ''.__class__.__mro__ }}",
243            "{{ self.__dict__ }}",
244        ] {
245            assert_hit(input);
246        }
247    }
248
249    /// 带引号的操作数与 FreeMarker 指令
250    #[test]
251    fn freemarker_and_quoted_operand_detected() {
252        for input in [
253            "{{7*'7'}}",
254            r#"{{7*"7"}}"#,
255            "<#assign ex=\"freemarker.template.utility.Execute\"?new()>${ex(\"id\")}",
256            r#"${"freemarker.template.utility.Execute"?new()("id")}"#,
257        ] {
258            assert_hit(input);
259        }
260    }
261
262    /// 反向对照:`#assign` 的那个 `#` 在 HTML/URL 里满地都是,`?new` 也一样
263    #[test]
264    fn assign_and_new_lookalikes_not_detected() {
265        for input in [
266            r##"<a href="#assign something">x</a>"##,
267            r##"<a href="#assign">x</a>"##,
268            "https://example.com/#assign-section",
269            "q?new=1",             // `?new` 后面不是 (
270            "{{ '7' }}",           // 带引号的数字,但没有运算
271            r#"{{ "hello" * 3 }}"#, // 运算符两侧没有数字对
272            // 求值探针的边界:两侧都是**字面量数字**才报,标识符操作数不报。
273            // `{{ 10 % 2 }}` 报 Critical 是已知上限(见文件头注释),不是待修的 bug。
274            "{{ count % 2 }}",
275            "{{ n % 10 }}",
276            "{{ a % b }}",
277            "The template renders the remainder of {{ a % b }} on the page.",
278        ] {
279            assert!(det().detect(input).is_none(), "false positive: {input}");
280        }
281    }
282
283    #[test]
284    fn edge_cases() {
285        assert!(det().detect("").is_none());
286        assert!(det().detect(" \t\n ").is_none());
287        assert!(det().detect("你好世界 こんにちは").is_none());
288        // near misses: delimiters incomplete, or magic names uppercase (case-sensitive)
289        assert!(det().detect("{7*7}").is_none());
290        assert!(det().detect("{{7*7").is_none());
291        assert!(det().detect("__CLASS__").is_none());
292        assert!(det().detect("{$x=7}").is_none());
293    }
294
295    #[test]
296    fn obfuscated_variants_detected() {
297        for input in [
298            "{{ ''.__class__.__MRO__[1] }}",
299            "{{ self.__dict__ }}",
300            "{{request.application.__globals__}}",
301        ] {
302            assert_hit(input);
303        }
304    }
305}
306