SQL 注入防护
概述
SQL 注入是 Web 应用最严重的安全漏洞之一。攻击者通过在输入中嵌入恶意 SQL 代码,可以读取、修改、删除数据库中的数据,甚至获取服务器控制权。PHP 预处理语句是防护 SQL 注入的最有效手段。
危险等级
SQL 注入属于 OWASP Top 10 中的 A03:2021 — Injection 类别,可能造成数据泄露、数据篡改、权限提升和服务器沦陷。
基础概念
注入原理
SQL 注入利用的是应用程序将用户输入直接拼接到 SQL 语句中执行。攻击者通过精心构造的输入改变 SQL 的语义。
php
<?php
// 漏洞代码 — 直接拼接 SQL
$id = $_GET['id'];
$sql = "SELECT * FROM users WHERE id = {$id}";
// 攻击者输入: 1 OR 1=1
// 实际执行: SELECT * FROM users WHERE id = 1 OR 1=1
// 结果: 返回所有用户数据
// 更危险的注入
$id = $_GET['id'];
$sql = "SELECT * FROM users WHERE id = {$id}";
// 攻击者输入: 1; DROP TABLE users; --
// 实际执行: SELECT * FROM users WHERE id = 1; DROP TABLE users; --
// 结果: 删除整个用户表常见注入点
| 注入点 | 攻击方式 | 危害 |
|---|---|---|
| WHERE 子句 | id = 1 OR 1=1 | 绕过认证、数据泄露 |
| ORDER BY | ORDER BY 1-- | 列数枚举 |
| UNION 注入 | UNION SELECT ... | 跨表数据窃取 |
| INSERT 注入 | 恶意注册、数据篡改 | 写入恶意数据 |
| UPDATE 注入 | 修改管理员密码 | 权限提升 |
| DELETE 注入 | 删除数据 | 数据破坏 |
语法与代码
预处理语句防护(PDO)
php
<?php
declare(strict_types=1);
// PDO 预处理语句 — 最安全的防护方式
$pdo = new PDO('mysql:host=localhost;dbname=app', 'user', 'pass');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// 安全: 使用预处理语句
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id');
$stmt->execute(['id' => $_GET['id']]);
// 安全: 命名占位符
$stmt = $pdo->prepare('
SELECT * FROM users
WHERE username = :username AND password = :password
');
$stmt->execute([
':username' => $_POST['username'],
':password' => $_POST['password'],
]);
// 安全: 问号占位符
$stmt = $pdo->prepare('SELECT * FROM products WHERE category = ? AND price > ?');
$stmt->execute([$_GET['category'], $_GET['min_price']]);预处理语句防护(MySQLi)
php
<?php
declare(strict_types=1);
$mysqli = new mysqli('localhost', 'user', 'pass', 'app');
$mysqli->set_charset('utf8mb4');
// MySQLi 预处理
$stmt = $mysqli->prepare('SELECT * FROM users WHERE email = ? AND status = ?');
$stmt->bind_param('ss', $email, $status);
$email = $_POST['email'] ?? '';
$status = 'active';
$stmt->execute();
$result = $stmt->get_result();
while ($row = $result->fetch_assoc()) {
// 处理数据
}
$stmt->close();IN 子句的处理
php
<?php
declare(strict_types=1);
// PDO: IN 子句需要动态构建占位符
$ids = [1, 2, 3, 5, 8];
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT * FROM users WHERE id IN ({$placeholders})");
$stmt->execute($ids);
// MySQLi: IN 子句
$count = count($ids);
$placeholders = implode(',', array_fill(0, $count, '?'));
$types = str_repeat('i', $count);
$stmt = $mysqli->prepare("SELECT * FROM users WHERE id IN ({$placeholders})");
$stmt->bind_param($types, ...$ids);
$stmt->execute();LIKE 注入防护
php
<?php
declare(strict_types=1);
// LIKE 中的 % 和 _ 是通配符,需要转义
function escapeLike(string $value): string
{
return addcslashes($value, '%_\\');
}
// 错误 — 通配符未转义
$stmt = $pdo->prepare("SELECT * FROM users WHERE name LIKE ?");
$stmt->execute(["%{$_GET['name']}%"]);
// 攻击者输入: % → 返回所有数据
// 正确 — 转义通配符
$searchTerm = '%' . escapeLike($_GET['name'] ?? '') . '%';
$stmt = $pdo->prepare('SELECT * FROM users WHERE name LIKE ?');
$stmt->execute([$searchTerm]);白名单验证
php
<?php
declare(strict_types=1);
// 白名单验证 — 最严格的防护
// 适用于: 排序字段、操作类型等固定值场景
function validateOrderBy(string $field, array $allowed): string
{
if (!in_array($field, $allowed, true)) {
return $allowed[0]; // 降级到默认值
}
return $field;
}
// 排序字段白名单
$allowedSortFields = ['id', 'name', 'createdAt', 'price'];
$sortField = validateOrderBy($_GET['sort'] ?? 'id', $allowedSortFields);
$sortOrder = ($_GET['order'] ?? 'asc') === 'desc' ? 'DESC' : 'ASC';
// 安全 — 字段名经过白名单验证
$stmt = $pdo->prepare("SELECT * FROM products ORDER BY {$sortField} {$sortOrder}");
// 表名白名单
$allowedTables = ['users', 'products', 'orders'];
$table = validateOrderBy($_GET['table'] ?? 'users', $allowedTables);
$stmt = $pdo->prepare("SELECT * FROM {$table} WHERE id = ?");表名和列名不能参数化
预处理语句的占位符只能用于值(VALUES),不能用于表名、列名、ORDER BY 方向等 SQL 关键字。这些场景必须使用白名单验证。
实战示例
ORM 防护
php
<?php
declare(strict_types=1);
// Eloquent (Laravel) — 使用预处理
$users = User::where('email', $email)->where('status', 'active')->get();
// 自动使用预处理语句
// 自定义查询仍需注意
$users = User::whereRaw('name LIKE ?', ['%' . escapeLike($keyword) . '%'])->get();
// DB 门面
$users = DB::table('users')->whereIn('id', $ids)->orderBy($sortField, $sortOrder)->get();输入验证与净化
php
<?php
declare(strict_types=1);
class InputValidator
{
/**
* 验证整数 ID
*/
public static function validateId(mixed $value): int
{
$id = filter_var($value, FILTER_VALIDATE_INT);
if ($id === false || $id <= 0) {
throw new InvalidArgumentException("无效的 ID");
}
return $id;
}
/**
* 验证字符串
*/
public static function validateString(string $value, int $maxLength = 255): string
{
$value = trim($value);
if (strlen($value) > $maxLength) {
throw new InvalidArgumentException("字符串过长");
}
return $value;
}
/**
* 验证枚举值
*/
public static function validateEnum(string $value, array $allowed): string
{
if (!in_array($value, $allowed, true)) {
throw new InvalidArgumentException("无效的值: {$value}");
}
return $value;
}
/**
* 验证日期
*/
public static function validateDate(string $value): string
{
$date = DateTime::createFromFormat('Y-m-d', $value);
if (!$date || $date->format('Y-m-d') !== $value) {
throw new InvalidArgumentException("无效的日期格式");
}
return $value;
}
}
// 使用示例
$userId = InputValidator::validateId($_GET['id'] ?? 0);
$status = InputValidator::validateEnum($_GET['status'] ?? 'active', ['active', 'inactive', 'banned']);安全查询构建器
php
<?php
declare(strict_types=1);
class SafeQueryBuilder
{
private PDO $pdo;
private array $allowedTables;
private array $allowedColumns;
public function __construct(PDO $pdo, array $allowedTables, array $allowedColumns)
{
$this->pdo = $pdo;
$this->allowedTables = $allowedTables;
$this->allowedColumns = $allowedColumns;
}
/**
* 安全的 SELECT 构建
*/
public function select(
string $table,
array $where = [],
?string $orderBy = null,
string $orderDir = 'ASC',
?int $limit = null,
?int $offset = null
): array {
if (!in_array($table, $this->allowedTables, true)) {
throw new InvalidArgumentException("表名不在白名单中");
}
$sql = "SELECT * FROM {$table}";
$params = [];
if (!empty($where)) {
$conditions = [];
foreach ($where as $column => $value) {
if (!in_array($column, $this->allowedColumns[$table] ?? [], true)) {
continue; // 跳过不在白名单中的列
}
$conditions[] = "{$column} = :{$column}";
$params[$column] = $value;
}
if (!empty($conditions)) {
$sql .= " WHERE " . implode(' AND ', $conditions);
}
}
if ($orderBy !== null) {
$orderBy = in_array($orderBy, $this->allowedColumns[$table] ?? [], true)
? $orderBy : 'id';
$orderDir = in_array(strtoupper($orderDir), ['ASC', 'DESC']) ? $orderDir : 'ASC';
$sql .= " ORDER BY {$orderBy} {$orderDir}";
}
if ($limit !== null) {
$sql .= " LIMIT :limit";
$params['limit'] = $limit;
}
if ($offset !== null) {
$sql .= " OFFSET :offset";
$params['offset'] = $offset;
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
}注意事项
转义函数对比
| 方法 | 安全性 | 推荐度 | 说明 |
|---|---|---|---|
| PDO 预处理 | 最高 | 强烈推荐 | 参数与 SQL 分离 |
| MySQLi 预处理 | 最高 | 强烈推荐 | 参数与 SQL 分离 |
PDO::quote() | 高 | 次选 | 手动转义,不如预处理方便 |
mysqli::real_escape_string() | 高 | 次选 | 手动转义 |
addslashes() | 低 | 不推荐 | 可被绕过 |
| 魔术引号 | 极低 | 已移除 | PHP 5.4 已移除 |
不要依赖 addslashes()
addslashes() 使用反斜杠转义,但某些字符集(如 GBK)可以绕过。PHP 5.4 已移除魔术引号(magic_quotes)。永远使用预处理语句。
次要注入点
php
<?php
// 1. ORDER BY 注入 — 预处理无法防护
// 使用白名单验证
$allowed = ['name', 'price', 'created_at'];
$sort = in_array($_GET['sort'] ?? 'name', $allowed, true) ? $_GET['sort'] : 'name';
// 2. LIMIT/OFFSET — 预处理可以防护
$stmt = $pdo->prepare('SELECT * FROM users LIMIT :limit OFFSET :offset');
$stmt->bindValue(':limit', (int) $_GET['limit'], PDO::PARAM_INT);
$stmt->bindValue(':offset', (int) $_GET['offset'], PDO::PARAM_INT);
// 3. IN 子句 — 需要动态占位符
$ids = array_map('intval', explode(',', $_GET['ids'] ?? ''));
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT * FROM users WHERE id IN ({$placeholders})");
$stmt->execute($ids);
// 4. 存储过程调用 — 使用预处理
$stmt = $pdo->prepare('CALL get_user_by_email(?)');
$stmt->execute([$email]);最佳实践
1. 防护层级
php
<?php
// 防护层级: 输入验证 → 预处理 → 最小权限 → 日志监控
// 第一层: 输入验证(白名单/类型检查)
$id = filter_var($_GET['id'], FILTER_VALIDATE_INT);
if ($id === false) {
http_response_code(400);
exit('无效参数');
}
// 第二层: 预处理语句
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([$id]);
// 第三层: 数据库最小权限
// 使用只读用户执行查询
// 第四层: 日志监控
// 记录异常查询模式2. 安全编码规范
php
<?php
// 规则1: 永远使用预处理语句执行 SQL
// 规则2: 表名和列名使用白名单验证
// 规则3: 数值参数使用 FILTER_VALIDATE_INT
// 规则4: LIKE 通配符需要转义
// 规则5: ORDER BY 方向只允许 ASC/DESC
// 规则6: 永远不要拼接用户输入到 SQL
// 规则7: 开启 PDO ERRMODE_EXCEPTION 捕获错误
// 规则8: 错误信息不要暴露 SQL 细节给用户