ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CSRF攻击原理与4大防御方案实战:同步令牌、双重Cookie、SameSite与请求头校验

CSRF攻击原理与4大防御方案实战:同步令牌、双重Cookie、SameSite与请求头校验

1. CSRF攻击的本质与工作流程

上一期我把CSRF攻击的发生原理、攻击链路拆了个底朝天,这一期直接上干货:4种我已经在真实项目里落地验证过的防御方案,以及每一种方案的适用场景、配置细节和会遇到什么坑。

先说一个真实场景。某运营后台系统上线前一天,某同事通过内部测试环境发现管理员的账号被刷了一堆垃圾订单,每次提交都带上了管理员的登录态,可管理员本人什么都没点。这就是教科书级别的CSRF攻击——攻击者根本不需要拿到你的Cookie或密码,只需要让受害者的浏览器向目标服务发起一个携带认证信息的请求就够了。

CSRF全称Cross-Site Request Forgery,中文叫跨站请求伪造。它有两条核心特征:第一,攻击发生在受害者的浏览器内,服务端视角根本分不清这个请求是用户主动发起的还是被诱导发起的;第二,攻击的根源不是Web应用没有做认证,而是认证机制本身被信任得太盲目——只要请求携带了合法的Cookie,服务端就默认它来自用户本人意愿。

拆开攻击链路就一目了然。受害者先登录了某站点A,浏览器里存着A的会话Cookie。此时受害者又访问了攻击者控制的站点B,B的页面上有一张图片或一个表单,请求的地址正好是A的某个敏感接口。浏览器在发起这个请求时,会自动带上属于A域名的Cookie,攻击者不需要读取Cookie内容,只需要让请求发出去。一旦A站点的后端没有校验请求来源、没有校验关键操作的其他认证要素,攻击就成功了。

这里有个初学者最容易混淆的点:CSRF和XSS完全是两回事。XSS靠的是往页面里注入脚本,本质是代码注入问题,攻击者能直接拿到用户会话;CSRF靠的是借用户之手发请求,本质是请求伪造问题,攻击者不需要读取响应内容,只需要请求被执行。搞清楚这一点,对后面的防御方案理解非常有帮助——防XSS需要过滤输入,防CSRF需要让攻击者伪造不出合法请求。

2. 4种可落地防御方案一览

防御CSRF的思路只有一条:让服务端能够区分出这个请求是用户主动发出的,而不是浏览器被劫持后自动发出的。围绕这个目标,行业里沉淀出了多个方案。我整理了一张总览表,后面逐项展开:

防御方案核心思路是否需要服务端存储状态适配场景防御强度
同步令牌模式服务端下发随机Token,请求时校验需要(Session存储)服务端渲染的传统Web应用强
双重提交CookieToken同时放在Cookie和请求参数中,比对两者不需要前后端分离、无状态API中上
SameSite属性浏览器跨站请求自动限制Cookie携带不需要现代浏览器全覆盖的场景中上(依赖浏览器)
自定义请求头校验强制请求携带自定义Header,利用同源策略拦截跨域请求不需要前后端分离、API服务强(需配合CORS策略)

先说结论:没有银弹。每个方案都有它的舒适区,也都有它防不住的东西。我在实际项目里见过不少团队把某一种方案当成万能药用了两三年,直到某次安全审计才暴露出问题。下面逐个拆解。

2.1 方案一:同步令牌模式(Synchronizer Token Pattern)

这是最经典、也是安全界公认最稳妥的方案,Java Web时代就被大量使用,到现在依然是防御CSRF的黄金标准。

原理很简单。服务端在渲染页面的时候,往表单里塞一个隐藏字段,字段值是一个随机生成的Token,同时把这个Token存进当前用户的会话中。用户提交表单时,服务端比对请求带回来的Token和会话里存的Token是否一致。攻击者的页面没有这个Token,因为跨站请求根本读不到目标站点渲染出的表单内容,所以伪造不出来。

