Skip to content

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 BYORDER 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 细节给用户

参考链接