Chapter 03

SQL 注入

理解注入原理,掌握参数化查询——彻底消除最危险的 Web 漏洞之一

注入原理:为什么 SQL 注入会发生

SQL 注入(SQL Injection)的根本原因是:应用程序将用户输入直接拼接进 SQL 语句,导致攻击者可以通过构造特殊输入来改变 SQL 语句的逻辑,甚至执行任意数据库操作。

漏洞代码示例(Python)

# 危险!字符串拼接构造 SQL
def get_user(username):
    query = "SELECT * FROM users WHERE username = '" + username + "'"
    return db.execute(query)

# 正常调用:get_user("alice")
# 生成 SQL:SELECT * FROM users WHERE username = 'alice'
# ✅ 正常工作

# 攻击输入:get_user("' OR '1'='1")
# 生成 SQL:SELECT * FROM users WHERE username = '' OR '1'='1'
# '1'='1' 永远为真 → 返回所有用户记录!

为什么 '1'='1' 能绕过认证

攻击者输入:' OR '1'='1' -- 原始 SQL 模板: SELECT * FROM users WHERE username='[INPUT]' AND password='[PASS]' 注入后的 SQL: SELECT * FROM users WHERE username='' OR '1'='1' --' AND password='xxx' ↑永远为真 ↑注释掉后面的密码验证 逻辑等价于: SELECT * FROM users WHERE TRUE → 返回第一条用户记录,通常是管理员账户

经典注入类型

1. 联合查询注入(UNION-based)

通过 UNION SELECT 将额外的查询结果附加到原始查询,直接在页面上显示数据库内容。

-- 攻击者在搜索框输入:
iPhone' UNION SELECT username, password, email FROM users --

-- 生成的完整 SQL:
SELECT id, name, price FROM products WHERE name='iPhone'
UNION SELECT username, password, email FROM users --'

-- 结果:页面显示产品列表的同时,也显示了所有用户的账号密码!

2. 布尔盲注(Boolean-based Blind)

当应用没有直接显示查询结果,但根据查询是否有结果展示不同页面时,可以通过真/假条件逐位猜测数据。

-- 判断数据库版本第一个字符是否为 '5':
1' AND SUBSTRING(version(),1,1)='5' --
-- 如果页面显示"正常"→ 第一个字符是 '5'
-- 如果页面显示"无结果"→ 不是 '5',继续猜下一个字符

-- 自动化工具 sqlmap 可在几分钟内通过盲注提取完整数据库

3. 时间盲注(Time-based Blind)

当应用对所有输入返回相同响应(无论真假)时,通过数据库的时间延迟函数来推断信息。

-- MySQL 时间盲注(条件为真时延迟 5 秒响应):
1' AND IF(SUBSTRING(password,1,1)='a', SLEEP(5), 0) --

-- 如果响应延迟 5 秒 → 密码第一个字符是 'a'
-- 通过逐字符测试,可以提取任意数据,只是速度较慢

-- 各数据库的延迟函数:
-- MySQL:SLEEP(5)
-- SQL Server:WAITFOR DELAY '0:0:5'
-- PostgreSQL:pg_sleep(5)
-- Oracle:dbms_pipe.receive_message('a',5)

SQL 注入的危害范围

数据泄露
读取任意数据库表的内容,包括用户密码哈希、个人信息、商业机密、信用卡数据。这是最常见的注入危害,历史上无数数据泄露事件的根本原因。
绕过认证
通过使登录 SQL 条件永远为真来绕过密码验证,直接以任意用户(通常是管理员)身份登录。经典 payload:' OR '1'='1admin'--
数据篡改
执行 INSERT、UPDATE、DELETE 语句修改或删除数据库中的记录。如果应用使用的数据库账号有写权限(这本身就是配置错误),危害极大。
执行系统命令
SQL Server 的 xp_cmdshell、MySQL 的 INTO OUTFILE、PostgreSQL 的 COPY TO/FROM PROGRAM 可以在某些配置下执行操作系统命令,实现完整的服务器控制(RCE)。

防御:参数化查询(最有效的防御方案)

参数化查询(Parameterized Queries)/ 预处理语句(Prepared Statements)是防止 SQL 注入的最有效、最根本的方法。其核心思想是:将 SQL 语句结构和数据分开发送——SQL 结构先发送给数据库预编译,数据作为独立参数传入,数据库引擎绝不会将参数内容当作 SQL 代码执行。

Java(JDBC)

// 危险:字符串拼接
String query = "SELECT * FROM users WHERE username = '" + username + "'";

// 安全:参数化查询
String sql = "SELECT * FROM users WHERE username = ?";
try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
    pstmt.setString(1, username);  // 参数以字面量传入,不作为SQL代码
    ResultSet rs = pstmt.executeQuery();
    // 无论 username 包含什么内容,都不会改变 SQL 结构
}

Python(psycopg2 / SQLite)

import psycopg2

conn = psycopg2.connect("dbname=mydb user=myuser")
cur = conn.cursor()

