首页 / 资讯中心 / 文章详情

CSRF攻击原理与防护:从模拟复现到Token/SameSite实战

CSRF攻击原理与防护:从模拟复现到Token/SameSite实战 ★ FEATURED ARTICLE
1. 先把这个套路看清楚CSRF 攻击模型与成功条件1.1 那个自动携带的 Cookie为什么会被利用CSRFCross-Site Request Forgery跨站请求伪造的核心是攻击者让受害者的浏览器替受害者向目标站点发出一个“受害者本人都不知道”的请求。它不需要窃取密码不需要拿到 Session ID甚至不需要执行目标站点上的任何脚本。很多人第一次听说 CSRF 时的反应是浏览器不是有同源策略吗为什么一个外部页面还能往目标站点发请求这里需要厘清一个关键点同源策略限制的是“读取响应”而不是“发送请求”。浏览器允许一个页面在img、form、script等标签里向任意地址发起请求并且会把该站点的 Cookie 一并带上。服务器端的代码通常只校验“这个 Session 是否有效”而不会分辨“这个请求是不是用户主动发出的”。打个比方你进了一家餐厅把外套挂在椅背上店员只要看到外套上绣着你的编号就会帮你结账。有人趁你不注意在另一个房间里悄悄递了一张纸条给店员上面写着“续点一份最贵的套餐记在 8 号座位账上”。店员认的是外套和编号而不是你的人。CSRF 就是那张纸条外套就是浏览器的 Cookie。1.2 攻击成功的四个必要条件不是所有请求都能被 CSRF 利用要攻击成立四个条件缺一不可受害者处于目标站点的“已登录”状态且浏览器保存着有效 Cookie。目标站点存在某个敏感操作只靠 Cookie 判断身份没有校验请求来源或携带自定义 Token。攻击者能诱导受害者访问自己控制的页面。目标操作的请求方式可以被外部页面自动构造比如 POST 表单、GET 图片。前两个条件属于“目标站点自身的问题”后两个条件属于“攻击者能控制的环节”。所以你会发现CSRF 防护的核心从来不是“别让用户访问恶意页面”因为攻击者诱导用户的手段实在太多——弹窗广告、论坛帖子里的链接、即时通讯工具里分享的短链接甚至一个伪装成验证码的按钮。真正能解决问题的是让服务器在收到请求时能够确认“这个请求确实来自用户本人正在操作的页面”。1.3 CSRF 与 XSS 的本质区别很多初学者会把 CSRF 和 XSS 混淆但它们是完全不同的攻击形态。XSS 是攻击者在目标站点内成功执行脚本相当于“在餐厅后厨安插了自己人”这个自己人可以帮忙做任何事——读取页面、窃取 Cookie、篡改 DOM、伪造操作。而 CSRF 全程发生在目标站点之外攻击者不需要在目标站点上运行任何代码他只是利用浏览器的自动携带机制把用户“当枪使”。这个区别决定了修复方式的差异XSS 的修复重点是输入输出过滤与上下文转义而 CSRF 的修复重点是请求校验。不过它俩又会叠加如果站点存在 XSS攻击者可以直接读取页面里的 CSRF Token再配合发起伪造请求这时候单靠 Token 防护也会形同虚设。所以做安全加固时XSS 和 CSRF 必须一起考虑不能割裂。1.4 影响范围与现实案例CSRF 能造成的后果取决于目标操作的敏感程度。高危场景一般集中在修改绑定手机号、邮箱、收货地址修改登录密码或找回密码流程账号解绑、权限变更订单状态修改、余额转账后台管理中的批量操作我见过一个比较典型的模拟案例一个后台管理系统的“一键清空日志”按钮是 GET 请求管理员只要在登录状态下浏览了被植入图片的论坛帖子日志就会被清空。这种攻击表面上看只是“误触”但如果被用在批量删除用户、修改佣金配置这类操作上损失就不是一点半点了。还有一个容易忽略的场景即使请求需要登录如果服务端把“登录态”存到了 localStorage 而不是 CookieCSRF 的经典利用方式就不太容易生效因为浏览器不会自动携带 localStorage 数据。但如果站点仍然保留着兼容旧版本的 Session Cookie那就又回到了 CSRF 的攻击范围内。2. 复现攻击本地靶场从 0 到 12.1 靶场环境搭建的两种思路复现 CSRF 不需要复杂的拓扑两个域名就能把问题说清楚。我在自己机器上用两个虚拟域名做实验victim-site.local模拟目标业务站点后端用 Flask 起服务attacker-site.local模拟攻击者控制的静态页面Windows 和 macOS 都在 hosts 文件里加两行映射127.0.0.1 victim-site.local 127.0.0.1 attacker-site.local为什么要把两个域名分开因为 CSRF 的核心是“跨站”。如果攻击页面和目标接口跑在同一个源下就变成同源请求了即便成功也不能说明问题。两个域名绑定到同一台机器甚至两个服务都跑在 127.0.0.1 的不同端口上也仍然算跨站因为源由协议、域名、端口三者共同决定。这里有个细节需要注意现代浏览器对跨站请求的 Cookie 携带已经陆续收紧了默认策略。较旧的内核在 Cookie 未显式指定 SameSite 属性时默认允许跨站携带而新的浏览器内核默认把 SameSite 定为 Lax也就是说跨站的 POST 表单自动携带 Cookie 会受限。为了让靶场演示稳定复现我在复现时有两种处理方式一是使用仍采用旧默认行为的内核版本二是在服务端设置 Cookie 时显式加上SameSiteNone; Secure并且让服务跑在 HTTPS 或本地可用证书的环境下。实际操作时我建议用第二种方式因为更能体现“服务端曾经默认没有防护”的经典状态。不过这样 CSRF 能成功恰恰是因为我显式关掉了浏览器层面的保护这本身也反向说明了 SameSite 的重要性。2.2 目标站点代码故意不做防护的版本先看目标站点的后端代码我用 Flask 写了一个极简版本。它只做两件事模拟登录以及处理“修改绑定手机号”的 POST 请求。from flask import Flask, request, session, redirect, render_template_string import os, secrets app Flask(__name__) app.secret_key demo-secret-key # 用一个内存字典模拟数据库 user_profile { demo_user: { phone: 13800000000, email: userexample.com, } } LOGIN_PAGE form methodpost action/login input typetext nameusername valuedemo_user button typesubmit登录/button /form PROFILE_PAGE h2个人中心/h2 p当前绑定手机号{{ phone }}/p form methodpost action/profile/updatePhone input typetext namephone placeholder新手机号 button typesubmit更新手机号/button /form app.route(/login, methods[GET, POST]) def login(): if request.method POST: session[username] request.form.get(username, demo_user) return redirect(/profile) return render_template_string(LOGIN_PAGE) app.route(/profile) def profile(): if username not in session: return redirect(/login) phone user_profile[session[username]][phone] return render_template_string(PROFILE_PAGE, phonephone) app.route(/profile/updatePhone, methods[POST]) def update_phone(): if username not in session: return 未登录, 403 new_phone request.form.get(phone, ) user_profile[session[username]][phone] new_phone return 手机号已更新为: new_phone if __name__ __main__: app.run(host0.0.0.0, port5001, ssl_contextadhoc)这个版本在登录后把用户名写进 Session更新手机号时只检查“是否登录”然后直接信任表单里的phone参数。问题非常典型请求是从哪个页面发出的、用户是否知情服务端完全没有判断。2.3 攻击者页面用一个自动提交的隐藏表单攻击者页面的逻辑非常简单只要访问这个页面浏览器就自动向victim-site.local/profile/updatePhone发起一个 POST 请求。因为表单是跨站提交会被携带上目标站点的 Cookie所以服务端会误认为这是用户本人的操作。!DOCTYPE html html langzh-CN head meta charsetUTF-8 title活动页/title /head body h1恭喜抽中奖品/h1 p点击下方按钮查看领取方式/p form idcsrf-form actionhttps://victim-site.local:5001/profile/updatePhone methodPOST input typehidden namephone value19900000000 /form script document.getElementById(csrf-form).submit(); /script /body /html我在演示时用一个静态服务器来托管这个页面cd /path/to/attacker-page python3 -m http.server 6000访问http://attacker-site.local:6000/index.html即可触发攻击。2.4 抓包与服务端日志实证演示的流程分三步先用浏览器打开https://victim-site.local:5001/login登录并进入个人中心确认手机号还是13800000000。新开一个标签页访问http://attacker-site.local:6000/index.html。回到个人中心刷新手机号变成了19900000000。如果后端日志记录了请求信息你会看到类似这样的输出访问 /profile/updatePhone Session 用户: demo_user 修改手机号为: 19900000000整个过程里攻击者没有拿到用户密码没有调用任何目标站点的脚本也没有接触 Session ID。它只是让受害者的浏览器完成了一次“替身操作”。我习惯在演示时打开浏览器的开发者工具看攻击页面发出的请求详情。你会注意到请求里带着目标站点的 Cookie而且请求来源是attacker-site.local。服务端只要稍微多做一步检查看一眼Origin或Referer就能识别这不是目标站页面发起的请求。但它没检查所以攻击成立。3. 三种主流防护手段原理、实现与取舍3.1 CSRF Token让请求带上服务端签发的随机通行证CSRF Token 是目前最常用、也最稳定的防线。它的核心思路是既然攻击者无法预知目标站点为当前用户会话生成的一个随机值那么就让所有敏感请求必须携带这个随机值服务端校验通过后才执行操作。完整的 Token 链路通常是这样用户请求一个页面比如个人中心时服务端生成一个随机 Token存入 Session。服务端在返回的 HTML 里把这个 Token 放进表单的隐藏字段或页面的某个变量中。用户提交表单时Token 跟着表单一起回传到服务端。服务端把请求里带回来的 Token 和 Session 中保存的 Token 做比对一致才放行。用 Flask 写一个最小实现import secrets import hmac app.before_request def csrf_protect(): # 对安全方法不校验 if request.method in (GET, HEAD, OPTIONS): return # 登录接口、未登录会话单独放行 if request.path /login: return expected session.get(csrf_token) if not expected: return CSRF token 缺失, 403 actual request.headers.get(X-CSRF-Token) or request.form.get(csrf_token, ) if not hmac.compare_digest(expected, actual): return CSRF 校验失败, 403 def generate_csrf_token(): if csrf_token not in session: session[csrf_token] secrets.token_urlsafe(32) return session[csrf_token]渲染页面时把generate_csrf_token()的结果传入模板input typehidden namecsrf_token value{{ csrf_token }}前端用 AJAX 提交时再从页面变量或meta标签里取 Token放进请求头fetch(/profile/updatePhone, { method: POST, headers: { Content-Type: application/x-www-form-urlencoded, X-CSRF-Token: document.querySelector(meta[namecsrf-token]).content }, body: phone19900000000, credentials: same-origin });这个方案能防住攻击最关键的一点是攻击者的页面无法提前得知当前用户会话对应的 Token 值。即使攻击者知道 Token 的生成算法也拿不到 Session 里的实际值。防御方要避免的常见错误是把 Token 放在 Cookie 里又有接口直接信任 Cookie 的值。如果服务端同时对“Cookie 中带 Token”和“请求参数中带 Token”做校验攻击者可以通过 CSRF 的方式让浏览器同时携带这两者吗这里有个技巧攻击者确实无法在跨站请求中主动构造 Cookie但如果请求体里的 Token 也从 Cookie 中读取而 Cookie 又会被自动携带就可能形成绕过。所以正确的做法是Token 必须放在请求参数或自定义头中而不是依赖 Cookie 自动携带。3.2 SameSite Cookie 策略改一个属性就能挡掉一大片攻击SameSite 是 Cookie 上的一个属性用来告诉浏览器“哪种跨站请求可以带上这个 Cookie”。它有三个取值属性值跨站请求携带 Cookie 行为典型影响Strict完全不在跨站请求中携带安全性最强但从别的站点跳转过来时首次访问可能表现为未登录Lax只有跨站 GET 顶层导航会携带POST、图片加载等都不带兼顾体验与安全适合大多数站点None跨站请求也可以携带但必须配合Secure属性用于确实需要跨站携带的场景不能滥用对大多数业务系统把会话 Cookie 设置成SameSiteLax是最平衡的选择。用户在正常流程里的体验几乎没有变化——顶多是用户从外部链接点击进入站点时浏览器会根据 Lax 策略把 Cookie 带上会话还能继续而在跨站的 POST 请求中这个 Cookie 不会出现绝大多数 CSRF 自动提交攻击就失效了。不过我不能把 SameSite 说得像是银弹。它有明显的局限性如果旧浏览器不认识这个属性它的默认处理可能等价于None等于没设。Lax允许跨站 GET 顶层导航携带 Cookie。如果一个敏感操作是用 GET 请求实现的攻击者依然可以通过window.location跳转或用户直接点击链接来触发。攻击者如果控制了目标站点的某个子域而这个子域与主域属于同站Cookie 仍然会被发给主域导致防护被绕过。所以我的习惯是SameSite 作为第一道便宜的防线CSRF Token 作为真正可靠的核心防线两者结合用而不是二选一。3.3 双重提交 Cookie无会话场景下的折中方案有些后端是无状态的Session 存在客户端或者需要做水平扩展服务端不方便维护一份 Token 清单。这时候可以选双重提交 Cookie 模式。它的做法是服务端先把一个随机值放进 Cookie。前端在发起请求时需要把这个随机值再放到请求的自定义头或表单字段里。服务端收到请求后比对 Cookie 中的值与请求中的值是否一致一致就通过。攻击者在跨站请求中无法同时做两件事他既不能让浏览器带上目标站点的合法 Cookie也不能在请求头里伪造出与 Cookie 相同的随机值除非存在子域接管或 XSS 等额外漏洞。// 前端读取 Cookie 中的随机值并放入自定义头 function getCookie(name) { const parts document.cookie.split(; ); for (let p of parts) { const [k, v] p.split(); if (k name) return v; } return ; } fetch(/api/transfer, { headers: { X-CSRF-Token: getCookie(csrf_token), }, method: POST, credentials: same-origin, });这种方案的优点是服务端不需要存储 Token适合纯 API 或分布式架构。缺点是如果攻击者能通过子域写入或覆盖 Cookie比如目标站点没有对子域 Cookie 做隔离防御强度就会下降。所以在高安全场景我更推荐用服务端会话方案或把 Token 放到自定义头里的双重提交变体而不是纯粹依赖 Cookie 字段。3.4 同源校验与二次验证是补充层同源校验的思路是检查请求头里的Origin或Referer如果不是从允许的来源发来的就拒绝。这个方案实现成本低但依赖浏览器正确发送请求头有些隐私设置或代理会去掉Referer而且Origin在部分旧浏览器中也可能缺失。它更适合作为辅助校验而不是唯一防线。二次验证则不同它对高风险操作加一道额外确认输入短信验证码、重新输入密码、Google Authenticator 动态码等。它不能替代 CSRF Token因为攻击者仍然可以伪造请求但二次验证增加了攻击成本和被用户发现的可能性。我通常在涉及资金、密码、权限变更的操作上强制二次验证在一般性修改上保留 Token 校验。四种方案的权衡我整理成一张表方案实现难度对用户体验影响安全性适用场景CSRF Token服务端 Session中等无感强大多数有状态的 Web 应用SameSiteLax/Strict低极低中到强所有站点都应开启双重提交 Cookie中等无感中无状态 API、分布式系统Origin/Referer 校验低无感中校验辅助层二次验证较高有感知强转账、改密、删除等高危操作4. 把防护落到真实项目改造步骤与观察点4.1 后端接入全局过滤器的正确姿势以我常用的方式来说CSRF 防护应该做在统一入口而不是每个接口单独写一遍。上面 Flask 示例里的before_request就是一个全局过滤器。在 Java 系的框架里通常是一个 Filter在 Node 里可以是中间件。设计过滤器时有几个容易踩坑的决策点第一哪些方法需要校验。一般只对“有副作用的请求”校验也就是 POST、PUT、PATCH、DELETE 这类。GET、HEAD、OPTIONS 通常不做校验但前提是代码里没有通过 GET 修改状态的坏习惯。如果有先改代码再谈 CSRF 校验顺序不能反。第二哪些路径需要豁免。登录接口本身往往还没有 Session Token如果也强制校验用户会直接卡在登录页。另外回调接口、公开的 Webhook、下载接口等也要按业务判断是否豁免。豁免名单应该尽可能短并且需要评审。第三Token 放在哪里。表单提交场景放在表单字段里AJAX 场景放在自定义请求头里。自定义头的优势是不会被浏览器自动携带攻击者更难伪造表单字段的优势是传统表单也能直接用。4.2 前端页面接入 Token 的细节传统后端渲染的页面在form里加上隐藏字段即可form action/profile/updatePhone methodPOST input typehidden namecsrf_token value... input typetext namephone button typesubmit更新/button /form如果是单页应用通常在后端渲染页面时输出一个meta标签meta namecsrf-token content...所有 AJAX 请求统一从 meta 里取 Token放到请求头里。这样做的原因是避免每个接口都从表单字段取代码会更集中、更容易保持一致。const csrfToken document.querySelector(meta[namecsrf-token]).content; async function updatePhone(phone) { const form new URLSearchParams(); form.append(phone, phone); const resp await fetch(/profile/updatePhone, { method: POST, headers: { Content-Type: application/x-www-form-urlencoded, X-CSRF-Token: csrfToken }, body: form, credentials: same-origin }); if (resp.ok) { location.reload(); } }需要注意credentials字段要按场景写对同源请求用same-origin跨域但需要带 Cookie 的用include。如果写错要么不带 Cookie 导致会话失效要么在跨域请求里把 Cookie 带给了不安全的接口扩大了攻击面。4.3 上线前敏感操作排查清单我在改造一个老项目时会按下面的清单过一遍所有接口而不是只盯着表单页面看找出所有 POST、PUT、DELETE 接口逐个确认是否已纳入 CSRF 过滤范围检查有没有用 GET 实现状态变更的接口比如/delete?id1、/logout、/status?actionclose这类全部改成 POST并考虑历史链接的兼容迁移确认登录、注册、密码找回之外不存在偷偷绕过 CSRF 校验的“白名单捷径”对于管理后台、数据库导出、批量操作等接口确认不是只靠 Cookie 做身份判断检查 SPA 页面在静态 CDN 环境下Token 是否可能被缓存到公共 CDN导致多个用户共用同一个 Token。Token 要和用户会话绑定不能放到静态资源里长期复用在日志中记录 CSRF 校验失败的请求来源方便安全监控但注意不要把 Token 全量打进日志日志泄露同样会导致防护失效这条清单不需要专用工具人工 review 一遍就能发现大部分问题。最容易被放过的是“看起来不敏感”的接口比如资料页的头像上传、昵称修改一旦攻击者能批量改动用户资料效果可能比改密码还隐蔽。5. 上线后最容易踩的坑与排查思路5.1 加了 Token 之后表单提交一直 403这是改造过程中遇到频率最高的问题原因通常集中在几个方面页面是服务端渲染的但被 CDN 或浏览器缓存了。用户拿到的是老页面里面的 Token 是旧的提交时服务端 Session 里的 Token 已经是新生成的了自然校验失败。Session 过期后页面还停留在浏览器标签里用户填写了很长时间提交时 Session 已经没了Token 对不上。过滤器把所有 POST 都拦截了包括登录接口而登录接口原本就没有 Token于是登录直接失败。Token 在生成和校验时经过了两层编码比如模板转义把变成了空格导致比对不一致。排查时我一般先看请求是否带上了 Token再看服务端打印的 Session 里的值。如果请求里根本没有 Token问题多半在前端模板或 AJAX 封装。如果两边都有但比对失败就要检查是否经过编码转换或者 Token 是否是每次渲染都重新生成。如果 Session 里压根没有 Token问题多半在生成 Token 的时机太晚或者过滤器没有在渲染前生成。5.2 设置 SameSite 之后出现“第一次跳转未登录”如果同一个站点从外部链接点进来用户明明登录过却显示未登录很可能是把会话 Cookie 设成了SameSiteStrict。Strict下跨站导航不携带 Cookie用户从邮箱、论坛、聊天工具点进你的站点时浏览器不会把会话 Cookie 带过去于是服务端认为这是一个新会话。这个行为本身是安全的但会影响转化率。大多数业务更适合SameSiteLax这样用户从外部链接点进来时GET 顶层导航还会带着 Cookie体验不受影响而跨站 POST 和子资源请求都被拦住了。如果某些关键业务一定要Strict需要做好用户体验设计比如在入口页增加“正在为你恢复登录状态”的提示或者通过一次本地跳转来重新触发会话。5.3 常见问题速查表现象可能原因解决方向Token 校验失败Session 过期、页面缓存、Token 编码变化检查请求头和 Session 中的值排除缓存登录接口被 403过滤器未豁免登录路径在 CSRF 过滤器中加入合规豁免名单从外站链接进入显示未登录Cookie 设置为 Strict评估是否能降级为 Lax或采用其他会话恢复方式AJAX 请求不带 Token前端封装未取 meta 标签统一封装请求头检查credentials配置Token 日志泄露后端把表单参数全部打印日志脱敏禁止打印敏感字段子域接管导致绕过双提交 Cookie 方案被攻击者控制子域改用服务端存储 Token隔离子域 Cookie5.4 极端情况下的纵深防御把 CSRF Token 加上不代表一劳永逸。如果目标站点存在 XSS 漏洞攻击者可以在页面里用 JavaScript 读取 Token 并直接构造请求CSRF 防护就会完全失效。所以在评估 CSRF 风险时必须同时排查 XSS。这也是为什么安全领域强调纵深防御CSRF Token、SameSite、输入输出过滤、二次验证各管一层单独拧一颗螺丝撑不住整面墙。另外需要考虑匿名状态下的 CSRF。有些操作不需要登录也能触发比如投票、取消订阅、垃圾举报等。这类接口如果被攻击者用来批量刷脏数据同样会造成业务损失。对这类接口可以额外引入业务校验码、频率限制或设备指纹等手段。我个人的原则是凡是会改变系统状态的请求默认都要经过 CSRF 校验凡是读操作默认不做 CSRF 校验但确保没有副作用。宁可多校验也不要留裸奔接口。在我实际维护的项目里把 CSRF 防护做成三层之后监控告警里基本看不到 CSRF 相关的失败请求了。但每次新增接口代码评审时我仍然会问一句这个接口能不能从别的站点通过表单自动提交如果是就套上 Token放进统一的过滤范围里。安全防护这件事最有价值的不是某一天的加固动作而是把“不信任请求来源”变成每次开发时默认的思维习惯。如果你正准备整改自己的站点建议先从把所有状态变更接口改成 POST 开始再加 SameSite 属性最后统一接入 CSRF Token。按这个顺序改踩坑最少收益也最直接。
阅读完成 · 觉得有帮助?
咨询建站