跳到主要内容

SQL 注入:数据库防线的生死战

SQL 注入是历史上最古老、却依然最具破坏力的安全漏洞之一。一句话总结它的本质:攻击者通过用户输入,将恶意 SQL 代码"注入"到应用程序的查询中,使数据库执行非预期的操作。

理解 SQL 注入的本质

SQL 注入的核心问题是混淆了"代码"与"数据"的边界。当应用程序将用户输入直接拼接到 SQL 语句中时,数据库无法区分哪些是程序原本的命令,哪些是攻击者注入的恶意代码。

一个经典的例子

假设有如下登录验证代码:

// 危险代码!
const query = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'";

正常情况下,用户输入 adminpassword123,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 语句和数据分离:

  1. SQL 语句先被编译,确定结构
  2. 参数作为纯数据传入,不会被解释为 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.formatJava 字符串格式化 SQL
${MyBatis 字符串替换
$queryRawUnsafePrisma 不安全查询
execute( + 字符串拼接原生 SQL 执行
rawRaw原始 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)]
);

总结

核心原则

  1. 参数化查询是唯一可靠的防御:不要依赖过滤、转义或其他方法
  2. ORM 不等于自动安全:了解你使用的工具的安全边界
  3. 最小权限原则:数据库账户只授予必要权限
  4. 纵深防御:参数化查询 + 输入验证 + 权限控制 + 错误处理

快速检查清单

  • 所有 SQL 查询都使用参数化(预编译语句)
  • 检查 MyBatis 的 ${} 使用情况
  • 检查 ORM 的原生 SQL 方法
  • 动态表名/列名使用白名单验证
  • 数据库账户权限最小化
  • 不向用户暴露数据库错误信息
  • LIKE 查询正确处理通配符
  • 已部署 WAF 作为额外防护层

参考资料