CSRF攻击与防护
2019年,有用户反映自己的银行账户莫名其妙地向陌生账户转账。
调查后发现:这是一家银行网站的CSRF漏洞。
攻击者构造了一个恶意网页,只要用户登录银行后访问这个页面,网页就会自动发起转账请求。用户的Cookie被浏览器自动发送,服务器以为是用户本人操作,于是执行了转账。
这就是CSRF(跨站请求伪造)的可怕之处:它不需要偷你的Cookie,只需要浏览器"帮你"发送请求。
今天这篇文章,带你彻底理解CSRF的原理和防护。
从一个问题开始
想象你登录了银行网站A,正在操作转账。
这时你收到一条链接,点进去是一个"抽奖页面"。
你在这个页面上点了"参与抽奖",但实际上这个页面的代码悄悄发送了一个请求:
<img src="http://bank.com/transfer?to=hacker&amount=10000">
因为你的浏览器仍然保存着银行网站的Cookie,这个请求会被自动发送,服务器以为是合法请求,于是执行了转账。
你什么都没做,但钱没了。
【直观类比】
CSRF就像"电话诈骗"
想象你给客服打过电话,客服记住了你的声音。
有一天,有人打电话给客服,自称是你,说要转账。
客服没有核对你的身份,直接执行了。
CSRF就是这样:你的浏览器保存了"身份证明"(Cookie),攻击者利用这一点,伪造你的身份发送请求。
为什么Cookie会"自动发送"?
这要说到HTTP的Cookie机制:
graph TD
A[用户登录银行] --> B[服务器返回Set-Cookie]
B --> C[浏览器保存Cookie]
C --> D[后续请求自动携带Cookie]
D --> E[攻击者构造恶意页面]
E --> F[用户访问恶意页面]
F --> G[浏览器自动发送Cookie到银行]
G --> H[银行以为是合法请求]
H --> I[转账成功!]
Cookie的发送遵循"同源策略"的变种规则:
- 请求A网站时,浏览器会自动带上A网站的Cookie
- 但请求B网站时,也会带上A网站的Cookie——只要是A网站的请求!
这就是CSRF能成功的根本原因。
核心原理
CSRF的攻击流程
graph TD
A[攻击者构造恶意页面] --> B[诱骗用户访问]
B --> C[页面包含自动提交的表单/请求]
C --> D[用户登录目标网站后访问]
D --> E[浏览器发送请求 + 自动带上Cookie]
E --> F[目标服务器验证Cookie后执行操作]
F --> G[攻击成功]
攻击方式1:自动提交的表单
<!-- 恶意页面 -->
<html>
<body>
<form action="http://bank.com/transfer" method="POST" id="csrf-form">
<input type="hidden" name="to" value="hacker" />
<input type="hidden" name="amount" value="10000" />
</form>
<script>
document.getElementById('csrf-form').submit();
</script>
</body>
</html>
攻击方式2:图片/脚本触发GET请求
<!-- 图片标签触发GET请求 -->
<img src="http://bank.com/delete?id=123" width="0" height="0">
<!-- script标签 -->
<script src="http://bank.com/logout"></script>
攻击方式3:AJAX请求
// 攻击者页面上的JavaScript
fetch('http://bank.com/transfer', {
method: 'POST',
credentials: 'include', // 携带Cookie
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: 'hacker',
amount: 10000
})
});
边界与特例
GET vs POST:哪种更容易被攻击?
但任何改变服务端状态的请求都应该防护CSRF,不管是什么方法。
浏览器安全机制:Samesite Cookie
现代浏览器支持Samesite属性:
Set-Cookie: session=abc123; Samesite=Strict
💡
从Chrome 80开始,默认Samesite=Lax。这是一个重要的安全变更,很多老的Web应用因此出现了跨域问题。
CORS vs CSRF:两个不同的东西
很多人搞混CORS和CSRF:
CORS不能防止CSRF:如果目标网站允许跨域请求,CSRF攻击仍然可以成功。
JSON API的CSRF
很多人以为JSON API不需要CSRF防护,因为:
// JSON请求不会执行JavaScript吗?
fetch('/api/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: '{"id": 123}'
});
实际上,JSON请求同样可以被CSRF攻击:
<!-- 表单可以发送JSON请求 -->
<form action="/api/delete" method="POST" enctype="text/plain">
<input name='{"id":123, "a":"' value='b"}'>
</form>
常见误区
误区1:CSRF需要XSS才能发起
不是。CSRF和XSS是两个独立的漏洞:
- XSS:在目标网站执行恶意脚本
- CSRF:利用用户在目标网站的已登录状态
攻击者可以直接构造恶意页面,不需要入侵目标网站。
误区2:HTTPS能防止CSRF
不能。HTTPS只加密传输通道,不验证请求来源。
误区3:只防护重要操作
错误。任何改变服务端状态的请求都需要防护:
- 转账:钱没了
- 修改密码:账号被劫持
- 删除数据:数据丢失
- 发帖/评论:声誉受损
- 修改邮箱:账号被绑定其他邮箱
误区4:Referer验证就够了
Referer可以被篡改或丢失:
// 攻击者可以控制请求头
fetch('http://target.com/action', {
headers: {
'Referer': 'http://target.com/legitimate-page'
}
});
而且:
- 用户可能设置了"不发送Referer"
- HTTPS页面不会发送Referer到HTTP页面
- 浏览器隐私模式可能不发送Referer
防护方案
1. CSRF Token(最常用)
sequenceDiagram
participant U as 用户
participant S as 服务器
participant B as 浏览器
S->>B: 返回表单页面<br/>包含CSRF Token: abc123
B->>U: 显示表单
U->>B: 提交表单<br/>Token: abc123
B->>S: POST请求<br/>Token: abc123
S->>S: 验证Token<br/>通过!
服务端生成随机Token,表单中携带Token,提交时验证:
# 服务端生成Token(存Session或JWT中)
session['csrf_token'] = secrets.token_hex(32)
# 表单中加入Token
# <form>
# <input type="hidden" name="csrf_token" value="abc123...">
# ...
# </form>
# 验证Token
def validate_csrf(request):
token = request.POST.get('csrf_token')
if token != session.get('csrf_token'):
abort(403, 'CSRF token mismatch')
// 前端Ajax请求时自动携带Token
$(document).ajaxSend(function(event, xhr, settings) {
var token = $('meta[name="csrf-token"]').attr('content');
xhr.setRequestHeader('X-CSRF-Token', token);
});
2. SameSite Cookie
# Flask设置SameSite Cookie
@app.route('/login', methods=['POST'])
def login():
response = make_response(redirect('/dashboard'))
response.set_cookie(
'session_id',
session_id,
samesite='Strict', # 或 'Lax'
secure=True,
httponly=True
)
return response
3. 双重提交Cookie
不需要服务端存储Token,Token同时存在于Cookie和请求参数中:
def validate_csrf(request):
# 从Cookie获取
cookie_token = request.cookies.get('csrf_token')
# 从请求获取
param_token = request.form.get('csrf_token')
if not cookie_token or not param_token:
abort(403)
if cookie_token != param_token:
abort(403)
4. 验证请求来源
def validate_origin(request):
origin = request.headers.get('Origin')
referer = request.headers.get('Referer')
allowed_origins = ['https://example.com']
if origin and origin in allowed_origins:
return True
if referer:
# 检查Referer是否来自允许的域名
parsed = urlparse(referer)
if parsed.netloc in allowed_origins:
return True
return False
5. 验证码/密码验证
对于高风险操作,要求用户重新输入密码:
@app.route('/transfer', methods=['POST'])
def transfer():
# 验证密码
password = request.form.get('password')
if not verify_password(session['user_id'], password):
abort(401, '密码错误')
# 验证CSRF Token
if not validate_csrf(request):
abort(403)
# 执行转账
return do_transfer(...)
6. 框架内置CSRF防护
// Spring Security(自动防护所有POST/PUT/DELETE)
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf() // 默认启用
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.and()
.formLogin();
}
}
# Django(表单自动带Token)
# settings.py
MIDDLEWARE = [
'django.middleware.csrf.CsrfViewMiddleware',
]
# 模板中自动包含Token
# <form>
# {% csrf_token %}
# ...
# </form>
// Angular(HTTP请求自动带Token)
// 拦截器
@Injectable()
export class CsrfInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (req.method !== 'GET') {
const token = this.cookieService.get('CSRF-TOKEN');
if (token) {
req = req.clone({setHeaders: {'X-CSRF-TOKEN': token}});
}
}
return next.handle(req);
}
}
记忆技巧
口诀
CSRF跨站请求伪造,Cookie自动发送是根源
Token验证最保险,Samesite Cookie来帮忙
重要操作二次验证,框架内置防护最强
Referer验证不可靠,Origin检查更有效
防护方案速查
实战检验
检验1:识别CSRF漏洞
访问一个修改用户资料的页面,查看表单是否有CSRF Token:
<!-- 没有Token = 可能有漏洞 -->
<form action="/profile/update" method="POST">
<input name="email" value="">
</form>
<!-- 有Token = 相对安全 -->
<form action="/profile/update" method="POST">
<input type="hidden" name="csrf_token" value="abc123...">
<input name="email" value="">
</form>
检验2:绕过CSRF防护
测试常见绕过技巧:
- 删除Token参数:某些实现只检查Token存在性,不检查有效性
- Token为空:某些实现允许空Token
- 修改请求方法:GET请求可能没有防护
- 子域名绕过:如果
a.example.com有CSRF,b.example.com可能没有
检验3:评估防护方案
以下代码是否安全?
# 检查Referer
def csrf_check(request):
referer = request.headers.get('Referer')
if referer and referer.startswith('https://example.com'):
return True
return False
不安全:
- Referer可以被设置
- HTTPS页面不会发送Referer到HTTP
- 用户可能关闭Referer发送
【面试官心理】
面试官问CSRF,其实是在测试你对"会话安全"的理解深度。知道CSRF原理是60分,知道Token机制是80分,能说出完整防护方案是90分,如果还能提到SameSite Cookie、JSON API的CSRF、与XSS的区别,那就是P7的水平了。
延伸阅读