Skip to main content

security_rust/injection/
format_string.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// 只报三类高信号形态:%n 写内存、连续泄露符读栈、超宽宽度炸 CPU。
8// 单个 %s/%d 是正常占位符,不报。
9// 分隔符只留 `.`/`-`/`_`/`:`:`,` 和空格是 printf 模板的常规分隔
10// (`printf("%s, %s, %s, %s\n", ...)`、`INSERT INTO t VALUES (%s, %s, %s, %s)`),
11// 放进来等于把正常 SQL/printf 模板全打成注入。
12static PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
13    vec![
14        // %n / %hn / %lln / %1$n:唯一能写内存的转换符。
15        // 前边界排除数字:`100%n`、`50%n/a` 是百分比串(还是它们真的想 printf `100%n`?)
16        // ——按"误报比漏报更糟"取舍,紧跟在数字后面的 %n 放过。
17        Regex::new(r"(?:^|[^0-9])%(?:\d{1,9}\$)?(?:hh|h|ll|l|L|z|j|t|q)?n\b").unwrap(),
18        // 宽度炸弹:%99999999d
19        Regex::new(r"%\d{6,}[diouxXeEfgGaAcspn]").unwrap(),
20        // %x%x%x / %p%p%p:连续读栈(中间没有分隔符,3 个就够)
21        Regex::new(r"(?:%[0-9]{0,4}(?:hh|h|ll|l|L|z|j|t|q)?[xXp]){3,}").unwrap(),
22        // %08x.%08x.%08x.%08x:带分隔的读栈。`.` 在正常格式串里常见(日期、UUID),
23        // 所以阈值提到 4——`%08x.%08x.%08x` 那个量级是格式串,不是栈转储。
24        Regex::new(r"(?:%[0-9]{0,4}(?:hh|h|ll|l|L|z|j|t|q)?[xXp][.\-_:]?){4,}").unwrap(),
25        // 四个以上连续 %s:挨个读栈上字符串
26        Regex::new(r"(?:%[0-9]{0,4}(?:hh|h|ll|l|L|z|j|t|q)?s[.\-_:]{0,2}){4,}").unwrap(),
27        // 混合连续泄露:%s%x%p%n 这类穿插写法
28        Regex::new(r"(?:%[0-9]{0,4}(?:hh|h|ll|l|L|z|j|t|q)?[sxXp][.\-_:]{0,2}){6,}").unwrap(),
29    ]
30});
31
32pub struct FormatStringDetector;
33
34impl Detector for FormatStringDetector {
35    fn name(&self) -> &'static str {
36        "format_string"
37    }
38
39    fn detect(&self, input: &str) -> Option<DetectionResult> {
40        regex_detect(
41            &PATTERNS,
42            self.name(),
43            AttackCategory::Injection,
44            Severity::Medium,
45            "Format string injection detected",
46            input,
47        )
48    }
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54    use crate::test_helpers::{assert_clean, assert_detected};
55
56    fn det() -> FormatStringDetector {
57        FormatStringDetector
58    }
59
60    fn assert_hit(input: &str) {
61        assert_detected(&det(), input, AttackCategory::Injection, Severity::Medium);
62    }
63
64    #[test]
65    fn name_is_format_string() {
66        assert_eq!(det().name(), "format_string");
67    }
68
69    #[test]
70    fn detects_percent_n_variants() {
71        for input in [
72            "%n",
73            "AAAA%n",
74            "%1$n",
75            "%hn",
76            "%lln",
77            "%08x%08x%08x%n",
78            "%p %p %n",
79        ] {
80            assert_hit(input);
81        }
82    }
83
84    #[test]
85    fn detects_width_bomb() {
86        for input in ["%99999999d", "%1000000s", "AAAA%2147483647d"] {
87            assert_hit(input);
88        }
89    }
90
91    #[test]
92    fn detects_repeated_leak_specifiers() {
93        for input in [
94            "%x%x%x%x",
95            "%p%p%p",
96            "%08x.%08x.%08x.%08x",
97            "%s%s%s%s",
98            "%x%p%s%x%p%s",
99        ] {
100            assert_hit(input);
101        }
102    }
103
104    #[test]
105    fn ignores_benign_inputs() {
106        for input in [
107            "Hello, this is a normal text input. Nothing suspicious here.",
108            "100% safe",
109            "50% off",
110            "discount 20%",
111            "%s 是占位符",
112            "%d%%",
113            "printf(\"%s\\n\", name)",
114            "a % b",
115            "下载进度 88%",
116            "http://example.com/?q=%20name",
117            "100%name",
118            "url: a%2Fb%3Fc",
119        ] {
120            assert_clean(&det(), input);
121        }
122    }
123
124    #[test]
125    fn ignores_normal_printf_and_sql_templates() {
126        // 分隔符含 `,`/空格时,正常的多占位符模板会被当成"连续读栈":
127        // printf 的 `%s, %s, %s, %s`、SQL 的 `VALUES (%s, %s, %s, %s)` 都是日常写法。
128        for input in [
129            "printf(\"%s, %s, %s, %s\\n\", a, b, c, d)",
130            "INSERT INTO t VALUES (%s, %s, %s, %s)",
131            "score=%s; name=%s; tag=%s; note=%s",
132            "user=%s ip=%s",
133        ] {
134            assert_clean(&det(), input);
135        }
136    }
137
138    #[test]
139    fn ignores_short_leak_sequences() {
140        // 3 个 %x 是调试格式串(`%08x.%08x.%08x` 打 MAC/时间戳),不是栈转储;
141        // 带分隔的阈值提到 4。
142        for input in ["%x %x %x", "%08x.%08x.%08x"] {
143            assert_clean(&det(), input);
144        }
145        // 连续无分隔的 3 个仍报
146        assert_hit("%x%x%x");
147    }
148
149    #[test]
150    fn ignores_percent_sign_after_digits() {
151        // `100%n` / `50%n/a` 里的 `%n` 紧跟在数字后——百分比串。加了前边界后放过。
152        for input in ["100%n", "50%n/a"] {
153            assert_clean(&det(), input);
154        }
155        assert_hit("AAAA%n");
156    }
157
158    #[test]
159    fn edge_cases() {
160        assert_clean(&det(), "");
161        assert_clean(&det(), "   ");
162        assert_clean(&det(), "%");
163        assert_clean(&det(), "%%");
164        assert_clean(&det(), "%s");
165        assert_clean(&det(), "%s%s%s");
166        // 连续泄漏不足 3 次
167        assert_clean(&det(), "%x%x");
168        assert_clean(&det(), "%p%p");
169    }
170}