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        regex_detect(
45            std::slice::from_ref(&*PATTERNS),
46            self.name(),
47            AttackCategory::Injection,
48            Severity::Medium,
49            "Format string injection detected",
50            input,
51        )
52    }
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58    use crate::test_helpers::{assert_clean, assert_detected};
59
60    fn det() -> FormatStringDetector {
61        FormatStringDetector
62    }
63
64    fn assert_hit(input: &str) {
65        assert_detected(&det(), input, AttackCategory::Injection, Severity::Medium);
66    }
67
68    #[test]
69    fn name_is_format_string() {
70        assert_eq!(det().name(), "format_string");
71    }
72
73    #[test]
74    fn detects_percent_n_variants() {
75        for input in [
76            "%n",
77            "AAAA%n",
78            "%1$n",
79            "%hn",
80            "%lln",
81            "%08x%08x%08x%n",
82            "%p %p %n",
83        ] {
84            assert_hit(input);
85        }
86    }
87
88    #[test]
89    fn detects_width_bomb() {
90        for input in ["%99999999d", "%1000000s", "AAAA%2147483647d"] {
91            assert_hit(input);
92        }
93    }
94
95    #[test]
96    fn detects_repeated_leak_specifiers() {
97        for input in [
98            "%x%x%x%x",
99            "%p%p%p",
100            "%08x.%08x.%08x.%08x",
101            "%s%s%s%s",
102            "%x%p%s%x%p%s",
103        ] {
104            assert_hit(input);
105        }
106    }
107
108    #[test]
109    fn ignores_benign_inputs() {
110        for input in [
111            "Hello, this is a normal text input. Nothing suspicious here.",
112            "100% safe",
113            "50% off",
114            "discount 20%",
115            "%s 是占位符",
116            "%d%%",
117            "printf(\"%s\\n\", name)",
118            "a % b",
119            "下载进度 88%",
120            "http://example.com/?q=%20name",
121            "100%name",
122            "url: a%2Fb%3Fc",
123        ] {
124            assert_clean(&det(), input);
125        }
126    }
127
128    #[test]
129    fn ignores_normal_printf_and_sql_templates() {
130        // 分隔符含 `,`/空格时,正常的多占位符模板会被当成"连续读栈":
131        // printf 的 `%s, %s, %s, %s`、SQL 的 `VALUES (%s, %s, %s, %s)` 都是日常写法。
132        for input in [
133            "printf(\"%s, %s, %s, %s\\n\", a, b, c, d)",
134            "INSERT INTO t VALUES (%s, %s, %s, %s)",
135            "score=%s; name=%s; tag=%s; note=%s",
136            "user=%s ip=%s",
137        ] {
138            assert_clean(&det(), input);
139        }
140    }
141
142    #[test]
143    fn ignores_short_leak_sequences() {
144        // 3 个 %x 是调试格式串(`%08x.%08x.%08x` 打 MAC/时间戳),不是栈转储;
145        // 带分隔的阈值提到 4。
146        for input in ["%x %x %x", "%08x.%08x.%08x"] {
147            assert_clean(&det(), input);
148        }
149        // 连续无分隔的 3 个仍报
150        assert_hit("%x%x%x");
151    }
152
153    #[test]
154    fn ignores_percent_sign_after_digits() {
155        // `100%n` / `50%n/a` 里的 `%n` 紧跟在数字后——百分比串。加了前边界后放过。
156        for input in ["100%n", "50%n/a"] {
157            assert_clean(&det(), input);
158        }
159        assert_hit("AAAA%n");
160    }
161
162    #[test]
163    fn edge_cases() {
164        assert_clean(&det(), "");
165        assert_clean(&det(), "   ");
166        assert_clean(&det(), "%");
167        assert_clean(&det(), "%%");
168        assert_clean(&det(), "%s");
169        assert_clean(&det(), "%s%s%s");
170        // 连续泄漏不足 3 次
171        assert_clean(&det(), "%x%x");
172        assert_clean(&det(), "%p%p");
173    }
174}