很多团队的落地方式是在后端写一个拦截器或者过滤器,对POST、PUT、DELETE这类会改变状态的请求统一校验Token,GET请求放行。这里有个非常重要的操作细节:Token必须同时存在于用户会话和请求参数中,且每次校验完毕、用户敏感操作完成后应当轮换一次。我见过一个项目,Token生成一次就永久有效,结果Token泄露之后攻击者能够在两个月内持续伪造请求,等发现的时候数据已经被改了一轮。

同步令牌模式最大的问题是对前后端分离架构不友好。前端渲染页面时拿不到服务端Session里存的Token,得额外暴露一个接口去取,取Token的接口本身又成了攻击面。所以这个方案在传统服务端渲染的项目里依然推荐,但在前后端分离的架构里我更倾向于用后面几种。

2.2 方案二:双重提交Cookie(Double Submit Cookie)

双重提交Cookie是专门为了解决无状态API场景的问题而生的方案。它的思路是把Token放在两个地方:一个放在Cookie里,一个放在请求参数或者自定义Header里,服务端只需要比对这两个值是否一致。

为什么这样可以防CSRF?因为攻击者发起跨站请求时,浏览器会自动携带目标域的Cookie,但攻击者的JS脚本没有能力往目标域的请求里写入自定义Header,也没有能力在请求参数里带上一个和Cookie值完全一致的Token。即使Cookie值被携带了,请求参数里的Token对不上,校验直接失败。

我在实际项目中落地这个方案时用的路径是:用户登录成功后,服务端生成一个随机Token,写入一个叫csrf_token的Cookie,同时前端在封装AJAX请求的公共函数里统一从Cookie读取这个值,放进每个请求的X-CSRF-Token头里。服务端拦截器校验这个Header的值和Cookie的值是否相等。

配置的时候有几个容易被忽略的细节,踩过才长记性:

  • Token不能通过页面渲染带入。有的团队图省事,把Token在HTML里输出出来,再让JS读取,这等于直接把这个屏障拆了——攻击者只要能控制页面的一部分内容,就能知道Token值。
  • Cookie的域名和作用路径要限制死。不要使用domain=.example.com这种写法,尽量只设在目标服务对应的域名上,缩小攻击面。
  • 同样需要定期轮换。登录态刷新时Token必须跟着刷新,我习惯的做法是每次登录或者权限变更时重新生成一次。

2.3 方案三:SameSite属性

SameSite是浏览器层面的防御机制,不依赖后端代码,改动极小。它的作用就是控制Cookie在跨站请求时要不要携带,有三个值:Strict、Lax、None。

Strict模式下,浏览器完全不允许跨站请求携带这个Cookie,不管是GET请求还是POST请求。这是最严格但最影响用户体验的模式——用户从外部链接点进你网站时,首次访问可能处于未登录状态,因为那次导航请求的Cookie被拦了。Lax模式放开了顶级导航的GET请求,用户从别的站点点链接进入时可以带着Cookie,但对POST表单提交这类请求依然拦截,是谷歌推荐的默认值。None模式则是跨站也允许携带,必须配合Secure属性,意味着只在HTTPS下生效。

这里必须提醒一句:别以为设了SameSite就高枕无忧了。它依赖浏览器,老版本浏览器的行为各不相同。我在某个项目里测过,某国产浏览器的部分旧版本对SameSite属性的支持存在兼容问题,解析异常时Cookie会被默认当成None处理。如果你们的用户群体里有大量老浏览器使用者,SameSite只能作为辅助防线,不能作为唯一防御。

落地时我的做法是三层叠加:SameSite=Lax兜底拦掉大部分普通攻击,再配合双重提交Cookie校验真正敏感的写操作。两层互相补位,比单一方案稳健得多。

2.4 方案四:自定义请求头校验

