CSRF(跨站请求伪造)这个老家伙,这么多年了,它在 OWASP Top 10 里的排名起起落落,但从来没有真正消失过。我做安全测试这几年,见过太多团队把防御重心全放在 XSS 和 SQL 注入上,结果一测 CSRF,发现后台管理员的密码修改接口、邮箱绑定接口、甚至支付转账接口,全都能被一个藏在论坛签名档里的“隐形链接”给打穿。SEED Labs 2.0 里这套关于 CSRF 的实战环境,恰好把攻击侧的原理拆解和防御侧的代码落地都串起来了,非常适合刚接触 Web 安全、或者做了几年开发但想系统理解漏洞本质的人来动手过一遍。
这篇文章我会按照自己在 SEED Labs 2.0 环境里的实操路径,带你完整走一遍攻击与防御的整个闭环。不只是把实验步骤抄一遍,而是把每一步背后的原理、为什么这么设计、以及真实业务中哪些环节最容易翻车都讲透,最后还会整理一份我在自己做测试时常用的问题排查清单。
1. 先从根上理解 CSRF:它到底是怎么“得手”的
很多新手看 CSRF 的经典攻击流程,觉得无非就是“诱导用户点一个链接”,但真到了实战和防御代码落地的时候,就会发现自己对漏洞本质的理解是模糊的。这一节先把底层逻辑拆清楚,后面做实验和写防御代码才不会跑偏。
1.1 一次伪造请求的本质拆解
CSRF 的攻击模型其实可以用一句话概括:攻击者让受害者的浏览器,带着受害者已登录的 Cookie,向目标网站发送一个攻击者精心构造的请求。这里面的关键点有三个:受害者的身份凭证(Cookie)、目标网站对请求的信任方式、以及攻击者构造请求的能力(也就是标题里说的 HTTP 请求伪造)。
咱们把这句话拆开看。浏览器有一个最基本的机制:当你在某个域名下发起请求时,浏览器会自动携带该域名下存储的 Cookie,而这个携带动作,是不区分“这个请求是你自己手动打开的”还是“你无意中访问了恶意页面而被强制触发的”。也就是说,只要请求是浏览器发出去的,Cookie 就会跟着走。
举个例子,受害者在银行 A 的网站登录过,浏览器里存了 A 网站的会话 Cookie。此时受害者又打开了攻击者放在论坛里的恶意帖子,帖子页面里有一段构造好的代码,这段代码会让浏览器悄悄向银行 A 的转账接口发起一个 POST 请求,请求里带着转账参数。因为请求是浏览器发的,Cookie 会被自动带上,D 银行的服务端校验了 Cookie,发现会话有效,就认为这是“受害者本人”的操作,于是转账成功。
整个过程中,攻击者根本不需要拿到受害者的 Cookie,它只需要知道目标网站哪个接口有敏感操作、参数长什么样就够了。这也就是 CSRF 被称为“借刀杀人”漏洞的原因:攻击者冒充的是受害者的浏览器行为,而不是直接用受害者的身份凭证。
1.2 问题的根源:信任边界的错位
了解了攻击链路,再深挖一层:CSRF 的本质漏洞到底是什么?答案是服务端无法区分“当前这个请求是由用户主动发起的”还是“由恶意页面间接发起的”。
咱们可以把浏览器理解成用户的“私人助理”,这个助理很听话,你告诉它去某个网站办事,它就会带上你放在它那里的门禁卡(Cookie)直接过去。问题是,这个助理分不清“你自己想去办的事”和“别人通过你让它去办的事”。攻击者看准了这一点,只要能让助理(浏览器)发出请求,就等于让受害者“替”它办事。
所以,CSRF 的防御思路从根源上就是两件事:要么让攻击者没法伪造出“合法请求的特征”(比如自定义请求头、Token 校验),要么让浏览器在跨站请求时不要自动携带身份凭证(比如 SameSite Cookie 属性)。后面防御章节的所有方案,都是围绕这两条思路展开的。
注意:CSRF 和 XSS 经常被混淆。XSS 的核心是“注入脚本”,最终目的是在受害者浏览器里执行恶意代码;CSRF 的核心是“伪造请求”,最终目的是让受害者在不知情的情况下完成某个敏感操作。两者的攻击路径完全不同,防御手段也完全不同,实验中不要混为一谈。
2. 实验环境准备与攻击场景搭建
SEED Labs 2.0 的实验环境是基于容器化的,整体非常轻量。不过这套环境对初学者来说有一个小门槛:它需要你对 Docker、Linux 命令、以及浏览器开发者工具有一定的熟悉度。好在这一节的内容我会把所有命令和细节都展开说明,你照着做就能跑起来。
2.1 环境需求与拓扑设计
我用的环境是 Ubuntu 虚拟机 + Docker,容器编排用的是实验自带的一套脚本。如果你之前没装过 SEED Labs 的镜像,需要先去拉取对应的镜像,再启动三个关键容器:
| 角色 | 作用 | 对应服务 |
|---|---|---|
| 受害者的浏览器环境 | 模拟用户访问恶意页面和合法网站 | 本地 Firefox / Chrome(绑定实验网络) |
| 恶意服务器容器 | 托管攻击者构造的恶意页面 | Apache,监听实验网络上的独立 IP |
| 目标 Web 应用容器 | 承载存在 CSRF 漏洞的业务系统 | Apache + PHP,提供模拟的转账、改密等接口 |
实验网络里,三个容器都在同一个子网段,域名的解析是通过修改受害者主机的/etc/hosts文件来完成的。这样设计的目的很明确:把“互联网上跨域访问”的场景在实验网络里模拟出来,同时保证攻击流量不会真的跑到外网去,安全性和可控性都有保障。
我在实际操作时遇到一个比较容易卡住的问题:容器启动命令需要指定--network=host或者使用实验脚本里预置的网络配置。如果你的环境是直接从零手动搭建(不依赖 SEED 实验脚本),建议把恶意服务器和受害者的 Firefox 放在同一个容器网络里,否则跨域请求被浏览器拦截时,你会误以为是 CSRF 攻击失效了,其实是环境问题。
2.2 三个角色的配置要点
受害者浏览器环境的配置,重点是让它可以同时访问两个“域名”:一个是合法的目标站点地址,另一个是恶意站点地址。我会把这两个地址映射到不同的容器 IP,然后在浏览器的代理配置里让所有流量走实验网络网关。
目标 Web 应用的配置更关键。实验中模拟的业务系统用的是 PHP 写的,里面有这么几个典型接口:
- 转账接口(GET 型接收参数)
- 修改邮箱接口(POST 型且附带 CSRF Token,刻意留了个绕过点)
- 修改资料接口(POST 型,无 Token,直接改数据库)
这里有个细节容易被忽略:PHP 表单在默认情况下不会自动做 CSRF 防护,也就是说,你如果直接用一段 HTML 表单提交到该接口,没有任何中间校验。所以实验里的那个“带 Token 的接口”是后来人为加上去的,目的就是让你在防御阶段亲眼看到 Token 为什么能拦住攻击、以及哪些场景下 Token 也拦不住。
恶意服务器的配置其实最简单,就是起一个 Apache 服务,并放置几个恶意页面文件。每个页面文件都对应一种攻击形式:自动提交的表单、图片标签加载、以及带 XML 数据格式的 XHR 请求。后面做攻击侧实验时,只需要改一下浏览器地址栏的 URL,切换到不同页面就行。
2.3 环境准备阶段的两个坑
我第一次搭这套环境时,踩过两个明显的坑,值得提前说。
第一,浏览器的 SameSite 属性默认值。不同浏览器、不同版本对 Cookie 的默认 SameSite 策略差别很大。Chrome 从某个版本开始,把未显式设置 SameSite 的 Cookie 默认成 Lax,这意味着跨站 POST 请求不会携带 Cookie,实验里很多攻击形式会莫名失败。解决办法是在目标 Web 应用设置 Cookie 时,显式加上SameSite=None; Secure(Secure 属性在 HTTP 实验环境下要另外处理),或者直接用实验环境自带的浏览器配置。
第二,浏览器代理设置污染。实验要求浏览器走代理访问恶意站点和合法站点,但如果你系统里装了其他代理插件(比如某些调试工具),可能会把所有流量都代理到错误的位置。我第二次搭环境时干脆关掉了系统级代理,只用浏览器自带的代理配置,问题立刻消失。
3. 攻击侧实操:三种形式的请求伪造与绕过技巧
环境就绪之后,就进入本次实验的重头戏:从攻击者的视角,构造各种形式的伪造请求。这一节我会按“从最简单到稍微复杂”的顺序,把三种常用的攻击形式逐一实现,并且解释每种方案的局限性和适用场景。
3.1 基于表单自动提交的 GET 型攻击
GET 型 CSRF 是所有攻击形式里最基础、也最容易理解的。假设目标网站的转账接口是这么写的:
// transfer.php?to=接收者&amount=金额 $to = $_GET['to']; $amount = $_GET['amount']; // 直接执行转账逻辑攻击者只需要准备一个页面,里面放一个表单,然后在页面加载时自动提交:
<html> <body> <form id="csrf_form" action="http://合法站点/transfer.php" method="GET"> <input type="hidden" name="to" value="攻击者账户" /> <input type="hidden" name="amount" value="1000" /> </form> <script> document.getElementById("csrf_form").submit(); </script> </body> </html>当受害者访问这个恶意页面时,页面一加载,JavaScript 就自动提交了表单,浏览器会带着合法站点的 Cookie,向transfer.php发出 GET 请求,转账参数也随之带了过去。
你可能会问:表单默认是 POST 方法,为什么这里要特意演示 GET?因为在真实场景里,很多老系统为了“方便分享链接”或者“调试时直接改 URL”,会把一些敏感操作(比如删除、发货、转账)设计成 GET 接口。这种做法在 RESTful 风格里是优雅的,但在安全上是灾难级的。只要接口是 GET,攻击者甚至不需要写表单,直接用<a>标签或图片标签就能打。
3.2 基于图片标签的 GET 型攻击
图片标签攻击的原理更取巧:浏览器在解析 HTML 时,遇到<img>标签会主动发起一次对src属性的 GET 请求,而且这个请求同样会携带 Cookie。所以攻击者可以把攻击链接直接伪装成图片地址:
<img src="http://合法站点/transfer.php?to=攻击者账户&amount=1000" width="0" height="0" />当受害者的浏览器加载恶意页面时,这个“看不见的图片”会静默触发转账请求。整个过程没有任何弹窗、没有任何交互,受害者可能只是刷了一下论坛首页,钱就转走了。
这种攻击最大的特点是隐蔽性和传播性极强。攻击者可以把这段代码直接嵌入到论坛签名、评论区、或者一封 HTML 邮件里,不需要受害者点击任何按钮,只要页面被加载就行。在实验里,我故意把这个恶意页面设计成一个“看起来完全无害的娱乐新闻页”,页面中贴满了各种图片,其中有一张的src就是转账链接,效果非常逼真。
实验提示:攻击成功的标志是,目标的数据库里多了一笔转账记录,或者日志里出现了一条来自恶意域名的 Referer 请求。建议在查看结果时直接查数据库,不要只看页面返回,因为浏览器发出的请求即使被处理,页面也未必会被展示出来。
3.3 基于表单自动提交的 POST 型攻击
真实业务系统里,敏感操作大部分都是 POST 接口(因为 GET 被安全规范限制)。所以 POST 型 CSRF 是更常见的攻击形态。但 POST 攻击有一个天然的难点:攻击页面必须包含符合目标接口校验规则的请求体。
最常用的 POST 攻击形式还是自动提交的表单,因为浏览器在提交 HTML 表单时,Content-Type 是application/x-www-form-urlencoded,这和很多服务端框架的默认解析方式完全吻合。下面这段代码是实验里修改邮箱接口的攻击页面:
<html> <body> <form id="email_form" action="http://合法站点/profile/update_email" method="POST"> <input type="hidden" name="email" value="attacker@example.com" /> </form> <script> window.onload = function() { document.getElementById("email_form").submit(); } </script> </body> </html>这个攻击页面在实验环境里直接就能成功,因为目标接口没有做任何 CSRF 防护。但如果你部署在真实环境里,通常会有几个变量影响成功率:
第一,接口是否校验了Content-Type。如果后端强制要求application/json,那么传统的表单型 POST 会直接被拒。第二,服务端是否校验了 Referer。如果校验了来源域名,则攻击页面需要想办法让 Referer 看起来来自合法站点(这通常比较困难,但也有绕过思路)。第三,接口是否自己拼接了自定义 Token。如果有 Token 且 Token 不可预测,那么表单攻击就会被拦下。
在实验里,我建议你先用这种最基础的形式跑通整个链路,然后再逐步加入防御逻辑,看攻击在哪个环节被阻断,这样才能建立“攻击-防御”的对应认知。
3.4 绕过 JSON 数据格式的限制
如果你的目标接口强制要求Content-Type: application/json,上面的表单攻击就没戏了,因为浏览器原生 HTML 表单并不能发出 JSON 请求。但攻击者可以换一种工具:用 JavaScript 的XMLHttpRequest或者fetch来构造请求,并显式设置 Content-Type。
这里有个关键的浏览器限制:跨域请求在携带自定义 Content-Type 时,会触发 CORS 预检请求(OPTIONS)。也就是说,如果目标站点不允许跨域请求携带application/json这种非简单请求头,浏览器会在正式请求发出前就被拦截。这个机制本质上就是 CSRF 攻击在“现代浏览器 + 严格 CORS 配置”下遇到的一堵墙。
不过实验里要演示的是“绕过”,所以我给恶意页面配置了这样一个场景:目标站点的 CORS 配置比较宽松,允许任意来源带application/json提交。于是攻击代码如下:
var xhr = new XMLHttpRequest(); xhr.open("POST", "http://合法站点/api/update_profile", true); xhr.withCredentials = true; xhr.setRequestHeader("Content-Type", "application/json"); xhr.send(JSON.stringify({email: "attacker@example.com"}));这段代码能成功的核心,是xhr.withCredentials = true这个参数,它保证了跨域请求里携带 Cookie。很多人做实验时漏了这一行,导致明明代码逻辑都对,请求就是不带 Cookie,一直失败。如果你在真实渗透测试里遇到类似情况,第一反应就检查这行有没有,第二检查服务端有没有显式返回Access-Control-Allow-Credentials: true。
4. 防御侧实操:从源头阻断 CSRF
攻击侧跑通之后,防御侧的实验会让人非常有成就感,因为每一步改动都能立刻看到攻击失效。这一节我会按防御强度从“轻到重”的顺序,讲清楚三种主流方案:SameSite 属性、CSRF Token、以及自定义请求头校验。
4.1 从浏览器层入手:SameSite Cookie 属性
SameSite 是浏览器提供的一个内置防线,它让 Cookie 的携带策略从“不分来源”改成“区分来源”,本质上就是解决了第一节讲到的“信任边界错位”问题。
设置方式很简单,服务端在返回Set-Cookie响应头时可以带一个属性:
Set-Cookie: sessionid=abc123; SameSite=Strict当 SameSite 设置为 Strict 时,浏览器在跨站请求中完全不携带该 Cookie;设置为 Lax 时,跨站的 GET 请求(比如用户点击正常链接)会携带,但跨站的 POST、表单提交不会;设置为 None 时,则任何跨站请求都会携带(相当于关掉防护)。
在实验里,我把目标站点的会话 Cookie 从默认改成了SameSite=Lax,重新跑一遍之前所有攻击页面,发现表单提交型和图片型的 GET 攻击全都失效了,因为浏览器在跨站 POST 和 GET 时没有带上 Cookie。只有一种情况还能成功:当 SameSite 是 None 且没有 Secure 属性时。
实操心得:SameSite=Lax 其实已经是现在浏览器的主流默认策略,但这不代表你可以完全依赖它。一是老浏览器不认这个属性;二是有些业务偏偏需要跨站携带 Cookie(比如单点登录场景),此时得显式设为 None,也就等于主动放弃这层防护。所以它只能作为第一道防线,不能作为唯一防线。
4.2 从服务端入手:CSRF Token 的核心机制
CSRF Token 是目前企业应用里最主流的防护手段,也是实验里防御部分的重头戏。它的核心思路是:服务端生成一个不可预测的随机 Token,存到会话里,并要求每个敏感请求都必须携带这个 Token。因为 Token 存储在会话和页面中,攻击者在一个独立页面里无法获取到受害者的 Token,所以伪造的请求会因校验失败而被拒绝。
具体实现可以分为三步:
第一步,Session 建立时生成 Token:
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));第二步,在表单里内嵌 Token:
<form method="POST" action="/transfer"> <input type="hidden" name="csrf_token" value="<?php echo $_SESSION['csrf_token']; ?>" /> ... </form>第三步,服务端在接收请求时校验:
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'] ?? '')) { die('CSRF token mismatch'); }这里最关键的一个细节是使用了hash_equals函数,而不是直接用==比较。为什么?因为字符串比较在 PHP 里存在时序攻击的风险:两个字符串逐字符比较时,如果某个字符不同,函数会立即返回,这样攻击者可以通过响应时间的细微差异来逐字推断 Token。hash_equals会把整个字符串都遍历一遍再返回,从时序上就不泄露有效信息。这个细节在实际开发里经常被忽略,但是在安全评审里属于必查项。
我在实验里分别测了“带 Token 的转账接口”和“不带 Token 的改密接口”两个场景。带 Token 的接口在攻击页面里由于无法获知 Token,提交后一律被拒绝;不带 Token 的接口则被直接打穿。这个对照实验最有说服力,能看到安全措施的效果是立竿见影的。
4.3 另外一种思路:自定义请求头验证
除了 Token,还有一个在企业微信、钉钉这类开放平台里经常看到的方案:自定义请求头。原理非常简单:通过 JavaScript 给请求添加一个自定义请求头,比如X-Requested-With: XMLHttpRequest,服务端再校验这个请求头是否存在。
为什么这种方案有效?因为浏览器里发跨站请求时,自定义请求头会触发 CORS 预检。但真正的原因是:攻击者用最基本的 HTML 表单是没法添加自定义请求头的,要想添加,必须用 JavaScript 发 XHR 请求,而跨站 XHR 又要过 CORS 这道关。
实验里实现起来也很简单:
fetch('/transfer', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, body: JSON.stringify({to: 'attacker', amount: 1000}) });服务端判断:
if (empty($_SERVER['HTTP_X_REQUESTED_WITH'])) { die('Invalid request'); }这个方法有一个显著的优点:实现成本极低,不需要在页面里嵌入 Token,也不需要维护会话状态,很多前后端分离的项目天生就会带这种请求头。但它的防护强度其实不算高,因为如果攻击者能找到一个同源页面的 XSS 漏洞,就可以用那段 XSS 脚本直接在目标站点域下发请求,自定义请求头一样能带上。所以它只能作为辅助手段,不能单独依赖。
4.4 防御方案的组合与取舍
没有哪一种防御方案是银弹,所以真实业务里通常需要组合使用。我在实验里最后验证的是一套组合策略:
| 防御层 | 方案 | 作用 |
|---|---|---|
| 浏览器层 | SameSite=Lax | 阻断绝大多数跨站携带 Cookie 的场景 |
| 服务端请求校验层 | CSRF Token | 阻断伪造请求中缺失 Token 的场景 |
| 业务逻辑层 | 敏感操作二次校验(如输入密码 / 验证码) | 即使攻击请求合法,也无法绕过用户确认 |
这套组合在实验环境里可以做到“所有攻击页面全部失效”,但在真实架构里还有一个变量需要注意:如果业务有跨域的需要(比如 Web 前端在app.example.com,后端在api.example.com),SameSite 就不可能设成 Lax,因为跨站(跨域名)请求会被拒。此时必须把 SameSite 设为 None,并把防御重心完全放到 Token 上。
另外一个容易翻车的点是:如果 Token 被放在 URL 参数里(而不是表单隐藏域或请求头),那么浏览器历史记录、Referer 头、日志系统都有可能泄露这个 Token,导致防护形同虚设。所以 Token 的传输位置一定要是请求体或自定义请求头,尽量不要拼在 URL 里。
5. 常见问题与排查技巧实录
做实验的过程中,我记录了 5 个典型的“看起来操作没问题但就是失败”的场景。这些问题放在真实漏洞测试和代码评审里也非常容易遇到,整理成速查表供你参考。
5.1 Cookie 没带上,攻击一直失败
现象:恶意页面成功加载,目标接口也有请求日志,但服务端返回“未登录”或“会话无效”。
排查顺序:先看请求里有没有 Cookie,再看 Cookie 的 Domain/Path 是否匹配目标站点,最后看 SameSite 属性是否阻碍了跨站携带。
我遇到过最隐蔽的一种情况是:Cookie 设置了Domain=.example.com,而实验里的目标站点用了子域名www.example.com,按理说也能带上。但攻击页面所在的恶意域名是attack.example.com,三者其实都在同一个主域下,浏览器却因为 SameSite 策略把 Cookie 拦了。这种“域名看起来在一棵树上,但安全策略却不认”的坑,最容易让人绕圈。
5.2 Token 校验位置错误导致误拦截
现象:正常的业务请求偶尔报“CSRF token mismatch”,但刷新页面后再试就正常了。
原因往往不是 Token 失效,而是你校验 Token 的时候用了$_POST['csrf_token'],但这个接口的 Token 实际是通过 JSON 请求体传过来的。PHP 默认不会把application/json的内容解析到$_POST里,所以取出来永远是空。
解决办法是要用file_get_contents("php://input")读取 JSON 原始数据,再解析出 Token 字段。这个细节在真实项目里特别常见,很多团队把 Token 从 form 表单改成 JSON 格式传输时,忘了同步修改服务端的读取方式。
5.3 框架自带的 CSRF 防护为什么还是被绕过
现在主流框架基本都有内置 CSRF 防护,但防护失效的案例仍然层出不穷。实验里我特意模拟了一种典型场景:目标网站用的是某 PHP 框架,框架默认开启了 CSRF Token 校验,按理说攻击页面应该失效,但结果攻击依然成功。
问题出在配置上——框架把 CSRF 白名单配得太大,比如对/api/*开头的请求默认跳过校验,理由是“接口都是程序访问,不涉及 Cookie”。而这个系统的真实业务里,前端页面恰恰通过/api/update_profile来修改邮箱,并且请求里带了 Cookie。白名单一开,等于整条防线直接失效。
这个案例想说明的是:任何安全机制,只要被“例外”侵蚀,就等于没有。安全配置的收口要比放开严格得多。
5.4 排查思路速查表
| 现象 | 大概率原因 | 快速验证方法 |
|---|---|---|
| 请求发出但不带 Cookie | SameSite 设置太严、Domain 不匹配 | 开发者工具查看请求头 Cookie 字段 |
| 请求带 Cookie 但业务失败 | Token 缺失或者 Token 位置不对 | 抓包查看请求体里的字段名 |
| 前置请求被 OPTIONS 拦截 | CORS 配置太严,拒绝跨域请求 | 观察网络面板的预检请求状态码 |
| 攻击成功但页面提示错误 | 接口逻辑本身的校验,与 CSRF 无关 | 查看服务端日志和数据库落库记录 |
| 部分用户环境攻击成功 | 浏览器版本差异导致 Cookie 策略不同 | 分别在 Chrome 和 Firefox 下复测 |
个人实操感受
做完整套攻击与防御闭环的练习之后,我对 CSRF 最大的感受是:它不需要什么高深的技术,攻击面完全取决于开发团队对“浏览器行为”的理解程度。表单能自动提交、图片能触发 GET、跨域请求能携带 Cookie,这些看起来都是浏览器的正常功能,但一旦接口设计得不够谨慎,这些“正常功能”就会变成漏洞利用的跳板。
如果你的目标是成为安全工程师,我建议在实验之外,再花点时间做两件事:一是有意识地阅读主流框架的 CSRF 防护源码,看看它们是怎么处理 Token 的生成、存储和校验的;二是自己动手写一个小型的请求日志中间件,把所有请求的 Method、Content-Type、Cookie、Token 字段都打出来,这样以后排查真实漏洞时,你一眼就能定位问题出在哪个环节。
防御侧的实验给我最大的启发是:安全方案永远是一个“成本与收益”的权衡过程。SameSite 一开,很多跨站业务场景就要重新设计;Token 一加,前后端就要维护额外的会话状态;自定义请求头虽然便宜,但不能解决 XSS 带来的连锁风险。最好的做法不是追求“绝对安全”,而是根据业务的实际暴露面,把几道防线合理地叠起来,让攻击者在每一步都要付出更高的成本。这套实验的价值,恰好在于把这个权衡过程完整地浓缩到了一个可控的环境里,跑一遍,比看十篇理论文章都管用。