做web渗透这些年,我越来越觉得CSRF漏洞被严重低估了。它不像SQL注入那样能把整个数据库拖出来摆在你面前,也不像XSS那样能弹个框让你直观感受到"被入侵",但CSRF往往藏在你最意想不到的业务逻辑里——一个链接、一张图片、一次自动提交的表单,就能在用户毫无感知的情况下完成改密、换绑、转账这类高敏感操作。这篇文章我想把这几年在授权测试里遇到CSRF的实战体会完整梳理一遍,从漏洞成因、攻击形态,到发现与验证的完整流程,再到我理解中真正能落地的深度防御体系。无论你是刚入门web渗透的新手,还是被CSRF防护搞得头疼的开发同学,这篇都能给你一套可以直接拿去用的思路。
1. 从一次"点链接被改邮箱"的模拟开始:CSRF的攻击路径复盘
1.1 一次完整的CSRF攻击推演
先看一个我经常在攻防演练里演示的场景。假设目标系统example.com有个非常常见的接口,用户修改绑定邮箱的请求长这样:
GET /user/changeEmail?email=attacker@evil.com HTTP/1.1 Host: example.com Cookie: sessionid=abc123注意几个细节:这是GET请求、敏感操作带了session Cookie、服务端没有校验任何token或来源。攻击者只需要把这个URL改写成一张"图片"或一个"链接":
<img src="https://example.com/user/changeEmail?email=attacker@evil.com" width="0" height="0" />然后把这段内容放到受害者会访问的地方:论坛帖子、聊天窗口、个人博客,哪怕是一封伪装成通知的邮件。受害者只要登录着example.com,再点开这个页面,浏览器就会自动向example.com发出那个改邮箱的请求,并自动携带sessionid Cookie。服务端收到请求,发现你有合法会话,参数也齐全,直接执行。
接下来攻击者顺着"找回密码"流程,用这个新邮箱接收重置链接,账号就彻底沦陷了。整个过程受害者完全没有感知——没有弹窗、没有跳转、没有需要确认的操作。
1.2 三个构成条件拆解:浏览器、会话与接口设计
CSRF能成立,必须有三个条件同时满足,缺一个都打不成:
第一,请求必须跨站发出。攻击者构造的页面放在另一个域名下,利用浏览器发起对目标站的请求。
第二,请求需要自动携带身份凭证。传统Cookie Session机制下,浏览器对同源请求会自动携带Cookie,而且跨站发请求时也一样带——只要目标域名匹配。这是CSRF能成立的物理基础,也是现在强调SameSite属性最重要的原因。
第三,服务端接口必须接受并处理这个请求,且操作具有状态改变副作用(修改数据、变更权限、触发交易等)。CSRF不关心你能不能"读到"响应,因为它不需要读响应,它只需要请求被处理。
这三个条件里,真正能做文章的是第三个。很多老系统的接口设计是"能通就行":改邮箱用GET、删文章用GET、转账用GET,加上没有token校验,等于把门敞开了。第二个条件是浏览器的默认行为,目前只能靠SameSite从浏览器侧干预。第一个条件是攻击者完全可以控制的。
1.3 别和XSS搞混:CSRF的核心信任关系
入行的时候我花了不少时间才把XSS和CSRF彻底分清楚,这里直接用一句话总结:XSS利用的是"用户对网站的信任",CSRF利用的是"网站对用户的信任"。
XSS是攻击者往目标页面里注入了恶意脚本,脚本在目标站点的上下文里运行,等于拿到了"内应"。而CSRF里攻击者全程不碰目标页面,只是借用浏览器这个"中间人"的身份去发请求。打个比方:XSS是有人混进你家客厅,在遥控器上贴了张纸条,你每次按遥控器都会执行他编好的动作;CSRF是有人隔着窗户用激光笔指挥你家智能音箱,"不用进你家,音箱也认你家的WiFi环境,照样能干活"。
这个区别在防御上很关键:XSS可以直接窃取Token、改写页面,从而绕过CSRF防御;CSRF本身则要求服务端"别信任浏览器的自动行为"。所以业内常说XSS能"杀死"CSRF防御——这也就是为什么深度防御必须多层叠加,而不能指望单点防护。
2. CSRF的攻击形态:GET、POST、JSON到接口型变种
2.1 经典GET型:图片暴击与导航带参
GET型CSRF是最古老也最直观的形态。因为浏览器加载外部资源天然会发起GET请求,img标签、link预加载、iframe、甚至CSS里的背景图,都能触发。构造成本极低:
<img src="https://example.com/user/logout" />别小看这个"一像素图片"。我在一次授权测试里审计过一个老旧的员工后台,退出登录接口是GET且无token。单点看这只是一个"强行登出",危害不大,但它能和另一个开放重定向漏洞组合,做成一个受害者很难感知的钓鱼链:先被登出,再被诱导重定向到仿冒登录页。CSRF的"低危"通常就是这样被组合放大的。
GET型CSRF的检测也最简单:Burp里看到敏感操作走了GET且无防护,基本可以直接确认。也正因为如此,现在稍微有点安全意识的团队都会规定"状态改变请求必须用POST"。但POST不是免死金牌,往下看。
2.2 自动提交的表单:POST型CSRF的看家本领
POST型CSRF是实战中最常见到的。攻击者把表单藏在页面里,用户一进页面就自动提交:
<form id="csrf" method="POST" action="https://example.com/user/changePwd"> <input type="hidden" name="newpass" value="Hacked123" /> <input type="hidden" name="confirm" value="Hacked123" /> </form> <script> document.getElementById("csrf").submit(); </script>很多新手问:跨域POST不是被CORS拦了吗?这里必须把概念理顺。浏览器同源策略阻止的是"跨域读取响应",并不阻止"跨域发送请求"。普通的application/x-www-form-urlencoded表单提交是"简单请求",根本不会触发CORS预检,浏览器会直接把这个请求发出去,响应读不到,但请求已经执行了。
这也是为什么"我们用POST了"这句话在CSRF讨论里毫无含金量。只要服务端没有校验请求来源,表单自动提交就能绕过。
2.3 JSON接口型:Content-Type校验与绕过的攻防史
现在前后端分离的项目多了,很多接口只接收application/json。有人觉得这下安全了吧?表单只能提交urlencoded格式啊。这里要说的是,JSON接口只是提高了CSRF攻击的构造门槛,并非免疫。
- 如果服务端严格校验Content-Type,且只认
application/json,那么跨站发送JSON请求会因为触发CORS预检而被浏览器拦下,这是最早大家觉得"JSON天然防CSRF"的原因。 - 但实际接口经常没这么严格。我看到过不少实现:服务端用框架的
@RequestBody接JSON,同时对urlencoded参数的兼容也没有关闭,攻击者把JSON字段平铺成POST参数也能被解析。这种情况非常普遍。 - 另一个历史经典绕过是Flash的307重定向,可以在跨站上下文中把POST请求及原始Content-Type转发到目标,这个在Flash退出历史舞台后基本失效了。
- 现在更常见的思路是检查接口是否接受
text/plain甚至application/x-www-form-urlencoded,有些网关或框架的容错解析会给你惊喜(或者惊吓)。
所以我的习惯是:遇到JSON接口,先用不同的Content-Type分别发一次请求,观察服务端是否照常处理。如果服务端对非预期Content-Type直接返回415,那这个接口的CSRF风险就低很多。
2.4 容易被忽略的Login CSRF与OAuth state问题
还有两种变体在实战中经常被漏掉。一种是Login CSRF:攻击者构造一个登录请求,让受害者"被登录"到攻击者的账号。用户后续在站内产生的浏览记录、订单历史、个人备注全记到了攻击者账号名下,长期下来是非常严重的隐私污染。另一种是OAuth授权回调缺少state参数,攻击者强制受害者绑定攻击者的第三方账号,后续利用账号关联关系做进一步操作。这两种的共同防御思路都是:登录和授权流程里加一个不可预测的随机参数,并在服务端校验它。
3. 授权测试环境下CSRF的发现、验证与利用思路
3.1 从Burp流量里挑"值得测"的接口
实战中不可能把所有接口都测一遍CSRF,时间上也不允许。我一般按这个优先级筛:
- 高敏感且不可逆的操作:改绑定手机/邮箱、修改密码、关闭多因子认证、转账、提现。
- 影响面大的操作:后台管理员改权限、批量删除、全局配置更新。
- 低频操作:这类操作通常没有完善的二次确认,开发容易忘加防护。
操作越敏感、影响面越大、流程越短,越值得优先测。筛选的时候我会重点看Cookie携带情况:如果请求Cookie里有sessionid、JSESSIONID这类会话标识,而且Header里没有X-CSRF-Token、X-Requested-With之类的自定义头,就进入候选名单。
3.2 Burp Suite生成CSRF PoC的实操步骤
确认候选请求后,用Burp生成PoC是最快的验证路径,步骤大致是:
- 浏览器登录测试账号,执行一次目标操作(比如修改昵称),Burp里找到对应请求。
- 右键选中请求,
Engagement tools -> Generate CSRF PoC。 - Burp会自动根据请求方法生成带表单或图片标签的HTML。如果是POST,把
submit()改成页面onload自动执行,或者保留一个按钮方便你手动演示。 - 把HTML保存成
.html文件,放到本地任意目录或临时服务器上,用另一个浏览器打开。注意用无痕窗口登录测试账号,模拟受害者的状态。 - 打开攻击页面后,回到目标站点确认操作是否真的被执行了。
这里必须强调一句:CSRF验证一定会产生真实的状态改变。我见过有同事在正式环境上拿真实账号测CSRF,结果把生产数据改了,复盘时非常被动。所有PoC验证必须在授权范围内、用专用测试账号、在测试环境或经过审批的演练环境里做,这个是底线。
3.3 存在Token时,按顺序尝试的绕过手法
如果目标接口有Token,别急着放弃。CSRF Token的防护效果完全取决于实现质量,我通常按下面这套顺序测:
- 直接删除Token:把Token参数整个去掉,如果服务端不校验"是否存在"而是只比对"非空",可能会直接放行。
- Token置空:保留参数名,传空值。有些框架用了
Optional或宽松的反序列化,空值也能过。 - 跨会话Token复用:自己登录一个账号拿到合法Token,放到攻击请求里测试目标账号。如果服务端把Token放在session里但校验逻辑不绑定用户,或者Token是全局固定的,这招能过。
- 双重提交Cookie缺陷:有些实现把Token写进Cookie,又要求请求参数里的Token与Cookie里的Token一致,攻击者完全可以在自己域名下种一个同名Cookie再配合子域或CRLF注入,形成"替身Token"。
- 换站外链诱导Referer绕过:如果服务端只做了Referer关键字匹配(比如判断是否包含目标域名),可以构造
https://example.com.evil.com这样的域名试图绕过。这种弱校验在真实系统里居然还很常见。
每测一步都要看响应差异,不要想当然。我的经验是,光看HTTP状态码不够,还要看响应体里有没有错误提示,有时200不代表成功,302跳转会掩盖真实执行结果。
3.4 靶场对比与漏洞定级
如果你想系统练CSRF的手感,我推荐三个靶场:
- DVWA:重点看Impossible级别的完整防护实现,能直观理解同步器Token、HTTP Only、SameSite这些概念在代码层面怎么落地。
- PortSwigger Web Security Academy:里面的CSRF实验覆盖Token绕过、Referer校验绕过、Login CSRF等场景,跟着做一遍比看十篇文档都管用。
- pikachu:中文靶场,适合和同事一起做技术分享时当演示材料。
定级方面,CSRF在CVSS里通常在中危徘徊,但实际危害取决于目标接口性质。改绑手机、关闭MFA这类能直接撬动账号控制权的,我认为至少应按高危对待,尤其当系统用户基数大、没有其他认证屏障时更要上调。报告里我会把"可利用性+影响面+业务闭环"三个维度写清楚,避免只给一个冷冰冰的分数。
4. 深度防御:框架、协议、业务与兜底缺一不可
4.1 同步器Token:框架内置防御的核心范式
先讲最正统的防御范式:同步器Token(Synchronizer Token Pattern)。核心思想是,服务端生成一个不可预测的随机Token,存入会话;页面渲染时把Token写进表单隐藏域或请求头;服务端收到请求后,比对请求里的Token与Session中的Token是否一致,不一致则拒绝。
用代码形容就是:
# Django风格示意(伪代码,不做实际实现) from django.views.decorators.csrf import csrf_protect @csrf_protect def change_email(request): if request.method == "POST": if not request.csrf_token == request.session["csrf_token"]: return HttpResponseForbidden("CSRF check failed") # 正常逻辑 ...现代主流框架基本都内置了这个机制:Spring Security的CsrfTokenRepository、Django的CSRF中间件、Laravel的VerifyCsrfToken、Express生态的csurf。我强烈建议直接用框架默认实现,别自己造轮子。自己写Token校验最常见的坑就是"校验了但校验不彻底",比如只在某几个接口加了校验而漏掉了新增接口,或者Token生成后没有足够随机性。
4.2 SameSite Cookie:浏览器侧最有效的一道闸
CTF和实战里经常听到"SameSite一开,CSRF少一半",这句话虽然不够严谨,但方向是对的。Chrome 80之后,未显式指定SameSite的Cookie默认按Lax处理,这直接拦截掉了绝大多数跨站POST请求。
三种模式的区别我整理成表格:
| 属性值 | 跨站GET(顶级导航) | 跨站GET(img/iframe等子资源) | 跨站POST | 适用场景 |
|---|---|---|---|---|
| Strict | 不携带Cookie | 不携带Cookie | 不携带Cookie | 安全要求最高的核心站点 |
| Lax | 携带Cookie | 不携带Cookie | 不携带Cookie | 绝大多数站点默认选择 |
| None + Secure | 携带Cookie | 携带Cookie | 携带Cookie | 必须跨站携带Cookie的场景,如单点登录 |
需要注意,Lax虽然拦了子资源加载和POST,但顶层导航的GET仍然会携带Cookie。如果系统里还有GET型状态改变接口,Lax也救不了你。另外"同站"不等于"同源",www.example.com和login.example.com在SameSite判定里是同一站(都是example.com的子域),互相发请求不受影响。这个细节在评估多域名系统时特别容易踩。
4.3 业务二次校验:防御绕过后的最后底线
框架层和协议层都可能被绕过,所以真正高价值的操作必须在业务层加一道人工确认。我见过的比较可靠的做法有:
- 改绑手机/邮箱:强制输入当前密码,或发送短信验证码到原手机号。
- 修改密码:要求先完成一次账号内的高强度认证再允许进入改密流程。
- 关闭双因子认证:必须有二次确认交互,比如弹窗、验证码、短时令牌。
- 提现/转账:U盾、动态令牌、手机验证码三选一。
这类校验的优势在于:就算某个CSRF绕过手法把框架防御全打穿了,攻击者也无法完成"业务闭环"——他拿不到用户的密码,收不到短信,调不起二次确认。这也是为什么我们说"深度防御",因为每一层都有能力单独兜住漏洞。
4.4 Origin/Referer校验与接口约定
如果不想全面引入Token机制(比如纯接口服务),至少应该做来源校验。
我最推荐校验Origin请求头,因为它由浏览器保证,包含精确的协议+域名+端口,而且不会被隐私策略裁剪掉。Referer作为辅助手段也有价值,但有几个坑:
- 部分浏览器/隐私扩展会不发送Referer,服务端如果直接拒绝"无Referer"请求,会误伤正常用户。
- Referer里可能包含敏感URL路径,不利于隐私。
- 弱校验(只判断是否包含目标域名)很容易被
example.com.evil.com绕过。
另一个被低估的接口约定是对自定义Header的利用。跨站简单请求无法自定义Header(否则会触发预检),所以服务端要求请求头必须带X-Requested-With: XMLHttpRequest或业务自定义的X-Client-Type,能挡住一大片表单自动提交。但这个方法有一个前提:CORS配置必须是严格白名单,不能让Access-Control-Allow-Origin: *和Access-Control-Allow-Headers: *同时出现,否则预检一过,自定义头就形同虚设了。
4.5 CORS防线与CSRF防御不可混为一谈
这个坑我见得太多了,单独拿出来说。CORS解决的问题是"跨域读取响应",CSRF防御解决的问题是"跨站发送请求要不要被接受"。这两个概念经常被混在一起,导致两个方向都做错。
比如有人把Access-Control-Allow-Origin配成动态反射,并让它跟着Origin头回显,本意是"方便前端跨域调用",结果等于把CSRF防御的辅助手段也废了。又比如有人以为"CORS默认不允许跨域,所以CSRF也没事"——实际上普通表单请求是简单请求,根本不触发CORS机制,该来的CSRF照样来。
正确的理解是:CORS应该只开放给可信来源,且永远不要通配符+携带凭据同时出现。CSRF防御要靠前面讲的Token、SameSite、来源校验来解决。做好CORS是必要的,但它绝不能替代CSRF防御。
5. 防了等于没防:几个典型翻车现场与检查清单
5.1 看起来有防护,实际能被绕过的五种配置
这些年审核过的CSRF防护代码里,反复出现五种"表面安全"的实现,我列出来给大家提个醒:
- 只在登录接口做CSRF防护,忽略了登录后最敏感的那批操作。攻击者需要的是"已登录用户"的接口,不是登录接口本身。
- Referer校验写成"包含域名即通过",结果被
https://example.com.attacker.com绕过,或者因为浏览器不发Referer而全部拒绝,造成大量真实用户无法操作。 - 双重提交Cookie把Token放在Cookie和参数里做一致性比对,但忘了校验两个Token是否都来自同一Cookie,攻击者在自己控制域下种Cookie就能伪造。
- Token没有与Session绑定,一个Token全局通用,属于"有防护和没防护一个样"。
- SPA项目把业务Token放进Cookie,图拦截器自动携带方便,却不设SameSite属性,等于把CSRF攻击面重新打开。前后端分离项目正确的做法是用
Authorization: Bearer头传递Token,天然避开CSRF这一类问题。
5.2 一份可直接对照的自查清单
我把这些年检查和修复CSRF问题的经验整理成一份清单,开发和测试都能直接对照着过:
| 检查项 | 建议做法 | 不推荐 |
|---|---|---|
| 状态改变请求方法 | 必须POST/PUT/DELETE,并在服务端限制方法 | 用GET承载敏感操作 |
| 会话Cookie属性 | SameSite=Lax或Strict + Secure + HttpOnly | SameSite未设置(旧浏览器) |
| Token机制 | 同步器Token/框架内置实现,与Session绑定 | 全局固定Token |
| 双重提交Cookie | 可用但必须确认Cookie与参数一致性校验逻辑 | 只比对参数与Cookie字面值 |
| 来源校验 | 校验Origin,Referer作辅助 | 只判断Referer是否含域名 |
| JSON接口 | 严格校验Content-Type,拒绝非预期格式 | 同时接受JSON与urlencoded参数 |
| 自定义请求头 | 服务端要求X-Requested-With等自定义头 | 没有要求、CORS配置通配 |
| 高敏感操作 | 增加验证码/二次密码/短信校验 | 仅依赖框架层防御 |
| 前端存储 | SPA用Authorization头传Token | 业务Token放Cookie且无SameSite |
5.3 测试与报告过程中的几点个人建议
最后分享几条从实际项目里攒出来的经验。第一,CSRF的测试一定在授权范围内进行,用专门的测试账号,不要在正式环境对真实用户操作做验证。第二,报告里除了完整复现路径和PoC,一定要给出针对当前项目的修复建议,最好直接写到代码层面:是加中间件、改Cookie属性、还是在业务层加二次校验,都要说清楚。第三,别把CSRF单独当"一个漏洞"看待,看看它能不能与其他问题组合:开放重定向、CORS错误配置、子域XSS,都会让CSRF的实际危害成倍增长。
我个人做安全测试时还有一个习惯:把自己想象成最懒的攻击者——不写脚本、不搞插件,就发一个HTML文件给目标浏览器,看它能不能被打穿。CSRF漏洞漏洞往往就藏在"开发觉得这不可能被利用"的自信里。你把这条思路走通了,这个漏洞基本就跑不掉了。