Skip to main content

security_rust/injection/
sql_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/// 强信号:形态本身只能来自攻击 —— 关键字出现在**语句位置**、或带着 SQL 实参。
8/// 判据是「形态」而不是「词出现」:`sleep(` 只有当它处在行首/引号/`)`/`;`/布尔关键字之后
9/// 才是注入,因为注入总是**追加**在原值上;`time.sleep(2)`、`retrying after sleep(2)`、
10/// `Add a sleep(1) between retries` 都不在那个位置。
11/// 17 条分支合并成 1 条 alternation —— `regex_detect` 对列表里每条 `Regex` 各跑一次
12/// `find`,干净输入下 17 次全文扫描变 1 次。分支顺序 = 原 vec 顺序(同一位置上取最左
13/// 分支;与「按列表顺序取第一条命中的模式」相比偏移量可能不同,档位不变)。
14/// 各分支共同的 `(?i)` 提升为外层 `(?i:…)`,作用域正好覆盖全部 17 条分支。
15static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
16    Regex::new(concat!(
17        // `[\s+]|%20`: `+` 与 `%20` 都是空格的 URL 编码;`/**/` 可切开关键字(UN/**/ION)
18        r"(?i:UN(?:/\*.*?\*/)?ION(?:[\s+]|%20)+(?:ALL(?:[\s+]|%20)+)?SELECT",
19        // 选择列表必须像 SQL(标识符/`*`/函数调用),否则英文散文 `select … from …` 全是误报
20        r#"|SELECT(?:[\s+]|%20)+(?:DISTINCT(?:[\s+]|%20)+)?(?:\*|[\w"'\[\].]+(?:\([^)\n]{0,40}\))?(?:\s*,\s*[\w"'\[\].]+(?:\([^)\n]{0,40}\))?)*)(?:[\s+]|%20)+FROM(?:[\s+]|%20)+[\w"\[\].]+"#,
21        // `/*!` 版本注释包着 SQL 语句时才是注入;Doxygen 的 `/*! \brief … */` 不是
22        r"|/\*!\d*\s*(?:union|select|insert|update|delete|drop|alter|sleep|benchmark|load_file|outfile|or|and|where)\b",
23        // 语句位置(见上)+ 可选的 URL 编码空格
24        r#"|(?:^|['"();]|\b(?:and|or|select|union|where|having)\b)[\s+]*sleep\s*\("#,
25        // BENCHMARK 的签名是 `(次数, 表达式)`:`benchmark(fn, { iterations: 10000 })` 不命中
26        r"|benchmark\s*\(\s*\d+\s*,",
27        // 同 `sleep(`:`Call pg_sleep(5) to pause the session` 与 PG 报错里的
28        // `function pg_sleep(integer) does not exist` 都在语句位置之外
29        r#"|(?:^|['"();]|\b(?:and|or|select|union|where|having)\b)[\s+]*pg_sleep\s*\("#,
30        // 文档里写 `exec sp_who2 lists active sessions` / `call exec xp_cmdshell` 时
31        // `exec` 前面是普通英文词;注入里它是栈式查询,前面是 `;` 或引号
32        r#"|(?:^|['"();])\s*exec\s+(?:sp_|xp_)"#,
33        // 必须带时间字面量:`use WAITFOR DELAY to pause before the retry` 不带
34        r#"|WAITFOR\s+DELAY\s+['"]?\d"#,
35        r"|'\s*OR\s*'1'\s*=\s*'1",
36        r"|'\s*OR\s*1\s*=\s*1\s*--",
37        // 只认带引号 / 十六进制实参的读取:`load_file(fd)`、`LOAD_FILE()` 是普通函数调用
38        r#"|LOAD_FILE\s*\(\s*(?:['"]|0x)"#,
39        // `INTO OUTFILE` 在真实语法里**必须**跟一个带引号的路径;SQL 教程里那句
40        // `Use INTO OUTFILE to export the result set to disk` 不跟引号(`/**/` 变体由下一条兜底)
41        r#"|INTO\s+(?:OUT|DUMP)FILE\s+['"]"#,
42        // MySQL 这里必须跟一个带引号的路径:`The outfile is written to the shared drive` 不命中
43        r#"|OUTFILE\s+['"]"#,
44        // 左上下文不收 `'` / `"`:栈式注入**必须**有 `;`,而引号后的 DDL/DML 是语法错误、
45        // 打不出注入。收进去反而命中每一条回显失败语句的日志 ——
46        // `ERROR 1064: ... near 'INSERT INTO users (id) VALUES (1)'`、
47        // `{"query": "INSERT INTO users VALUES (1)"}`、`[INFO] sql="DROP TABLE tmp"`
48        r#"|(?:^|[();])\s*DROP\s+TABLE"#,
49        r#"|(?:^|[();])\s*INSERT\s+INTO"#,
50        // 注释符必须**紧贴**那个引号,且只认单引号:`"`/`)` 后面隔一段空白再接注释符的
51        // 形态在正常代码里遍地都是 —— Rust 原始字符串终止符 `r#"…"#`(`"#).unwrap(),`)、
52        // C 的 `f(a, b) /* note */`、Velocity 的 `)` 换行 `#foreach`、shell 的
53        // `grep 'x' -- file` 全被判 Critical(本仓库自己的源码就有十几处)。
54        // `--` 仍要求其后是空白/行尾/`+`(MySQL 的 URL 编码空格),SSI 的 `"-->` 不受影响。
55        r#"|(?:'\)?#[^"'\n]*(?:\n|$)|'\)[ \t]*--(?:\s|$|\+)|'--(?:\s|$|\+)|'/\*|\)[ \t]*--(?:\s|$|\+))"#,
56        // 注释后的布尔关键字必须带 SQL 操作数:Markdown 的 `# Or use the CLI`、
57        // 散文里的 `-- and the conclusions` 都不带
58        r#"|(?:/\*.*?\*/|--|#)\s*(?:or\s+['"(\d]|and\s+['"(\d]|union\s+(?:all\s+)?select\b|select\s+(?:\*|['"(\d]|[\w`\[\]]+\s*(?:,|from\b))))"#,
59    ))
60    .unwrap()
61});
62
63/// 弱信号:词一出现就命中,但形态与「文档在讨论数据库」逐字节同形。
64/// `information_schema` 在 PG 报错日志、SQL 教程里都正常出现;而它真正的攻击形态
65/// (`UNION SELECT … FROM information_schema`、`SELECT … FROM information_schema`)
66/// 已由强档的两条覆盖。所以报 Low:仍然检出(不静默漏报),单条 5 分不触发拒绝,
67/// 是否升级交给调用方按聚合分决定。
68/// 只有 1 条分支,仍收成单个 `Regex` 与强档同型 —— 调用点用
69/// `std::slice::from_ref` 包成单元素切片,少一层 `Vec` 间接。
70static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
71    Regex::new(r"(?i:information_schema)")
72    .unwrap()
73});
74
75pub struct SqlInjectionDetector;
76
77impl Detector for SqlInjectionDetector {
78    fn name(&self) -> &'static str {
79        "sql_injection"
80    }
81
82    fn detect(&self, input: &str) -> Option<DetectionResult> {
83        regex_detect(
84            std::slice::from_ref(&*STRONG_PATTERNS),
85            self.name(),
86            AttackCategory::Injection,
87            Severity::Critical,
88            "SQL injection detected",
89            input,
90        )
91        .or_else(|| {
92            regex_detect(
93                std::slice::from_ref(&*WEAK_PATTERNS),
94                self.name(),
95                AttackCategory::Injection,
96                Severity::Low,
97                "SQL keyword present (weak signal)",
98                input,
99            )
100        })
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    fn det() -> SqlInjectionDetector {
109        SqlInjectionDetector
110    }
111
112    fn assert_hit(input: &str) {
113        assert_hit_at(input, Severity::Critical);
114    }
115
116    fn assert_hit_at(input: &str, severity: Severity) {
117        crate::test_helpers::assert_detected(&det(), input, AttackCategory::Injection, severity);
118    }
119
120    #[test]
121    fn name_is_sql_injection() {
122        assert_eq!(det().name(), "sql_injection");
123    }
124
125    #[test]
126    fn detects_common_payloads() {
127        for input in [
128            "1 UNION SELECT password FROM users",
129            "1; SELECT pg_sleep(5)",
130            "admin' OR '1'='1",
131            "SELECT * FROM users WHERE id=1",
132            "id=1 /*!50000union select*/",
133            "1; WAITFOR DELAY '0:0:5'",
134            "username' OR 1=1 --",
135            "admin'--",
136            "1') --",
137            "x'#comment",
138            "x'#comment&foo=bar",
139            "-- or 1=1",
140            "/*x*/ union select",
141            // `+` / `%20` 是空格的 URL 编码
142            "1+UNION+SELECT+username,password+FROM+users",
143            "1%20UNION%20SELECT%201",
144            "SELECT+id+FROM+users",
145            // `/**/` 切开关键字
146            "UN/**/ION SELECT 1,2,3",
147            // 选择列表
148            "SELECT name, email FROM users",
149            "SELECT COUNT(*) FROM users",
150            "SELECT 用户名 FROM 用户表",
151            "SELECT DISTINCT id FROM t",
152            "SELECT id, count(*) FROM logs",
153            "a'/**/or 1=1",
154        ] {
155            assert_hit(input);
156        }
157    }
158
159    #[test]
160    fn benign_inputs_not_detected() {
161        for input in [
162            "Hello, this is a normal text input. Nothing suspicious here.",
163            "Please choose an option below",
164            "I will sleep well tonight",
165            "The benchmark results look great",
166            "Drop me a line when you arrive",
167            "The information desk is on the second floor",
168            "q=2024--2025",
169            "q=donation=5",
170            "穿越之霸道总裁爱上我--重生之都市修仙",
171            "chapter 2024--2025 更新",
172            "donation=5&q=test",
173        ] {
174            assert!(det().detect(input).is_none(), "false positive: {input}");
175        }
176    }
177
178    /// 真实世界的干净输入:散文、HTML、URL、JSON、日志。
179    /// 上面 `benign_inputs_not_detected` 是人工构造的近失配(`select from users` 测的是
180    /// “没有空格”),挡不住真正的误报 —— `href="#"` 与英文 `select … from …` 就是这样漏出去的。
181    #[test]
182    fn realistic_text_not_detected() {
183        for input in [
184            // HTML:`#` 是属性值,不是 MySQL 行注释
185            r##"<a href="#">Back to top</a>"##,
186            "<a href='#'>",
187            "See the anchor href=\"#\" for details.",
188            r##"<a href="#main">Skip to content</a>"##,
189            r##"<a href="#section-2">Jump</a>"##,
190            r##"<p>Read the <a href="#faq">FAQ</a> before contacting support.</p>"##,
191            r##"<a href="/about">About us</a>"##,
192            r##"<link rel="stylesheet" href="/static/app.css?v=3">"##,
193            r##"<div id="top">"##,
194            "color: #09f; background: #fff;",
195            // 英文散文里的 select … from …
196            "Please select a date from the calendar below.",
197            "Select your language from the menu.",
198            "select the file from your computer",
199            "I will select the best option from these.",
200            "Please select the country from the dropdown list above.",
201            "We will select the winner from all entries on Friday.",
202            "Please describe the issue, then select a priority from the list.",
203            "Veuillez sélectionner votre langue dans le menu.",
204            "Our team will respond to your request within 24 hours.",
205            // URL 与 JSON
206            "https://docs.example.com/guide#getting-started",
207            "https://shop.example.com/products?color=%23ff0000&sort=price_asc",
208            "http://example.com/search?q=best+laptop+2026&page=2",
209            r##"{"theme":"dark","accent":"#fff","bg":"#1a1a1a"}"##,
210            r##"{"build":{"target":"x86_64","profile":"release","features":[]}}"##,
211            // 日志
212            "2026-09-30T12:00:00Z INFO  request completed status=200 path=/api/v1/items duration=42ms",
213            "level=error msg=\"disk usage at 91%\" service=ingest host=node-7",
214            // 杂项:`#` 的字面用法、加号、引号
215            "The # symbol is also known as a hash or pound sign.",
216            "Contact us at support@example.com or call +1-555-0100.",
217        ] {
218            assert!(det().detect(input).is_none(), "false positive: {input}");
219        }
220    }
221
222    /// 收紧过的模式必须**双向**钉住:上半段是「收紧不能丢检出」,下半段是
223    /// 「放宽不能放任误报」。每条正常内容都是真实形态 —— 代码、日志、散文、文档,
224    /// 不是合成的近失配。
225    #[test]
226    fn tightened_shapes_detect_attacks_and_ignore_ordinary_text() {
227        // `sleep(`:语句位置才是注入(注入是追加到原值上的)
228        assert_hit("1 AND SLEEP(5)");
229        assert_hit("1 OR SLEEP(5)");
230        assert_hit("1; SELECT SLEEP(5)");
231        assert_hit("1' AND SLEEP(5)-- ");
232        // `BENCHMARK(次数, 表达式)` 是 MySQL 的签名
233        assert_hit("1 AND BENCHMARK(1000000,MD5(1))");
234        // `pg_sleep(` / `exec sp_` / `WAITFOR DELAY`:同为语句位置判据
235        assert_hit("1 AND pg_sleep(5)");
236        assert_hit("1; EXEC xp_cmdshell 'dir'");
237        assert_hit("1; EXEC sp_executesql N'SELECT 1'");
238        assert_hit("1' EXEC xp_cmdshell 'dir'");
239        assert_hit("1; WAITFOR DELAY '0:0:5'");
240        assert_hit("1; WAITFOR DELAY 0:0:5");
241        // `/*!` 版本注释包着 SQL 语句
242        assert_hit("1 AND /*!50000SLEEP(5)*/");
243        // 带引号 / 十六进制实参的文件读取
244        assert_hit("1 UNION SELECT LOAD_FILE('/etc/passwd')");
245        assert_hit("1 UNION SELECT LOAD_FILE(0x2f657463)");
246        assert_hit("1 UNION SELECT 'x' INTO OUTFILE '/tmp/a'");
247        assert_hit("1 UNION SELECT 'x' INTO DUMPFILE '/tmp/a'");
248        assert_hit("1 UNION SELECT 'x' INTO/**/OUTFILE '/tmp/a'");
249        // 语句位置的 DDL/DML
250        assert_hit("1; DROP TABLE users");
251        assert_hit("1; INSERT INTO users VALUES(1)");
252        assert_hit("1'; DROP TABLE users");
253        assert_hit("1'; INSERT INTO users VALUES(1)");
254        // 注释后的布尔关键字带 SQL 操作数
255        assert_hit("-- or 1=1");
256        assert_hit("# or 1=1");
257        assert_hit("/*x*/ union select");
258
259        for input in [
260            // `time.sleep` / 重试退避 / 日志里的耗时说明
261            "Add a sleep(1) between retries to avoid hammering upstream.",
262            "2026-09-30T12:00:01Z WARN retrying after sleep(2)",
263            "        except TransientError:\n            sleep(2 ** i)",
264            "time.sleep(0.5)  # backoff",
265            // 基准测试调用:第一个实参是函数,不是次数
266            "benchmark(parseConfig, { iterations: 10000 });",
267            // Doxygen 的 `/*!` 注释
268            "/*! Parses the config. */\nint parse(const char *p);",
269            // 普通函数调用与文档里提到的函数名
270            "The <code>load_file(fd)</code> helper reads a descriptor.",
271            "MySQL's <code>LOAD_FILE()</code> needs the FILE privilege.",
272            "The outfile is written to the shared drive every night.",
273            "Please insert into the form the reference number shown above.",
274            "Then drop table 12 for the party at the venue.",
275            // 文档在**讲解**这些函数:函数名前面是普通英文词,不是语句位置
276            "Call pg_sleep(5) to pause the session during a migration.",
277            "ERROR: function pg_sleep(integer) does not exist",
278            "The stored procedure exec sp_who2 lists active sessions.",
279            "Legacy jobs call exec xp_cmdshell, which is disabled by default.",
280            "In T-SQL use WAITFOR DELAY to pause before the retry.",
281            "Use INTO OUTFILE to export the result set to disk.",
282            "INTO DUMPFILE writes a single row without delimiters.",
283            // 日志回显失败语句:引号后面跟 DDL/DML 是**被记录的文本**,不是栈式注入
284            "ERROR 1064: You have an error in your SQL syntax near 'INSERT INTO users (id) VALUES (1)' at line 1",
285            "ERROR 1051: Unknown table 'users' near 'DROP TABLE users_old' at line 1",
286            r#"{"query": "INSERT INTO users VALUES (1)"}"#,
287            r#"[INFO] sql="DROP TABLE tmp_users""#,
288            // Markdown 标题、破折号散文、CSS 注释:注释后跟的是普通英文词
289            "# Or use the CLI instead.",
290            "## And then run the tests.",
291            "The results -- and the conclusions -- were surprising.",
292            "Wait -- or restart the service.",
293            "/* reset */ and then apply the theme",
294        ] {
295            crate::test_helpers::assert_clean(&det(), input);
296        }
297    }
298
299    /// 收紧后的注释符模式:只认**紧贴单引号**的 `--` / `#` / `/*`(`#` 后可跨一个 `)`)。
300    /// 旧写法允许 `"` 或裸 `)` 之后隔任意空白再接注释符,于是 Rust 原始字符串终止符
301    /// `r#"…"#`、C 的 `f(a, b) /* note */`、Velocity 的 `)` 换行 `#foreach` 全部命中
302    /// Critical —— 单是本仓库自己的源码就有十几处。
303    #[test]
304    fn quote_comment_lookalikes_are_not_injection() {
305        // 攻击方向:注释符紧贴单引号,仍是 Critical
306        for input in [
307            "admin'--",
308            "1') --",
309            "1) --",
310            "x'#comment",
311            "x'#comment&foo=bar",
312            "1')#x",
313            "a'/**/or 1=1",
314            "username' OR 1=1 --",
315        ] {
316            assert_hit(input);
317        }
318        // 正常代码方向:注释符前面不是单引号
319        for input in [
320            // Rust 原始字符串终止符 —— 本仓库 src/ 里十几处
321            r##"let re = Regex::new(r#"\d+"#).unwrap(),"##,
322            "static P: LazyLock<Vec<Regex>> = LazyLock::new(|| vec![Regex::new(r\"#x\").unwrap()]);",
323            // C / JS 块注释,以及括号后的自减
324            "int r = compute(a, b) /* note: keep in sync */;",
325            "if (n) --i;",
326            // Velocity:`)` 换行 `#foreach`(`#` 前是换行,不是引号)
327            "#set($x = 5)\n#foreach($item in $items)",
328            // shell / CLI 里的 `#` 与 `--`
329            "echo 'hello' # say hi",
330            "grep 'pattern' -- file.txt",
331            "git commit -m 'msg' -- file.txt",
332            // 单引号字符串里的 `/*`,但不是紧贴注释符
333            "x = 'a' /* c */",
334        ] {
335            crate::test_helpers::assert_clean(&det(), input);
336        }
337    }
338
339    /// 降档:`information_schema` 是「词出现」型信号,PG 报错日志与 SQL 教程里都正常
340    /// 出现,而它真正的注入形态(`UNION SELECT … FROM`、`SELECT … FROM`)由强档覆盖。
341    /// 所以仍检出(不静默漏报),但只报 Low,单条不触发调用方的拒绝线。
342    #[test]
343    fn information_schema_is_low_not_critical() {
344        for input in [
345            "ERROR query failed: relation \"information_schema\" does not exist",
346            "Read <code>information_schema.tables</code> to list tables.",
347        ] {
348            assert_hit_at(input, Severity::Low);
349        }
350        // 降档的是「词」,不是注入本身
351        for input in [
352            "1 UNION SELECT table_name FROM information_schema.tables",
353            "1 AND (SELECT 1 FROM information_schema.tables)",
354        ] {
355            assert_hit(input);
356        }
357    }
358
359    #[test]
360    fn edge_cases() {
361        assert!(det().detect("").is_none());
362        assert!(det().detect(" \t\n ").is_none());
363        assert!(det().detect("你好世界 こんにちは").is_none());
364        // near misses: keyword present but not the payload form
365        assert!(det().detect("UNOIN SILE CT *").is_none());
366        assert!(det().detect("select from users").is_none());
367        assert!(det().detect("sleep 5").is_none());
368    }
369
370    #[test]
371    fn obfuscated_variants_detected() {
372        for input in [
373            "1 UnIoN SeLeCt password",
374            "Sleep(5)",
375            "1; SELECT Pg_Sleep(10)",
376            "' or '1'='1",
377        ] {
378            assert_hit(input);
379        }
380    }
381}