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