「用 PDO 预处理就不会有注入」——这句话只对了一半。参数化能保护值,但保护不了标识符和结构。这篇把实际代码里反复出现的几个误区拆开讲。
一、先明确预处理的边界
PDO 预处理能安全处理的只有一种东西:值占位符。
// 安全:值是参数
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$email]);它不能处理这些:
- 表名、列名
ORDER BY的字段和方向LIMIT/OFFSET(部分驱动下)IN ()里的元素个数- SQL 关键字
原因是预处理的本质是「先把语句结构发给数据库解析,再单独传值」。结构一旦定了就不能再变。
二、误区一:拼接表名/字段名
// 危险
$sql = "SELECT * FROM {$table} WHERE {$column} = ?";
$pdo->prepare($sql)->execute([$value]);$table 和 $column 是直接拼进 SQL 结构的,占位符救不了。
正确做法是白名单:
const ALLOWED_SORT = ['created_at', 'views', 'id'];
const ALLOWED_DIR = ['asc', 'desc'];
$sort = in_array($_GET['sort'] ?? '', ALLOWED_SORT, true) ? $_GET['sort'] : 'created_at';
$dir = strtolower($_GET['dir'] ?? 'desc') === 'asc' ? 'ASC' : 'DESC';
$sql = "SELECT * FROM posts ORDER BY `{$sort}` {$dir} LIMIT 20";白名单比「过滤特殊字符」可靠得多——因为它的逻辑是「只允许已知安全的」,而不是「试图列出所有危险字符」。
三、误区二:LIMIT ? 绑定成字符串
这是最经典的一个坑。
$stmt = $pdo->prepare('SELECT * FROM posts LIMIT ?');
$stmt->execute([20]); // 报错当 PDO::ATTR_EMULATE_PREPARES 为 false(真·服务端预处理)时,MySQL 要求 LIMIT 的参数是整数类型,而 execute() 数组里的值默认按字符串发送,于是报:
SQLSTATE[42000]: Syntax error or access violation: 1064解法是显式指定类型:
$stmt = $pdo->prepare('SELECT * FROM posts LIMIT :limit OFFSET :offset');
$stmt->bindValue(':limit', (int) $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', (int) $offset, PDO::PARAM_INT);
$stmt->execute();注意不能用 execute([...]) 传数组,必须用 bindValue(),否则类型信息丢失。
四、误区三:IN 子句
// 错误:只绑了一个值,但 SQL 里有三个问号
$stmt = $pdo->prepare('SELECT * FROM posts WHERE id IN (?, ?, ?)');占位符数量必须和实际元素数量一致,所以要动态生成:
$ids = array_values(array_unique(array_map('intval', $ids)));
if ($ids === []) {
return []; // 空数组要提前返回,否则 SQL 变成 IN () 语法错误
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT * FROM posts WHERE id IN ({$placeholders})");
$stmt->execute($ids);这里 $placeholders 拼的是问号,不是用户输入,所以是安全的。
如果 $ids 可能上千个,MySQL 对单个语句的参数个数有上限(默认 max_prepared_stmt_count、以及 65535 个占位符的硬限制),建议分批:
foreach (array_chunk($ids, 500) as $chunk) {
$ph = implode(',', array_fill(0, count($chunk), '?'));
$stmt = $pdo->prepare("SELECT * FROM posts WHERE id IN ({$ph})");
$stmt->execute($chunk);
// 合并结果
}五、误区四:LIKE 里的通配符
$stmt = $pdo->prepare('SELECT * FROM posts WHERE title LIKE ?');
$stmt->execute(['%' . $keyword . '%']);这段代码没有注入风险,但有逻辑漏洞:用户输入里的 % 和 _ 会被当成通配符。
搜 50% 会匹配到所有以 50 开头的内容;搜 a_b 会匹配 axb。如果这是后台管理或者有性能敏感的表,还可能被构造出 %%%%%% 这种全表扫描。
要转义:
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $keyword);
$stmt = $pdo->prepare("SELECT * FROM posts WHERE title LIKE ? ESCAPE '\\\\'");
$stmt->execute(['%' . $escaped . '%']);注意 ESCAPE 后面的反斜杠在 PHP 字符串里要写成 '\\\\',实际发给 MySQL 的是 '\\'。
六、误区五:DSN 里漏了 charset
// 有隐患
$pdo = new PDO('mysql:host=127.0.0.1;dbname=test', $user, $pass);如果连接字符集和服务端不一致,某些宽字节字符集(GBK 等)下存在经典的多字节注入问题。虽然现在基本都是 utf8mb4,但显式声明是零成本的保险:
$pdo = new PDO(
'mysql:host=127.0.0.1;dbname=test;charset=utf8mb4',
$user,
$pass,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 出错抛异常,别静默
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // 用服务端真预处理
]
);ATTR_EMULATE_PREPARES => false 有两个好处:真的走服务端预处理,以及 LIMIT 报错能被及时发现(而不是在模拟模式下悄悄工作、换环境才炸)。
七、误区六:二次注入
这个最容易被忽略。数据进库时安全了,但从库里读出来又拼进 SQL:
// 第一步:入库,参数化,安全
$stmt = $pdo->prepare('INSERT INTO users (nickname) VALUES (?)');
$stmt->execute([$_POST['nickname']]); // 用户填了:admin' --
// 第二步:从库里读出来,直接拼接
$row = $pdo->query("SELECT * FROM users WHERE id = 1")->fetch();
$sql = "UPDATE users SET intro = '{$row['nickname']}' WHERE id = 2"; // 这里被注入了关键认知:数据库里的数据不代表可信。任何进入 SQL 的变量,不管来源是用户输入还是数据库,都要走参数化。
八、一个可以抄走的封装
final class Db
{
public function __construct(private PDO $pdo) {}
/** @param array $params */
public function query(string $sql, array $params = []): PDOStatement
{
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt;
}
public function fetchAll(string $sql, array $params = []): array
{
return $this->query($sql, $params)->fetchAll();
}
public function fetchOne(string $sql, array $params = []): ?array
{
$row = $this->query($sql, $params)->fetch();
return $row === false ? null : $row;
}
} 配套两条规则:
- 所有 SQL 必须走
query(),禁止在业务代码里直接$pdo->query()拼字符串 - 只有白名单校验过的标识符才允许用
{}插进 SQL,其余一律用占位符
小结
| 场景 | 占位符能用吗 | 正确做法 |
|---|---|---|
| 普通值 | ✅ | ? 或 :name |
| 表名 / 列名 | ❌ | 白名单 |
ORDER BY | ❌ | 白名单 |
LIMIT / OFFSET | ⚠️ | bindValue + PARAM_INT |
IN () | ⚠️ | 动态生成占位符 |
LIKE 通配符 | ✅(但要转义 % _) | ESCAPE |
记住一句话就够了:参数化保护值,白名单保护结构。



0 条评论