CSRF(跨站请求伪造)这名字,听起来很高深,但如果你做过几年Web渗透或者安全开发,对这玩意儿一定不陌生。它不需要攻击者拿到你的账号密码,只需要你刚好登录着某个站点,然后诱导你在浏览器里发出一个预先设计好的请求,事情就成了。相比XSS需要费劲找执行点、构造载荷,CSRF的利用门槛低到让人发指——一个<img>标签、一个自动提交的form表单,甚至一条带参链接,都可能成为攻击面。但这个漏洞又特别容易被“误判为已修复”,很多开发团队以为加个token就万事大吉,结果绕一下又回来了。
这篇文章我想系统聊一聊CSRF的原理、主流防御方案,以及防御措施到底能怎么被绕过。文章适合三类人看:一是做安全测试的朋友,想把手里的CSRF测试做得更完整;二是后端开发,想搞明白框架自带的CSRF防护到底防住了什么、没防住什么;三是正在做安全架构评审的负责人,需要一套能落地的纵深防御思路。我把这几年在项目里实测过的案例、踩过的坑、绕过的防御都整理在下面了,尽量做到“能直接抄作业”。
1. CSRF攻击的本质与前置条件
1.1 一次CSRF攻击的完整拆解
先说个我实际遇到的场景。某次给一个内部管理系统做测试,后台有个“添加用户”的接口,POST传参。当时我直接用Burp改了Referer为空,请求照样成功返回——说明后台没校验来源。进一步看,整个后台所有POST接口都没有token、没有SameSite保护,裸奔状态。
接着我现场拼了一个HTML页面,把form的action指到后台地址,诱导管理员点击。页面文案写着“您有一条新消息,请点击查看”,然后click事件触发表单提交。管理员一旦登录状态打开这个页面,后台直接多出一个攻击者账号。
拆解一下这个攻击的完整链路,可以分为四个环节:
- 受害者已登录目标站点。浏览器里存在有效的会话Cookie,且这个Cookie的过期时间没到。很多站点会话动辄一两天,给攻击者留下了很宽的时间窗口。
- 攻击者构造恶意请求。这个请求可以是任意合法请求的“复制品”,比如转账、改密码、发帖、添加管理员。攻击者不需要知道Cookie内容,只需要知道接口地址和参数格式。
- 浏览器在不知不觉中发起请求。跨站请求的发送不受同源策略限制。表单提交、img标签加载、script标签加载,这些都是天然可利用的载体。请求一旦发出,浏览器会自动携带目标站点的Cookie。
- 服务端无法分辨请求来源。服务端看到的只是一个合法的、带正确会话标识的请求,它不会知道这个请求是用户在浏览器里主动点击发起的,还是被一个第三方页面“遥控”发出的。
这个问题的根源,说到底就是HTTP协议的无状态性和浏览器的自动Cookie携带机制一起造成的信任错位。服务端默认了“带合法会话的请求 = 用户自己的操作”,但这个隐含假设在CSRF面前完全站不住脚。
1.2 为什么Cookie认证会成为CSRF的温床
很多同学会问:同源策略不是限制跨域请求吗?怎么表单提交还能跨域成功?
这里要区分一个关键点:同源策略限制的是“读取响应”,不是“发送请求”。
你可以跨域提交一个form表单,目标服务器也能正常处理并返回响应,只是作为攻击者,我们读取不到这个响应内容。但对于CSRF攻击来说,我们不关心响应内容,我们只关心请求能否被执行成功。
这就像你站在门口递给门卫一张写好的纸条,门卫看了纸条就开门了,至于纸条是不是你本人写的,门卫根本不关心——他信任的是“纸条是正在值勤的人递进来的”这个身份。
Cookie认证机制恰好强化了这个“纸条”的逻辑。浏览器在同一个域名下的所有请求都会自动带上对应的Cookie,哪怕这个请求是从一个完全陌生的第三方页面发起的。尤其对于传统的Session Cookie方案,服务端拿到Cookie就认为是同一会话的合法用户,完全不验证请求的“发起者”是谁。
1.3 触发CSRF的三大前置条件
不是随便一个站点都能打CSRF的。我在评估一个目标时,会快速过一下这三个检查点:
- 受害者在目标站点处于登录态。这是最基础的条件。如果目标站点用无状态的JWT且存在
localStorage里,浏览器不会自动携带,CSRF基本不成立。反过来,只要是Cookie里存会话标识的,就要重点看。 - 存在可被攻击者预测的敏感操作接口。接口是固定URL加上固定参数格式,比如
http://bank.example.com/transfer?to=xxx&amount=1000。如果接口参数里有非随机、可猜测的动态值(比如用户ID),攻击者完全能模拟。 - 服务端对该接口的校验不完整。没有CSRF Token、没有Origin/Referer校验、Cookie没设SameSite,或者校验逻辑能被绕过。
只要这三个条件同时满足,CSRF就是成立的。问题在于很多团队只修了其中一处,以为补上了,结果绕一下又废了。
2. 主流CSRF防御方案及其适用边界
2.1 方案一:服务端Token(Synchronizer Token Pattern)
这是最经典、也最可靠的CSRF防御方案。它的核心思路是:在渲染表单的时候,服务端生成一个随机Token并绑定到当前会话;提交请求时,客户端必须把这个Token带回来;服务端再比对请求里的Token是否和会话里存的Token一致。不一致就拒绝。
攻击者之所以没办法伪造这个Token,是因为他无法预测Token值,也无法从目标域读取Token——受同源策略限制。
一个最简的实现长这样(Python Flask示例):
import secrets from flask import Flask, session, request, abort app = Flask(__name__) app.secret_key = 'replace-with-a-secure-random-value' def get_csrf_token(): if 'csrf_token' not in session: session['csrf_token'] = secrets.token_urlsafe(32) return session['csrf_token'] @app.route('/form') def show_form(): token = get_csrf_token() return f''' <form method="post" action="/transfer"> <input type="hidden" name="csrf_token" value="{token}"> <input name="amount" type="text"> <button type="submit">提交</button> </form> ''' @app.route('/transfer', methods=['POST']) def transfer(): submitted = request.form.get('csrf_token') if not submitted or submitted != session.get('csrf_token'): abort(403) # 业务逻辑 return 'ok'这个方案真正要注意的是几个容易写歪的点:
- Token必须与用户会话绑定。很多实现只校验Token存在且等于Cookie里的某个值,但没有把Token和当前登录用户关联起来。这就导致同一个Token可以被多个用户复用,攻击者只要先在自己浏览器里拿到一个有效Token,就能用来打别人。
- Token要一次性使用还是会话级使用。会话级Token体验好,但风险窗口更大;一次性Token安全性更高,但需要处理多标签页同时操作的问题。我个人推荐在敏感操作(改密码、转账)上用一次性Token,在普通操作上用会话级Token。
- 不能把Token放在URL里。Token走URL的话,会通过Referer泄露给第三方站点,还会被代理服务器、浏览器历史记录、访问日志记录,等于白做了。
- 生成Token要用密码学安全随机数。
random.random()这类可预测的生成器会导致Token可预测,攻击者能在一定程度上枚举。Python里用secrets,Java里用SecureRandom,别省这一步。
2.2 方案二:Double Submit Cookie
Double Submit Cookie的思路是:服务端在响应中种一个随机Token到Cookie里,前端通过JS读取这个Cookie,然后在发起请求时把Token放到自定义头或者请求参数里;服务端校验Cookie里的Token和请求里的Token是否一致即可。
关键的区别在于,这个方案不需要在服务端保存Token状态。因为攻击者无法从目标域写入或篡改Cookie值,所以如果请求头里的Token和Cookie里的Token能对上,就说明请求大概率是从受害者的浏览器发出的。
import secrets from flask import Flask, request, abort, make_response app = Flask(__name__) @app.route('/login', methods=['POST']) def login(): token = secrets.token_urlsafe(32) resp = make_response('login ok') resp.set_cookie('csrf_token', token, secure=True, samesite='Lax') return resp @app.route('/api/transfer', methods=['POST']) def transfer(): cookie_token = request.cookies.get('csrf_token') header_token = request.headers.get('X-CSRF-Token') if not cookie_token or not header_token or cookie_token != header_token: abort(403) return 'ok'前端在发请求时手动带上这个头:
fetch('/api/transfer', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCookie('csrf_token') }, body: JSON.stringify({ to: 'attacker', amount: 1000 }) });这个方案比服务端Token省存储,但有一个隐患:它要求Cookie里这个Token不能设HttpOnly,因为前端JS要读取它。而一旦站点上存在任何XSS,Token就会随Cookie一起被偷走。所以我更推荐把Double Submit Cookie当成服务端Token的备选方案,适用于无状态API比较多的场景,但不适合XSS风险还没收敛的站点。
2.3 方案三:SameSite Cookie属性
SameSite是浏览器层面的防御,它告诉浏览器:跨站请求时,别把这个Cookie带过去。这个方案的好处是零代码侵入,只要在设置Cookie时加上属性就行。
resp.set_cookie('session', sid, secure=True, samesite='Lax')在Django里可以在配置里统一设置:
SESSION_COOKIE_SAMESITE = 'Lax' CSRF_COOKIE_SAMESITE = 'Lax'SameSite有三个值,我建议至少要理解到Lax这一档:
- Strict:跨站请求一律不携带Cookie。最安全,但用户体验差——比如从外部链接跳回站点时,用户还是“未登录”状态,需要重新登录。
- Lax(现代浏览器的默认值):跨站请求中,只允许顶层导航的GET请求携带Cookie。POST表单、AJAX、img加载这些跨站请求都不会带Cookie。这是安全与体验的折中。
- None:允许所有跨站请求携带Cookie,但必须配合
Secure属性(只能HTTPS)。相当于完全关闭了SameSite保护。
这里存在一个常见的误解:不是设置SameSite=Lax就万事大吉了。Lax只是挡掉了大部分跨站POST和异步请求,但GET类型的敏感操作依然可能被利用。举个例子,如果你的“退出登录”是GET接口、或者某个查询接口能改状态,Lax管不住这类通过新窗口打开的GET请求。这也是我一直强调“不要用GET改状态”的原因。
2.4 辅助校验和二次确认
除了上面三种主流方案,还有几个辅助手段值得组合使用:
- Origin/Referer校验。服务端检查请求头里的Origin或者Referer是否来自可信域名。这个方案成本最低,但问题最多,后面我会详细讲怎么绕过。建议只当作辅助手段,不要单独依赖。
- 自定义请求头。要求敏感操作必须通过AJAX发起,并带上
X-Requested-With或者其他自定义头。浏览器跨站的form表单和img标签没法自定义头部,所以能拦住大多数非预期请求。但JSONP、flash这类老古董可能会钻空子,而且对API的调用方式限制较大。 - 敏感操作二次确认。改密码、转账、绑定手机号这类高风险操作,要求用户重新输入密码或短信验证码。这个方案对CSRF是降维打击,因为攻击者既拿不到密码,也收不到短信验证码。成本是用户体验变差,但真正重要的操作值得这么做。
3. 防御绕过:攻击者视角的五大突破口
3.1 Token泄露与可预测问题
先聊一个很多团队都会犯的错:Token生成用了弱随机数。曾经测过一个内部系统,它的Token就是一个8位纯数字。我直接用Burp Intruder枚举了1万次,跑了不到半小时,就把一个有效Token跑出来了。这种Token即使校验逻辑写得再严,也等于没有。
Token泄露的路径也很多,最常见的是这几条:
- Token放在URL参数里。用户点了一个外链跳出去,外站从Referer里就能看到完整URL,Token跟着泄露。如果站点访问日志还被上级或者第三方采集,那泄露面就更大了。
- CORS配置错误。如果目标站点配置了
Access-Control-Allow-Origin: *,或者更糟糕地直接回显Origin且Access-Control-Allow-Credentials: true,攻击者就可以在自家页面里用AJAX请求目标站点,读取含Token的页面内容。我遇到过某站点前后端分离后,后端把CORS开成了“允许所有来源”,结果所有带Token的页面都能被跨域读走。 - 浏览器插件和恶意扩展。用户装了什么乱七八糟的插件,有时会把页面内容发到第三方。这个很难从服务端规避。
针对这类情况,防御侧能做的就是:Token生成必须用密码学安全随机数,Token尽量别放URL,CORS白名单收敛到具体域名而不是*。
3.2 Token绑定缺失与复用
这个坑我在不少项目里都见过。Token确实校验了,但Token没有绑定用户会话。
什么意思?假设服务端只在session里存了一个全局Token,所有用户共享同一个。攻击者自己注册个账号,拿到这个Token,然后诱导其他登录用户提交请求——由于Token是全局的,校验直接通过。
另一种情况是:Token绑定到了会话,但会话本身没有过期校验。攻击者只要能拿到一个有效Token(哪怕是很久以前的),配合一个还在有效期内的会话Cookie,或者根本不校验会话时效,就能一直用。
再一个常见问题是:服务端在校验Token的时候,只校验请求里带了Token且长度正确,但没有和session里存的值比对。等于只检查了“有没有带安全帽”,没检查“安全帽是不是自己的”。遇到这种情况,我直接随便填一个32位字符串就能绕过去。
所以测试时,我会重点关注三件事:Token是否随登录用户变化、Token是否与session值逐一比对、Token是否会在一次会话结束后失效。
3.3 SameSite=Lax下的绕过路径
很多开发以为设了SameSite=Lax就安全了,但有几条路径仍然可以突破。
第一,子域种植。SameSite的“站”是按注册域(eTLD+1)来算的,a.example.com和b.example.com属于同一个站,跨子域请求不算跨站,Cookie照常携带。如果目标站点有某个子域可以被攻击者部署内容(比如上了线但是没人管的测试子域、开放注册的UGC子域),那SameSite就等于失效了。
第二,Lax允许顶层导航GET。我在前面提过,Lax下用户点击链接跳转到目标域,GET请求依然会带Cookie。如果目标站点有敏感的GET接口,比如“删除文章?id=1”、“退出登录”,攻击者只需要诱导用户点击一个普通链接即可触发。
第三,同站攻击面组合。如果攻击者已经能在一个同站子域里执行任意代码(可能是子域存在XSS,或者子域是可控的第三方服务),那么从那个子域发起的请求本身就是“同站请求”,SameSite完全不设防。这类攻击叫“Same-Site Attack”,本质上已经不是单纯的CSRF了,而是子域信任链问题。
3.4 Double Submit Cookie的攻破思路
Double Submit校验的是“Cookie里的Token”和“请求里的Token”是否一致。那攻击者的目标就变成:有没有办法让目标域下的Cookie和请求里带的Token值一致?
最容易想到的突破口还是子域种植Cookie。浏览器的Cookie作用域支持Domain属性,如果目标域存在任何可控子域,攻击者就可以在子域上种一个Domain=.example.com的Cookie,覆盖目标域下原有的csrf_token。然后攻击者再在请求里带上同样名字的Token值。两边一对比,完全一致,防御直接被绕穿。
举个例子,evil.example.com是攻击者可控子域,攻击者在这个子域上通过JS执行:
document.cookie = "csrf_token=attacker_token; Domain=.example.com; Path=/";然后构造一个跨站请求,在自定义头里也带上attacker_token:
POST /api/transfer HTTP/1.1 Host: shop.example.com Cookie: session=...; csrf_token=attacker_token X-CSRF-Token: attacker_token to=attacker&amount=1000服务端校验Cookie和Header一致,校验通过。用户的其他会话Cookie仍然有效,但CSRF Token已经被攻击者“合法地”覆盖了。
这种情况下,Double Submit Cookie的防御就成了摆设。所以如果业务里必须用Double Submit,务必把Cookie的Domain收敛到最小范围,并且定期检查所有子域的可控性。
3.5 Origin/Referer校验的缺陷利用
用Origin或Referer做校验,最大的问题是实现不严谨。我在测试里见过几种典型的错误写法:
- 字符串包含匹配。代码里写
referer.startswith("https://example.com"),攻击者用https://example.com.attacker.com作为自己的域名,直接绕过。 - 空Referer放行。很多实现里,如果请求没有带Referer或者Origin,服务端直接放行。而浏览器在以下几种情况下会发送无Referer的请求:从
data:URI发起的请求、在sandbox属性包裹的iframe里提交表单、某些隐私模式下的跳转。攻击者可以构造一个sandbox的iframe来触发请求:
<iframe sandbox="allow-forms" srcdoc=" <form action='https://shop.example.com/api/transfer' method='POST'> <input name='to' value='attacker'> <button>提交</button> </form> <script>document.forms[0].submit()</script> "></iframe>sandbox会阻止脚本读取父页面信息,同时让浏览器不发送Referer,刚好踩中“空Referer放行”的逻辑。
- 只校验了Referer,没校验Origin。Referer包含完整路径,而Origin只包含协议、域名和端口。很多情况下Referer本身也会被浏览器省略或裁剪,所以更推荐的校验头是Origin。但只校验Origin同样有边界问题——比如非浏览器客户端(curl、Python脚本)完全可以伪造任意Origin头。不过话说回来,CSRF的威胁模型本来就是针对浏览器的,能兼容一下非浏览器客户端更好。
4. 实操:CSRF测试流程与踩坑记录
4.1 测试前的观察与准备
拿到目标后,我一般先花十几分钟做基础侦察,搞清楚三件事:
- 目标站点的会话机制。打开浏览器开发者工具,看Cookie列表:哪些Cookie是会话标识,有没有设置
HttpOnly、SameSite、Secure。如果会话是存在localStorage的JWT,CSRF测试可以直接降级。 - 敏感操作的请求特征。用Burp抓一次“转账”“改密”等操作的请求,看请求方法、Content-Type、是否带token、token放在哪里。
- 服务端校验逻辑。把请求里的token删除、篡改、换一个值,观察响应差异。很多框架默认的CSRF校验被全局关掉时,响应码和正常请求完全一样,很容易区分。
Burp Suite是我做这类测试的主力工具,配一个Chromium插件把浏览器流量代理进来,用起来最顺手。如果是纯API项目,也可以用Postman导出请求,配合脚本做参数变异。
4.2 快速判定CSRF的思考清单
下面这张清单是我在每个项目里都会过一遍的,建议直接截图保存:
| 检查项 | 关注点 | 风险判定 |
|---|---|---|
| 会话Cookie是否设置了SameSite | Strict/Lax/None | None且无Token防护为高危 |
| 敏感操作是否用POST | GET可带参可直接触发 | GET改状态为高危 |
| 请求是否带CSRF Token | 是否存在于请求体或自定义头 | 无Token为高危 |
| Token是否绑定当前会话 | 用户A的Token能否用于用户B | 可复用为高危 |
| Token位置是否在URL | URL可被Referer/日志泄露 | 泄露后有中等风险 |
| Cookie双重提交方案下Cookie是否HttpOnly | 需要JS读取则必然非HttpOnly | 结合XSS评估 |
| CORS配置是否允许任意来源 | 是否回显Origin且带Credentials | 可读页面即高危 |
| Origin/Referer校验逻辑 | 是否包含匹配、空值放行 | 存在即中高风险 |
| 是否要求二次认证 | 敏感操作是否需要密码/验证码 | 无则为中风险 |
4.3 PoC构造与验证实例
验证CSRF时,最直接的方式就是生成一个恶意页面。对于传统的表单提交场景,我常用这样一个模板:
<html> <body> <h1>点击查看您的账户异常通知</h1> <form id="poc" action="https://shop.example.com/api/transfer" method="POST"> <input type="hidden" name="to" value="attacker999"> <input type="hidden" name="amount" value="10000"> </form> <script> // 页面加载后延迟200ms自动提交,避免触发浏览器的自动播放拦截等机制 setTimeout(function() { document.getElementById('poc').submit(); }, 200); </script> </body> </html>把页面部署到自己的服务器上,用同一个浏览器登录目标站点后访问该页面,然后回到目标站点确认业务是否被执行。如果执行成功,CSRF基本就实锤了。
如果目标站点的API要求Content-Type: application/json,直接表单提交是无效的。此时可以尝试用fetch加mode: no-cors来发请求,或者用表单里嵌套一个带name的JSON字段配合后端宽松解析(某些后端框架会同时解析form和json)。总之,需要根据目标站点的请求格式灵活构造。
4.4 实测中容易误判的几个场景
做CSRF测试这些年,有几个情况我误判过,也看别人误判过,写出来给大家避坑。
第一,浏览器插件干扰。我曾经在一个测试环境里一直以为目标有CSRF,后来发现是某个翻译插件自动给所有请求加了自定义头,把token校验给“帮”着过了。换一个干净浏览器环境重新测,漏洞其实并不存在。反过来也有插件帮你拦掉请求导致漏报的情况。所以正式测试前,最好关掉无关插件,用干净的用户画像(Profile)开测。
第二,GET/POST方法切换。有些站点明明做的POST接口,但同时也处理GET参数。我习惯把POST改成GET再测一遍,如果GET也能触发业务变更,那就是个加分项,漏洞等级可以往上提。
第三,Cookie Domain不一致。在一个多子域系统里,主站登录时种下的Session Cookie通常作用域是.example.com,但如果某个子域单独种了自己的Session Cookie,那么攻击者诱导用户访问子域时,跨站请求带的可能是“未登录”的Cookie,导致CSRF测试失败。需要确认测试时用的Cookie作用域和目标接口在同一个站点上下文。
第四,同站点(Same-Site)请求不算CSRF但算信任问题。发起请求的页面如果和目标是同一个站,即使跨子域,也不能算CSRF。但这个“同站信任”被滥用时,往往比CSRF更严重——它能直接绕过SameSite和不少token机制。测试时要额外关注,报告里可以单独立类。
5. 纵深防御:从补丁式修复到体系化加固
5.1 代码层面的三保险
我在给团队做修复评审时,通常会建议至少叠三层:
第一层,所有敏感操作默认走POST或PUT,并在后端强制校验请求方法。GET请求不允许产生副作用,这是RESTful语义,也是CSRF防御的基础。
第二层,CSRF Token + SameSite Cookie双管齐下。Token负责服务端校验,SameSite负责从浏览器层面拦截跨站携带。即使其中一个被绕过,另一个还在。双保险能挡住绝大多数常规攻击。
第三层,真正的风险操作加二次认证。像修改密码、绑定新手机号、大额转账这类操作,加一个密码输入框或者短信验证码。这一步对CSRF来说基本是免疫的。
5.2 框架默认能力的正确使用
很多现代Web框架是有内置CSRF防护的,关键是别用错。比如Spring Security默认开了CSRF Token校验;Django的CSRF中间件默认开了,前端表单要加{% csrf_token %};Express虽然没有内置,但csurf中间件或lusca都能快速加上。
容易踩的坑是:前后端分离项目里,后端开了CSRF防护,但前端因为没带Token导致接口全部403,开发随手就把中间件关掉了。这个操作等于把安全交给运气。正确做法是区分场景:如果前端是纯AJAX,就用Double Submit Cookie方案,要求前端在请求头里带上从Cookie读到的Token;如果前端是服务端渲染,直接用同步Token最省心。
5.3 运行环境层与运维层的配合
除了代码,运行环境也能出一份力:
- 全站强制HTTPS,并给所有Cookie加
Secure属性,防止Token和Session在明文传输中泄露。 - 收敛CORS白名单,在生产环境禁止
Access-Control-Allow-Origin: *,如果有多个子域,白名单逐项列清楚。 - 定期巡检子域,子域DNS记录和解析状态要纳入资产管理,不使用的子域及时下线。很多CSRF绕过都是从一个无人维护的子域撕开的。
- 访问日志中脱敏URL参数,避免Token随URL出现在日志平台。
5.4 几句个人体会
坦白说,CSRF是我见过“修复率”和“实际修复效果”差距最大的漏洞之一。很多团队以为加了个token就完工了,但token要么放在URL里,要么全局共享,要么生成算法可预测,测试的时候一绕就穿。真正有效的防御,从来不是依赖某一个单独机制,而是把“Cookie SameSite + Token校验 + 二次认证 + 辅助头检查”组合成一个整体,让攻击者付出的成本远大于可能的收益。
另外想说一点:没有绝对的安全,但有相对的风险控制。对普通业务,把CSRF Token和SameSite做到位,已经能挡住90%以上的攻击;对金融、账号体系这类高价值资产,多花一点用户体验的代价做二次认证,是值得的。安全这件事,永远是“在可能的成本范围内,把攻击者的路堵到最死”。