人人都会AI编程

23.5 SQL 注入攻击原理与防护方案

更新时间:2026-07-11

SQL 注入是互联网应用中最古老、最普遍、也是危害最大的安全漏洞之一。它之所以常年在 OWASP Top 10 中位列前茅,不是因为原理有多复杂,而是因为开发者稍有不慎就会留下隐患。理解它的攻击方式和防御手法的本质,比背会十条规则重要得多。

23.5.1 攻击原理:为什么一段字符串会变成代码

SQL 注入的核心问题在于:不可信的用户输入被直接拼接到 SQL 语句中,从而改变了 SQL 语句原本的语义。 本来该是“数据”的东西,被数据库误当成了“代码”来执行。

假设有一个最经典的登录验证场景,后端用拼接字符串的方式构造 SQL:

SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'";

当用户正常输入 admin123456 时,最终执行的 SQL 是:

SELECT * FROM users WHERE username = 'admin' AND password = '123456';

这完全符合预期。但如果攻击者在用户名字段输入 admin' --(注意最后一个空格和两个横杠),密码随意填,拼接后的 SQL 就变成:

SELECT * FROM users WHERE username = 'admin' --' AND password = 'xxx';

在 SQL 中,-- 是单行注释符,后面的内容全部被忽略。于是这条语句等价于只验证了用户名,密码检查被完全绕过。攻击者不需要知道任何人的密码,只要知道一个存在的用户名,就可以直接登录。

这还只是最简单的例子。更危险的情况包括:

  • 利用 UNION SELECT 将别的表的数据拖出来,比如在输入框里填 ' UNION SELECT id, username, password FROM users --,让页面回显全部用户信息。
  • 利用 ; DROP TABLE users; -- 这样的批处理语句删除整张表(如果数据库允许堆叠查询)。
  • 通过 1' AND (SELECT SLEEP(5)) -- 这类延时注入来盲猜数据库结构,即使页面没有直接回显错误信息,也能用“是否响应慢了”来判断猜测是否正确。
  • 借助 LOAD_FILE()INTO OUTFILE 等函数读写服务器文件(需要 FILE 权限),甚至写入 WebShell 文件来获取系统控制权。

整个过程逻辑很简单:任何来自外部的输入,只要未经处理就嵌入 SQL 语句,用户就可以通过精心构造的输入,使原本的 SQL 结构发生改变,从而执行任意数据库操作。

23.5.2 从 MySQL 角度看注入的落地保护

在真实的生产环境中,数据库端也有不少机制可以限制注入成功后的破坏程度,这些机制不是防御注入的手段,而是“万一被拿下了,尽量降低损失”的兜底策略。包括但不限于:

  • 最小权限原则:应用程序连接的数据库账号(如 app_user)只授予 SELECT、INSERT、UPDATE、DELETE 这些业务必需的权限,绝不授予 FILESUPERDROP DATABASE 等高危权限。即使被注入了,攻击者也找不到文件写入或删除整个库的权限。
  • sql_mode 中的严格配置:开启 STRICT_TRANS_TABLES 等严格模式,可以让畸形的数据插入直接报错,而不是静默地“修正”后写入,从而更早暴露异常。
  • secure_file_priv 参数:将 secure_file_priv 设置为一个固定的目录(或空字符串表示禁用),可以有效限制 LOAD_FILEINTO OUTFILE 的文件读写范围,防止利用注入操作任意文件。
  • 关闭不必要的错误回显:生产环境中 display_errors 应被关闭,数据库连接的 PDO::ATTR_ERRMODE 等错误模式应设为返回错误码而不是抛出异常信息到客户端。这能防止攻击者通过报错信息获取表名、列名等敏感结构。

这些措施让注入攻击更难“做大”,但并不能从根源上杜绝注入。真正有效的防护必须写在各层代码中。

23.5.3 根本防护方案:参数化查询(预编译语句)

彻底消除 SQL 注入的唯一方法是:永远不要让用户输入参与组成 SQL 语句的代码部分,只允许它作为参数的值进行传递。 这就是参数化查询的本质,也是各大开发框架的首选实践。

在 JDBC 中,使用 PreparedStatement 就是最典型的实现:

String sql = "SELECT * FROM users WHERE username = ? AND password = ?";
PreparedStatement pstmt = connection.prepareStatement(sql);
pstmt.setString(1, username);
pstmt.setString(2, password);
ResultSet rs = pstmt.executeQuery();