这个方案的原理稍微绕一点,但理解之后会觉得很巧妙。浏览器的同源策略规定,网站A的JavaScript无法读取网站B返回的内容,若要跨域,需要网站B通过CORS头部明确允许。其中有一个细节很多人不知道:发送自定义Header的跨域请求,会触发CORS预检请求,也就是OPTIONS请求。攻击者的跨站页面发起的请求,例如通过表单提交或图片标签,根本不可能附带自定义Header,更没法通过预检。

后端实现只需要加一层校验:凡是需要防护的请求,必须携带某个约定的自定义Header(比如X-Requested-With或X-CSRF-Guard),没有的一律拒绝。前端只要在AJAX请求里加一个Header就行了。

这个方案的优势很突出:服务端不用管Token、不用管Session、不用管Cookie,逻辑极简;缺点也明显——依赖CORS配置的正确性。如果服务端对预检请求响应了错误的CORS头部,比如Access-Control-Allow-Origin: *,攻击者又恰好能往跨域请求里塞自定义Header(Chrome实际默认不允许,但配置错误的场景确实存在),这个防线就形同虚设了。所以用这个方案前,必须先全局梳理一遍CORS策略,别让后门自己留出来。

我在项目里的实际配置是,拦截器判断请求路径是否属于敏感操作,若是则校验Header,同时专门写了一个CORS配置类,只允许可信的前端域名发起跨域访问。这两个配置绑定在一起,缺一个都不能上线。

3. 实战落地:方案选型与配置实例

理论说完了,该看真东西了。这一节我以某个模拟项目X为例,带大家走一遍完整落地流程。项目背景是:一个前后端分离的中后台管理系统,登录态用Cookie存JWT,后端提供RESTful API,前端用Axios发请求。

3.1 方案选型:为什么我最终选了双重提交Cookie

项目是前后端分离的,同步令牌模式首先排除掉:前端拿Token还得专门开接口,得不偿失。SameSite做了兜底,让Cookie在跨站POST请求时不携带,但由于用户群体不确定浏览器版本,单独依赖不踏实。自定义请求头校验可以,但之前的团队没怎么管CORS配置,存量接口里跨域设置得乱七八糟,真要梳理出白名单得花不少时间。

最终选择了双重提交Cookie + SameSite=Lax叠加的方案。选它的理由有三个:

  1. 不需要额外存储Token,服务端无状态,契合现有架构;
  2. 前端只需要在请求封装里加一个Header,改动面小;
  3. 叠加SameSite后,即使某一层被绕过,另一层还能拦住。

3.2 服务端实现步骤

后端用的某主流Web框架为例,实际操作时照着思路迁移到你自己的语言和框架就行。

第一步:登录成功后生成Token并写入Cookie。Token用强度足够的随机数生成,不能是自增数字,不能是时间戳的base64。

import secrets def generate_csrf_token() -> str: # secrets.token_urlsafe 默认长度是32字节,熵足够 return secrets.token_urlsafe(32) def set_login_cookie(response, user_id): csrf_token = generate_csrf_token() # 设置两分钟后永久的有效期,随登录态走 response.set_cookie( "csrf_token", csrf_token, max_age=7200, httponly=False, # 注意这里不能设为True samesite="Lax", secure=True, ) # 同时把csrf_token返回给前端,供后续请求读取 return csrf_token

这里有个反直觉的点:httponly这个属性通常应该设为True防XSS窃取Cookie,但在双重提交方案里必须设为False,因为前端的JavaScript需要读取这个Cookie的值来放到Header里。如果设为True,前端读不到Token,整个方案的链路就断了。

第二步:写一个拦截器,对所有非GET请求做校验。

def csrf_middleware(request): if request.method in ("GET", "HEAD", "OPTIONS"): return None # 这些请求不校验 cookie_token = request.cookies.get("csrf_token") header_token = request.headers.get("X-CSRF-Token") if not cookie_token or not header_token: return error_response(403, "CSRF token missing") if cookie_token != header_token: return error_response(403, "CSRF token mismatch") return None

