
前阵子对单位内部的一个交易系统做安全评估明明接口都加了防CSRF的Token我还是用一个隐藏iframe就打穿了。排查了半天才找到原因Token存在Cookie里做双提交校验而某个子域恰好允许写Cookie。如果对CSRF的理解停留在“跨站自动带Cookie”这个层面这类绕过根本不可能发现。所以我想认真写一个CSRF系列第一期先把地基打牢CSRF到底在伪造什么、哪些业务场景最容易中招、怎么自己动手从一个带漏洞的靶场完整复现一次攻击以及主流的CSRF防御方案各自存在哪些设计陷阱。这篇文章面向的是Web开发者、安全测试工程师和刚入门做漏洞研究的朋友看完你应该能自己判断“这个接口有没有CSRF问题有的话怎么证明”。1. 信任边界被打破CSRF到底在伪造什么1.1 浏览器自动携带Cookie成了攻击者的“免费劳动力”几乎所有CSRF讲解都会提到一个事实HTTP协议本身是无状态的维持登录态要靠Cookie、Authorization头这类凭证。而浏览器有一个“贴心”机制——只要请求的目标域名匹配就会自动带上对应的Cookie全程不需要用户干预。攻击者利用的就是这份“自动”。我特别喜欢的类比是门禁卡门卫只认卡不认人。攻击者不需要偷你的卡他只需要想办法让你本人走到门口门禁一刷就自动放行。放到CSRF里“你本人走到门口”等于“你的浏览器发出一个请求”“门禁放行”等于“服务端接受了这个带合法Cookie的请求”。整个过程攻击者完全不需要知道你的Cookie内容也没有必要知道因为浏览器会替他把事情办完。这里有个容易混淆的点提前说清楚CSRF里的“跨站”在现代浏览器安全语境下是 scheme registrable domain比如 example.com端口不算。很多人拿两个不同端口的本地服务演示CSRF严格来说那是同站做演示没问题但如果你要验证SameSite策略的拦截行为就必须用两个不同的域名。这一篇里我会把这两种情况都交代到。1.2 三个必须同时满足的前提条件CSRF要成立下面三个条件缺一不可目标请求依赖浏览器自动携带的凭证来认证身份。Cookie最常见HTTP Basic Auth同理客户端证书也属于这一类。请求里的关键参数攻击者可以完全预测。如果转账需要短信验证码、需要输入支付密码、需要图形验证码攻击者就没法在无感知的情况下完成攻击因为这些东西不在攻击者的知识范围内。这也是为什么支付类接口普遍比普通业务接口安全的原因——它们天然多了一层攻击者无法预测的因子。服务端没有校验请求来源或者校验可以被绕过。这是核心也是防御要解决的根本问题。三条同时满足CSRF才成立。你看很多所谓“CSRF漏洞”实际是条件不齐的比如修改密码接口要求输入旧密码那旧密码就是攻击者预测不了的因子这个接口其实不能算CSRF漏洞。测漏洞时先对照这三条能省下大量无效工作。1.3 与XSS的边界为什么很多人总把两者搞混CSRF和XSS经常被放在一起讨论但两者本质完全不同。简单来说XSS是攻击者的脚本在目标站点的页面上执行获得了目标域的代码执行能力CSRF是攻击者的页面在自己的域名下渲染只是向目标站点发起了一个跨站请求。你可以理解为XSS是在银行大厅里贴假公告CSRF是骗你自己去柜台办一笔不该办的转账。防御方向更是截然不同。XSS要解决的是“不可信数据如何在HTML/JS/CSS里安全输出”CSRF要解决的是“如何确认请求是用户主动发起的”。所以你不能指望XSS的那套输入过滤、输出编码来解决CSRF反过来也不行。实际项目里一个站点同时存在这两种问题非常常见但它们需要完全独立的修复方案。2. 高发场景拆解这些业务接口天生就是CSRF的重灾区2.1 纯会话驱动的状态变更操作CSRF最典型的攻击目标是“仅凭会话就能完成的状态变更操作”。列几个我经常见的高危场景业务接口风险场景修改密码如果接口不校验旧密码攻击者诱导受害者把密码改为自己指定的值随后直接接管账号绑定手机号/邮箱攻击者先绑定自己的联系方式再通过找回密码流程完成接管修改收货地址电商场景下可导致订单被篡改引发后续的退款欺诈转账/发红包资金类操作影响最直接也是攻击者最想要的删除/注销账号数据破坏配合社工能造成更大损失退出登录危害相对小但会给攻击者制造一个钓鱼窗口期判断接口是否值得重点关注就看它的操作是否满足“低门槛高影响”。改昵称这类接口当然也能CSRF但危害有限不太值得投入精力。真正要紧的是能改变账号归属、能产生资金流动、能破坏数据的操作。2.2 GET请求滥用一个img标签就能打成一片老生常谈但永远在发生开发者图方便把删除、修改、转账都做成GET接口。浏览器加载img src...、script src...的时候都会自动发出GET请求这意味着攻击者可以在任何他控制的页面上放一行img标签受害者只要打开页面请求就已经出去了。除了img还有iframe、link、CSS里的url()甚至服务端发起链接预取时也能触发。一个更细的坑如果你的前端用fetch或者axios发的是GET请求浏览器同样会在跨站时自动带Cookie除非SameSite拦截而很多开发者以为“我加了Token校验就没事了”——但Token可能只加在了POST接口上。后面讲到防御的时候我会再展开这条。2.3 前后端分离与JSON接口带来的新盲区很多团队会以为JSON接口天然不怕CSRF因为跨站fetch发application/json时会触发CORS预检预检过不去请求就发不出去。这话对了一半。真相是如果接口同时接受表单格式或text/plain预检就绕过去了。很多框架对Content-Type的解析比预想中更宽容。前端请求库如果去掉了自定义头或者老系统里某个隐藏入口直接拼表单防御就没了。还有一种常见“虚假安全感”前端封装请求库时给所有请求加了X-Requested-With: XMLHttpRequest这个头。这个头会触发CORS预检间接挡住跨站请求。但如果你在某个接口里关了预检、或者用了JSONP回调、或者老系统绕开前端直接提交表单防线就形同虚设。“靠框架特性防CSRF”非常不靠谱后面我会专门讲为什么。3. 动手复现用两个页面打穿一个转账系统3.1 搭建一个自带漏洞的最小转账系统先用Flask起一个最小可用的转账系统代码故意去掉所有CSRF防御用来还原“裸奔”状态下的攻击过程。我用固定的secret_key不然每次重启Flasksession都会重新签名之前登录的会话全部失效复现时会莫名其妙掉登录态。# app.py from flask import Flask, request, session, render_template_string app Flask(__name__) # 仅用于本地演示生产环境必须使用高强度的随机值 app.secret_key dev-secret-key users {alice: pass123} balances {alice: 1000, bob: 0} app.route(/login, methods[GET, POST]) def login(): if request.method POST: username request.form.get(username, ) password request.form.get(password, ) if users.get(username) password: session[username] username return 登录成功 return 用户名或密码错误 return render_template_string( form methodpost input nameusername placeholder用户名 input namepassword typepassword placeholder密码 button登录/button /form ) app.route(/transfer, methods[POST]) def transfer(): username session.get(username) if not username: return 未登录, 401 to_account request.form.get(to, ) try: amount int(request.form.get(amount, 0)) except ValueError: return 金额格式错误, 400 if amount 0 or amount balances[username]: return 金额不合法, 400 balances[username] - amount balances.setdefault(to_account, 0) balances[to_account] amount return f转账成功{username} 剩余 {balances[username]} app.route(/coupon, methods[GET]) def coupon(): # 模拟开发者图省事用GET实现状态变更操作 username session.get(username) if not username: return 未登录, 401 to_account request.args.get(to, bob) try: amount int(request.args.get(amount, 100)) except ValueError: return 金额格式错误, 400 if amount 0 or amount balances[username]: return 金额不合法, 400 balances[username] - amount balances.setdefault(to_account, 0) balances[to_account] amount return f优惠券领取成功{to_account} 收到 {amount}{username} 剩余 {balances[username]} app.route(/balance) def balance(): username session.get(username) if not username: return 未登录, 401 return f{username} 的余额{balances[username]} if __name__ __main__: app.run(host127.0.0.1, port5000, debugFalse)代码里有两个接口值得注意/transfer是POST接口标准的状态变更操作/coupon是GET接口参数直接暴露在URL上还特意写成了“领取优惠券”这种诱导性业务。实际系统里就是你删库的接口、改价的接口。启动方式pip install flask python app.py然后浏览器访问http://127.0.0.1:5000/login用 alice / pass123 登录。3.2 攻击页面A隐藏iframe加自动提交表单攻击页面的核心思路是页面看起来人畜无害背后偷偷提交一个表单。下面这个页面我放在攻击者控制的服务器上本地演示就用8000端口起一个静态服务。!DOCTYPE html html headmeta charsetutf-8title积分活动/title/head body h2每日签到领积分/h2 p页面加载后自动完成签到无需任何操作。/p iframe namesilent styledisplay:none/iframe form idf methodPOST actionhttp://127.0.0.1:5000/transfer targetsilent input typehidden nameto valuebob input typehidden nameamount value100 /form script document.getElementById(f).submit() /script /body /html几个关键点targetsilent指向一个隐藏的iframe表单提交的响应会加载到iframe里页面不会发生明显的跳转用户完全无感知。表单字段全部是hidden用户看不到任何转账痕迹。页面标题和正文写的是“签到领积分”和实际发生的转账没有半点关系这就是攻击页面的欺诈性所在。在命令行起一个静态服务把这个文件放到对应目录python -m http.server 8000 --directory ./attack然后访问http://127.0.0.1:8000/attack.html再去http://127.0.0.1:5000/balance看余额alice已经少了100bob多了100。这里强调一下本地实验的特殊性127.0.0.1:5000和127.0.0.1:8000是同host不同端口在SameSite的语义下属于同一站点所以Cookie不受Lax策略拦截表单POST也能带上会话。真实跨站攻击中攻击页面一般托管在另一个域名下目标站点Cookie如果设置了SameSiteNone或者未设置时才容易成功。这个差异很多文章不会讲但你在排查真实攻击时特别重要。3.3 攻击页面B诱导点击的GET型链接GET型状态变更接口的攻击方式完全不同。SameSiteLax策略允许跨站的顶层导航GET请求携带Cookie——也就是用户点击一个链接跳转过去的那种请求。所以点击链接就能中招。!DOCTYPE html html headmeta charsetutf-8title每日福利/title/head body h2每日福利/h2 p点击下方链接即可领取一张100元优惠券/p a hrefhttp://127.0.0.1:5000/coupon?tobobamount100立即领取/a /body /html受害者点击“立即领取”后浏览器直接导航到/coupon?tobobamount100这个请求属于顶层导航GET在SameSiteLax的默认策略下会带上alice的会话Cookie转账立即生效。有人可能会问img标签不也能触发GET吗注意img标签的GET是子资源请求在Lax策略下不会携带跨站Cookie。也就是说“任何GET接口都能用img打”这个说法现在已经不准确了真正可靠的触发方式是“诱导点击链接”。这也是为什么我反复强调开发时不要把状态变更操作放在GET里——它在现代浏览器策略下依然有被利用的可能而且利用成本极低。3.4 完整复现步骤与失败排查清单完整复现链路按顺序做一遍启动后端python app.py。浏览器访问http://127.0.0.1:5000/login用 alice / pass123 登录确认能访问/balance看到余额。将攻击页面保存为attack.html放到./attack目录再执行python -m http.server 8000 --directory ./attack。访问http://127.0.0.1:8000/attack.html页面提示“签到成功”之类的内容实际上表单提交到了/transfer。回到http://127.0.0.1:5000/balance观察余额变化。如果复现不出来按这个顺序排查现象可能原因验证方法返回“未登录”会话丢失可能被同端口新登录覆盖或者secret_key变化直接访问/balance确认是否还能看到余额请求成功但余额没变攻击页面表单action写错、端口写错、参数名对不上F12打开Network面板看请求发出的URL和表单数据返回403或跨域拦截报错浏览器CORS策略阻挡了页面读取响应但请求本身可能已经发出影响照样存在切换到“网络”标签页查看请求是否发出、响应状态在真实跨站域名下复现失败目标Cookie设置了SameSiteLax/Strict跨站POST不带CookieF12检查Application面板里的Cookie属性我把常见问题列成了一张表你在自查时直接对号入座就行。实际渗透测试和代码审计阶段最快的验证方法就是用浏览器的DevTools观察请求是否携带了目标站点的Cookie比反复猜测快得多。4. 防御原理与设计陷阱Token和SameSite没那么简单4.1 一个合格的CSRF Token至少满足四个条件业界最主流的防御方案是CSRF Token思路很简单在请求里加一个攻击者无法预知的随机值服务端校验这个值。但Token设计里翻车的案例一点都不少一个合格的Token至少满足四件事每个会话一个不能全站所有人共用一个。如果Token是全局固定值攻击者自己注册一个账号拿到Token然后拼进攻击表单所有用户的请求都能通过校验。使用密码学安全的随机数生成。Python里用secrets.token_urlsafe(32)不要用uuid、时间戳、用户ID拼接这类可预测值。Token不能存放在客户端可写的位置。如果Token本身就存在Cookie里攻击者一旦能写Cookie整个方案就崩了。这一点是双提交Cookie的命门后面单独讲。校验使用恒定时间比较。用hmac.compare_digest这类函数防止时序侧信道泄露Token信息。除了生成和校验Token的派发渠道也容易出问题。最常见的是页面渲染时把Token塞进表单的hidden字段前端提交时带上。前后端分离的纯API模式下一般会提供独立的Token接口前端拿到后放入自定义请求头。这个流程很容易被做歪。我再列几个实际遇到的翻车案例Token通过URL参数传递结果Referer头把Token带给了第三方等于把钥匙挂在门外。Token校验只针对POST接口GET接口完全裸奔。校验失败时返回200而不是403日志里全是“正常”请求安全监控完全瞎掉。失败必须返回明确的错误状态码并记录告警日志否则攻击者扫描你的时候你连痕迹都找不到。4.2 SameSite Cookie默认策略帮了大忙但别指望它兜底SameSite属性是浏览器侧的一道重要防线。三种模式区别如下属性值跨站请求携带Cookie的行为适用场景Lax顶层导航的GET请求会携带POST和子资源请求不携带Chrome 80之后的默认值平衡了安全和体验Strict任何跨站请求都不携带Cookie安全性最强但用户体验差很多登录后跳转直接失效None均携带但必须同时设置Secure属性跨域单点登录等场景必须显式开启Chrome 80以后未显式设置SameSite属性的Cookie默认按Lax处理。这相当于浏览器层面给整个互联网的CSRF防御水平提了一个档次很多老的POST型CSRF攻击在现代浏览器里默认就带不上Cookie了。但别高兴太早。实际项目里以下三种情况会让SameSite形同虚设站点因为要做跨域单点登录、第三方支付回调把Cookie设成了SameSiteNone; Secure。这等于告诉浏览器跨站也带上CookieCSRF防线让掉一大半。同站内存在可控子域。SameSite的“站”按scheme可注册域名判断子域之间算同一站Cookie互通。攻击者只要能在任意一个子域上放页面比如用户上传的HTML文件所在的静态域名他发起的请求在你的Cookie眼里就是“同站”的SameSite完全不管用。用户点击链接触发的GET请求在Lax下依然携带Cookie所以所有GET状态变更接口必须单独处理。所以我的态度很明确SameSite是有价值的纵深防御层但它不应该成为唯一防线。尤其当你发现系统里存在任何子域不受控、任何路径能上传HTML、任何地方设置了None你就必须把Token和Origin校验做扎实。4.3 Origin与Referer正确姿势和常见误用服务端校验请求来源是另一类常见方案。Origin头在跨站POST请求时几乎一定会带上而且不像Referer那样会携带完整URL相对更干净。正确做法是维护一份可信来源白名单然后校验Origin头的值是否在白名单内。注意三个坑只检查“有没有Origin头”而不校验值的做法等于没防。跨站请求一样有Origin只要服务端不拒绝它就放行了。服务器做302跳转时Origin头可能丢失。有些团队的校验逻辑是“无Origin就放行”这一放行就导致整套防御失效。如果你同时对接口开启了宽松的CORS配置比如Access-Control-Allow-Origin: *那么CSRF的Origin校验也会被一起架空这两块配置必须联动审查。Referer校验的坑更多最常见的是只校验前缀攻击者注册一个example.com.attacker.com的域名就能绕过去还有空Referer放行的逻辑攻击页面加一个meta namereferrer contentno-referrer就能让请求不带Referer。这两个坑我在后面绕过章节还会展开讲。5. 绕过入口盘点用攻击者视角给自己的系统做体检5.1 Content-Type与框架宽容度先看一个被反复误解的点很多人以为POST加JSON的接口天然防CSRF因为跨站fetch不能发application/json。但这个结论依赖前端必须用fetch发JSON、并且后端只解析JSON两个前提。现实里至少有三种打法第一种表单的enctype可以直接设成text/plain。HTML表单允许的三种enctype里text/plain是简单请求不需要预检。如果你的服务端用request.get_data()读原始body、或者框架对Content-Type不敏感就能直接解析。第二种利用后端框架对参数来源的宽容处理。很多PHP、Python老项目里$_POST和$_REQUEST不区分Content-Type表单格式的数据照样被解析进业务代码里。攻击者只需要把请求body写成tobobamount100Content-Type用表单格式就能绕过“必须是JSON”的前置要求。第三种纯JSON接口没有做Token校验但某个隐藏的兼容接口接收表单格式。这种接口往往因为历史原因留着开发已经忘记它的存在但CSRF攻击不关心你是否记得。我在测试一个系统时常规做法就是先扒出接口清单再看每个接口接受的Content-Type然后把“校验Token的中间件是否绑定在特定Content-Type上”这个因果关系彻底摸清楚。很多时候绕过不是因为攻击者多聪明而是因为防御只覆盖了某一种Content-Type表单格式一打就穿。5.2 双提交Cookie的命门谁能写Cookie谁就赢双提交Cookie方案在无状态架构里非常流行尤其是前后端完全分离、不想引入服务端session的项目。它的逻辑是服务端随机生成Token放进Cookie同时要求请求的参数或者Header里带上同样的Token校验两者是否一致。听起来挺巧妙也很容易实现。但攻击者只要能让受害者的浏览器带上一个自己指定的Cookie这个方案就彻底失效了。因为Token不是存在服务端会话里的而是存在Cookie里让客户端“自证”服务端只校验“Cookie里的Token和请求参数里的Token是否一致”完全不关心Token是谁生成的。那攻击者怎么种Cookie呢我见过几条真实路线子域可控。攻击者拿到某个子域后可以在该子域上通过JS写Cookie到父域如document.cookie csrf_tokenattacker_value;domainexample.com;path/;。由于SameSite不区分子域这个Cookie在父域接口的请求中照样被携带。同站内存在其他漏洞。比如一个反射型XSS、或者某个接口存在CRLF注入攻击者借它种下一个伪造Cookie。服务端错误地把Token固定成某个可预测值或者干脆不生成、允许客户端自定义。后者听起来荒唐但我审计时真的见过——为了让“接口测试方便”而加了个开发后门没删干净。所以我对双提交Cookie的结论是在无法保证所有子域可控、无法保证没有任何XSS/注入漏洞的前提下它只能算临时方案。安全边界要求高的系统老老实实用服务端会话绑定Token哪怕麻烦一点。5.3 Referer白名单的经典绕法Referer校验是最古老的CSRF防御手段但至今还有大量系统在用。绕法也很经典。第一种是白名单写得太宽。很多系统只判断“Referer是否以 https://example.com 开头”攻击者注册一个https://example.com.attacker.com的域名就能通过。判断方式如果是字符串前缀匹配任何以example.com开头的攻击者域名都被放行。第二种是诱导出空Referer。攻击页面里加meta namereferrer contentno-referrer或者把攻击请求放在sandbox iframe里、用某些数据URL场景Referer头就会消失。如果服务端的逻辑是“无Referer时直接放行”这一招直接打通。第三种是降级。用户从一个HTTPS页面发起请求到HTTP目标或者反向跳转经过HTTP中间层浏览器出于安全考虑会丢弃Referer。这种场景在混合内容合规检查不严的站点里很常见。真正可用的Referer策略应该是不带Referer就拒绝、白名单做精确匹配而不是前缀匹配。但即便如此Referer本身还会因各种浏览器扩展、代理、隐私模式产生奇奇怪怪的行为所以它只适合当辅助校验不适合当主力防御。5.4 子域与SameSite边界前面已经提到过SameSite不隔离子域example.com 和 sub.example.com 在SameSite语义下是同一站Cookie互通。攻击者只要在任意子域上有一个页面他发起的请求对目标站点来说就是“同站请求”SameSite策略不做任何拦截。这个边界经常被忽略。我见过一个真实的例子目标站点的主站防御做得非常到位Token和SameSite都配了但用户上传头像的域名是cdn.example.com而且允许上传SVG文件。攻击者上传一个带恶意表单的SVGSVG本质是HTML可以在里面跑脚本和表单受害者访问https://cdn.example.com/evil.svg时表单提交请求里带着主站的Cookie——因为同站SameSite放行Token又只防跨站页面携带的请求同站请求完全绕过了Token的判定场景。这个案例说明一个道理纵深防御不是把几道墙并列放在一起而是每一层都要覆盖不同的威胁面。SameSite挡的是跨站场景Token挡的是“攻击者预测不了随机值”的场景Origin校验挡的是来源不可信的场景三者服务于不同的前提假设。缺一层另外两层往往会连带失效。我在给团队做CSRF自查时最后都会落到一张清单上检查项达标标准状态变更接口是否拒绝GET所有写操作必须POST或其他非GET方法Token是否绑定会话每个会话独立不存储在Cookie可写区域Token校验是否覆盖所有Content-Type表单、JSON、text/plain都要校验或者统一拒绝Cookie的SameSite设置是否符合业务非必要不设None设了必须有Origin/Token兜底校验失败是否有告警返回403并记录来源IP、UA、请求路径是否全链路排查子域所有可写页面、可上传HTML的地方都在控制范围内这套清单看起来简单但它背后踩过的坑一点不简单。我在实际测试里见过太多“加了Token还是被打穿”“SameSite设了Strict还是被绕”“Origin校验了还是漏”的情况根因基本都是上面几个点里的某一条被忽略。这期先把原理、场景和复现讲透下一期我准备往更深的对抗方向走说说Token设计缺陷的细分类型、登录CSRF和API场景里的特殊绕过以及浏览器策略的变化对老系统带来的真实冲击。如果你是刚接触CSRF建议先把本地靶场跑一遍——亲手把一个请求发出去、亲眼看到Cookie是怎样被带上的比读一百篇文章都管用。