先还原一个具体场景。某天下午A同学登录了一个购物网站浏览完商品后随手关掉了页面没有退出登录。接着他去逛论坛点开了一个帖子里附带的图片图片转圈很久也没加载出来他也没当回事。第二天再登录购物网站发现多了一笔从来没见过的小额订单收货地址不是自己的支付方式也绑定了别人的号码。整个过程里A同学没有输过密码没有点过可疑按钮甚至没在购物网站里再打开过任何页面。这不是什么离奇故事而是一次典型的 CSRF 攻击。CSRF 全称 Cross-Site Request Forgery中文叫跨站请求伪造是 Web 安全入门阶段绕不开的基础攻击之一。它利用的是浏览器自动携带登录凭证的机制加上目标网站对“已登录用户”的信任让攻击者在受害者完全不知情的情况下以受害者的名义向目标网站发出恶意请求。这篇内容会从“为什么浏览器会放行这种请求”讲起一步步拆解 CSRF 攻击成立的三个条件然后带你动手复现 GET 型和 POST 型两个最小 Demo再把主流的三种防御思路逐一分析清楚。适合刚接触 Web 安全的同学、日常写接口但没细想过安全问题的开发者以及准备给团队做安全分享的人参考。1. 先搞懂 CSRF 在“借”你的什么权限1.1 一次攻击的完整时间轴还是回到开头那个场景把时间轴拆开看用户登录购物网站服务端下发一个会话 Cookie。这个 Cookie 就是用户的“登录凭证”浏览器之后每次向这个域名发请求时都会自动带上它。用户切到了攻击者控制的页面。攻击者在这个页面里埋了一个“请求触发器”可能是图片、链接也可能是自动提交的表单。浏览器发现页面里有这个触发器于是向购物网站发起请求。因为 Cookie 是跟着域名走的浏览器在发请求时自动把购物网站域名的 Cookie 附了上去。购物网站收到请求通过 Cookie 识别出“这是已登录用户”就直接执行了对应逻辑比如创建订单、修改信息、转账。攻击者最厉害的地方在于整个过程中真正的受害者完全不知道自己的浏览器发出了这个请求。攻击者也根本不需要受害者看到任何东西只需要保证这个请求“长得像用户自己发的”就行。1.2 同源策略到底管不管跨站请求很多人第一反应是浏览器不是有同源策略吗跨站请求怎么会这么顺利这里要澄清一个关键误区。同源策略约束的是 JavaScript 对响应内容的“读取”而不是请求的“发送”。浏览器允许跨域发出请求尤其像img加载、表单提交这一类“简单请求”EM 甚至不需要服务器返回特殊的跨域头。攻击者正是抓住了这一点。他看不到目标接口返回的数据也不需要看到因为他要的是请求落地执行。转账的响应内容虽然被浏览器挡住了但钱确实已经被转出去了。1.3 用一把车钥匙理解整个过程把 CSRF 想成一幅画面你的车钥匙就放在车里车停在楼下。这辆车认钥匙不认人谁坐进驾驶座只要钥匙在车就认为这是车主本人。攻击者要做的是把一个“假乘客”推进你的车。乘客不需要偷走钥匙因为钥匙本来就在车里。他只需要让车以为他是你就够了。浏览器替你保管的登录 Cookie就是那把车钥匙。跨站请求伪造攻击的本质就是有人往你的车里塞了一个他控制的假乘客车目标网站却因为钥匙在手义无反顾地开了门。2. CSRF 攻击成立必须凑齐的三块拼图2.1 第一块拼图敏感请求的参数能算出来攻击者要伪造一个请求就必须提前知道请求的 URL、参数名、参数值。比如“把 1000 元转给某个账户”对应的请求是/transfer?toattackeramount1000参数清清楚楚没有任何随机数。这就是为什么安全规范里常见到一句话敏感操作必须用 POST而且不能只依赖请求方法。GET 请求的参数会出现在 URL、历史记录、日志、代理记录里泄露面太大攻击者很容易摸清接口结构。但 POST 本身也不代表安全只要参数可预测用表单照样能伪造。跨站表单提交属于简单请求不需要预检浏览器会照常带 Cookie。2.2 第二块拼图登录凭证会跟着请求自动走登录凭证最常见的形式是 Cookie。Cookie 存储在浏览器里浏览器在向目标域名发起请求时自动附加。这里要展开讲一个很多人混淆的概念跨站和跨域的区别。攻击页面和目标网站确实是两个“网站”但 Cookie 是跟着“目标域名”本身走的。攻击页面只是发出了一条指向目标域名的请求浏览器认的是目标域名自然会把目标域名的 Cookie 带过去。需要补充的是现代浏览器对第三方 Cookie 有一些默认限制但 CSRF 里携带的 Cookie 其实是“第一方”的。真正决定 Cookie 会不会在跨站请求中被带过去的是 Cookie 上的SameSite属性而不是浏览器是否阻止第三方 Cookie 存储。这一点放到第四章详细说但你要先记住浏览器在跨站请求里自动带登录态这件事没有被彻底封死。2.3 第三块拼图请求里没有任何只有用户知道的秘密这句话是 CSRF 防御体系的核心逻辑。如果服务端在请求里额外要求一个 Token一个只有当前用户会话里存在的随机字符串攻击者没办法预先知道那么他就算能伪造 URL、能带上 Cookie也拿不出这个 Token请求就会被拒绝。没有 Token 的情况下攻击者的整个伪造过程就是一种“无脑搬运”所有参数都是可预测的带不带加密、带不带签名都无所谓。这也是为什么参数越可预测、校验越少CSRF 越好打。2.4 拼图凑齐后攻击者只差一次诱导三块拼图齐了攻击者还需要让受害者的浏览器发出这个请求。手段非常多直接用img标签的src作为请求地址在 iframe 里偷偷加载目标页面构造一个带隐藏字段的表单并自动提交用脚本创建表单后执行submit()诱导用户点击一个链接前提是目标接口用 GET 处理敏感操作在论坛、聊天工具、邮件里发一个外链这些手段的共同点是不需要受害者和攻击者发生任何可感知的交互。所以 CSRF 在不少教材里也被称为“一次点击攻击”甚至有更形象的叫法是“睡梦攻击”。3. 动手复现两个最小化 CSRF 攻击 Demo3.1 三分钟搭出一个带漏洞的目标站纸上谈兵没意思直接动手。下面这个例子用 Python 的 Flask 框架代码量很小适合本地实验。目标站跑在127.0.0.1:5000攻击页面跑在127.0.0.1:5001两个端口模拟“两个不同的网站”。先写目标站。我这里故意不写任何 CSRF 防护只做了“是否登录”的判断from flask import Flask, request, session app Flask(__name__) app.secret_key just-for-demo app.route(/login) def login(): session[user] victim return 已登录 app.route(/transfer) def transfer(): if user not in session: return 请先登录, 403 to request.args.get(to) amount request.args.get(amount) # 这里直接执行转账逻辑没有任何来源校验 return f向 {to} 转账 {amount} 元 if __name__ __main__: app.run(port5000)这个转账接口只检查了“用户有没有登录”完全没检查“这个请求是不是用户本人主动发起的”。在真实项目里这种接口非常常见尤其是早期开发的内部系统。启动目标站先访问http://127.0.0.1:5000/login让浏览器种下会话 Cookie。3.2 GET 型 CSRF一个 img 标签打穿在另一个端口放一个攻击页面。页面内容极简只有一个隐藏的图片标签img srchttp://127.0.0.1:5000/transfer?toattackeramount1000 styledisplay:none /操作步骤保持目标站登录状态。用另一个端口启动这个 HTML 页面比如在5001端口。打开攻击页面。图片加载不出来是正常的但浏览器已经向目标站发起了/transfer请求。看目标站的日志会发现请求进来了而且返回了“向 attacker 转账 1000 元”。整个过程完全没有用户操作。图片加载时浏览器自动带了目标站的 Cookie目标站看到 Cookie 就认定是本人操作转账逻辑直接执行。3.3 POST 型 CSRF隐藏表单自动提交有人觉得“把接口改成 POST 就安全了”。这是错的。跨站表单提交同样是简单请求不会被预检拦截。目标站加一个 POST 接口app.route(/transfer_post, methods[POST]) def transfer_post(): if user not in session: return 请先登录, 403 to request.form.get(to) amount request.form.get(amount) return f向 {to} 转账 {amount} 元攻击页面换成一个自动提交的隐藏表单form actionhttp://127.0.0.1:5000/transfer_post methodPOST idf input typehidden nameto valueattacker input typehidden nameamount value1000 /form script document.getElementById(f).submit(); /script页面加载的瞬间表单就提交了浏览器照样自动带上 Cookie接口照样被执行。POST 并没有改变“请求自带凭证”的机制只是把传输方式从 URL 挪到了请求体里。3.4 这轮实验说明的两件事第一CSRF 的重点不在请求方法而在参数是否可预测、有没有额外校验。GET 和 POST 只是传输方式攻击者真正抓住的是“参数可预测加无额外校验”这个组合。第二防御必须放在服务端。浏览器层面不可能替你判断“该不该接受这个请求”因为浏览器只是按照既定规则发送请求、附带凭证。只有服务端自己验证请求的来源和操作意图才能挡住这类攻击。另一个值得注意的细节是攻击页面因为同源策略其实读不到目标接口返回的内容。但攻击者根本不需要读响应他只需要请求被执行。这一点再次印证了前面的结论同源策略限制的是“读”而不是“发”。4. 防御方案逐个拆解Token 是主力SameSite 和来源校验是补位4.1 CSRF Token给请求加一道只有用户知道的暗锁CSRF Token 是目前最主流的防御方式。核心思路很简单服务端生成一个随机且不可预测的字符串只有服务端和当前用户的会话知道。渲染表单时把 Token 放进页面提交敏感请求时必须带上服务端对比一致才执行。Token 必须满足三个条件随机性强。用secrets.token_hex(16)这类密码学安全随机数生成器不要用时间戳、用户 ID、UUID 拼接这些肉眼能看穿规律的方案。每个会话独立。至少保证同一会话内值不变敏感操作后可以轮换防止长期复用。服务端能校验。Token 可以存在服务端 Session、Redis 或内存里校验逻辑放在业务逻辑执行之前。一个带 Token 的最小防御示例import secrets app.route(/transfer_page) def transfer_page(): if csrf_token not in session: session[csrf_token] secrets.token_hex(16) return form action/transfer_safe methodPOST input typehidden namecsrf_token value session[csrf_token] input typetext nameto input typetext nameamount button typesubmit转账/button /form app.route(/transfer_safe, methods[POST]) def transfer_safe(): if session.get(csrf_token) ! request.form.get(csrf_token): return CSRF校验失败, 403 # 校验通过继续执行攻击者为什么拿不到这个 Token因为他的恶意页面在另一个源没有读取目标站 Cookie 的权限更不知道你 Session 里存了什么。Token 在服务端校验时是“只有用户会话里才存在的秘密”。这里要特意讲一个常见错误实现。有人把 Token 放在 Cookie 里提交参数时要求也带 Token然后只比较这两个值是否相等。这个方案叫 Double Submit理论上可行但前提是攻击者无法向受害者浏览器写入 Cookie 或篡改 Cookie。如果站点存在 XSS 漏洞或者某个子域可以被注入 Cookie这个方案就会崩溃。更稳妥的做法是只把 Token 放在 Session 或请求头里不要拿 Cookie 里的值做最终校验依据。关于过期策略Token 绑定用户防止一个 Token 在不同账号之间复用操作后轮换防止重放过期时间不宜太长避免整个会话生命周期里一个 Token 永远有效。4.2 SameSite Cookie浏览器层面的默认防线SameSite 是 Set-Cookie 上的一个属性控制跨站请求时是否携带 Cookie。取值有三个Strict任何跨站请求都不带 Cookie。Lax允许顶级导航用户点击链接跳转时带 Cookie其他跨站场景比如 img、iframe、fetch、自动表单不带 Cookie。None都带但必须配合Secure也就是只能用在 HTTPS 上。现代浏览器默认值已经是Lax这挡掉了一大批传统 CSRF 攻击。攻击页面用 img、iframe、自动表单发起的请求默认不会携带登录 Cookie目标站只能把请求当成游客处理。但不能因为这个默认值就放弃防御。原因有三点。第一SameSite 判定“站”用的是注册域名eTLD1跨子域不算跨站。Cookie 如果种在.example.com上那么sub1.example.com和sub2.example.com之间的请求Cookie 依然会带上。公司体系里只要有一个子域被攻破或者子域上托管了攻击者可控的页面SameSite 就会失效。第二Lax下点击链接时的 GET 请求仍会带 Cookie。如果某个敏感接口是 GET攻击者诱导用户点击一条特制链接照样能打穿。所以“敏感操作必须用 POST”这条原则不能丢。第三老浏览器和部分非浏览器客户端不认 SameSite或者行为不一致。自己维护的旧系统服务端必须显式设置不能依赖默认值。显式设置示例resp.set_cookie(sessionid, session_id, samesiteLax, secureTrue)4.3 Origin/Referer 校验只能做辅助不能做主力另一种思路是服务端校验请求头里的Origin或Referer来源是自家域名就放行否则拒绝。这个方案为什么只能作为辅助因为请求头本身有局限性。Referer可能因为用户隐私插件、从 HTTPS 跳到 HTTP、某些跳转方式而缺失。Origin在部分请求场景下不携带尤其是某些 GET 导航。所以不能把“来源合法”当成唯一依据更不能在来源缺失时直接拒绝否则正常用户会被误伤。就算要用也注意校验的是完整Origin或注册域名不能只判断字符串“包含”某个关键词。比如判断origin是否包含example.com攻击者用evilexample.com就能绕过这种低级错误在真实项目里并不少见。正确的姿态是Token 做主力SameSite 做浏览器层默认防线来源校验做纵深防御里的最后一道补位。4.4 优先用框架内置的 CSRF 防护很多成熟框架已经内置了 CSRF 防护能力没必要自己造轮子。Python 生态里常见的 Web 框架表单组件自带 Token 机制Java 服务端常用的安全框架也内置了 CSRF 过滤器。Node.js 生态也有对应的防护中间件。直接使用框架能力的好处是实现经过了大量社区审计校验逻辑覆盖了各种边界情况比团队自己临时写的方案靠谱得多。我见过有团队自己设计了一个看起来很好的 Token 方案结果因为生成逻辑可预测、Token 存在 Cookie 里以及未绑定会话几个问题叠加最后被攻击者用固定 Token 脚本一打就穿。用框架也不是就一劳永逸。前端组件封装时要确保所有 POST、PUT、DELETE 请求默认带上 Token而不是只在某个页面里手动加。很多项目是后端配好了前端没发或者前端发了后端没校验两者对不上就互相甩锅。5. 实战中的坑与排查清单经验篇5.1 实际项目里最容易踩的四个坑坑一把“登录校验”当成“CSRF 校验”。很多后端只判断 Session 里有没有用户从没想过“这个请求是用户主动发的还是被诱导发的”。这是最常见的认知盲区也是很多接口被打穿的根本原因。坑二Token 放在 Cookie 里直接比对。攻击者如果通过其他链路向受害者的浏览器写入一个可控的 Cookie比对就会直接通过。CSRF Token 的核心价值在于“攻击者不可预测”如果校验依据来自攻击者可影响的地方防御就名存实亡。坑三顶级域 Cookie 在所有子域复用。公司体系里一个主域挂十几个子域Cookie 种在顶级域上只要任意一个子域存在 XSS 或被托管了攻击者可控的页面CSRF 就能穿透。纵向拆分系统时Cookie 作用域要克制能按子域隔离的就不要全部共用。坑四异步接口忘了加校验。表单页面配了 Token但内部 AJAX 接口、JSON API 没配攻击者换一种方式发请求同样命中。所有会修改状态或触发敏感操作的接口都必须走同一套校验逻辑不能有例外。5.2 CSRF 和 XSS 的边界到底在哪入门者最容易把 CSRF 和 XSS 混在一起。这里做一张对比表维度CSRFXSS攻击本质伪造请求执行恶意脚本触发方式浏览器自动携带凭证注入代码在页面里运行主要危害替用户执行操作窃取数据、篡改页面、劫持会话典型防御CSRF Token、SameSite Cookie输入输出编码、内容安全策略两者关系XSS 可窃取 Token绕过 CSRF 防御CSRF 防御对 XSS 本身无效核心区分点一句话CSRF 关心的是“谁发起的请求”XSS 关心的是“谁在页面里跑了代码”。一个专注操纵请求一个专注注入脚本。5.3 一份可以直接用的排查清单检查项状态说明所有敏感操作是否强制 POST/DELETE是/否GET 请求的 Token 会随 URL 泄露敏感接口是否有 CSRF Token 校验是/否校验必须放在业务逻辑执行之前Token 是否绑定会话是/否防止跨会话复用Token 是否随机且不可预测是/否别用时间戳、用户 ID 拼接登录后的 Cookie 是否设置了 SameSite是/否至少用 Lax可按业务需求升级为 Strict是否存在顶级域共用 Cookie是/否子域防护要单独评估异步接口是否也做了校验是/否前后端都要覆盖校验失败时是否有日志和告警是/否CSRF 攻击通常表现为大量校验失败5.4 几点实操建议一是关键操作加用户二次确认。短信验证码、独立确认页本质上是把“用户意图”再验证一遍这对 CSRF 来说是终极手段。二是日志里对“校验失败”要有监控。CSRF 攻击往往就表现为大量 403日志如果不记录丢了都不知道。三是定期用工具或者写脚本对核心链路做一次安全自测模拟一下跨站发请求十分钟就能发现很多问题。四是团队里传一句话CSRF 防的不是陌生人而是“合法用户被劫持的操作”这句话比背任何概念都有用。我在实际项目里有个惯性动作每次设计一个会修改状态或造成资金变动的接口都会先问自己两句话这个接口的参数是不是完全可预测的有没有一个只有用户本人会话里才存在的值参与校验如果这两点都没想清楚接口就不算完成。CSRF 相比 XSS、SQL 注入这些攻击没有那么“华丽”但它一直藏在日常浏览行为里。理解它不需要太多前置知识只需要记住一个画面浏览器是一个会自动帮你带钥匙的心腹攻击者的工作就是想办法让这个“心腹”替他去碰门。守住服务端的校验逻辑就是给这扇门再加一道只有用户自己知道的暗锁。最后再分享一个小习惯写完接口后自己拿两个域名各起一个最小页面试着打自己一次。绝大多数 CSRF 问题十分钟就能现行。