注意校验函数里用的是恒等比较,不要用to_uppercase之类的处理,免得攻击者通过编码差异绕过。

第三步:前端封装Ajax请求时统一带Header。

// 基于axios的封装,其他框架思路相同 axios.defaults.withCredentials = true; axios.interceptors.request.use(config => { const token = document.cookie .split('; ') .find(row => row.startsWith('csrf_token=')) ?.split('=')[1]; if (token) { config.headers['X-CSRF-Token'] = token; } return config; });

需要强调一个关键点:withCredentials必须为true,否则跨域请求不会携带目标域的Cookie,整个CSRF校验根本走不通。这个配置最容易被漏掉,我第一次对接时前端同事在公共请求里忘了配,结果线上所有写操作的拦截器日志全是CSRF token missing。

3.3 静态资源与白名单路径处理

实际项目里不是所有接口都要校验CSRF。我的经验是把请求分成三类:

  • 需要校验:登录态相关的写操作,如修改密码、修改资料、下单、删除资源等;
  • 不需要校验:登录接口本身(此时还没有Token)、GET请求、健康检查接口、静态资源;
  • 特殊处理:对外暴露的Webhook回调接口,这类接口的调用方通常是第三方服务器,不是浏览器,携带不了浏览器Cookie,所以需要单独设置API密钥,不能用CSRF Token方案硬套。

白名单配置要收敛到具体路径,不要图省事直接放行整个子路径前缀,除非这个前缀下真的没有敏感操作。

4. 实测验证:攻击模拟与防护效果

防御方案上线后,必须实际模拟CSRF攻击来验证效果,这步我没见过哪个成功项目跳过去过。下面是我自己做验证时的完整过程,可以照着操作。

4.1 搭一个攻击测试页面

新建一个HTML文件,模拟攻击者的页面。核心逻辑就是用自动提交的表单向目标接口发POST请求:

<!DOCTYPE html> <html> <body> <h2>攻击测试页面</h2> <form action="https://your-api.example.com/api/user/password" method="POST"> <input type="hidden" name="new_password" value="evilpass123"> <script>document.forms[0].submit();</script> </form> </body> </html>

把这个页面放到另一个域名下打开。注意,你的浏览器必须先登录了目标系统,保证Cookie存在。

4.2 验证结果与日志关键信息

未接入防御时,这个表单提交会直接成功,密码被改掉。接入双重提交Cookie后,请求进入拦截器,因为表单请求没有携带X-CSRF-TokenHeader,直接返回403。在服务端日志里能找到类似这样的记录:

[CSRF] Rejected request from 203.0.113.25: missing X-CSRF-Token

这个IP可能已经暴露了关键信息。攻击者可以控制表单的action地址和参数,但控制不了请求头和Cookie的业务逻辑。请求头如果想篡改,需要真正控制受害者浏览器里的代码执行环境——那已经是XSS层面的事了。

4.3 自动化测试脚本

手工测试只能覆盖一种场景,建议把CSRF校验的自动化测试用例补进CI流水线里。测试矩阵至少包含这些:

测试场景预期结果
无Cookie无Header提交POST403
有Cookie无Header提交POST403
Cookie和Header值不一致403
Cookie和Header值一致成功
GET请求不带任何Token允许通过
跨域OPTIONS预检请求允许通过

实测发现一个有趣的现象:部分测试框架在模拟请求时默认不带Cookie,这会导致测试结果永远是403,误以为方案有问题。排查了半天发现是Mock客户端需要显式启用Cookie管理,属于工具使用细节,写进测试代码注释里防止后来人再踩。

5. 常见问题与排查技巧实录

这段内容是从多个真实项目调试中整理出来的问题清单,每一件都是我或同事真实踩过的坑。

5.1 移动端App请求被误拦截

