前阵子对单位内部的一个交易系统做安全评估,明明接口都加了防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,不然每次重启Flask,session都会重新签名,之前登录的会话全部失效,复现时会莫名其妙掉登录态。
# 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 method="post"> <input name="username" placeholder="用户名"> <input name="password" type="password" 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(host="127.0.0.1", port=5000, debug=False)代码里有两个接口值得注意:/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> <head><meta charset="utf-8"><title>积分活动</title></head> <body> <h2>每日签到领积分</h2> <p>页面加载后自动完成签到,无需任何操作。</p> <iframe name="silent" style="display:none"></iframe> <form id="f" method="POST" action="http://127.0.0.1:5000/transfer" target="silent"> <input type="hidden" name="to" value="bob"> <input type="hidden" name="amount" value="100"> </form> <script> document.getElementById('f').submit() </script> </body> </html>几个关键点:
target="silent"指向一个隐藏的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已经少了100,bob多了100。
这里强调一下本地实验的特殊性:127.0.0.1:5000和127.0.0.1:8000是同host不同端口,在SameSite的语义下属于同一站点,所以Cookie不受Lax策略拦截,表单POST也能带上会话。真实跨站攻击中,攻击页面一般托管在另一个域名下,目标站点Cookie如果设置了SameSite=None或者未设置时才容易成功。这个差异很多文章不会讲,但你在排查真实攻击时特别重要。
3.3 攻击页面B:诱导点击的GET型链接
GET型状态变更接口的攻击方式完全不同。SameSite=Lax策略允许跨站的顶层导航GET请求携带Cookie——也就是用户点击一个链接跳转过去的那种请求。所以点击链接就能中招。
<!DOCTYPE html> <html> <head><meta charset="utf-8"><title>每日福利</title></head> <body> <h2>每日福利</h2> <p>点击下方链接即可领取一张100元优惠券:</p> <a href="http://127.0.0.1:5000/coupon?to=bob&amount=100">立即领取</a> </body> </html>受害者点击“立即领取”后,浏览器直接导航到/coupon?to=bob&amount=100,这个请求属于顶层导航GET,在SameSite=Lax的默认策略下会带上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设置了SameSite=Lax/Strict,跨站POST不带Cookie | F12检查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设成了
SameSite=None; Secure。这等于告诉浏览器跨站也带上Cookie,CSRF防线让掉一大半。 - 同站内存在可控子域。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 name="referrer" content="no-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写成to=bob&amount=100,Content-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_token=attacker_value;domain=example.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 name="referrer" content="no-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文件。攻击者上传一个带恶意表单的SVG(SVG本质是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是怎样被带上的,比读一百篇文章都管用。