CSRF跨站请求伪造:原理、复现实验与防御方案实战
发布时间:2026/10/19 4:12:18 锦皓数字建站

CSRF这个名词在Web安全面试里出现的频率几乎和SQL注入一样高但真正弄懂它的人其实不多。我最早把它彻底跑通是在一次内部授权的渗透测试项目上目标是某实验室自建的模拟交易系统。前前后后花了大半天从“看教程似懂非懂”到“亲手完成攻击复现并对接防御代码”中间踩了不少坑。这篇文就把整个过程拆开揉碎从原理、实验环境搭建、三种典型攻击场景复现到标准防护手段和排查实录全部过一遍。项目标题里说了“附可运行测试案例”所以文中所有代码都是可以直接跑起来的适合刚入门Web安全、准备安全类面试、或者正在给自家项目补安全边界的读者参考。先说结论CSRF之所以危险是因为浏览器自动携带Cookie这个机制本身没有“区分请求来源”的能力。攻击者不用偷密码、不用破会话只要诱导已登录用户发起一个精心构造的请求就能以用户身份执行操作。更麻烦的是很多团队直到被刷了几笔订单、改了几次密码才意识到接口缺了校验。下面进入正题。1. CSRF 攻击的底层原理拆解1.1 攻击成立的三要素缺一不可任何一次CSRF攻击背后都有三个条件同时成立。不是“用户访问了恶意网站就会中招”而是必须满足这几个前提第一存在一个关键操作接口。这个接口能让用户执行敏感动作比如转账、改密、发帖、删除数据。注意这里说的“敏感”不一定指破坏性操作凡是能改变服务端状态的接口都算包括修改昵称、收藏商品这种日常功能。第二会话凭证的自动携带。目标站点用Cookie维持登录态浏览器在发出请求时会把当前域名的Cookie一并带上无论这个请求是用户主动点的还是恶意页面里某个标签触发的。这里的关键是“自动”两个字——用户没有参与浏览器替你完成了携带动作。第三请求参数可被预测。转账接口需要的目标账号、金额登录接口需要的用户名、密码这些参数要么是写死或可猜的要么能通过探测拿到。攻击者不需要知道当前用户的密码或会话ID只要拼装出合法的参数组合即可。我用一个大家都有体感的例子来解释你登录了某论坛保持登录状态没退出。这时候打开了另一个网站这个网站页面上藏着一行代码内容是“给A用户发一条私信”。你的浏览器会带着论坛的Cookie去执行这个操作而论坛服务端只知道“有个已登录用户调用了发私信接口”它完全不知道这个请求是从哪个页面发出的。发起请求的人是你但决定请求内容的人不是你。这就是CSRF的本质跨站Cross-Site——请求从A站发出目标却是B站伪造Request Forgery——请求内容完全由攻击者构造。放到现实生活里就像有人趁你人在银行柜台办业务递上一张纸条冒充是你签字的取款单。1.2 登录态为何会被浏览器“自动带上”要理解Cookie为什么会在跨站请求中被自动携带得先厘清一个概念Cookie不像Token那样需要你在代码里显式读取它是浏览器持久化存储的一部分并且有一套完整的发送规则。这套规则的判断维度有两个一是请求要到达的域名二是Cookie的作用域Domain和Path。只要请求的目标域名在Cookie的Domain范围内浏览器就把Cookie放进请求头它根本不关心这个请求是由哪个页面发起的。也就是说你在evil.com上发起的请求如果目标是bank.com的转账接口浏览器会携带bank.com域下的Cookie因为请求的方向是往bank.com去的。这是浏览器设计之初的机制本意是为了方便用户跨页面保持会话。比如你从首页跳到详情页两个页面的请求都需要带上同一个Cookie如果每次都询问用户是否携带这产品就没法用了。但它也为CSRF留下了空间。这里有个容易混淆的点第三方Cookie和SameSite。浏览器在收到某个域的Set-Cookie时会根据当前页面上下文判断这个Cookie是第一方还是第三方。Chrome在2020年后默认把未声明SameSite的Cookie视为Lax模式这意味着跨站POST请求默认不携带Cookie。但Lax模式仍然允许跨站GET子请求携带Cookie。所以CSRF并没有因此消失只是从“所有请求都可利用”收窄成了“GET请求仍可利用某些环境下的POST仍有绕过空间”。后面讲实验的时候我会专门演示这个差异。1.3 CSRF 和 XSS 不要混为一谈很多新人把CSRF和XSS搞混其实两者的信任模型完全不同。XSS是攻击者往你的页面里注入恶意脚本属于“代码注入”CSRF是攻击者利用浏览器对已认证用户的隐式信任属于“请求伪造”。再打个比方。XSS好比小偷混进你家在你的电脑上运行了恶意程序CSRF好比有人给你家保姆打了个电话冒充你本人下达了指令。前者的破坏力取决于脚本能拿到多少权限后者的破坏力取决于“保姆”也就是服务端有多好骗。两者的防御思路也完全不一样XSS靠输入过滤、输出转义、CSP策略CSRF靠校验请求来源和附加不可预测的Token。在一个真实项目中两者往往同时存在比如存储型XSS可以直接把CSRF Token读出来发给攻击者从而绕过Token防护。这也是为什么重灾区通常是内部后台系统、论坛、CMS这类既存着用户Cookie、又允许富交互内容的场景。2. 搭建一个可运行的实验环境既然标题里的重点是“实验测试”那就得上真家伙。我选用Flask来搭目标服务原因很简单代码量少、依赖干净、session机制是标准实现能原汁原味地还原CSRF发生的现场。2.1 环境准备与项目结构实验环境推荐用虚拟环境隔离避免污染全局的Python。具体步骤如下mkdir csrf_lab cd csrf_lab python3 -m venv venv source venv/bin/activate pip install flask然后在项目根目录下建三个文件app.py目标应用包含登录、转账两个接口。attacker_get.htmlGET型CSRF攻击页面。attacker_post.htmlPOST型CSRF攻击页面。再看一下目录结构csrf_lab/ ├── app.py ├── attacker_get.html └── attacker_post.html实验全程在同一台机器上用两个不同端口模拟“两个站点”目标站跑在5000端口攻击站直接由静态HTML文件模拟。当然你也可以用python3 -m http.server 8888在攻击页面目录里起一个静态服务这样更接近真实环境。需要注意的一点如果页面文件直接双击打开文件路径是file://协议部分浏览器对Cookie发送规则的处理和http协议下略有差异。为了排除干扰建议统一用http协议访问攻击页面。向在某实验室的模拟交易系统上我用同样的方法在10分钟内复现了几年前的漏洞那次经历让我彻底明白当CSRF没有防护时写一次成功的攻击对攻击者而言就是写一个HTML文件的事。2.2 设计一个“有漏洞”的转账接口目标服务实现两个核心功能登录和转账。登录成功后在session里记录用户名转账接口读取session判断是否已认证然后根据传入参数完成金额变动。from flask import Flask, request, session, redirect, url_for app Flask(__name__) app.secret_key csrf-lab-secret-key # 模拟数据库用户名 - 密码、余额 users { alice: {password: alice123, balance: 1000}, bob: {password: bob123, balance: 500}, } app.route(/login, methods[GET, POST]) def login(): if request.method POST: username request.form.get(username) password request.form.get(password) user users.get(username) if user and user[password] password: session[username] username return redirect(url_for(index)) return 登录失败, 401 return form methodpost 用户名: input typetext nameusernamebr 密码: input typepassword namepasswordbr button typesubmit登录/button /form app.route(/) def index(): user users.get(session.get(username, )) if not user: return redirect(url_for(login)) return f当前用户{session[username]}余额{user[balance]} app.route(/transfer, methods[GET, POST]) def transfer(): # 只校验会话不校验请求来源也不校验Token username session.get(username) if not username: return 请先登录, 403 user users[username] if request.method GET: to_user request.args.get(to) amount int(request.args.get(amount, 0)) else: to_user request.form.get(to) amount int(request.form.get(amount, 0)) if not to_user or to_user not in users: return 收款人不存在, 400 if amount 0: return 金额无效, 400 if amount user[balance]: return 余额不足, 400 user[balance] - amount users[to_user][balance] amount return f转账成功{username} 向 {to_user} 转账 {amount} 元 if __name__ __main__: app.run(host127.0.0.1, port5000, debugTrue)注意代码里的几个设计点/transfer同时接受GET和POST这里为了完整演示攻击面故意保留了GET方式。真实生产环境里转账这种操作绝对是POST但很多后台的批量操作接口、导出接口仍然用GET所以GET型CSRF并没有绝迹。同时int(request.args.get(amount, 0))这种直接转型的写法存在报错风险但不影响本次实验核心目的。启动服务后浏览器打开http://127.0.0.1:5000用alice账号登录能看到余额显示为1000。3. 三种典型攻击场景复现3.1 GET 型 CSRF一行代码的事GET型CSRF是最原始的形态。攻击者只需要在恶意页面里嵌入一个指向目标接口的资源标签浏览器解析HTML时会自动发出这个GET请求不需要任何用户交互。attacker_get.html内容如下!DOCTYPE html html headtitle免费抽奖活动/title/head body h1恭喜你获得一次抽奖机会/h1 !-- 这张图片的src就是攻击载荷 -- img srchttp://127.0.0.1:5000/transfer?tobobamount500 width1 height1 /body /html前提条件alice在访问这个攻击页面之前已经登录了目标站且登录Cookie尚未过期。攻击页面上那张1像素的图片对用户完全不可见但浏览器会立刻向目标地址发出请求。实操时先把alice登录后的Cookie保留着再在另一个标签页打开攻击页面。回到目标站首页刷新会发现alice的余额变成了500bob的余额变成了1000。抓包看一下这个请求请求行是GET /transfer?tobobamount500 HTTP/1.1 Host: 127.0.0.1:5000 Referer: http://localhost:8888/attacker_get.html Cookie: session...看到了吗Cookie字段被浏览器自动加进去了但Referer暴露了请求来自攻击页面。这也是后面讲Referer校验时的重要依据。3.2 POST 型 CSRF表单自动提交POST型比GET稍微复杂一点但也没复杂到哪里去。如果用img标签请求POST接口浏览器会把它当成GET请求发出去所以需要换一种方式构造一个隐藏表单然后用JavaScript自动提交。attacker_post.html内容如下!DOCTYPE html html headtitle视频会员领取/title/head body form idsteal actionhttp://127.0.0.1:5000/transfer methodPOST input typehidden nameto valuebob input typehidden nameamount value800 /form h2请稍候正在加载视频资源.../h2 script // 页面加载完成后自动提交表单用户毫不知情 document.getElementById(steal).submit(); /script /body /html表单里有idstealJS在页面加载完成后调用submit()。这一步不需要用户点击也不需要用户看到任何与转账相关的字样。页面显示的是“视频会员领取”实际执行的是转账800元。实测效果和GET型没有本质区别唯一的变化是请求变成了POST参数不在URL里而在请求体中。这里有一个值得注意的细节在Chrome当前默认的SameSite-Lax规则下跨站POST请求的Cookie默认不发。但如果是同站子域名之间、或者攻击页面和目标站在同一个域名下Cookie仍然会带上。更关键的是如果目标站自己把Cookie的SameSite设成了NonePOST型CSRF照样畅通无阻。在实际渗透测试中POST型CSRF更常见因为大多数开发者的安全意识足以让他们把敏感操作改成POST但仅仅改方法是不够的。真正有效的POST型CSRF防护是校验Token或严格的来源校验而不是祈祷浏览器不自动发Cookie。3.3 JSON 型 CSRF跨域表单与简单请求有不少现代Web应用前后端通过JSON交互比如转账接口只接收application/json。很多团队觉得“我用JSON接收参数跨站请求很难伪造”这个想法在某种程度上是安全的但需要两个前提。第一个前提目标接口拒绝text/plain、multipart/form-data等非JSON格式的Content-Type。第二个前提后端不会同时兼容JSON和表单两种解析方式。如果后端只解析JSON攻击页面构造请求就比较麻烦。因为跨域发起application/json请求会触发CORS预检而预检请求通常会被目标站拒绝。但有一种情况例外——如果接口宽泛到连text/plain也解析成JSON那就回到POST型CSRF的老路上。具体做法是form idjsonForm actionhttp://127.0.0.1:5000/transfer_json methodPOST input typehidden name{to:bob,amount:800} value /form script document.getElementById(jsonForm).submit(); /script尽管这个表单长得怪异但浏览器会把它编码为application/x-www-form-urlencoded或multipart/form-data发送。如果后端碰巧用宽松的解析方式比如先取request.get_data()再强行JSON解析这个请求就能被识别。所以在做JSON型CSRF测试时我通常先做一步探测看看后端能否解析text/plain或表单格式的请求体。这一步操作成本极低但往往能发现意想不到的绕过路径。真实的某个案例中目标接口声明只接收JSON但开发者在代码里同时调用了request.values读取参数直接导致JSON防护形同虚设。4. 防护方案与实战检测实验做完重点转向防护。CSRF的防护不是加一行代码就完事需要理解Token设计原理、SameSite的边界以及多重校验的组合拳。4.1 双提交 Token 机制设计与实现当前业界最主流的CSRF防护是CSRF Token。核心思路是在表单或请求头里附加一个随机的、不可预测的Token服务端在处理敏感请求时校验Token的合法性。攻击者无法预测Token值所以构造不出合法的请求。标准的实现路径是这样用户访问页面时服务端生成一个随机Token写入服务端session或Cookie并同时渲染到表单隐藏字段或页面的meta标签里用户提交表单或发起Ajax请求时带上这个Token服务端拿到请求后校验Token与会话中记录的值是否一致。一致则放行不一致则拒绝。参考实现如下import hmac import secrets app.before_request def csrf_protect(): if request.method in (POST, PUT, DELETE, PATCH): # 从请求头或表单字段中取Token token request.headers.get(X-CSRF-Token) or request.form.get(csrf_token) session_token session.get(csrf_token) if not token or not session_token or not hmac.compare_digest(token, session_token): return CSRF Token校验失败, 403 app.route(/login, methods[GET, POST]) def login(): # 在登录成功后生成Token并写入session if request.method POST: ... session[username] username session[csrf_token] secrets.token_urlsafe(32) return redirect(url_for(index))代码里用了hmac.compare_digest而不是普通的字符串比较是为了防止时序侧信道攻击。secrets.token_urlsafe(32)生成的随机字符串包含约256位熵暴力破解几乎没有可行性。前端在发Ajax请求时把Token塞进请求头fetch(/transfer, { method: POST, headers: { Content-Type: application/json, X-CSRF-Token: document.querySelector(meta[namecsrf-token]).content }, body: JSON.stringify({to: bob, amount: 800}) });注意一个易错点Token必须绑定用户会话不能全局共用一个静态Token。如果所有用户共用一个Token攻击者自己注册一个账号就能拿到合法TokenCSRF防护直接失效。同时Token在每次会话生成后不变还是每次请求都刷新取决于业务对用户体验的要求。刷新太频繁会打断多标签页操作不刷新则存在Token泄露后的时效窗口目前主流框架的默认做法是会话内不变。4.2 SameSite Cookie 的边界与取舍在Cookie上设置SameSite属性是浏览器层面提供的最省力的CSRF防线。它有三级设置SameSiteStrict完全禁止跨站携带任何跨站请求都不带Cookie。SameSiteLax允许跨站GET请求携带POST等非安全方法禁止。同时允许顶层导航时的GET请求携带。SameSiteNone不限制携带但要求Secure属性即必须是HTTPS连接。Chrome从2020年起默认对未设置该属性的Cookie按Lax处理。这意味着大量老应用在不知不觉中获得了部分CSRF防护能力这波是浏览器替开发者做了一层安全垫。但从攻击者视角看Lax模式并不是万无一失。它仍然允许跨站GET请求携带Cookie所以对于GET型CSRFLax模式挡不住。有些站点还设置SameSiteNone; Secure以适配跨域单点登录场景这等于明确告诉浏览器“跨站请带Cookie”CSRF风险完全回到原点。实践中我强烈建议对敏感Cookie一律设置SameSiteLax或Strict不用犹豫。如果业务必须跨站携带Cookie比如单点登录靠SameSite是靠不住的老老实实上CSRF Token。不要尝试用SameSiteNone来解决“请求带不上Cookie”的问题后再加Token这属于给自己制造不必要的攻击面。4.3 Referer 与 Origin 校验的补位价值Referer校验属于“来源校验”方案原理是检查请求头里的Referer字段是否以目标站点的域名开头。它比Token实现简单但容易踩坑只能作为辅助手段。问题在于三个场景用户从地址栏直接输入网址或浏览器书签访问时请求不带Referer此时要不要放行放行的话攻击者可以设置referrerpolicyno-referrer让攻击请求也不带Referer。不同路径的站内跳转、页面内Ajax请求的Referer值可能因页面内部跳转而不同。HTTPS页面跳转到HTTP页面时浏览器默认不发送Referer导致误杀正常请求。设计Referer校验时严谨的做法是要求Referer必须存在并且其Host部分与目标Host一致。但对于放空Referer的请求必须拒绝否则就会留下一个明显的绕过洞。一个更稳妥的方案是校验Origin请求头因为Origin在跨站请求中总是存在且不会被页面里的referrerpolicy策略直接控制。但它也有局限性老版本浏览器和某些跳转场景不会带上Origin所以部署时仍需要做兼容性测试。4.4 双重提交 Cookie 模式的取舍除了服务端存储Token还有一种实践是双重提交Cookie服务端把Token放在Cookie里前端在请求头里回显同一Token服务端只比对请求头中的Token与Cookie中的Token是否一致不需要在服务端额外存储。app.before_request def csrf_protect_double_submit(): if request.method in (POST, PUT, DELETE, PATCH): header_token request.headers.get(X-CSRF-Token) cookie_token request.cookies.get(csrf_token) if not header_token or not cookie_token or not hmac.compare_digest(header_token, cookie_token): return CSRF Token校验失败, 403这个方案的好处是不占服务端存储适合分布式环境。但它有一个被讨论很多的弱点如果攻击者能向目标域名写入Cookie比如存在子域名Cookie投递漏洞或者中间人攻击场景理论上就能同时伪造Cookie和请求头。即便如此在大多数常规威胁模型下双重提交Cookie的防护强度仍优于裸奔。选择哪种方案我的建议是单体应用优先服务端存储型Token框架自带的最省心微服务、分布式场景优先双重提交Cookie避免会话状态同步的麻烦。4.5 绕过思路与攻防实测反思做渗透测试时不能只看“有没有Token”还要测“Token怎么校验、在哪校验”。常见的校验缺陷有两种。第一种是只校验POST不校验GET。比如团队防线只给POST接口加了Token校验但某接口同时支持GET攻击者把请求改成GET就能绕过。我在实验中就故意把/transfer保留成GETPOST双支持就是为了演示这种绕过。第二种是校验了参数但Token放在请求体里而框架的日志或反向代理配置把请求体记录了。如果日志泄露到攻击者手里Token也就失效了。所以日志系统里一定要把X-CSRF-Token和请求体里的csrf_token字段脱敏这个小细节很容易被忽视。第三种常见缺陷是Token在响应页面里渲染但同时页面里存在XSS漏洞。攻击者XSS拿到Token后CSRF和XSS结合形成链式攻击单项防护瞬间瓦解。这也是为什么我在开篇说CSRF的坑在于它太隐蔽有时候你修好了洞其他洞又在接应。5. 检测工具与问题排查实录5.1 手工构造请求与自动化检测思路实验过程中手工抓包是基本功。浏览器开发工具就能看到请求头、Cookie、Referer足够完成大部分验证。如果需要更细致的控制我常用两款工具。第一类是代理抓包工具可以用来拦截和修改请求验证“如果我在请求里加一个伪造的Token会怎样”“如果我把Referer删掉会怎样”。这适合做精细化测试。第二类是浏览器自动化工具适合批量验证接口是否存在CSRF。思路是登录目标站写一段脚本遍历所有需要认证的接口先直接请求一次统计响应再清空Referer、去掉Token后重新请求看服务端是否拒绝。如果两者响应一致说明接口大概率存在CSRF缺陷。写一段极简的检测逻辑用Python的requests库示意import requests base_url http://127.0.0.1:5000 s requests.Session() # 登录获取Cookie s.post(base_url /login, data{username: alice, password: alice123}) # 正常请求带Referer resp_normal s.post(base_url /transfer, data{to: bob, amount: 10}, headers{Referer: base_url /}) print(正常请求状态码:, resp_normal.status_code) # 模拟跨站请求无Referer resp_csrf s.post(base_url /transfer, data{to: bob, amount: 100}) print(无Referer请求状态码:, resp_csrf.status_code)若第二次请求的状态码仍是200且转账成功基本可以判定这个接口存在CSRF漏洞。自动化工具能做更大的覆盖面但前提是找到合适的鉴权方式。5.2 常见问题与排查技巧速查我再整理一个问题速查表都是实际实验中高频踩坑的点既适用于复现测试也适用于防御侧自查。现象可能原因排查方法解决方向攻击页面执行后目标站未发生状态变化SameSiteLax拦截了跨站POST的Cookie打开目标站看是否仍处于登录状态改用GET型测试或使用不受限的Cookie请求发出但返回403浏览器未携带Cookie抓包看请求头有无Cookie字段检查跨站会话与SameSite配置JSON接口拒绝跨站表单服务端只解析application/json用Postman直接发表单类型请求观察报错表单改简单请求或验证后端兼容性同一页面多标签操作时Token失效Token被刷新逻辑覆盖查看session中Token更新时间延长Token有效期或使用框架标准机制日志中泄露了Token全局日志打印了请求头检索日志关键字X-CSRF-Token日志脱敏或过滤敏感字段自己写校验但始终不生效Token比对方式有误或在错误位置取数打印服务端session里的Token与请求里的Token进行对比统一从请求头取Token再补充一个真实过程里的小坑本地联调时目标服务跑在http://127.0.0.1:5000攻击页面跑在http://localhost:8888。为什么我能看到攻击页面因为这个环境下Cookie是按域区分的127.0.0.1和localhost是不同的地址但大多数浏览器将它们视为同源。测试前要确认Cookie确实能带上否则会把“攻击没生效”误判成“接口安全”。5.3 测试报告如何落地实验做完最终要落到一份可传送的测试报告。我的习惯是组一个四段式模板漏洞描述接口URL、受影响方法、复现过程。危害说明结合业务场景说明攻击者可执行什么操作如任意转账、越权修改。复现步骤照代码块先登录再访问攻击页面逐行列出。修复建议指明加Token、设置SameSite属性、校验Origin等具体方案。报告里不写模糊话术比如“建议加强校验”这种句子等于没写。要具体到“在/transfer接口所有POST方法处理器前添加如下代码”并附上代码片段。这样的报告开发看到能直接改测试看到能直接回归。个人在实际操作中的一个体会是CSRF的攻防不是一次性的它是一个持续的博弈过程。每修掉一个绕过路径很可能在测试中没有立刻暴露的新绕过点又会浮现。真正稳的方案永远是多层校验叠加Cookie层面设SameSite请求层面加Token服务端再校验Origin。三层都上了CSRF才算是基本堵死。如果时间紧至少也要做到“Token SameSiteLax”这个组合优先级高于单独使用Referer校验。这套组合我在多个内部模拟项目中验证过都是稳定可用的。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。