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 模板全打成注入。
12/// 6 条分支合并成 1 条 alternation —— `regex_detect` 对列表里每条 `Regex` 各跑一次
13/// `find`,干净输入下 6 次全文扫描变 1 次。分支顺序 = 原 vec 顺序(同一位置上取最左
14/// 分支;与「按列表顺序取第一条命中的模式」相比偏移量可能不同,档位不变)。
15static PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
16    Regex::new(concat!(
17        // %n / %hn / %lln / %1$n:唯一能写内存的转换符。
18        // 前边界排除数字:`100%n`、`50%n/a` 是百分比串(还是它们真的想 printf `100%n`?)
19        // ——按"误报比漏报更糟"取舍,紧跟在数字后面的 %n 放过。
20        r"(?:^|[^0-9])%(?:\d{1,9}\$)?(?:hh|h|ll|l|L|z|j|t|q)?n\b",
21        // 宽度炸弹:%99999999d
22        r"|%\d{6,}[diouxXeEfgGaAcspn]",
23        // %x%x%x / %p%p%p:连续读栈(中间没有分隔符,3 个就够)
24        r"|(?:%[0-9]{0,4}(?:hh|h|ll|l|L|z|j|t|q)?[xXp]){3,}",
25        // %08x.%08x.%08x.%08x:带分隔的读栈。`.` 在正常格式串里常见(日期、UUID),
26        // 所以阈值提到 4——`%08x.%08x.%08x` 那个量级是格式串,不是栈转储。
27        r"|(?:%[0-9]{0,4}(?:hh|h|ll|l|L|z|j|t|q)?[xXp][.\-_:]?){4,}",
28        // 四个以上连续 %s:挨个读栈上字符串
29        r"|(?:%[0-9]{0,4}(?:hh|h|ll|l|L|z|j|t|q)?s[.\-_:]{0,2}){4,}",
30        // 混合连续泄露:%s%x%p%n 这类穿插写法
31        r"|(?:%[0-9]{0,4}(?:hh|h|ll|l|L|z|j|t|q)?[sxXp][.\-_:]{0,2}){6,}",
32    ))
33    .unwrap()
34});
35
36pub struct FormatStringDetector;
37
38impl Detector for FormatStringDetector {
39    fn name(&self) -> &'static str {
40        "format_string"
41    }
42
43    fn detect(&self, input: &str) -> Option<DetectionResult> {
44        // 全部 6 条分支的每次重复都以字面量 `%` 开头 —— 无 `%` 的输入不可能命中,
45        // 直接返回。1MB 无 `%` 干净文本实测 1.9ms → 0.09ms(release,min-of-5)。
46        // 前提只对**当前分支集**成立:新增分支若不要求 `%`,这里会变成漏报,
47        // 必须同步放宽守卫。
48        if !input.contains('%') {
49            return None;
50        }
51        regex_detect(
52            std::slice::from_ref(&*PATTERNS),
53            self.name(),
54            AttackCategory::Injection,
55            Severity::Medium,
56            "Format string injection detected",
57            input,
58        )
59    }
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use crate::test_helpers::{assert_clean, assert_detected};
66
67    fn det() -> FormatStringDetector {
68        FormatStringDetector
69    }
70
71    fn assert_hit(input: &str) {
72        assert_detected(&det(), input, AttackCategory::Injection, Severity::Medium);
73    }
74
75    #[test]
76    fn name_is_format_string() {
77        assert_eq!(det().name(), "format_string");
78    }
79
80    #[test]
81    fn detects_percent_n_variants() {
82        for input in [
83            "%n",
84            "AAAA%n",
85            "%1$n",
86            "%hn",
87            "%lln",
88            "%08x%08x%08x%n",
89            "%p %p %n",
90        ] {
91            assert_hit(input);
92        }
93    }
94
95    #[test]
96    fn detects_width_bomb() {
97        for input in ["%99999999d", "%1000000s", "AAAA%2147483647d"] {
98            assert_hit(input);
99        }
100    }
101
102    #[test]
103    fn detects_repeated_leak_specifiers() {
104        for input in [
105            "%x%x%x%x",
106            "%p%p%p",
107            "%08x.%08x.%08x.%08x",
108            "%s%s%s%s",
109            "%x%p%s%x%p%s",
110        ] {
111            assert_hit(input);
112        }
113    }
114
115    #[test]
116    fn ignores_benign_inputs() {
117        for input in [
118            "Hello, this is a normal text input. Nothing suspicious here.",
119            "100% safe",
120            "50% off",
121            "discount 20%",
122            "%s 是占位符",
123            "%d%%",
124            "printf(\"%s\\n\", name)",
125            "a % b",
126            "下载进度 88%",
127            "http://example.com/?q=%20name",
128            "100%name",
129            "url: a%2Fb%3Fc",
130        ] {
131            assert_clean(&det(), input);
132        }
133    }
134
135    #[test]
136    fn ignores_normal_printf_and_sql_templates() {
137        // 分隔符含 `,`/空格时,正常的多占位符模板会被当成"连续读栈":
138        // printf 的 `%s, %s, %s, %s`、SQL 的 `VALUES (%s, %s, %s, %s)` 都是日常写法。
139        for input in [
140            "printf(\"%s, %s, %s, %s\\n\", a, b, c, d)",
141            "INSERT INTO t VALUES (%s, %s, %s, %s)",
142            "score=%s; name=%s; tag=%s; note=%s",
143            "user=%s ip=%s",
144        ] {
145            assert_clean(&det(), input);
146        }
147    }
148
149    #[test]
150    fn ignores_short_leak_sequences() {
151        // 3 个 %x 是调试格式串(`%08x.%08x.%08x` 打 MAC/时间戳),不是栈转储;
152        // 带分隔的阈值提到 4。
153        for input in ["%x %x %x", "%08x.%08x.%08x"] {
154            assert_clean(&det(), input);
155        }
156        // 连续无分隔的 3 个仍报
157        assert_hit("%x%x%x");
158    }
159
160    #[test]
161    fn ignores_percent_sign_after_digits() {
162        // `100%n` / `50%n/a` 里的 `%n` 紧跟在数字后——百分比串。加了前边界后放过。
163        for input in ["100%n", "50%n/a"] {
164            assert_clean(&det(), input);
165        }
166        assert_hit("AAAA%n");
167    }
168
169    #[test]
170    fn percent_free_long_input_stays_clean() {
171        // 无 `%` 的输入在 detect 顶部走 `contains` 快速路径返回。
172        // 长文本:守卫若吞掉命中(或误报),这条先炸。
173        let clean = "The quick brown fox jumps over the lazy dog. ".repeat(2000);
174        assert_clean(&det(), &clean);
175    }
176
177    #[test]
178    fn percent_bearing_non_attack_stays_clean() {
179        // 含 `%` 的输入绕过守卫进正则,结果与守卫前一致:命中只看分支语义。
180        for input in ["100% done", "Save 30% today", "a % b % c"] {
181            assert_clean(&det(), input);
182        }
183    }
184
185    #[test]
186    fn edge_cases() {
187        assert_clean(&det(), "");
188        assert_clean(&det(), "   ");
189        assert_clean(&det(), "%");
190        assert_clean(&det(), "%%");
191        assert_clean(&det(), "%s");
192        assert_clean(&det(), "%s%s%s");
193        // 连续泄漏不足 3 次
194        assert_clean(&det(), "%x%x");
195        assert_clean(&det(), "%p%p");
196    }
197}