先说个亲身经历。早些年我给一家做在线教育的平台做渗透测试,业务逻辑里有个“修改绑定手机号”的功能,当时我就在想,如果管理员账号能改绑手机,是不是就能顺藤摸瓜拿到更多权限。结果实测下来,这个接口确实存在CSRF漏洞,而且配合浏览器自动携带Cookie的特性,我构造了一个恶意页面,只要管理员用同一个浏览器点开,他的手机号就被悄悄换绑了。后面要接管账号、重置密码,几乎是顺理成章。那次之后我意识到,CSRF这种漏洞看着不起眼,但一旦打到管理员或核心业务逻辑上,破坏力远超想象。
这篇文章就围绕CSRF攻击展开,从原理、经典漏洞场景,再到落地级防御方案,一步步拆开讲清楚。适合刚入门的安全新人理解漏洞本质,也适合开发同学、运维同学排查和修复线上问题。
1. 认识CSRF:核心原理与攻击面
1.1 什么是CSRF:一场“借刀杀人”的骗局
CSRF(Cross-Site Request Forgery,跨站请求伪造)的核心思路,用大白话讲就是:攻击者没法直接偷你的Cookie,但他能利用你对某个网站的“登录信任”,诱导你的浏览器发出一个伪造请求,而这个请求会被服务器当成是你本人发出的合法操作。
理解这个漏洞要把“浏览器”和“服务器”分开看待。浏览器是“人”和“网站”之间的中介,它负责携带身份凭证(Cookie、Session等),但浏览器本身并不聪明,它分不清当前这个请求是用户自己点的,还是被恶意网页强制触发的。服务器更是“脸盲”,它只校验请求里有没有合法的Session标识,至于这个请求的发起源头、用户的真实意图,它一概不知。
所以CSRF能成立,关键是满足三个条件:
- 用户已经在目标站点登录,且浏览器持有有效的Session或Cookie。
- 目标站点的漏洞接口没有校验请求来源(Origin/Referer),也没有Token一类的随机校验参数。
- 攻击者能构造出一个让浏览器自动发起跨站请求的页面,比如图片标签、表单自动提交、AJAX请求等等。
这三个条件缺一不可。防御方只要死死卡住其中一个,CSRF就打不通。
1.2 CSRF与XSS的本质区别
很多新人容易把CSRF和XSS搞混,其实这俩是两条路线。
XSS(跨站脚本攻击)的核心是“注入脚本”,攻击者把恶意JavaScript代码注入到受信任的页面里,然后借脚本去偷Cookie、改页面、伪造用户操作。XSS攻击的是“用户与页面之间的信任关系”,最终拿到的是代码执行能力。
CSRF攻击的核心是“伪造请求”,攻击者不需要在目标页面里注入任何代码,他只需要让用户浏览器发起一个特定请求,服务器就会以为这是用户的操作。CSRF攻击的是“服务器对浏览器请求的信任”,最终拿到的是“用户身份的操作权”。
举个更直白的对比:XSS是“小偷溜进你家,翻箱倒柜”,CSRF是“小偷在你不知情的时候,拿着你的钥匙从外面把门打开,指挥你家电器干活”。钥匙还在你手里,但电器根本不认识谁才是真正的主人。
如果站内已经存在XSS,那攻击者可以直接用脚本发起AJAX请求,CSRF的很多限制都能被绕过。所以生产环境里,XSS和CSRF经常是“组合拳”出现,修的时候要连着一块修。
1.3 攻击面梳理:哪些功能最容易出问题
CSRF漏洞的本质是“状态变更请求缺乏来源校验”,所以凡是涉及数据修改、状态变化、权限操作的接口,都应该列入排查范围。我有次给某政务系统做等保测评,功能清单上光是“增删改”类的接口就有两百多个,要是一个个手工测,测到天黑也测不完。后来我就按“影响面从高到低”排了个优先级,效果好了很多。
高优先级的功能类型如下表所示:
| 功能类型 | 典型接口 | CSRF风险等级 | 说明 |
|---|---|---|---|
| 账号体系 | 修改密码、修改邮箱、换绑手机、注销账号 | 极高 | 一旦被伪造,直接导致账号被接管 |
| 权限操作 | 添加管理员、修改权限、审核通过 | 极高 | 伪造后就是垂直越权,后果严重 |
| 交易/支付 | 转账、下单、修改收货地址、退款申请 | 高 | 直接关联资金和物流信息 |
| 配置管理 | 修改系统配置、更新黑白名单、上传文件 | 高 | 篡改配置后可能进一步打入内网 |
| 内容操作 | 删帖、改文章、批量操作、投票点赞 | 中 | 配合钓鱼页面可实现自动化破坏 |
| 查询类接口 | 搜索、查看详情、下载 | 低 | 查询类默认不纳入CSRF防护范围,但涉及敏感数据泄露的查询也要关注 |
我在实际项目中的习惯是:凡是这个接口“操作之后很难撤销”的,都要优先测。比如说修改邮箱,改完之后攻击者马上可以发起找回密码流程,受害者再想找回自己的账号就很被动了。这类接口一旦确认有CSRF,漏洞报告的危害定级基本都能达到中高危。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 经典漏洞场景:为什么这些案子能打中
2.1 GET型CSRF:一行代码引发的血案
最早的CSRF漏洞案例里,绝大部分是GET请求。原因也好理解,早期很多功能为了省事,把“状态变更”也塞到了GET请求里,比如“删除用户?id=1”“退出登录”“修改邮箱?email=xxx”,这种接口只要让浏览器请求一次就算完成操作。
攻击者构造一个恶意页面,里面放一个隐藏的图片标签:
html复制<img src="https://bank.example.com/transfer?toAccount=attacker&amount=10000" style="display:none" />
当受害者登录网银后,浏览器自动向这个URL发起请求。因为受害者Session仍然有效,服务器收到请求后直接执行转账。图片标签的src请求是从页面发起的,浏览器不会拦截,也不会要求用户确认,整个过程在用户毫无感知的情况下就完成了。
现在很多现代框架默认要求POST,但这个坑并没有绝迹。我见过不少老系统改造时只把前端的form从GET改成了POST,后端接口却同时兼容GET和POST请求参数,导致攻击者改用GET照样能打穿。所以排查CSRF的时候,不能光看前端提交方式,要抓包确认后端真实接受的HTTP方法是什么。
2.2 POST型CSRF:表单自动提交的攻击
POST型CSRF比GET型稍微复杂一点,因为浏览器不会自动帮你发POST请求,需要借助JavaScript或者自动提交的form表单。最常见的攻击手法是构造一个隐藏表单,配合JavaScript自动submit。
html复制<html>
<body>
<form id="csrfForm" action="https://bank.example.com/transfer" method="POST">
<input type="hidden" name="toAccount" value="attacker" />
<input type="hidden" name="amount" value="10000" />
</form>
<script>
document.getElementById("csrfForm").submit();
</script>
</body>
</html>
这里有一个经常被初学者忽视的知识点:浏览器在同源策略里限制的是“读取跨域响应”,但“发出跨域请求”本身是不受限制的。也就是说,你的页面可以往第三方网站发请求,只是你读不到响应而已。CSRF攻击恰好利用的就是这个“可发不可读”的盲区——攻击者根本不需要读取响应,只要请求能到达目标服务器,任务就算完成了。
这也是很多开发同学最难理解的一点:为什么我的接口明明没开CORS,还是被浏览器正常发出去了?因为在CSRF场景中,CORS限制的是响应的读取,而不是请求的发起点。
2.3 JSON型CSRF:跨域请求的另类玩法
JSON数据格式在现代Web开发里非常普遍,前端用AJAX发送Content-Type: application/json的请求很常见。如果后端的接口只校验了JSON格式、没有校验Token,那么攻击者构造一个跨域表单请求,Content-Type会被浏览器设置成text/plain,但后端如果对Content-Type校验不严、只解析body内容,这个请求照样能被处理。
这里有个非常经典的绕过场景。比如后端PHP代码这样写:
php复制$data = json_decode(file_get_contents("php://input"), true);
// 直接使用 $data 里的参数,不校验来源
攻击者构造的form表单虽然Content-Type是text/plain,但body内容可以发送任意格式的JSON字符串。后端通过php://input读取原始输入流再进行JSON解析,跟Content-Type是什么完全不冲突。于是这个请求就被正常处理了。
更早期的CORS配置不当还会让JSON型CSRF可以直接通过AJAX发起跨域请求,服务器配置了Access-Control-Allow-Origin: *且允许携带凭证时,攻击者能够直接读取响应内容,那这种漏洞就不再是盲打了,而是完全意义上的“跨域接管”。
2.4 常见绕过场景:Token保护的盲区
很多系统用了Token防护,以为高枕无忧,实测下来绕过手段也非常多。比如以下几种场景在我实际渗透中遇到过不少次:
- Token存在Cookie里跟Session绑定,但校验时只对比两个字段是否一致,攻击者拿自己的Token替换掉Cookie里的Token就能通过校验。
- Token是全局静态值,所有用户共用同一个Token,攻击者自己就能拿到合法Token,伪造请求时带上就行。
- Token在服务端校验但接口支持重放,整个会话过程中Token都不刷新,攻击者在用户触发请求前先“借用”一次合法Token。
- 同源策略配置不当,子域名可控或者CORS配置宽松,攻击者可以在可控域名下发起同源请求,直接拿到合法Token。
每次遇到“我们系统有Token所以肯定安全”的说法,我都会拿这几个场景去测一遍。Token确实能拦住一大半脚本小子,但真正严谨的CSRF防护,靠的是纵深防御,而不是单点依赖。
3. 落地级防御方案:每一步都要有依据
3.1 正确理解同源校验:Origin和Referer怎么用
校验请求来源是最直接的防御方式之一,但用起来有不少细节。
我先说结论:优先校验Origin头,Referer作为辅助和兜底。
Origin头是浏览器在处理跨域请求时主动携带的字段,绝大多数现代浏览器都支持。它只表示“请求从哪个源发出”,不会泄露完整URL路径,隐私性好。Referer字段包含完整URL路径,可能包含敏感信息(比如token拼在URL里),而且某些浏览器插件、隐私模式、跳转过程中可能会去掉Referer,如果后端强校验Referer,用户正常操作反而会被误伤。
我踩过的一个坑是:当时给客户系统加CSRF防御,只校验了Referer,结果用户从另外的页面跳转过来时,Referer是目标站点的上一个页面地址,匹配逻辑写得太严,直接把正常请求拦截了。后来改成“同时检查Origin和Referer,两个字段中只要有一个可信就放行”,误杀率大幅降低。
除了校验逻辑,还要注意一个问题:别对同源请求过于信任。SaaS系统里经常会有多域名共存的情况,安全运维同学要梳理出完整的可信域名白名单。白名单里一旦混入了用户可控的域名,比如user.attacker.example.com,等同于CSRF防御失效。
纯代码示例(Java实现):
java复制public class CsrfOriginChecker {
private static final Set<String> ALLOWED_ORIGINS = Set.of(
"https://www.example.com",
"https://account.example.com"
);
public static boolean isSafeRequest(HttpServletRequest request) {
String origin = request.getHeader("Origin");
String referer = request.getHeader("Referer");
// 非浏览器场景(如App、服务端调用)可以不校验来源
if (origin == null && referer == null) {
return true;
}
// 两个来源字段都被浏览器带上的时候,任何一个可信即可
if (origin != null && ALLOWED_ORIGINS.contains(origin)) {
return true;
}
return referer != null && ALLOWED_ORIGINS.stream()
.anyMatch(domain -> referer.startsWith(domain));
}
}
这只是校验的雏形,严谨的工程落地还会考虑代理层、网关层统一处理,避免每个业务团队重复造轮子。
3.2 Synchronizer Token:业界最成熟的主力方案
Synchronizer Token Pattern,翻译过来叫“同步令牌模式”,是目前应用最广泛的CSRF防护方案。它的原理是:服务端生成一个随机Token,把它绑定到用户会话里,前端在提交请求时把这个Token一起提交,服务端校验Token是否存在且匹配。
这个方案的逻辑很简单,但有几个工程细节经验必须说:
第一,Token必须由服务端生成,不能由前端生成,也不能由客户端自定义。安全随机数生成器(Java里用SecureRandom,Python里用secrets.token_urlsafe)配合至少128位的长度是基本要求。用时间戳、用户ID、简单自增数字生成的Token都能被预测,攻击者可以自己算出来,等于没有防护。
第二,Token要跟Session绑定。具体来说,服务端把Token存在Session里,校验时对比请求提交的Token和Session里的Token是否一致。如果只是把Token放在Cookie里再跟请求参数对比,攻击者可以先在自己的浏览器里拿到一个合法Token,塞进伪造请求的Cookie里,照样可以绕过。
第三,同一个用户的Token要不要滚动更新?从安全角度看,每次请求后更换Token能防止重放攻击,但从用户体验和并发请求来看,频繁换Token反而可能造成多标签页互相顶掉登录态。我的建议是:核心业务(改密、支付)单独生成一次性Token,普通业务沿用会话级Token,双轨并行。
我用过的最省心的一种实现,是Spring Security内置的CsrfFilter:
java复制http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.ignoringRequestMatchers("/api/public/**", "/api/webhook/**")
);
CookieCsrfTokenRepository会把Token写到Cookie里(注意HttpOnly要设成false,否则前端读不到),前端通过meta标签读取Cookie里的Token值,再放到请求头里。这种方式对前后端分离的项目很友好。
3.3 双重提交Cookie模式:无状态场景的好选择
前面说的Synchronizer Token需要存储会话状态,对于无状态API服务或者微服务架构,把Token存Redis也是一种方案,但部署上会多一层依赖。这时可以考虑“双重提交Cookie”模式。
它的做法是这样的:服务端在用户登录时,生成一个随机Token写入Cookie;同时前端在JS环境里拿到这个Cookie值,放在自定义请求头(比如X-Csrf-Token)里一起提交。服务端校验时,只需要比较请求头里的Token和Cookie里的Token是否一致,不需要额外存储。
为什么这样能抵御CSRF?因为攻击者的恶意页面无法读取目标站点的Cookie(受同源策略限制),所以他没法在伪造请求里带上跟Cookie一致的请求头。而浏览器发起的跨站请求虽然会自动携带Cookie,但无法带上攻击者指定的自定义请求头,校验自然就通过不了。
这个方案的取舍点在于:每个子域名下的Cookie都是独立的,如果系统有很多子域名,需要把Cookie作用域设为顶级域名,这样任何一个子域名的XSS或者可控页面都能读取Token,安全边界会变大。另外Token存在Cookie里且不设HttpOnly的话,一旦站内存在XSS,Token也会跟着被偷。所以双重提交Cookie适合的是“无状态API + 无XSS风险”的场景,如果站内XSS问题还没修复,优先考虑同步令牌模式。
3.4 SameSite Cookie属性:浏览器层面的制度性拦截
SameSite是浏览器提供的一个关键安全属性,它能在Cookie跨站请求时决定“要不要自动携带”。这个属性防CSRF的思路跟前面几种完全不同——前面几种都是在应用层校验,SameSite是在浏览器这层就做了拦截。
三个值的区别:
Strict:严格模式,只要不是同站请求,一律不携带Cookie。安全等级最高,但用户体验确实有影响,比如从第三方网站跳转到本站,登录态完全丢,需要重新登录。Lax:宽松模式,所有跨站子请求(图片、脚本、iframe)不携带Cookie,但顶级导航的GET请求(比如用户点击链接跳转)会携带。兼顾了安全和大部分正常业务场景。None:不限制,跨站请求正常携带,但必须配合Secure属性(只走HTTPS),否则现代浏览器直接忽略。
我在项目里优先推荐Lax。因为CSRF攻击中最主要的手法就是跨站子请求(form自动提交、图片、AJAX),Lax模式下这些请求全部不带Cookie,攻击面直接砍掉一大半。而用户正常从邮件、IM软件点击链接跳转进来(顶级导航),Cookie依然能带,登录态不会断,体验几乎无损。
设置方法也很简单:
http复制Set-Cookie: JSESSIONID=xxxx; Path=/; Secure; HttpOnly; SameSite=Lax
需要注意,SameSite不是万能的。如果系统存在CORS配置不当、子域名可控、或者用户浏览器过旧不支持SameSite的情况,还是得依赖应用层的Token校验。我把SameSite定位为“第一道闸口”,Token校验是“第二道闸口”,两层叠加才算靠谱。
3.5 自定义请求头兜底方案
还有一类比较轻量的做法是给关键接口增加自定义请求头,比如X-Requested-With: XMLHttpRequest。
这样做的逻辑是:浏览器在跨域请求时,如果尝试带自定义请求头,会触发CORS预检(OPTIONS请求)。在目标站点没有配置对应CORS白名单的情况下,预检就会失败,攻击者的JS方案没法携带这个自定义头。而form表单提交的方式,又没法设置自定义头。两头一堵,请求很难被伪造。
但必须说实话:这个方案的单点可靠性不算高。它依赖的是浏览器跨域机制,如果站点配置了宽松的CORS策略,攻击者的AJAX请求也能带上自定义头。所以我在实际项目中只把它当作辅助手段,不会单独作为核心防线。
更稳妥的结构是:SameSite=Lax + Synchronizer Token + 双击提交校验(敏感操作二次确认),再配合CORS白名单和敏感操作的短信/验证码确认,基本就是企业级CSRF防御该有的样子。
4. 常见问题与排查技巧实录
4.1 接口老改不动,怎么低成本快速止血
很多老系统接口一大堆,一时半会儿改不过来,但漏洞不能拖着不修。这种场景下我一般按以下步骤推进:
第一步,全局启用SameSite。在负载均衡/网关层或应用层统一为Cookie添加SameSite=Lax属性。这一步不需要改业务代码,但能拦截掉绝大多数跨站子请求类的CSRF。某客户系统大概有两千多个接口,光这一步上线后,扫描器报的CSRF直接归零。
第二步,过滤器统一校验。写一个Filter或者Interceptor,对所有非GET、非HEAD、非OPTIONS请求做来源校验并统一放行白名单接口。如果时间足够,顺手加上自定义请求头的校验逻辑。
第三步,关键接口单独加固。优先处理管理员相关接口、支付转账接口、密码修改接口,这些接口单独加Token校验和二次确认弹窗。这一步不用全覆盖,先保命。
第四步,逐步替换。把老系统里手动管理Token的方式,统一迁移到框架自带的CSRF保护,比如Spring Security、Django的CsrfViewMiddleware,避免以后再出现开发人员忘了加防护的情况。
这个方案的好处是每走一步都能明显降低风险,不用等全部改造完成才能上线。
4.2 线上用户反馈“操作报错”了怎么办
加了防御之后,最常遇到的问题就是“误伤正常用户”。常见表现有:
- 用户复制商品链接分享给朋友,朋友打开后提交订单失败。
- 用户从微信/钉钉内置浏览器跳转过来,页面正常但操作按钮点击无响应。
- 用户开了隐私模式或者浏览器隐私插件,任何带Token的请求都很不稳定。
排查思路是这样:先看服务端日志,定位是哪一层拦截掉的。如果是SameSite拦截,日志里通常能看到请求头里的Cookie被浏览器剥离的痕迹;如果是Token校验失败,日志会提示Token mismatch。然后根据拦截信息判断是校验逻辑写太严格还是用户场景特殊,再针对性调整白名单的匹配策略。
调试工具上,我推荐直接开一个Chrome的无痕窗口,配合开发者工具里的Network面板查看每个请求头。如果发现请求头里根本没有Token,那就是前端JS读取Token的时机不对;如果Token带了但服务端校验不过,就是会话里的Token和请求里的Token不一致,多半是Token滚动更新逻辑出了问题。
4.3 判断CSRF是否真正修复的验收方法
修复完不能光看代码,要真打一遍。我平时的验收清单是这样的:
- 使用当前会话登录,构造一个跨域页面(本地起个简单HTTP服务就行),然后用该页面发起目标接口请求,观察是否被拦截。
- 用Burp Suite抓取正常请求,把请求里的Token字段去掉后再重放,看服务端是否拒绝。
- 把Token改成错误值再重放,看服务端是否拒绝。
- 修改Origin/Referer为恶意域名,看服务端是否识别并拦截。
- 从浏览器正常操作一遍,确认无功能影响。
- 多标签页同时提交,确认Token滚动更新不会误伤并发操作。
如果这六条全部通过,基本可以判定CSRF防护是有效的。注意每次测试都要清除浏览器缓存和Cookie,避免之前的会话残留干扰判断。
4.4 防御CSRF的三个“不要”
最后按照个人经验,总结几条容易踩的坑:
不要自己发明加密算法。CSRF Token的生成必须用成熟的密码学安全随机数,不要用Math.random(),也不要用可预测的用户信息拼接。你说的“伪随机”和“真随机”的区别,在攻击者面前一定是能被撕开口子的。
不要只在POST接口校验。HTTP方法本身就是可以被攻击者指定的,GET同样能带body,POST也不是不能带URL参数。校验时应该看“接口是否产生状态变更”,而不是“请求方法是不是POST”。
不要忘记注销和会话过期场景。如果服务器不校验Session是否过期,攻击者拿着一个旧Token就能反复重放。加防御的时候,务必确认Token的一次性校验逻辑在Session销毁时同步失效。
5. 写在最后:我对CSRF防御的一点体会
我在实际测试里见过太多为了“展示技术”而强行搞出来的CSRF漏洞报告,嘴上说得天花乱坠,结果一看,连最基础的Origin都没校验。真正的落地级防御,从来不靠某一个“银弹”,而是靠一层层互相补位:SameSite在浏览器层拦截,Token校验在应用层拦截,来源校验作为兜底,敏感操作再加二次确认。每一层单独看都有弱点,但叠在一起,攻击者的攻击成本就指数级上升。
最后再分享一个小技巧:很多团队上线新功能时只给开发同学讲“怎么做”,不讲“为什么这样做”。我后来习惯在团队里组织安全开发的培训,把CSRF这几个经典绕过案例拿出来当反面教材讲一遍。效果立竿见影——新接口从第一版开始就默认带Token,而不是等安全扫描发现问题再回来补丁。这个习惯一旦建立起来,整个系统的安全水位都会跟着抬高一截。
