1use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7static PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
16 Regex::new(concat!(
17 r"(?:^|[^0-9])%(?:\d{1,9}\$)?(?:hh|h|ll|l|L|z|j|t|q)?n\b",
21 r"|%\d{6,}[diouxXeEfgGaAcspn]",
23 r"|(?:%[0-9]{0,4}(?:hh|h|ll|l|L|z|j|t|q)?[xXp]){3,}",
25 r"|(?:%[0-9]{0,4}(?:hh|h|ll|l|L|z|j|t|q)?[xXp][.\-_:]?){4,}",
28 r"|(?:%[0-9]{0,4}(?:hh|h|ll|l|L|z|j|t|q)?s[.\-_:]{0,2}){4,}",
30 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 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 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 for input in ["%x %x %x", "%08x.%08x.%08x"] {
154 assert_clean(&det(), input);
155 }
156 assert_hit("%x%x%x");
158 }
159
160 #[test]
161 fn ignores_percent_sign_after_digits() {
162 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 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 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 assert_clean(&det(), "%x%x");
195 assert_clean(&det(), "%p%p");
196 }
197}