Skip to main content

security_rust/injection/
ssi_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/// 强信号:只能是恶意用法 —— 执行命令、导出整个环境、包含绝对路径 / `..` 穿越。
8/// 3 条分支合并成 1 条 alternation —— `regex_detect` 对列表里每条 `Regex` 各跑一次
9/// `find`,干净输入下 3 次全文扫描变 1 次。分支顺序 = 原 vec 顺序(同一位置上取最左
10/// 分支;与「按列表顺序取第一条命中的模式」相比偏移量可能不同,档位不变)。
11static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
12    Regex::new(concat!(
13        r"<!--#exec cmd=",
14        // `<!--#include file="header.html" -->` 是 Apache SSI 的常规写法(每一个
15        // .shtml 页面都有),只有绝对路径或 `..` 穿越才是注入。
16        r#"|<!--#include file=["']?(?:/|\.\.)"#,
17        r"|<!--#printenv",
18    ))
19    .unwrap()
20});
21
22/// 弱信号:SSI 指令「出现」本身就是这些指令的功能 —— echo 变量、fsize/flastmod
23/// 取文件信息、config 设格式、相对路径 include,正常与恶意形状完全相同,单条不拒绝。
24/// 5 条分支合并成 1 条 alternation —— `regex_detect` 对列表里每条 `Regex` 各跑一次
25/// `find`,干净输入下 5 次全文扫描变 1 次。分支顺序 = 原 vec 顺序(同一位置上取最左
26/// 分支;与「按列表顺序取第一条命中的模式」相比偏移量可能不同,档位不变)。
27static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
28    Regex::new(concat!(
29        r"<!--#include file=",
30        r"|<!--#echo var=",
31        r"|<!--#fsize",
32        r"|<!--#flastmod",
33        r"|<!--#config",
34    ))
35    .unwrap()
36});
37
38pub struct SsiInjectionDetector;
39
40impl Detector for SsiInjectionDetector {
41    fn name(&self) -> &'static str {
42        "ssi_injection"
43    }
44
45    fn detect(&self, input: &str) -> Option<DetectionResult> {
46        regex_detect(
47            std::slice::from_ref(&*STRONG_PATTERNS),
48            self.name(),
49            AttackCategory::Injection,
50            Severity::High,
51            "SSI Server-Side Include injection detected",
52            input,
53        )
54        .or_else(|| {
55            regex_detect(
56                std::slice::from_ref(&*WEAK_PATTERNS),
57                self.name(),
58                AttackCategory::Injection,
59                Severity::Low,
60                "SSI directive present (weak signal)",
61                input,
62            )
63        })
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70
71    fn det() -> SsiInjectionDetector {
72        SsiInjectionDetector
73    }
74
75    fn assert_hit_at(input: &str, severity: Severity) {
76        crate::test_helpers::assert_detected(&det(), input, AttackCategory::Injection, severity);
77    }
78
79    #[test]
80    fn name_is_ssi_injection() {
81        assert_eq!(det().name(), "ssi_injection");
82    }
83
84    #[test]
85    fn detects_common_payloads() {
86        for (input, severity) in [
87            // 强信号:可执行 / 越权读取,High
88            (r#"<!--#exec cmd="cat /etc/passwd"-->"#, Severity::High),
89            (r#"<!--#include file="/etc/passwd"-->"#, Severity::High),
90            (r#"<!--#include file="../../etc/passwd"-->"#, Severity::High),
91            (r#"<!--#printenv-->"#, Severity::High),
92            // 弱信号:指令出现本身即正常 SSI 功能,Low
93            (r#"<!--#echo var="DATE_LOCAL"-->"#, Severity::Low),
94            (r#"<!--#fsize file="index.html"-->"#, Severity::Low),
95            (r#"<!--#flastmod file="index.html"-->"#, Severity::Low),
96            (r#"<!--#config timefmt="%B"-->"#, Severity::Low),
97        ] {
98            assert_hit_at(input, severity);
99        }
100    }
101
102    /// 正常 .shtml 页面。`include file="footer.html"` / `echo var="DATE_LOCAL"` /
103    /// `config timefmt=…` 是 Apache SSI 的文档写法,每个用了服务端包含的站点都有;
104    /// 它们曾按 High 被判注入,整页被拒。
105    #[test]
106    fn stock_ssi_page_is_not_high() {
107        let page = "<!--#include file=\"header.html\" -->\n\
108                    <!--#echo var=\"DATE_LOCAL\" -->\n\
109                    <!--#config timefmt=\"%Y\" -->\n\
110                    <!--#fsize file=\"footer.html\" -->";
111        assert_hit_at(page, Severity::Low);
112        // 弱信号仍检出(不是「删掉检测」),只是不再单独越过拒绝线
113        assert!(det().detect(page).is_some());
114    }
115
116    #[test]
117    fn benign_inputs_not_detected() {
118        for input in [
119            "Hello, this is a normal text input. Nothing suspicious here.",
120            "<!-- this is a plain comment -->",
121            "The page was generated at 3:00 PM",
122            "Include the file below the table",
123            "The include directive resolves the path at build time.",
124        ] {
125            assert!(det().detect(input).is_none(), "false positive: {input}");
126        }
127    }
128
129    #[test]
130    fn edge_cases() {
131        assert!(det().detect("").is_none());
132        assert!(det().detect(" \t\n ").is_none());
133        assert!(det().detect("你好世界 こんにちは").is_none());
134        // near misses: directive incomplete, or uppercase (patterns are case-sensitive)
135        assert!(det().detect("<!--#exec").is_none());
136        assert!(det().detect(r#"<!--#EXEC cmd="ls"-->"#).is_none());
137        assert!(det().detect("<!-- #exec cmd=\"ls\" -->").is_none());
138    }
139}