Kodokon kodokon.com

Web 安全:XSS、CSRF 与注入攻击

拆解 XSS、CSRF 和 SQL 注入,精确理解 htmlspecialchars、令牌和预处理语句到底拦住了什么。

12 分钟 · 3 题

在 Kodokon 中打开本课

三大类 Web 漏洞 - XSSCSRF注入 - 都有着同一个根源:用户提供的数据最终被当作代码(HTML、SQL 或一个合法的请求)来解释。我们先从 XSS 说起:如果你不加转义就显示来自 $_GET 的值,攻击者就能注入一个 <script> 标签,在你其他访客的浏览器里运行 JavaScript - 窃取会话、在他们不知情的情况下执行操作。对于 ?comment=<script>alert(1)</script>,下面第一个输出会运行这段脚本,第二个则把它中和掉。这里有两个标志很重要:ENT_QUOTES 会连单引号一起转义(否则用单引号界定的属性仍然可以被注入),而 ENT_SUBSTITUTE 会把无效的 UTF-8 序列替换掉,而不是返回一个空字符串。从 PHP 8.1 起,这两个标志终于成为默认值,但把它们明确写出来能表明你的意图,也能保护那些运行在旧版本上的代码。

PHP
<?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):一个存储在会话中、并在每个会改变状态的请求里都要求提供的随机密钥。第三方网站既读不到它,也猜不出它,所以无法提供它。

HTML
<?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
<?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));
值永远不会并入 SQL 文本:注入是不可能的。

知识检测

确认你已牢记本课的重点内容。

  1. 为什么要给 htmlspecialchars 传入 ENT_QUOTES 标志?
    • 为了连单引号也一起转义,否则用单引号界定的属性仍然可以被注入
    • 为了强制把字符串转换成 UTF-8
    • 为了把内容中的 script 标签剥离掉
    • 为了加快长字符串的转义速度
  2. 为什么要用 hash_equals 而不是 === 来比较 CSRF 令牌?
    • hash_equals 以恒定时间进行比较,这能防止通过测量响应时间来逐字节地猜出令牌
    • hash_equals 在比较之前会自动重新计算令牌的哈希值
    • 只要两个字符串的编码不同,=== 就会失败
  3. 是什么让预处理语句对 SQL 注入免疫?
    • 它会自动转义值中包含的引号
    • 查询和值会分别发送给引擎,引擎绝不会把值重新解释为 SQL
    • 它会检查每个值是否与目标列的类型相符
    • 它会把执行限制在单条 SQL 语句上