App内嵌WebView请求,和普通浏览器不一样,Cookie管理策略掺杂了原生代码逻辑。常见症状:用户登录状态正常,但所有写操作都返回403。排查后发现,原生层用独立的Cookie存储策略,WebView的JS读取document.cookie是空的,导致Header带不上Token。

解决办法是在App端由原生层在发起请求时自动注入X-CSRF-Token头,或者改用自定义请求头校验方案,不依赖Cookie中的数据。这个方案在App场景下反而更简单:原生网络库统一给所有请求加Header,服务端校验通过即可。

5.2 前后端分离时Token只在Cookie里,前端读不到

上面已经提过,httponly必须设为False。但有些团队出于XSS防御惯性,把它设成True,然后发现前端永远带不上Header。这个矛盾的本质是:双重提交方案在“防CSRF”和“防XSS窃取Cookie”之间做了取舍。

我推荐的做法是,敏感的登录态Cookie保持httponly=True,单独设置一个专用的csrf_tokenCookie,不存任何身份信息,专门留给前端读。这样一来,即便Token被XSS窃取,攻击者拿到的也只是一个只能用于CSRF校验的随机值,拿不到用户的会话本身。

5.3 登录接口校验了CSRF导致用户无法登录

逻辑顺序问题。登录请求发生时,用户还没有拿到新的Token,让登录接口走CSRF校验必然导致死锁。白名单里必须放行登录接口。同时,登录成功后的接口最好返回新的Token,因为登录动作本身就是一次Token轮换时机。登出接口同理,但要注意登出前校验一次,防止被恶意登出骚扰。

5.4 服务端有多个随机算法,Token强度不足

某项目用random.randint(100000, 999999)生成Token,只用了6位数字。这个强度对于CSRF防御完全不够,攻击者可以暴力枚举。正确做法是用密码学安全随机数生成器,比如Python的secrets、Java的SecureRandom、Node.js的crypto.randomBytes。Token长度至少16字节,建议32字节。

5.5 拦截器只校验部分方法,被OPTIONS绕过

有的团队为了省事,只对POST请求做校验,PUT和DELETE不校验,攻击者把请求方法改成PUT直接穿透。我见过的一次真实事故就是这个原因:扫描器发现某接口支持PUT修改资源,改个方法就绕过了全部校验。

正确的做法是对所有写操作统一校验。框架层面通常能拿到请求方法,但也要注意,有些旧版Web框架对自定义方法支持不完整,会退化成POST,这时候校验逻辑必须以路径为维度而不是以方法为维度。

6. 写在最后的实操建议

四套方案各自的定位,我用一句话总结:同步令牌模式是稳健的基石,但和现代前后端分离架构有点代沟;双重提交Cookie是无状态服务的主选,但要记住它防不住同站子域名的攻击,如果存在可控的子域名,建议升级为samesite加同步模式;SameSite是成本最低的兜底防线,但别指望它单独抗事;自定义请求头校验是最轻量的一层,前提是整个站点的CORS策略不乱来。

我自己的实战原则是:优先选双重提交Cookie加SameSite叠加,再给登录、授权等敏感接口加一层额外的二次校验,比如输入密码或验证码。这不是防御过度,而是把纵深防御的思路落到实际。你可以想象一下,CSRF攻击一次就能让未授权的写操作成功,即便有Token机制,攻击者配合其他漏洞绕过Token的场景依然存在,多一点校验就多一分缓冲。

最后聊一个扩展方向:如果你的系统里有跨域的业务需求,比如小程序、App、第三方开放平台,上面的方案需要做一些适配。我最近在做的某跨平台系统就碰上了小程序请求无法携带自定义Header、也无法读取Cookie的难题,替代方案是使用签名机制,让客户端用算法对请求参数签名,服务端用密钥验签。这套方案的细节和踩坑过程,等下一期(续篇)再整理给大家。

安全攻防是一场永不停歇的追逐,今天的防御技巧过两年可能就有新的绕过手法,保持学习,保持质疑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进