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.html:GET型CSRF攻击页面。attacker_post.html:POST型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 method="post"> 用户名: <input type="text" name="username"><br> 密码: <input type="password" name="password"><br> <button type="submit">登录</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(host="127.0.0.1", port=5000, debug=True)注意代码里的几个设计点:/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> <head><title>免费抽奖活动</title></head> <body> <h1>恭喜你获得一次抽奖机会!</h1> <!-- 这张图片的src就是攻击载荷 --> <img src="http://127.0.0.1:5000/transfer?to=bob&amount=500" width="1" height="1"> </body> </html>前提条件:alice在访问这个攻击页面之前,已经登录了目标站,且登录Cookie尚未过期。攻击页面上那张1像素的图片对用户完全不可见,但浏览器会立刻向目标地址发出请求。
实操时,先把alice登录后的Cookie保留着,再在另一个标签页打开攻击页面。回到目标站首页刷新,会发现alice的余额变成了500,bob的余额变成了1000。
抓包看一下这个请求,请求行是:
GET /transfer?to=bob&amount=500 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> <head><title>视频会员领取</title></head> <body> <form id="steal" action="http://127.0.0.1:5000/transfer" method="POST"> <input type="hidden" name="to" value="bob"> <input type="hidden" name="amount" value="800"> </form> <h2>请稍候,正在加载视频资源...</h2> <script> // 页面加载完成后自动提交表单,用户毫不知情 document.getElementById("steal").submit(); </script> </body> </html>表单里有id="steal",JS在页面加载完成后调用submit()。这一步不需要用户点击,也不需要用户看到任何与转账相关的字样。页面显示的是“视频会员领取”,实际执行的是转账800元。
实测效果和GET型没有本质区别,唯一的变化是请求变成了POST,参数不在URL里而在请求体中。这里有一个值得注意的细节:在Chrome当前默认的SameSite-Lax规则下,跨站POST请求的Cookie默认不发。但如果是同站子域名之间、或者攻击页面和目标站在同一个域名下,Cookie仍然会带上。更关键的是,如果目标站自己把Cookie的SameSite设成了None,POST型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 id="jsonForm" action="http://127.0.0.1:5000/transfer_json" method="POST"> <input type="hidden" 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[name="csrf-token"]').content }, body: JSON.stringify({to: "bob", amount: 800}) });注意一个易错点:Token必须绑定用户会话,不能全局共用一个静态Token。如果所有用户共用一个Token,攻击者自己注册一个账号就能拿到合法Token,CSRF防护直接失效。同时,Token在每次会话生成后不变还是每次请求都刷新,取决于业务对用户体验的要求。刷新太频繁会打断多标签页操作,不刷新则存在Token泄露后的时效窗口,目前主流框架的默认做法是会话内不变。
4.2 SameSite Cookie 的边界与取舍
在Cookie上设置SameSite属性,是浏览器层面提供的最省力的CSRF防线。它有三级设置:
SameSite=Strict:完全禁止跨站携带,任何跨站请求都不带Cookie。SameSite=Lax:允许跨站GET请求携带,POST等非安全方法禁止。同时允许顶层导航时的GET请求携带。SameSite=None:不限制携带,但要求Secure属性,即必须是HTTPS连接。
Chrome从2020年起默认对未设置该属性的Cookie按Lax处理。这意味着大量老应用在不知不觉中获得了部分CSRF防护能力,这波是浏览器替开发者做了一层安全垫。
但从攻击者视角看,Lax模式并不是万无一失。它仍然允许跨站GET请求携带Cookie,所以对于GET型CSRF,Lax模式挡不住。有些站点还设置SameSite=None; Secure以适配跨域单点登录场景,这等于明确告诉浏览器“跨站请带Cookie”,CSRF风险完全回到原点。
实践中,我强烈建议:
- 对敏感Cookie一律设置
SameSite=Lax或Strict,不用犹豫。 - 如果业务必须跨站携带Cookie(比如单点登录),靠SameSite是靠不住的,老老实实上CSRF Token。
- 不要尝试用
SameSite=None来解决“请求带不上Cookie”的问题后再加Token,这属于给自己制造不必要的攻击面。
4.3 Referer 与 Origin 校验的补位价值
Referer校验属于“来源校验”方案,原理是检查请求头里的Referer字段是否以目标站点的域名开头。它比Token实现简单,但容易踩坑,只能作为辅助手段。
问题在于三个场景:
- 用户从地址栏直接输入网址或浏览器书签访问时,请求不带Referer,此时要不要放行?放行的话,攻击者可以设置
referrerpolicy="no-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保留成GET+POST双支持,就是为了演示这种绕过。
第二种是校验了参数但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 常见问题与排查技巧速查
我再整理一个问题速查表,都是实际实验中高频踩坑的点,既适用于复现测试,也适用于防御侧自查。
| 现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 攻击页面执行后目标站未发生状态变化 | SameSite=Lax拦截了跨站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 + SameSite=Lax”这个组合,优先级高于单独使用Referer校验。这套组合,我在多个内部模拟项目中验证过,都是稳定可用的。