拆解 XSS、CSRF 和 SQL 注入,精确理解 htmlspecialchars、令牌和预处理语句到底拦住了什么。
在 Kodokon 中打开本课三大类 Web 漏洞 - XSS、CSRF 和注入 - 都有着同一个根源:用户提供的数据最终被当作代码(HTML、SQL 或一个合法的请求)来解释。我们先从 XSS 说起:如果你不加转义就显示来自 $_GET 的值,攻击者就能注入一个 <script> 标签,在你其他访客的浏览器里运行 JavaScript - 窃取会话、在他们不知情的情况下执行操作。对于 ?comment=<script>alert(1)</script>,下面第一个输出会运行这段脚本,第二个则把它中和掉。这里有两个标志很重要:ENT_QUOTES 会连单引号一起转义(否则用单引号界定的属性仍然可以被注入),而 ENT_SUBSTITUTE 会把无效的 UTF-8 序列替换掉,而不是返回一个空字符串。从 PHP 8.1 起,这两个标志终于成为默认值,但把它们明确写出来能表明你的意图,也能保护那些运行在旧版本上的代码。
<?php
declare(strict_types=1);
$comment = $_GET['comment'] ?? '';
echo '<div>' . $comment . '</div>';
function e(string $value): string
{
return htmlspecialchars(
$value,
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
}
echo '<div>' . e($comment) . '</div>';CSRF 利用了一个简单的事实:浏览器会自动为任何指向你域名的请求附上会话 cookie,哪怕这个请求是由第三方网站触发的。因此一个恶意表单可以借用受害者的会话去提交 /account/delete。经典的防御手段是同步令牌(synchronizer token):一个存储在会话中、并在每个会改变状态的请求里都要求提供的随机密钥。第三方网站既读不到它,也猜不出它,所以无法提供它。
<?php
session_start();
if (!isset($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
$sent = $_POST['csrf'] ?? '';
$valid = is_string($sent)
&& hash_equals($_SESSION['csrf'], $sent);
$isPost = $_SERVER['REQUEST_METHOD'] === 'POST';
if ($isPost && !$valid) {
http_response_code(403);
exit('Invalid CSRF token');
}
?>
<form method="post">
<input type="hidden" name="csrf"
value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
<button>Delete my account</button>
</form>SQL 注入遵循同样的模式:把用户的值拼接进一条查询,等于让用户来写 SQL - ' OR 1=1 -- 会把一个筛选条件变成整张表。解决办法不是转义,而是预处理语句(prepared statement):查询文本(连同它的 :email 占位符)和具体的值会分别传给引擎,因此引擎绝不会把数据和代码混为一谈。一个专家级细节:在 MySQL 上,PDO 默认会模拟预处理语句,在客户端进行转义;请把 PDO::ATTR_EMULATE_PREPARES 设为 false,才能得到真正原生的预处理语句。
<?php
declare(strict_types=1);
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(
PDO::ATTR_ERRMODE,
PDO::ERRMODE_EXCEPTION
);
$pdo->exec(
'CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL
)'
);
$email = $_GET['email'] ?? '';
$stmt = $pdo->prepare(
'SELECT id FROM users WHERE email = :email'
);
$stmt->execute(['email' => $email]);
var_dump($stmt->fetch(PDO::FETCH_ASSOC));