SQL 注入:数据库防线的生死战
SQL 注入是历史上最古老、却依然最具破坏力的安全漏洞之一。一句话总结它的本质:攻击者通过用户输入,将恶意 SQL 代码"注入"到应用程序的查询中,使数据库执行非预期的操作。
理解 SQL 注入的本质
SQL 注入的核心问题是混淆了"代码"与"数据"的边界。当应用程序将用户输入直接拼接到 SQL 语句中时,数据库无法区分哪些是程序原本的命令,哪些是攻击者注入的恶意代码。
一个经典的例子
假设有如下登录验证代码:
// 危险代码!
const query = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'";
正常情况下,用户输入 admin 和 password123,SQL 变为:
SELECT * FROM users WHERE username = 'admin' AND password = 'password123'
但攻击者输入 admin'-- 作为用户名:
SELECT * FROM users WHERE username = 'admin'--' AND password = 'xxx'
-- 是 SQL 注释符,后面的密码验证被注释掉,攻击者无需密码即可登录 admin 账户。
更危险的例子
攻击者输入 ' OR '1'='1 作为用户名:
SELECT * FROM users WHERE username = '' OR '1'='1' AND password = 'xxx'
由于 '1'='1' 永远为真,这个查询会返回表中的所有用户,攻击者通常能获得管理员账户。
SQL 注入的类型
1. 布尔盲注(Boolean-based Blind)
当应用只返回"存在"或"不存在"两种结果时,攻击者通过构造条件逐位猜测数据。
攻击示例:
-- 猜测数据库名第一个字符是否为 'a'
SELECT * FROM users WHERE id = 1 AND SUBSTRING(DATABASE(), 1, 1) = 'a'
-- 如果返回结果,说明第一个字符是 'a'
-- 继续猜测下一个字符...
SELECT * FROM users WHERE id = 1 AND SUBSTRING(DATABASE(), 2, 1) = 'b'
2. 时间盲注(Time-based Blind)
当应用完全不返回任何可区分信息时,攻击者利用延时函数通过响应时间判断条件是否成立。
攻击示例:
-- MySQL
SELECT * FROM users WHERE id = 1 AND IF(SUBSTRING(DATABASE(), 1, 1) = 'a', SLEEP(5), 0)
-- 如果页面响应延迟 5 秒,说明条件成立
-- PostgreSQL
SELECT * FROM users WHERE id = 1 AND (SELECT CASE WHEN SUBSTRING(DATABASE(), 1, 1) = 'a' THEN pg_sleep(5) END)
-- SQL Server
SELECT * FROM users WHERE id = 1; IF SUBSTRING(DB_NAME(), 1, 1) = 'a' WAITFOR DELAY '0:0:5'
3. 报错注入(Error-based)
利用数据库的错误信息泄露数据。
攻击示例:
-- MySQL UpdateXML 报错注入
SELECT * FROM users WHERE id = 1 AND UPDATEXML(1, CONCAT(0x7e, (SELECT DATABASE()), 0x7e), 1)
-- 错误信息中会显示数据库名
-- XPATH syntax error: '~mydatabase~'
4. 联合查询注入(Union-based)
利用 UNION 操作符将恶意查询结果与正常查询结果合并。
攻击示例:
-- 首先确定列数
SELECT * FROM users WHERE id = 1 ORDER BY 1 -- 成功
SELECT * FROM users WHERE id = 1 ORDER BY 2 -- 成功
SELECT * FROM users WHERE id = 1 ORDER BY 3 -- 失败,说明有 2 列
-- 获取数据库信息
SELECT * FROM users WHERE id = -1 UNION SELECT DATABASE(), USER()
-- 获取所有表名
SELECT * FROM users WHERE id = -1 UNION SELECT table_name, NULL FROM information_schema.tables WHERE table_schema = DATABASE()
-- 获取用户数据
SELECT * FROM users WHERE id = -1 UNION SELECT username, password FROM users
5. 堆叠查询(Stacked Queries)
在某些情况下,可以执行多条独立的 SQL 语句。
攻击示例:
-- MySQL(需要特定配置)
SELECT * FROM users WHERE id = 1; DROP TABLE users;--
-- SQL Server(通常支持)
SELECT * FROM users WHERE id = 1; INSERT INTO users(username, password) VALUES('hacker', 'hacked')
现代架构下的 SQL 注入风险
ORM 并非万无一失
很多开发者认为使用 ORM(如 MyBatis、Hibernate、TypeORM、Prisma)就能完全避免 SQL 注入,这是一个危险的误解。
MyBatis 中的陷阱
MyBatis 提供两种参数替换方式:
<!-- 安全:#{} 使用预编译 -->
<select id="getUser" resultType="User">
SELECT * FROM users WHERE id = #{id}
</select>
<!-- 危险:${} 直接拼接字符串 -->
<select id="searchUsers" resultType="User">
SELECT * FROM users WHERE name LIKE '%${keyword}%'
</select>
#{} vs ${}:
| 特性 | #{} | ${} |
|---|---|---|
| 处理方式 | 预编译参数 | 直接替换 |
| SQL 注入 | 安全 | 危险 |
| 适用场景 | 数据值 | 表名、列名、ORDER BY |
ORDER BY 的安全处理:
<!-- 危险 -->
<select id="listUsers" resultType="User">
SELECT * FROM users ORDER BY ${orderColumn}
</select>
<!-- 安全:使用白名单验证 -->
<select id="listUsers" resultType="User">
SELECT * FROM users
<choose>
<when test="orderColumn == 'name'">ORDER BY name</when>
<when test="orderColumn == 'create_time'">ORDER BY create_time</when>
<otherwise>ORDER BY id</otherwise>
</choose>
</select>
TypeORM 中的危险用法
// 危险:使用字符串拼接
const users = await repository.query(
`SELECT * FROM users WHERE name = '${name}'`
);
// 安全:使用参数化查询
const users = await repository.query(
'SELECT * FROM users WHERE name = ?',
[name]
);
// 安全:使用 QueryBuilder
const users = await repository
.createQueryBuilder('user')
.where('user.name = :name', { name })
.getMany();
Prisma 的安全性
Prisma 默认是安全的,因为它使用参数化查询:
// 安全:Prisma 自动参数化
const user = await prisma.user.findFirst({
where: { name: userInput }
});
// 危险:使用 $queryRaw 时需要小心
const users = await prisma.$queryRaw`SELECT * FROM users WHERE name = ${userInput}`;
// 使用 Prisma.sql 模板标签是安全的
// 危险:字符串拼接
const users = await prisma.$queryRawUnsafe(
`SELECT * FROM users WHERE name = '${userInput}'`
);
NoSQL 注入
不要以为 NoSQL 数据库就没有注入风险。
MongoDB 注入
// 危险:直接使用用户输入
db.users.find({ username: req.body.username, password: req.body.password });
// 攻击者发送 JSON:
// { "username": "admin", "password": { "$ne": "" } }
// 相当于:password != "",绕过密码验证
安全写法:
// 安全:验证输入类型
if (typeof req.body.username !== 'string' || typeof req.body.password !== 'string') {
return res.status(400).send('Invalid input');
}
db.users.find({
username: req.body.username,
password: req.body.password
});
// 或使用 $eq 显式指定相等比较
db.users.find({
username: { $eq: req.body.username },
password: { $eq: req.body.password }
});
$where 子句注入
// 极其危险:$where 允许执行 JavaScript
db.users.find({
$where: `this.username == '${userInput}'`
});
// 攻击输入:' || this.password.charAt(0) == 'a
// 可以逐字符猜测密码
安全原则:永远不要在 $where 子句中使用用户输入。
防御措施
核心策略:参数化查询(预编译语句)
参数化查询是防御 SQL 注入的黄金标准。它将 SQL 语句和数据分离:
- SQL 语句先被编译,确定结构
- 参数作为纯数据传入,不会被解释为 SQL
工作原理:
普通查询: SELECT * FROM users WHERE name = '数据'
数据可能改变 SQL 结构
参数化查询: SELECT * FROM users WHERE name = ?
? 是占位符,数据只是数据
各语言实现
JavaScript (Node.js)
// mysql/mysql2
const connection = mysql.createConnection({...});
// 安全:使用 ? 占位符
connection.query(
'SELECT * FROM users WHERE id = ? AND status = ?',
[userId, status],
(err, results) => { /* ... */ }
);
// 安全:命名参数(mysql2)
connection.execute(
'SELECT * FROM users WHERE id = :id AND status = :status',
{ id: userId, status: status },
(err, results) => { /* ... */ }
);
// pg (PostgreSQL)
const { Pool } = require('pg');
const pool = new Pool({...});
// 安全:使用 $1, $2 占位符
const result = await pool.query(
'SELECT * FROM users WHERE id = $1 AND status = $2',
[userId, status]
);
// Sequelize ORM
const users = await User.findAll({
where: {
id: userId,
status: status
}
});
Python
import sqlite3
import psycopg2
from sqlalchemy import create_engine, text
# sqlite3
conn = sqlite3.connect('database.db')
cursor = conn.cursor()
# 安全:使用 ? 占位符
cursor.execute('SELECT * FROM users WHERE id = ?', (user_id,))
# psycopg2 (PostgreSQL)
conn = psycopg2.connect(...)
cursor = conn.cursor()
# 安全:使用 %s 占位符
cursor.execute('SELECT * FROM users WHERE id = %s', (user_id,))
# SQLAlchemy
engine = create_engine('postgresql://...')
with engine.connect() as conn:
# 安全:使用 text() 和命名参数
result = conn.execute(
text('SELECT * FROM users WHERE id = :id'),
{'id': user_id}
)
# Django ORM(自动安全)
user = User.objects.get(id=user_id)
Java
import java.sql.*;
import org.springframework.jdbc.core.JdbcTemplate;
// JDBC 原生
String sql = "SELECT * FROM users WHERE id = ? AND status = ?";
PreparedStatement stmt = connection.prepareStatement(sql);
stmt.setInt(1, userId);
stmt.setString(2, status);
ResultSet rs = stmt.executeQuery();
// Spring JdbcTemplate
@Autowired
JdbcTemplate jdbcTemplate;
// 安全:使用 ? 占位符
List<User> users = jdbcTemplate.query(
"SELECT * FROM users WHERE id = ?",
new Object[]{userId},
(rs, rowNum) -> new User(rs.getLong("id"), rs.getString("name"))
);
// Spring Data JPA(自动安全)
Optional<User> user = userRepository.findById(userId);
// MyBatis
// XML: <select id="getUser">SELECT * FROM users WHERE id = #{id}</select>
@Select("SELECT * FROM users WHERE id = #{id}")
User getUser(@Param("id") Long id);
Go
import (
"database/sql"
_ "github.com/go-sql-driver/mysql"
)
// database/sql
db, err := sql.Open("mysql", "user:password@/dbname")
// 安全:使用 ? 占位符
rows, err := db.Query("SELECT * FROM users WHERE id = ?", userId)
// 查询单行
var user User
err = db.QueryRow("SELECT id, name FROM users WHERE id = ?", userId).Scan(&user.ID, &user.Name)
// GORM(自动安全)
var user User
db.First(&user, userId) // WHERE id = ?
// 原生 SQL
var users []User
db.Raw("SELECT * FROM users WHERE id = ?", userId).Scan(&users)
PHP
// PDO
$pdo = new PDO('mysql:host=localhost;dbname=mydb', 'user', 'password');
// 安全:使用命名参数
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id');
$stmt->execute(['id' => $userId]);
// 安全:使用 ? 占位符
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([$userId]);
// Laravel Eloquent(自动安全)
$user = User::find($userId);
// Laravel Query Builder
$user = DB::table('users')->where('id', $userId)->first();
// 危险:直接拼接(绝对不要这样做)
$sql = "SELECT * FROM users WHERE id = " . $userId; // 危险!
辅助防御措施
1. 输入验证
虽然不能替代参数化查询,但输入验证可以减少攻击面:
function validateId(id) {
// ID 应该是正整数
const num = parseInt(id, 10);
if (isNaN(num) || num <= 0) {
throw new Error('Invalid ID');
}
return num;
}
function validateUsername(username) {
// 用户名只允许字母数字下划线,长度 3-20
if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
throw new Error('Invalid username format');
}
return username;
}
2. 最小权限原则
数据库账户只应拥有必要的权限:
-- 应用账户权限(推荐)
GRANT SELECT, INSERT, UPDATE ON mydb.users TO 'app_user'@'%';
GRANT SELECT ON mydb.products TO 'app_user'@'%';
-- 不授予 DROP, ALTER, CREATE 等权限
-- 管理员账户(仅限管理操作)
GRANT ALL PRIVILEGES ON mydb.* TO 'admin'@'localhost';
3. 错误信息处理
不要向用户暴露数据库错误:
// 危险:暴露详细错误
app.get('/users/:id', async (req, res) => {
try {
const user = await getUser(req.params.id);
res.json(user);
} catch (err) {
res.status(500).send(err.message); // 可能泄露表结构
}
});
// 安全:通用错误消息
app.get('/users/:id', async (req, res) => {
try {
const user = await getUser(req.params.id);
res.json(user);
} catch (err) {
console.error(err); // 服务端记录详细日志
res.status(500).json({ error: '服务器内部错误' });
}
});
4. Web 应用防火墙(WAF)
WAF 可以检测和阻止常见的 SQL 注入攻击,但不应作为主要防御手段:
# ModSecurity 规则示例
SecRule ARGS "@rx (?i:union.*select|select.*from|insert.*into|delete.*from)" \
"id:1001,phase:2,deny,status:403,msg:'SQL Injection Detected'"
检测与测试
代码审查清单
搜索以下模式,检查是否存在 SQL 注入风险:
| 模式 | 说明 |
|---|---|
" + 或 ' + | 字符串拼接 SQL |
String.format | Java 字符串格式化 SQL |
${ | MyBatis 字符串替换 |
$queryRawUnsafe | Prisma 不安全查询 |
execute( + 字符串拼接 | 原生 SQL 执行 |
raw 或 Raw | 原始 SQL 查询 |
自动化测试工具
# SQLMap - 自动化 SQL 注入检测
sqlmap -u "https://example.com/users?id=1" --batch
# OWASP ZAP - 综合 Web 安全扫描
zap-baseline.py -t https://example.com
# SAST 工具
# SonarQube, Checkmarx, Fortify 等
手动测试 Payload
# 基础测试
1
1'
1"
1 OR 1=1
1' OR '1'='1
1" OR "1"="1
# 注释符测试
1'--
1'#
1'/*
1';--
# UNION 测试
1 UNION SELECT NULL--
1 UNION SELECT NULL, NULL--
1 UNION SELECT NULL, NULL, NULL--
# 时间盲注测试
1' AND SLEEP(5)--
1'; WAITFOR DELAY '0:0:5'--
高级场景处理
动态表名/列名
当需要动态指定表名或列名时,不能使用参数化查询,需要白名单验证:
const allowedTables = ['users', 'products', 'orders'];
const allowedColumns = {
users: ['id', 'name', 'email', 'created_at'],
products: ['id', 'name', 'price', 'stock'],
orders: ['id', 'user_id', 'total', 'status']
};
function buildQuery(tableName, columnName, id) {
// 白名单验证
if (!allowedTables.includes(tableName)) {
throw new Error('Invalid table name');
}
if (!allowedColumns[tableName]?.includes(columnName)) {
throw new Error('Invalid column name');
}
// 安全:表名和列名已通过白名单验证
// id 使用参数化
return {
sql: `SELECT ${columnName} FROM ${tableName} WHERE id = ?`,
params: [id]
};
}
动态 ORDER BY
const allowedOrderColumns = ['name', 'created_at', 'price'];
const allowedOrderDirections = ['ASC', 'DESC'];
function buildOrderQuery(orderColumn, orderDirection) {
if (!allowedOrderColumns.includes(orderColumn)) {
orderColumn = 'id'; // 默认值
}
if (!allowedOrderDirections.includes(orderDirection.toUpperCase())) {
orderDirection = 'ASC';
}
return `ORDER BY ${orderColumn} ${orderDirection}`;
}
LIKE 查询
// 用户输入可能包含 LIKE 通配符
function safeLikeSearch(keyword) {
// 转义 LIKE 通配符
const escaped = keyword.replace(/[%_]/g, '\\$&');
return `%${escaped}%`;
}
// 使用
const users = await db.query(
'SELECT * FROM users WHERE name LIKE ? ESCAPE "\\\\"',
[safeLikeSearch(userInput)]
);
总结
核心原则
- 参数化查询是唯一可靠的防御:不要依赖过滤、转义或其他方法
- ORM 不等于自动安全:了解你使用的工具的安全边界
- 最小权限原则:数据库账户只授予必要权限
- 纵深防御:参数化查询 + 输入验证 + 权限控制 + 错误处理
快速检查清单
- 所有 SQL 查询都使用参数化(预编译语句)
- 检查 MyBatis 的
${}使用情况 - 检查 ORM 的原生 SQL 方法
- 动态表名/列名使用白名单验证
- 数据库账户权限最小化
- 不向用户暴露数据库错误信息
- LIKE 查询正确处理通配符
- 已部署 WAF 作为额外防护层