1. 项目概述:为什么CSRF依然是悬在头顶的剑?
做Web开发或者安全测试有些年头了,CSRF(跨站请求伪造)这个老话题,几乎每次做安全审计都会遇到。表面上看,现在框架都内置了防护,比如Spring Security、Django的CSRF中间件,好像开箱即用,万事大吉。但实际情况是,很多团队要么对原理一知半解,配置不当;要么过度依赖单一机制,比如只校验Referer,结果被轻易绕过。最近在复盘几个内部项目的安全漏洞时,发现由CSRF防护不当引发的问题依然占了不少比例,尤其是那些自研的、或者对第三方组件做了深度定制的系统。
CSRF攻击的本质,是攻击者诱骗已经登录了目标网站的用户,去访问一个恶意构造的页面,从而以该用户的身份执行非本意的操作。比如,用户登录了网银,然后不小心点了一个“抽奖”链接,这个链接背后可能就隐藏着一个向攻击者账户转账的请求。因为浏览器会自动带上用户的会话Cookie,服务器无法区分这个请求是用户自愿发出的,还是被伪造的。所以,防护的核心思路就是让服务器有能力判断这个请求是否来源于它信任的页面。目前主流的两种防护思路——Referer校验和CSRF Token——就是基于这个逻辑诞生的。但就像任何安全机制一样,如果理解不透彻、实现不严谨,它们本身也会成为新的攻击面。这篇文章,我就结合实战中的案例,把这两种防护机制的里里外外、攻防对抗的细节,掰开揉碎了讲清楚。
2. Referer校验:一道容易被绕过的“前门”
很多应用会把HTTP请求中的Referer头作为判断请求来源的依据。这个想法很直观:如果一个修改密码的POST请求,其Referer显示来自attacker.com而不是本应用自己的域名,那显然有问题,应该拒绝。但正是这种直观,容易让人放松警惕。
2.1 Referer校验的工作原理与常见实现误区
Referer(注意,在HTTP标准里这个单词就拼错了,应该是Referrer,但将错就错沿用至今)是一个可选的HTTP请求头,它表示当前请求是从哪个页面链接过来的。浏览器在用户点击链接或提交表单时,通常会(但不是绝对)自动添加这个头。
一个简单的、有缺陷的校验代码可能长这样(以Python Flask为例):
from flask import request, abort @app.route('/change_email', methods=['POST']) def change_email(): # 有缺陷的Referer校验逻辑 referer = request.headers.get('Referer') if referer: # 错误示例1:仅检查是否包含自己的域名 if 'my-secure-app.com' not in referer: abort(403, 'Invalid request origin.') # 如果Referer头不存在,则跳过检查! # ... 执行更改邮箱的逻辑 ...这段代码有两个致命问题:
- 存在性依赖:只在Referer头存在时才校验。攻击者可以通过技术手段让浏览器不发送Referer头。
- 校验逻辑脆弱:使用
in操作符检查域名是否包含。这为绕过留下了巨大空间。
2.2 实战中的Referer校验绕过手法
在实际的渗透测试中,绕过这类脆弱的Referer校验是家常便饭。下面我列举几种最常见且有效的手法。
2.2.1 利用<meta>标签移除Referer
这是最直接的方法。如果应用只在Referer存在时校验,那么让浏览器不发这个头就行了。攻击者可以在托管CSRF攻击的HTML页面中,加入以下标签:
<!DOCTYPE html> <html> <head> <meta name="referrer" content="no-referrer"> <!-- 或 content="never", "no-referrer-when-downgrade" 等,具体策略影响不同 --> </head> <body> <form action="https://victim-app.com/change_email" method="POST"> <input type="hidden" name="email" value="attacker@evil.com"> <input type="submit" value="点击抽奖!"> </form> <script>document.forms[0].submit();</script> </body> </html><meta name="referrer" content="no-referrer">这行代码指示浏览器在从该页面发起任何请求时,都不携带Referer头。于是,受害者访问这个页面并自动提交表单时,发往victim-app.com的请求中将没有Referer头,从而绕过“存在才校验”的逻辑。
注意:现代浏览器对Referrer策略的支持非常细致,
no-referrer是最严格的。在构造POC(概念验证)时,需要根据目标用户的浏览器环境进行测试。
2.2.2 利用子域名或路径进行匹配绕过
如果应用的校验逻辑是“检查Referer是否以https://victim-app.com开头”,攻击者可以注册一个像victim-app.com.attacker.net这样的域名。当请求从这个域名下的页面发出时,Referer可能是https://victim-app.com.attacker.net/csrf.html,它确实以https://victim-app.com开头,从而通过校验。
更常见的是“检查Referer是否包含victim-app.com”这种逻辑。攻击者可以将目标域名放在URL的查询参数(query string)或片段标识符(fragment)中:
https://attacker.net/csrf?victim-app.comhttps://attacker.net/csrf#victim-app.com
这样,Referer头里就包含了所需的字符串。但是,这里有一个非常重要的现代浏览器行为变化:出于隐私考虑,许多浏览器(如Chrome、Safari)默认会在Referer头中剥离URL的查询字符串和片段部分。这意味着你精心构造的?victim-app.com可能不会被发送到服务器。
2.2.3 强制浏览器发送完整URL(含查询参数)
为了让绕过生效,攻击者需要控制恶意页面的响应头,强制浏览器发送完整的URL。这通过设置Referrer-Policy响应头实现:
HTTP/1.1 200 OK Content-Type: text/html Referrer-Policy: unsafe-url <!DOCTYPE html> ...设置Referrer-Policy: unsafe-url后,从该页面发出的所有请求,其Referer头都会包含完整的源URL(包括协议、主机、路径和查询字符串)。这样,包含victim-app.com的查询参数就能被成功发送到目标服务器,从而可能绕过基于字符串包含的校验。
实操心得:在测试Referer绕过时,务必使用与目标用户群体一致的浏览器版本和默认配置进行测试。Burp Suite等工具捕获的请求可能包含完整URL,但这不代表真实浏览器环境也会如此。浏览器的隐私沙盒(Privacy Sandbox)和默认Referrer策略在不断演进,昨天的POC今天可能就失效了。
2.3 Referer校验的局限性总结
基于以上分析,单纯依赖Referer校验是极不安全的,主要原因如下:
- 可靠性问题:Referer头是浏览器可选添加的,用户或浏览器扩展可以禁用它。一些网络环境(如从HTTPS页面跳转到HTTP,出于安全考虑)或使用
rel="noreferrer"的链接也会导致Referer缺失。 - 隐私泄露风险:完整的Referer可能包含敏感信息(如搜索词、会话ID等),严格校验它可能与隐私保护策略冲突。
- 易被绕过:如上所述,存在多种成熟的技术可以操纵或移除Referer。
- 破坏用户体验:对于从本地文件(
file://协议)、浏览器扩展页面发起的合法请求,Referer可能为空或不符,导致合法功能失败。
因此,Referer校验最多只能作为深度防御(Defense in Depth)中的辅助手段,绝不能作为唯一的CSRF防护措施。更可靠的方法是使用CSRF Token。
3. CSRF Token防护:构建可靠的同步令牌模式
CSRF Token(跨站请求伪造令牌)是目前业界防御CSRF攻击最有效、最推荐的方法。其核心思想是要求每个状态变更的请求(POST、PUT、DELETE等)必须携带一个服务器生成的、不可预测的令牌,该令牌与当前用户会话绑定。
3.1 CSRF Token的工作原理与生命周期
一个健壮的CSRF Token机制包含三个关键环节:生成、分发、验证。
3.1.1 生成:确保随机性与唯一性
Token必须是高强度的随机数,攻击者无法猜测或枚举。通常使用加密安全的随机数生成器(CSPRNG)。
import secrets import hashlib import time def generate_csrf_token(session_id): # 结合会话ID和时间戳,增加熵值 raw_token = f"{session_id}{secrets.token_hex(16)}{int(time.time())}" # 使用HMAC或哈希函数生成最终令牌,增加服务器验证能力(可选) token = hashlib.sha256(raw_token.encode()).hexdigest() return token关键点:Token不应仅仅是一个随机字符串,最好能与会话(Session)绑定。这样即使Token被泄露(例如通过XSS),只要会话结束,Token也就失效了。
3.1.2 分发:安全地传递给客户端
生成的Token需要放在用户访问的页面中,以便在提交表单时能随请求一起发回。最常见的方式是放在表单的隐藏域(hidden field)里。
<!-- 服务器端渲染(如Jinja2, JSP) --> <form action="/transfer" method="POST"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="text" name="amount"> <input type="submit" value="转账"> </form>对于单页面应用(SPA),Token通常通过一个初始的API调用获取,然后存储在内存或Web Storage中,并在后续的请求头(如X-CSRF-Token)中携带。
// 前端:从后端获取Token fetch('/api/csrf-token', { credentials: 'include' }) .then(res => res.json()) .then(data => { window.csrfToken = data.token; }); // 发起敏感请求时,在头部携带 fetch('/api/transfer', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken }, credentials: 'include', body: JSON.stringify({ amount: 1000 }) });注意事项:绝对不要将CSRF Token通过Cookie发送给客户端进行验证。Cookie是浏览器会自动携带的东西,这违背了CSRF防护的初衷(攻击者也能利用自动携带的Cookie)。Cookie应该只用于维护会话(Session ID),而CSRF Token应放在请求体或自定义头中。
3.1.3 验证:服务器端的严格比对
当服务器收到请求时,必须执行验证:
- 从请求中提取Token(从表单字段
csrf_token或自定义头X-CSRF-Token)。 - 从当前用户会话中取出之前存储的Token。
- 使用恒定时间比较(constant-time comparison)函数比对两个Token是否一致。这是为了防止基于响应时间的时序攻击(Timing Attack)。
import hmac def validate_csrf_token(request_token, session_token): # 使用hmac.compare_digest进行恒定时间比较 if not request_token or not session_token: return False return hmac.compare_digest(request_token, session_token) @app.route('/transfer', methods=['POST']) def transfer_money(): # 从表单获取Token request_token = request.form.get('csrf_token') # 从会话中获取Token session_token = session.get('csrf_token') if not validate_csrf_token(request_token, session_token): abort(403, 'Invalid CSRF token.') # 验证通过,处理业务逻辑 # ... # 重要:处理成功后,可以考虑重新生成Token(一次性使用) session['csrf_token'] = generate_csrf_token(session.sid)3.2 针对CSRF Token的进阶攻击与防护
没有绝对的安全。即使使用了CSRF Token,如果实现不当,依然可能被绕过。
3.2.1 令牌验证逻辑缺陷
- 依赖请求方法:有些应用只对POST请求验证Token,而对GET请求不验证。攻击者可以将有害操作构造在GET请求中(例如
<img src="/delete_account?confirm=yes">),从而绕过防护。原则:所有状态变更的请求,无论方法,都应验证Token。 - 令牌存在性校验:和Referer一样,如果应用逻辑是“有Token就校验,没有就不校验”,那就形同虚设。原则:对于需要防护的端点,Token是必须的,缺失即拒绝。
- 令牌未绑定会话:Token如果全局通用,或者绑定的是另一个不安全的Cookie(而非会话Cookie),攻击者可以先用自己的账户获取一个Token,然后诱导受害者使用这个Token。原则:Token必须与当前用户的会话ID强绑定。
3.2.2 令牌泄露与滥用
- 通过XSS窃取Token:如果网站存在XSS漏洞,攻击者可以注入脚本,读取页面中的Token,然后构造一个包含正确Token的伪造请求。这凸显了纵深防御的重要性:CSRF Token不能防止XSS,而XSS可以摧毁CSRF防护。必须同时做好输入输出编码、内容安全策略(CSP)等来防XSS。
- Token放在Cookie中:这是一个灾难性的错误。如果服务器从Cookie中读取Token进行验证(即“双重Cookie提交”模式的一种错误实现),攻击者可以通过子域名Cookie污染(如果域名设置不严格)等方式进行攻击。重申:Token应该放在请求体或头部,验证时与会话中的值比对,而不是和Cookie中的值比对。
3.3 同站Cookie(SameSite Cookie):一道重要的辅助防线
除了CSRF Token,现代浏览器提供的SameSiteCookie属性是防御CSRF的利器。它可以控制Cookie在跨站请求中是否被发送。
SameSite=Strict:最严格。Cookie仅在同站请求(即当前页面的站点与请求目标站点一致)中发送。这意味着从其他网站链接过来时,用户即使已登录,也不会携带会话Cookie,服务器会将其视为未登录状态。这提供了最强的防护,但可能影响用户体验(例如从邮件链接点回网站需要重新登录)。SameSite=Lax(默认值):平衡安全与体验。在跨站的顶级导航(如点击链接)的GET请求中会发送Cookie,但在跨站的POST请求或通过<img>,<script>等标签发起的请求中不发送。这能有效阻止大多数CSRF攻击(因为CSRF通常通过表单POST或自动发起的GET实现),同时保持了主要导航流程的可用性。SameSite=None:Cookie在所有上下文中发送,但必须同时设置Secure属性(即仅限HTTPS)。
设置示例(在HTTP响应头中):
Set-Cookie: sessionid=abc123; Path=/; HttpOnly; Secure; SameSite=LaxSameSite的绕过与注意事项: 尽管SameSite=Lax是强大的默认防护,但并非无懈可击。
- GET型CSRF:如果应用允许通过GET请求进行状态变更(如
GET /delete?id=1),SameSite=Lax不会阻止此类请求携带Cookie。因此,切勿用GET方法实现有副作用的操作。 - 同站(Same-Site)攻击:如果攻击者能控制目标站点的子域名(
sub.victim.com),或者利用站点的其他功能(如用户评论、文件上传)注入攻击载荷,那么发起的请求属于“同站”请求,SameSite限制无效。这需要结合其他安全措施,如严格的子域名隔离、用户内容沙箱化等。
4. 实战部署:构建多层CSRF防御体系
在实际项目中,我们应该采用“不把鸡蛋放在一个篮子里”的策略,构建多层次的防御。
4.1 防御策略组合拳
首选且核心:使用同步令牌模式的CSRF Token。
- 为每个用户会话生成唯一、随机的Token。
- 将Token嵌入到所有状态变更请求的表单或请求头中。
- 在服务器端严格验证Token的有效性和匹配性。
- 考虑对高敏感操作(如转账、改密)使用一次性Token。
关键辅助:为会话Cookie设置
SameSite=Strict或Lax属性。- 对于绝大多数应用,将主要的会话Cookie设置为
SameSite=Lax是安全且用户体验友好的最佳实践。 - 对于后台管理、金融操作等极高安全要求的子系统,可以考虑使用
SameSite=Strict。
- 对于绝大多数应用,将主要的会话Cookie设置为
深度防御:实施自定义请求头校验。
- 对于通过AJAX/Fetch API发起的请求,要求携带一个自定义头(如
X-Requested-With: XMLHttpRequest)。由于浏览器同源策略(SOP)的限制,普通<form>提交或<img>标签无法添加自定义头,这能有效阻挡简单的CSRF攻击。但注意,如果CORS配置不当,此方法可能被绕过。
- 对于通过AJAX/Fetch API发起的请求,要求携带一个自定义头(如
谨慎参考:实施严格的Origin/Referer校验。
- 作为Token和SameSite的补充,可以对敏感请求校验
Origin或Referer头。Origin头在跨域请求中更可靠,且不会包含路径信息,隐私泄露风险更低。 - 必须注意处理头缺失的情况,不能因为头缺失就放行。逻辑应该是:“如果存在Origin/Referer头,则必须校验通过;如果不存在,则请求必须包含有效的CSRF Token”。这可以防止攻击者通过移除头部来绕过。
- 作为Token和SameSite的补充,可以对敏感请求校验
4.2 不同技术栈下的实现要点
- Spring Security (Java):默认已启用CSRF防护。它会自动生成Token(
_csrf),你需要确保在所有非只读(non-idempotent)的表单中包含它(使用Thymeleaf的th:action会自动添加,或手动添加<input type="hidden" name="_csrf" th:value="${_csrf.token}"/>)。对于REST API,你可能需要根据情况调整或禁用CSRF(并结合其他如JWT+SameSite Cookie的防护)。 - Django (Python):中间件
django.middleware.csrf.CsrfViewMiddleware默认启用。在模板中使用{% csrf_token %}标签。对于AJAX请求,需要从Cookie中读取csrftoken并设置在X-CSRFToken请求头中。 - Express.js (Node.js):可以使用
csurf中间件(注意其维护状态)或csrf-csrf等库。务必配合cookie-parser和会话中间件使用。注意在SPA中正确配置Token的获取和发送方式。 - 现代前端框架 (React/Vue/Angular):Token通常通过一个初始请求从后端获取,存储在内存或状态管理库中。使用HTTP拦截器(Interceptor)或请求封装库(如axios)自动为每个非只读请求添加
X-CSRF-Token头。切记不要将Token存储在localStorage或sessionStorage中,以防XSS攻击导致泄露。
5. 常见问题排查与安全测试实录
在开发和测试过程中,CSRF防护相关的问题层出不穷。下面是我整理的一些典型场景和排查思路。
5.1 开发与集成中的常见坑点
问题1:Token验证总是失败,返回403。
- 检查点1:Token是否被正确包含在请求中?使用浏览器开发者工具的“网络”(Network)选项卡,查看实际发出的请求负载(Payload)或头部,确认Token字段存在且值非空。
- 检查点2:前后端Token存储和获取的键名是否一致?前端提交的字段名是
csrf_token,后端却在csrf-token里找,当然找不到。检查表单字段名或请求头名。 - 检查点3:会话(Session)是否一致?Token是绑定到会话的。如果前端请求没有正确携带会话Cookie(例如
fetch请求未设置credentials: 'include'),后端就会在一个新的、空的会话里找Token,自然找不到。确保API请求的凭证模式正确。 - 检查点4:Token是否过期或被刷新?检查后端逻辑,是否在每次验证后或登录/登出时重新生成了Token?如果是,前端需要用新的Token更新后续请求。
问题2:使用SameSite=Lax后,从第三方网站跳转回来需要重新登录。
- 分析:这是
SameSite=Lax的预期行为。对于通过<a>链接的跨站顶级导航,GET请求会携带Cookie,通常不会导致登出。但如果用户是通过OAuth授权回调、支付回调等POST形式的跨站请求返回,Lax策略下Cookie不会被发送,导致会话丢失。 - 解决方案:对于关键的、预期会有跨站POST回调的端点(如OAuth callback URL),可以考虑为该特定请求使用
SameSite=None; Secure的Cookie,或者确保回调流程不依赖主会话Cookie(例如使用一次性code换取令牌)。
5.2 安全测试中的验证与绕过尝试
在对应用进行渗透测试时,如何验证CSRF防护是否有效?
基础测试:尝试直接构造请求
- 使用Burp Suite的“生成CSRF PoC”功能,对一个敏感操作(如修改邮箱)的请求生成测试HTML。
- 在浏览器中(最好是与受害者环境隔离的浏览器实例)登录目标应用,然后打开生成的HTML文件。
- 观察操作是否成功。如果成功,说明防护完全缺失或存在严重缺陷。
测试Token防护:
- 移除/修改Token:在重放请求中,删除或随意修改Token值,看请求是否被拒绝。
- 复用Token:用一个用户的Token,替换到另一个用户的请求中,看是否被拒绝(测试会话绑定)。
- 测试Token存在性依赖:直接发送一个没有Token字段的原始请求,看是否被拒绝。
测试Referer/Origin校验:
- 移除头部:在Burp Repeater中,将请求的
Referer或Origin头删除,重放请求,看是否通过。 - 修改头部:将头部值改为一个攻击者控制的域名(如
https://attacker.com),或包含目标域名的其他域名(如https://target.com.attacker.com),看校验逻辑是否足够严格(是否检查精确匹配、是否检查协议和端口)。
- 移除头部:在Burp Repeater中,将请求的
测试SameSite Cookie:
- 检查应用设置的会话Cookie是否包含
SameSite属性。 - 尝试从另一个域发起跨站POST请求,使用浏览器工具或编写简单页面测试Cookie是否被发送。
- 检查应用设置的会话Cookie是否包含
一个真实的踩坑记录:在一次测试中,目标应用对/api/change-password端点实施了CSRF Token校验,但对/api/profile/update端点却没有,因为后者被开发者误认为是“非敏感”操作。然而,update接口允许修改登录邮箱。攻击者通过CSRF修改了受害者的邮箱,然后利用“忘记密码”功能重置了密码,从而完全接管账户。教训:CSRF防护的范围必须覆盖所有会导致状态变更的端点,不能凭主观判断“敏感性”。
6. 总结与最佳实践清单
经过上面这些拆解,我们可以把构建健壮CSRF防护的核心要点,浓缩成下面这个可操作、可检查的清单:
- 强制使用CSRF Token:为每个用户会话生成高强度随机Token,并将其嵌入所有可能改变状态的请求(不仅仅是POST,还包括PUT、PATCH、DELETE等)中。Token必须存储在服务器端会话里。
- 实施严格的服务器端验证:对于受保护的端点,必须验证请求中的Token是否与会话中存储的Token一致。验证逻辑必须严谨,不能因为Token缺失或格式错误就跳过。使用恒定时间比较函数。
- 正确设置Cookie的SameSite属性:将主要的会话Cookie设置为
SameSite=Lax(这是现代浏览器的默认值,但显式声明更安全)。对于需要跨站POST请求的特定场景(如第三方登录回调),谨慎评估并使用SameSite=None; Secure。 - 实施深度防御:
- 对敏感请求,额外校验
Origin或Referer头。逻辑应为“若存在则必须合法,若不存在则必须依赖Token”。 - 为API请求要求自定义头(如
X-Requested-With),但这不能作为唯一防护。
- 对敏感请求,额外校验
- 安全的Token传递与存储:
- 不要通过Cookie发送用于验证的Token。
- 在前端,Token应放在表单隐藏域或HTTP请求头中。
- 避免将Token持久化在localStorage/sessionStorage中,以防XSS。对于SPA,可放在内存或仅会话期内有效的存储中。
- 遵循RESTful和安全设计原则:
- 不要用GET请求执行有副作用的操作。GET请求应幂等且安全。
- 严格实施用户输入验证和输出编码,防止XSS漏洞,因为XSS可以绕过CSRF防护。
- 部署内容安全策略(CSP),进一步限制脚本执行源,增加攻击难度。
- 定期进行安全测试与审计:
- 将CSRF漏洞扫描纳入CI/CD流程。
- 定期使用Burp Suite、OWASP ZAP等工具进行手动和自动化的测试。
- 对自研的防护逻辑进行代码审查,重点关注Token生成、存储、验证的每一个环节。
安全是一个持续的过程,而不是一个可以一劳永逸开启的开关。CSRF防护看似基础,但细节决定成败。理解每一种防护机制的原理和局限,进行正确的组合与配置,才能为你的Web应用筑起一道坚实的防线。在实际操作中,最省心的方法依然是优先使用成熟框架内置的、经过广泛验证的CSRF防护模块,并确保按照官方最佳实践进行配置,而不是盲目地自己从头造轮子。