这与你手动拼接字符串的核心区别在于:? 占位符仅代表“数据值”,数据库在编译 SQL 时就确定了语句的结构,后续传入的任何内容都只会被当作纯数据,而不会成为 SQL 命令的一部分。即使攻击者传入 admin' --,它也只是被当成 username 字段的一个字符串字面量来搜索,不会变成注释。

在 PHP 的 PDO 扩展中,使用预编译语句也要特别注意,需要将 PDO::ATTR_EMULATE_PREPARES 设为 false,强制使用服务端原生的预处理,否则某些情况下可能还是通过模拟的拼接实现,带来风险。正确写法:

$pdo = new PDO($dsn, $user, $pass, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES => false,
]);
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = :username AND password = :password');
$stmt->execute(['username' => $username, 'password' => $password]);

对于 Python 的 PyMySQL / mysql-connector,也同样应使用 cursor.execute(sql, params) 中的参数绑定,而不是 % 字符串格式化。

在 ORM 框架中,比如 MyBatis,最容易踩坑的是 $# 的区别:

  • #{userId} 会被解析为参数占位符 ?,安全,可以防止注入。
  • ${userId} 则是直接进行字符串替换,也就是拼接 SQL,绝对不能用于外部输入,除非你真的需要动态拼列表名或排序字段,且必须经过严格的白名单校验。

很多注入漏洞,就是因为在 MyBatis 或 Hibernate 里为了“动态 SQL”的方便随手用了 $ 导致的。动态拼接表名、列名时,也必须使用白名单:比如前端传来的排序字段 sortField,代码里只允许匹配 ['id', 'username', 'create_time'] 这几个值,否则拒绝。

23.5.4 多层防御与纵深策略

参数化查询是根,但它只管住了“数据值”的通道。完整的安全防线应当是:

第一层:输入验证与过滤

即使使用了预编译,也应该根据业务规则对输入进行长度、格式、类型的严格校验。例如,手机号只应该包含数字,日期字段必须符合 YYYY-MM-DD 格式,用户名不允许出现分号或注释符。这不仅能捕获恶意输入,还能防范业务垃圾数据的写入。但切勿把过滤黑名单当作主要防御手段——“宁可错杀不可漏过”在这里不成立,注入手法千变万化,过滤逻辑极容易被绕过。

第二层:使用存储过程(需谨慎)

存储过程自身并不具备天然的防注入能力,如果在过程内仍旧拼接动态 SQL 并执行,一样会产生注入。只有当存储过程全部使用参数输入,且内部不进行动态拼接时,它才可以成为一道防御。对于复杂业务,不建议仅依靠存储过程来规避注入。

第三层:Web 应用防火墙(WAF)

在应用前面的网关或反向代理处部署 WAF,能够识别并拦截大量已知的注入恶意请求。不过 WAF 应该视为辅助层,无法覆盖所有的变异payload,而且可能因为规则不当造成误杀。永远不要把 WAF 当成可以替代代码层防御的方案。

第四层:最小化错误信息暴露

线上环境绝不能把数据库的原始错误信息(如表名、列名、SQLStack)直接返回给前端,而应该只展示一个通用的“服务异常”提示。攻击者在探测阶段高度依赖错误回显来构造攻击串,这个通道的关闭可以极大增加攻击成本。

第五层:定期安全审计与代码扫描

防御机制难免有遗漏,应当在 CI/CD 管道中集成静态代码扫描工具,自动检测出大量拼接 SQL 的模式。人工渗透测试和安全审查也要周期性进行,尤其针对登录、搜索、排序、分页、导出等常有动态条件的地方重点检查。

23.5.5 实战总结与自查清单

真正能在日常开发中落地防注入的,不是长篇大论,而是几条强制遵守的习惯:

  • 任何来自用户(包括 URL、POST 表单、HTTP Header、Cookie 等)的输入,若要嵌入 SQL,一律使用参数占位符,绝不拼接。
  • 动态表名、字段名、排序方向等无法使用参数绑定的场景,必须使用白名单校验,不在白名单内的直接拒绝。
  • MyBatis 中禁止对传入的实体参数使用 ${};如果使用了,必须在代码评审中重点记录并说明原因。
  • 所有数据库连接均使用最小权限账号,FILESUPER 等权限在应用账号上应被移除。
  • 定时检查全项目 SQL 语句,确认没有遗漏的拼接点;同时保持数据库版本更新和补丁升级。

SQL 注入是一个彻头彻尾可以避免的问题。它不依赖于高深的算法,只依赖于对“代码与数据分离”这一原则的严格遵守。用好参数化查询,在思想上把每一段外部输入都当敌人看待,你就能从根本上杜绝这个持续了几十年的老漏洞。