CSRF攻击原理与防御绕过:从Token到SameSite的完整攻防指南

📅 发布时间:2026/10/19 10:06:30
CSRF攻击原理与防御绕过:从Token到SameSite的完整攻防指南
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?toxxxamount1000。如果接口参数里有非随机、可猜测的动态值比如用户ID攻击者完全能模拟。服务端对该接口的校验不完整。没有CSRF Token、没有Origin/Referer校验、Cookie没设SameSite或者校验逻辑能被绕过。只要这三个条件同时满足CSRF就是成立的。问题在于很多团队只修了其中一处以为补上了结果绕一下又废了。2. 主流CSRF防御方案及其适用边界2.1 方案一服务端TokenSynchronizer 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 methodpost action/transfer input typehidden namecsrf_token value{token} input nameamount typetext button typesubmit提交/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里用secretsJava里用SecureRandom别省这一步。2.2 方案二Double Submit CookieDouble 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, secureTrue, samesiteLax) 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要读取它。而一旦站点上存在任何XSSToken就会随Cookie一起被偷走。所以我更推荐把Double Submit Cookie当成服务端Token的备选方案适用于无状态API比较多的场景但不适合XSS风险还没收敛的站点。2.3 方案三SameSite Cookie属性SameSite是浏览器层面的防御它告诉浏览器跨站请求时别把这个Cookie带过去。这个方案的好处是零代码侵入只要在设置Cookie时加上属性就行。resp.set_cookie(session, sid, secureTrue, samesiteLax)在Django里可以在配置里统一设置SESSION_COOKIE_SAMESITE Lax CSRF_COOKIE_SAMESITE LaxSameSite有三个值我建议至少要理解到Lax这一档Strict跨站请求一律不携带Cookie。最安全但用户体验差——比如从外部链接跳回站点时用户还是“未登录”状态需要重新登录。Lax现代浏览器的默认值跨站请求中只允许顶层导航的GET请求携带Cookie。POST表单、AJAX、img加载这些跨站请求都不会带Cookie。这是安全与体验的折中。None允许所有跨站请求携带Cookie但必须配合Secure属性只能HTTPS。相当于完全关闭了SameSite保护。这里存在一个常见的误解不是设置SameSiteLax就万事大吉了。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里就能看到完整URLToken跟着泄露。如果站点访问日志还被上级或者第三方采集那泄露面就更大了。CORS配置错误。如果目标站点配置了Access-Control-Allow-Origin: *或者更糟糕地直接回显Origin且Access-Control-Allow-Credentials: true攻击者就可以在自家页面里用AJAX请求目标站点读取含Token的页面内容。我遇到过某站点前后端分离后后端把CORS开成了“允许所有来源”结果所有带Token的页面都能被跨域读走。浏览器插件和恶意扩展。用户装了什么乱七八糟的插件有时会把页面内容发到第三方。这个很难从服务端规避。针对这类情况防御侧能做的就是Token生成必须用密码学安全随机数Token尽量别放URLCORS白名单收敛到具体域名而不是*。3.2 Token绑定缺失与复用这个坑我在不少项目里都见过。Token确实校验了但Token没有绑定用户会话。什么意思假设服务端只在session里存了一个全局Token所有用户共享同一个。攻击者自己注册个账号拿到这个Token然后诱导其他登录用户提交请求——由于Token是全局的校验直接通过。另一种情况是Token绑定到了会话但会话本身没有过期校验。攻击者只要能拿到一个有效Token哪怕是很久以前的配合一个还在有效期内的会话Cookie或者根本不校验会话时效就能一直用。再一个常见问题是服务端在校验Token的时候只校验请求里带了Token且长度正确但没有和session里存的值比对。等于只检查了“有没有带安全帽”没检查“安全帽是不是自己的”。遇到这种情况我直接随便填一个32位字符串就能绕过去。所以测试时我会重点关注三件事Token是否随登录用户变化、Token是否与session值逐一比对、Token是否会在一次会话结束后失效。3.3 SameSiteLax下的绕过路径很多开发以为设了SameSiteLax就安全了但有几条路径仍然可以突破。第一子域种植。SameSite的“站”是按注册域eTLD1来算的a.example.com和b.example.com属于同一个站跨子域请求不算跨站Cookie照常携带。如果目标站点有某个子域可以被攻击者部署内容比如上了线但是没人管的测试子域、开放注册的UGC子域那SameSite就等于失效了。第二Lax允许顶层导航GET。我在前面提过Lax下用户点击链接跳转到目标域GET请求依然会带Cookie。如果目标站点有敏感的GET接口比如“删除文章?id1”、“退出登录”攻击者只需要诱导用户点击一个普通链接即可触发。第三同站攻击面组合。如果攻击者已经能在一个同站子域里执行任意代码可能是子域存在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_tokenattacker_token; Domain.example.com; Path/;然后构造一个跨站请求在自定义头里也带上attacker_tokenPOST /api/transfer HTTP/1.1 Host: shop.example.com Cookie: session...; csrf_tokenattacker_token X-CSRF-Token: attacker_token toattackeramount1000服务端校验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 sandboxallow-forms srcdoc form actionhttps://shop.example.com/api/transfer methodPOST input nameto valueattacker button提交/button /form scriptdocument.forms[0].submit()/script /iframesandbox会阻止脚本读取父页面信息同时让浏览器不发送Referer刚好踩中“空Referer放行”的逻辑。只校验了Referer没校验Origin。Referer包含完整路径而Origin只包含协议、域名和端口。很多情况下Referer本身也会被浏览器省略或裁剪所以更推荐的校验头是Origin。但只校验Origin同样有边界问题——比如非浏览器客户端curl、Python脚本完全可以伪造任意Origin头。不过话说回来CSRF的威胁模型本来就是针对浏览器的能兼容一下非浏览器客户端更好。4. 实操CSRF测试流程与踩坑记录4.1 测试前的观察与准备拿到目标后我一般先花十几分钟做基础侦察搞清楚三件事目标站点的会话机制。打开浏览器开发者工具看Cookie列表哪些Cookie是会话标识有没有设置HttpOnly、SameSite、Secure。如果会话是存在localStorage的JWTCSRF测试可以直接降级。敏感操作的请求特征。用Burp抓一次“转账”“改密”等操作的请求看请求方法、Content-Type、是否带token、token放在哪里。服务端校验逻辑。把请求里的token删除、篡改、换一个值观察响应差异。很多框架默认的CSRF校验被全局关掉时响应码和正常请求完全一样很容易区分。Burp Suite是我做这类测试的主力工具配一个Chromium插件把浏览器流量代理进来用起来最顺手。如果是纯API项目也可以用Postman导出请求配合脚本做参数变异。4.2 快速判定CSRF的思考清单下面这张清单是我在每个项目里都会过一遍的建议直接截图保存检查项关注点风险判定会话Cookie是否设置了SameSiteStrict/Lax/NoneNone且无Token防护为高危敏感操作是否用POSTGET可带参可直接触发GET改状态为高危请求是否带CSRF Token是否存在于请求体或自定义头无Token为高危Token是否绑定当前会话用户A的Token能否用于用户B可复用为高危Token位置是否在URLURL可被Referer/日志泄露泄露后有中等风险Cookie双重提交方案下Cookie是否HttpOnly需要JS读取则必然非HttpOnly结合XSS评估CORS配置是否允许任意来源是否回显Origin且带Credentials可读页面即高危Origin/Referer校验逻辑是否包含匹配、空值放行存在即中高风险是否要求二次认证敏感操作是否需要密码/验证码无则为中风险4.3 PoC构造与验证实例验证CSRF时最直接的方式就是生成一个恶意页面。对于传统的表单提交场景我常用这样一个模板html body h1点击查看您的账户异常通知/h1 form idpoc actionhttps://shop.example.com/api/transfer methodPOST input typehidden nameto valueattacker999 input typehidden nameamount value10000 /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%以上的攻击对金融、账号体系这类高价值资产多花一点用户体验的代价做二次认证是值得的。安全这件事永远是“在可能的成本范围内把攻击者的路堵到最死”。