SQL 注入防范
导学
SQL 注入(SQL Injection)是 Web 安全领域中危害最大、最常见的攻击方式之一。它让攻击者能在你的 SQL 语句中插入恶意代码,从而窃取、篡改或删除数据。本节从原理到防御,彻底讲透 SQL 注入。
定义
SQL 注入:攻击者通过在用户输入中插入恶意 SQL 片段,使应用程序执行非预期的数据库操作。其本质是用户输入被当作代码执行。
建立测试环境
为了完整演示 SQL 注入的危害和防范方法,首先建立统一的测试数据库环境。
-- 创建测试数据库
CREATE DATABASE IF NOT EXISTS app_db CHARACTER SET utf8mb4;
USE app_db;
-- 创建员工表
CREATE TABLE employees (
emp_id INT AUTO_INCREMENT PRIMARY KEY,
emp_name VARCHAR(20),
dept VARCHAR(20),
score DECIMAL(5,2)
);
-- 创建分数表
CREATE TABLE scores (
score_id INT PRIMARY KEY AUTO_INCREMENT,
emp_id INT,
score DECIMAL(5,2)
);
-- 插入测试数据
INSERT INTO employees (emp_name, dept, score) VALUES
('大翔', '技术部', 100),
('白歌', '技术部', NULL);
INSERT INTO scores (emp_id, score) VALUES
(1, 90),
(2, 85);
当前数据状态(employees 表):
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 1 | 大翔 | 技术部 | 100 |
| 2 | 白歌 | 技术部 | NULL |
当前数据状态(scores 表):
| score_id | emp_id | score |
|---|---|---|
| 1 | 1 | 90 |
| 2 | 2 | 85 |
经典注入案例
场景一:绕过登录验证
正常登录场景:
应用程序拼接的 SQL(假设用户输入 大翔 和 技术部):
SELECT * FROM employees WHERE emp_name = '大翔' AND dept = '技术部';
正常查询结果:
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 1 | 大翔 | 技术部 | 100 |
结果:返回 1 条记录,查询成功,验证通过。
攻击者注入场景:
攻击者输入:
- 用户名:
' OR '1'='1 - 部门:
' OR '1'='1
应用程序拼接后的 SQL:
SELECT * FROM employees WHERE emp_name = '' OR '1'='1' AND dept = '' OR '1'='1';
注入攻击结果:
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 1 | 大翔 | 技术部 | 100 |
| 2 | 白歌 | 技术部 | NULL |
结果:返回 2 条记录(全表数据),攻击者无需知道任何真实数据即可绕过验证。
结果解读:
'1'='1'是一个永远为真的条件,导致WHERE子句失去过滤作用。AND的优先级高于OR,但攻击者构造的输入使逻辑变为(emp_name='' OR '1'='1') AND (dept='' OR '1'='1'),由于'1'='1'为真,整个条件恒真。- 如果应用程序取返回结果的第一条记录作为登录用户,攻击者可能直接以
admin身份登录。
场景二:数据窃取(UNION 注入)
正常查询场景:
应用程序拼接的 SQL(用户输入 2):
SELECT * FROM scores WHERE emp_id = 2;
正常查询结果:
| score_id | emp_id | score |
|---|---|---|
| 2 | 2 | 85 |
结果:返回 1 条记录,只看到当前员工的分数。
攻击者注入场景:
攻击者输入:2 UNION SELECT emp_id, emp_name, score FROM employees
应用程序拼接后的 SQL:
SELECT * FROM scores WHERE emp_id = 2
UNION
SELECT emp_id, emp_name, score FROM employees;
注入攻击结果:
| score_id | emp_id | score |
|---|---|---|
| 2 | 2 | 85 |
| 1 | 大翔 | 100 |
| 2 | 白歌 | NULL |
结果:攻击者不仅看到了当前员工的分数,还通过 UNION 注入窃取了 employees 表中的全部员工数据。
结果解读:
UNION要求前后两个 SELECT 语句的列数相同。攻击者通过猜测或试错,使第二个查询的列数与第一个查询一致。- 两个 SELECT 语句的列数相同(都是 3 列),因此 UNION 可以成功执行。
- 这种攻击不需要知道表结构,攻击者可以通过多次试探获取数据库的完整信息(表名、列名、数据)。
场景三:数据删除(毁灭性注入)
正常删除场景:
应用程序拼接的 SQL(用户输入 2):
DELETE FROM scores WHERE score_id = 2;
执行前数据:
| score_id | emp_id | score |
|---|---|---|
| 1 | 1 | 90 |
| 2 | 2 | 85 |
执行后数据:
SELECT * FROM scores;
| score_id | emp_id | score |
|---|---|---|
| 1 | 1 | 90 |
结果:score_id = 2 的记录被正常删除。
攻击者注入场景:
攻击者输入:1; DROP TABLE scores; --
应用程序拼接后的 SQL:
DELETE FROM scores WHERE score_id = 1; DROP TABLE scores; -- ;
注入攻击结果:
SELECT * FROM scores;
结果:
ERROR 1146 (42S02): Table 'app_db.scores' doesn't exist
结果:
score_id = 1的记录被删除(攻击者指定的第一个语句)。scores表被完全删除,数据永久丢失。--是 MySQL 的注释符,其后的内容被忽略,避免了语法错误。
结果解读:
- 分号
;在 MySQL 中用于分隔多条 SQL 语句。如果应用程序未限制单条语句执行,攻击者可以注入额外的破坏性命令。 - 这种攻击可能导致数据表被删除、数据被篡改,甚至整个数据库被清空。
- 防范的关键在于:永远不要让用户输入有机会被解析为 SQL 代码。
防范方法
方法一:参数化查询(PreparedStatement)—— 唯一正确的做法
原理:参数化查询将 SQL 语句和参数分开传递给数据库。数据库先编译 SQL 模板,再将参数作为纯数据绑定,参数永远不会被解析为 SQL 代码。
Java 示例(正确做法):
// 正确:使用 PreparedStatement,参数作为纯数据处理
String sql = "SELECT * FROM employees WHERE emp_name = ? AND dept = ?";
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setString(1, "' OR '1'='1"); // 即使输入包含恶意字符,也只是普通字符串
pstmt.setString(2, "' OR '1'='1");
ResultSet rs = pstmt.executeQuery();
MySQL 层面验证(模拟参数化查询效果):
-- 使用 PREPARE 语句模拟参数化查询
PREPARE stmt FROM 'SELECT * FROM employees WHERE emp_name = ? AND dept = ?';
SET @user = ''' OR ''1''=''1';
SET @dept = ''' OR ''1''=''1';
EXECUTE stmt USING @user, @dept;
参数化查询结果:
结果:返回 0 条记录(空结果集)。
-- 清理
DEALLOCATE PREPARE stmt;
结果解读:
- 当输入
' OR '1'='1被作为参数绑定时,数据库将其视为一个完整的字符串值,而不是 SQL 表达式。 - 实际执行的等效查询为:
SELECT * FROM employees WHERE emp_name = "' OR '1'='1" AND dept = "' OR '1'='1"。 - 数据库中不存在员工名为
' OR '1'='1且部门为' OR '1'='1的记录,因此返回空结果,查询失败。 - 这是防范 SQL 注入的唯一正确方法,所有其他方法都只是辅助手段。
方法二:存储过程(辅助手段)
存储过程如果内部使用参数化查询,可以提供额外的安全层。
第一步:创建安全的登录存储过程
DELIMITER //
CREATE PROCEDURE SafeQuery(IN p_emp_name VARCHAR(20), IN p_dept VARCHAR(20))
BEGIN
-- 存储过程内部仍然使用参数化的 SELECT,不拼接字符串
SELECT * FROM employees WHERE emp_name = p_emp_name AND dept = p_dept;
END //
DELIMITER ;
第二步:使用正常参数调用
CALL SafeQuery('大翔', '技术部');
正常调用结果:
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 1 | 大翔 | 技术部 | 100 |
第三步:使用恶意参数调用
CALL SafeQuery(''' OR ''1''=''1', ''' OR ''1''=''1');
恶意调用结果:
结果:返回 0 条记录(空结果集)。
结果解读:
- 存储过程的输入参数
p_emp_name和p_dept被数据库视为纯数据。 - 即使存储过程中直接写
WHERE emp_name = p_emp_name,数据库内部仍然按参数绑定处理。 - 警告:如果存储过程内部拼接 SQL(如使用
CONCAT构建动态 SQL 再执行),同样会存在注入风险。
第四步:创建不安全的存储过程(反面教材)
DELIMITER //
CREATE PROCEDURE UnsafeQuery(IN p_emp_name VARCHAR(20), IN p_dept VARCHAR(20))
BEGIN
SET @sql = CONCAT('SELECT * FROM employees WHERE emp_name = ''', p_emp_name, ''' AND dept = ''', p_dept, '''');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END //
DELIMITER ;
第五步:用恶意参数调用不安全的存储过程
CALL UnsafeQuery(''' OR ''1''=''1', ''' OR ''1''=''1');
不安全调用结果:
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 1 | 大翔 | 技术部 | 100 |
| 2 | 白歌 | 技术部 | NULL |
结果解读:
UnsafeQuery存储过程内部使用CONCAT拼接 SQL 字符串,与应用程序拼接 SQL 没有本质区别。- 攻击者的恶意输入被直接嵌入到 SQL 中,再次成功绕过验证,返回了全部用户数据。
- 结论:存储过程本身不能防止注入,只有在存储过程内部也使用参数化查询时才安全。
方法三:最小权限原则(数据库层面防御)
即使应用程序存在注入漏洞,数据库用户的权限最小化可以极大降低损失。
第一步:创建不同权限的数据库用户
-- 创建只读用户
CREATE USER 'readonly_app'@'localhost' IDENTIFIED BY 'ReadOnly123!';
GRANT SELECT ON app_db.* TO 'readonly_app'@'localhost';
-- 创建读写用户(无删表权限)
CREATE USER 'readwrite_app'@'localhost' IDENTIFIED BY 'ReadWrite123!';
GRANT SELECT, INSERT, UPDATE, DELETE ON app_db.* TO 'readwrite_app'@'localhost';
-- 查看权限
SHOW GRANTS FOR 'readonly_app'@'localhost';
SHOW GRANTS FOR 'readwrite_app'@'localhost';
第二步:模拟只读用户遭受注入攻击
使用 readonly_app 用户连接数据库,尝试执行注入语句:
-- 以 readonly_app 身份执行
USE app_db;
-- 尝试 UNION 注入(只读权限下可以执行)
SELECT * FROM scores WHERE emp_id = 2
UNION
SELECT emp_id, emp_name, score FROM employees;
只读用户注入结果:
| score_id | emp_id | score |
|---|---|---|
| 2 | 2 | 85 |
| 1 | 大翔 | 100 |
| 2 | 白歌 | NULL |
-- 尝试 DROP TABLE 注入(只读权限下执行失败)
DROP TABLE scores;
结果:
ERROR 1142 (42000): DROP command denied to user 'readonly_app'@'localhost' for table 'scores'
第三步:模拟读写用户遭受注入攻击
使用 readwrite_app 用户连接数据库:
-- 以 readwrite_app 身份执行
USE app_db;
-- 尝试 DELETE 注入(可以执行,造成数据丢失)
DELETE FROM scores WHERE score_id = 1;
-- 尝试 DROP TABLE 注入(无权限,执行失败)
DROP TABLE scores;
结果:
ERROR 1142 (42000): DROP command denied to user 'readwrite_app'@'localhost' for table 'scores'
结果解读:
readonly_app用户虽然可以执行SELECT注入(数据泄露),但无法执行DROP TABLE、TRUNCATE等破坏性操作。readwrite_app用户可以执行DELETE注入造成数据丢失,但仍然无法删除表结构。- 如果应用程序使用
root用户连接数据库,注入攻击者将拥有最高权限,可以执行任意操作(包括DROP DATABASE)。 - 最小权限原则是 SQL 注入防范的重要辅助防线:即使应用层被突破,数据库层也能限制损失范围。
方法四:输入验证和转义(不可靠,不推荐单独使用)
// 错误示范:手动转义,容易遗漏
emp_name = emp_name.replace("'", "\\'");
MySQL 层面验证转义的局限性:
-- 假设应用程序将单引号转义为 \'
SET @escaped_input = '\\' OR \\'1\\'=\\'1';
-- 某些场景下,转义仍然可能被绕过(如宽字节注入)
-- 这是不推荐依赖手动转义的根本原因
结果解读:
- 手动转义容易被绕过:宽字节注入、Unicode 编码、注释符
--、#、多语句执行;等技巧都可能使转义失效。 - 不同的数据库驱动、字符集、SQL Mode 下,转义规则可能不同。
- 永远不要依赖手动转义,参数化查询是唯一可靠的防御方式。
方法五:启用安全的 SQL Mode
-- 查看当前 SQL Mode
SELECT @@sql_mode;
-- 设置安全的 SQL Mode(禁止某些危险行为)
SET GLOBAL sql_mode = 'STRICT_TRANS_TABLES,NO_BACKSLASH_ESCAPES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO';
验证 NO_BACKSLASH_ESCAPES 的安全意义:
-- 默认模式下,反斜杠是转义字符
SET SESSION sql_mode = '';
SELECT '\\'' AS test;
结果:
| test |
|---|
| ' |
-- 开启 NO_BACKSLASH_ESCAPES 后
SET SESSION sql_mode = 'NO_BACKSLASH_ESCAPES';
SELECT '\\'' AS test;
结果:
| test |
|---|
| ' |
注意:在
NO_BACKSLASH_ESCAPES模式下,反斜杠不再作为转义字符,这可以防止某些利用反斜杠的注入技巧。
结果解读:
NO_BACKSLASH_ESCAPES使反斜杠\失去转义能力,防止攻击者利用\'等技巧绕过转义。- 但 SQL Mode 只是辅助措施,不能替代参数化查询。
SQL 注入防范清单
| 必须做 | 绝不要做 |
|---|---|
| 使用参数化查询(PreparedStatement) | 字符串拼接 SQL |
| 最小权限原则(应用账号只给必要权限) | 应用使用 root 账号 |
| 密码哈希存储(bcrypt/SHA-256 + salt) | 明文存储密码 |
启用 SQL Mode:NO_BACKSLASH_ESCAPES | 信任任何用户输入 |
| 日志审计异常查询 | 在生产环境显示详细错误信息 |
常见误区
| 误区 | 正解 |
|---|---|
| "我用的是框架,不会有注入" | 框架提供了安全方法,但如果开发者使用字符串拼接(如某些 ORM 的 raw() 方法),仍然会注入。 |
| "存储过程能完全防止注入" | 存储过程内部如果拼接 SQL,同样会注入。 |
| "我只是内部系统,不需要防注入" | 内部系统同样有被渗透的风险,且内部数据往往更敏感。 |
面试考点
Q:什么是 SQL 注入?如何防范?
SQL 注入是攻击者通过输入恶意 SQL 片段,让应用执行非预期数据库操作的攻击。唯一正确的防范方法是参数化查询(PreparedStatement),将 SQL 和数据分离。辅助措施包括:最小权限原则、密码哈希、输入校验、WAF。
Q:参数化查询为什么能防止注入?
数据库驱动将 SQL 模板和参数分两次发送。数据库先解析和编译 SQL 模板,参数随后作为纯数据绑定,永远不会被当作 SQL 代码解析。即使参数中包含
' OR '1'='1,也只会被当作普通字符串值处理。
Q:MySQL 的 NO_BACKSLASH_ESCAPES SQL Mode 有什么安全意义?
默认情况下
\'会被视为转义的单引号。开启NO_BACKSLASH_ESCAPES后,反斜杠不再作为转义字符,这可以防止某些利用反斜杠的注入技巧。
小结
- SQL 注入是用户输入被当作代码执行的攻击
- 参数化查询是唯一正确的防御方式
- 最小权限原则、密码哈希、输入校验是辅助防线
- 永远不要拼接 SQL 字符串,永远不要信任用户输入
下一章引子:安全讲完了,接下来进入数据库性能的核心——如何发现慢查询、分析执行计划、优化索引和 SQL。