# 危险:格式化字符串
cur.execute(f"SELECT * FROM users WHERE username = '{username}'")

# 安全:使用 %s 占位符(注意:不是 Python 字符串格式化,是 psycopg2 的参数机制)
cur.execute(
    "SELECT * FROM users WHERE username = %s",
    (username,)  # 元组形式传入参数
)

# SQLite 使用 ? 占位符
cur.execute("SELECT * FROM users WHERE username = ?", (username,))

PHP(PDO)

// 危险:字符串拼接(即使 mysql_real_escape_string 也有边缘案例)
$query = "SELECT * FROM users WHERE username = '" . $username . "'";

// 安全:PDO 参数化查询
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :username");
$stmt->bindParam(':username', $username, PDO::PARAM_STR);
$stmt->execute();
$user = $stmt->fetch(PDO::FETCH_ASSOC);

// 或更简洁的方式:
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ?");
$stmt->execute([$username]);

Go(database/sql)

// 危险:fmt.Sprintf 拼接
query := fmt.Sprintf("SELECT * FROM users WHERE username = '%s'", username)

// 安全:使用 $ 占位符
row := db.QueryRow(
    "SELECT id, email FROM users WHERE username = $1",
    username,  // 参数单独传入
)
var id int
var email string
row.Scan(&id, &email)

Node.js(mysql2)

// 危险:模板字符串拼接
const query = `SELECT * FROM users WHERE username = '${username}'`;

// 安全:使用 ? 占位符
const [rows] = await connection.execute(
    'SELECT * FROM users WHERE username = ?',
    [username]  // 参数数组
);

// Knex.js 查询构建器(自动参数化)
const user = await knex('users')
    .where({ username: username })
    .first();

ORM 的安全性

现代 ORM(Sequelize/SQLAlchemy/Hibernate/GORM)默认使用参数化查询,通常比手写 SQL 更安全。但 ORM 也有安全陷阱:

# SQLAlchemy — 安全的 ORM 查询(自动参数化)
user = session.query(User).filter(User.username == username).first()

# 危险!直接传入原始 SQL 字符串(绕过了 ORM 的保护)
user = session.execute(f"SELECT * FROM users WHERE username = '{username}'")

# 如果必须写原始 SQL,使用 text() + 绑定参数
from sqlalchemy import text
result = session.execute(
    text("SELECT * FROM users WHERE username = :name"),
    {"name": username}
)
ORM 的常见安全误区

输入验证与白名单

参数化查询是防注入的第一道防线,输入验证是第二道。即使参数化查询防止了注入,合法的业务输入也应该验证格式,以减少其他类型的攻击面。

import re
from wtforms.validators import ValidationError

# 1. 类型校验(年龄必须是正整数)
def validate_age(age):
    if not isinstance(age, int) or age < 0 or age > 150:
        raise ValueError("年龄必须是 0-150 之间的整数")

# 2. 白名单校验(用户名只允许字母数字下划线)
def validate_username(username):
    if not re.match(r'^[a-zA-Z0-9_]{3,32}$', username):
        raise ValueError("用户名只能包含字母、数字、下划线,长度 3-32")

# 3. ORDER BY 列名白名单(不能参数化,必须白名单)
ALLOWED_SORT_COLUMNS = {'created_at', 'username', 'email'}
def safe_order_by(column):
    if column not in ALLOWED_SORT_COLUMNS:
        raise ValueError(f"不支持按 {column} 排序")
    return column  # 安全的列名,可直接拼入 SQL

最小数据库权限原则

即使发生了注入,如果数据库账号权限足够小,危害也会大幅降低。原则:应用连接数据库时,只授予该应用实际需要的最小权限

-- 为应用创建专用数据库账号(不要用 root!)

-- 只读应用(如公开展示的博客)
CREATE USER 'blog_reader'@'localhost' IDENTIFIED BY 'strong_password';
GRANT SELECT ON blog_db.posts TO 'blog_reader'@'localhost';

-- 业务应用(读写,但不能修改表结构)
CREATE USER 'app_user'@'10.0.0.%' IDENTIFIED BY 'strong_password';
GRANT SELECT, INSERT, UPDATE, DELETE ON myapp.* TO 'app_user'@'10.0.0.%';
-- 不授予:DROP, CREATE, ALTER, EXECUTE(防止攻击者删表/执行存储过程)
-- 不授予:SUPER, FILE(防止写入文件系统)
-- 不授予:xp_cmdshell(SQL Server,彻底禁用系统命令执行)
防御 SQL 注入的完整清单
本章小结

SQL 注入的根本原因是将用户输入拼接进 SQL 语句。防御的核心是参数化查询——通过将 SQL 结构和数据分离,使攻击者的输入永远被当作字面量数据处理,而不能改变 SQL 逻辑。配合输入验证白名单和最小权限原则,可以在多个层面阻断注入攻击。记住:任何"转义"方案(addslashesmysql_escape_string)都不如参数化查询可靠,应避免作为主要防御手段。