4、防御SQL注入:使用预处理语句(Prepared Statements)和参数化查询,PDO与MySQLi的安全实践
SQL注入,这玩意儿可以说是Web安全领域的老牌杀手了。我入行那会儿,几乎每个新项目都能扫出一堆注入漏洞。说白了,它就是攻击者把SQL代码伪装成普通数据,骗过你的数据库执行引擎。
怎么防?最靠谱的办法就是预处理语句和参数化查询。今天咱们就聊聊这个,顺便对比一下PDO和MySQLi这两种主流方案。
4.1 为什么拼接SQL很危险?
先看一个反面教材。很多新手喜欢这么写:
$username = $_POST['username'];
$password = $_POST['password'];
$sql = "SELECT * FROM users WHERE username = '$username' AND password = '$password'";
$result = mysqli_query($conn, $sql);
你想想看,如果攻击者在用户名输入框里填了 ' OR '1'='1,那SQL就变成了:
SELECT * FROM users WHERE username = '' OR '1'='1' AND password = ''
嗯,这等于把整个用户表都暴露了。我在项目中遇到过不止一次,有些老系统就是这么被拖库的。
4.2 预处理语句的原理
预处理语句的核心思想很简单:先把SQL骨架发给数据库,再单独发送参数。数据库收到骨架后,会先解析、编译,这时候参数还没到。等参数发过来时,数据库已经知道哪些是代码、哪些是数据了。
说白了,就是让数据库自己区分「命令」和「数据」,而不是靠开发者手动转义。这样攻击者再怎么折腾,他的输入也只会被当作字符串处理,不会变成SQL代码。
核心要点:预处理语句将SQL逻辑与数据分离,从根本上杜绝了注入的可能。这不是「更难注入」,而是「无法注入」。
4.3 PDO方案:我的首选
我个人习惯用PDO。为什么?因为它支持多种数据库,切换起来方便。而且PDO的预处理语法更干净,写起来舒服。
4.3.1 连接数据库
$dsn = 'mysql:host=localhost;dbname=test;charset=utf8mb4';
$user = 'root';
$pass = 'password';
try {
$pdo = new PDO($dsn, $user, $pass);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
} catch (PDOException $e) {
die('连接失败:' . $e->getMessage());
}
小提示:设置 ATTR_EMULATE_PREPARES 为 false 很重要。这能强制使用真实的预处理,而不是让PDO模拟。我踩过这个坑,模拟模式下某些特殊字符还是会有问题。
4.3.2 使用命名占位符
$sql = "SELECT * FROM users WHERE email = :email AND status = :status";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':email' => $_POST['email'],
':status' => 'active'
]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
命名占位符的好处是,参数多了也不容易搞混。我曾经维护过一个项目,里面有十几个参数,用问号占位符简直要命,后来全改成命名占位符了。
4.3.3 使用问号占位符
$sql = "INSERT INTO users (username, email, password) VALUES (?, ?, ?)";
$stmt = $pdo->prepare($sql);
$stmt->execute([$_POST['username'], $_POST['email'], $hashedPassword]);
问号占位符写法更简洁,适合参数少的场景。但要注意顺序,别搞反了。
4.4 MySQLi方案:面向过程与面向对象
如果你只用MySQL,MySQLi也是个好选择。它有两种风格,我分别说一下。
4.4.1 面向对象风格
$mysqli = new mysqli('localhost', 'root', 'password', 'test');
if ($mysqli->connect_error) {
die('连接失败:' . $mysqli->connect_error);
}
$sql = "SELECT * FROM users WHERE username = ? AND password = ?";
$stmt = $mysqli->prepare($sql);
$stmt->bind_param('ss', $username, $password);
$username = $_POST['username'];
$password = $_POST['password'];
$stmt->execute();
$result = $stmt->get_result();
$user = $result->fetch_assoc();
注意 bind_param 的第一个参数 'ss',它表示两个参数都是字符串类型。s代表string,i代表integer,d代表double,b代表blob。这个类型声明是MySQLi的特色,能帮数据库做更精确的优化。
4.4.2 面向过程风格
$conn = mysqli_connect('localhost', 'root', 'password', 'test');
if (!$conn) {
die('连接失败:' . mysqli_connect_error());
}
$sql = "INSERT INTO articles (title, content, author_id) VALUES (?, ?, ?)";
$stmt = mysqli_prepare($conn, $sql);
mysqli_stmt_bind_param($stmt, 'ssi', $title, $content, $authorId);
$title = $_POST['title'];
$content = $_POST['content'];
$authorId = 1;
mysqli_stmt_execute($stmt);
面向过程风格在老项目里很常见。我个人觉得,新项目还是用面向对象风格比较好,代码更清晰。
4.5 PDO vs MySQLi:怎么选?
我经常被问到这个问题。直接上对比表:
| 特性 | PDO | MySQLi |
|---|---|---|
| 数据库支持 | 12种数据库 | 仅MySQL |
| 命名占位符 | 支持 | 不支持 |
| 参数类型声明 | 自动推断 | 手动指定 |
| 面向对象 | 纯OO | OO + 过程 |
| 预处理性能 | 优秀 | 优秀 |
| 学习曲线 | 中等 | 较低 |
我的建议是:新项目用PDO,老项目如果已经是MySQLi就别强行改了。两个都能防住SQL注入,关键是用对方法。
警告:别以为用了预处理就万事大吉了。如果你在SQL里拼接表名、列名或者ORDER BY的字段,预处理是帮不了你的。这些标识符必须手动白名单过滤。
4.6 避坑指南
我曾经接手过一个项目,代码里明明用了预处理,但还是被注入了。查了半天,发现有人在 execute() 之前又拼接了字符串。嗯,这等于白干。
还有几个常见坑:
- 不要用
real_escape_string替代预处理——它只能防一部分,而且容易漏掉编码问题。 - 不要关闭错误模式——把
ERRMODE_EXCEPTION打开,这样SQL出错时能及时发现。 - 批量插入也要用预处理——循环执行
execute()就行,别图省事去拼接。
4.7 完整示例:用户注册功能
最后给一个完整的注册功能,把今天讲的东西串起来:
<?php
// 数据库配置
$dsn = 'mysql:host=localhost;dbname=test;charset=utf8mb4';
$user = 'root';
$pass = 'password';
try {
$pdo = new PDO($dsn, $user, $pass);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
} catch (PDOException $e) {
die('数据库连接失败');
}
// 接收并验证输入
$username = trim($_POST['username'] ?? '');
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
if (empty($username) || empty($email) || empty($password)) {
die('所有字段都是必填的');
}
// 密码哈希,千万别存明文
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
// 使用预处理语句插入数据
$sql = "INSERT INTO users (username, email, password, created_at) VALUES (?, ?, ?, NOW())";
$stmt = $pdo->prepare($sql);
try {
$stmt->execute([$username, $email, $hashedPassword]);
echo '注册成功!用户ID:' . $pdo->lastInsertId();
} catch (PDOException $e) {
// 处理唯一索引冲突等情况
if ($e->getCode() == 23000) {
echo '用户名或邮箱已存在';
} else {
echo '注册失败,请稍后重试';
}
}
你看,整个流程里没有一处拼接SQL。这就是预处理语句的魅力——你根本不需要去想「这个字符要不要转义」,因为数据库替你处理好了。
记住一句话:只要能用预处理的地方,就别用手动拼接。这是我从无数次安全审计中总结出来的铁律。