CSRF 跨站请求伪造:身份的隐形窃贼
CSRF (Cross-Site Request Forgery) 是一种"借刀杀人"的攻击方式。它的核心是:攻击者诱导受害者在已登录的网站上执行非本意的操作,而用户对此毫不知情。
理解 CSRF 的本质
与 XSS 不同,CSRF 并不需要窃取用户的 Cookie 或 Session。它利用的是浏览器的自动身份验证机制:当你访问一个网站时,浏览器会自动带上该网站的所有 Cookie。
攻击原理
CSRF 成功的三要素
- 受害者已登录:目标网站存在有效的会话
- 自动认证:浏览器自动携带 Cookie
- 可预测的请求:攻击者知道请求的格式和参数
攻击示例
基础攻击:隐藏表单自动提交
攻击者网站 evil.com 上的恶意页面:
<!DOCTYPE html>
<html>
<head>
<title>恭喜您中奖了!</title>
</head>
<body>
<h1>恭喜您获得 100 万大奖!</h1>
<p>正在领取...</p>
<!-- 隐藏表单:自动提交到银行网站 -->
<form id="csrf-form" action="https://bank.com/transfer" method="POST" style="display:none">
<input type="hidden" name="to" value="attacker_account">
<input type="hidden" name="amount" value="100000">
<input type="hidden" name="currency" value="USD">
</form>
<script>
// 页面加载后自动提交表单
document.getElementById('csrf-form').submit();
</script>
</body>
</html>
当已登录 bank.com 的受害者访问这个页面时,表单会自动提交到银行,银行服务器验证 Cookie 后执行转账。
图片标签攻击(GET 请求)
如果敏感操作使用 GET 方法:
<!-- 银行危险的 GET 接口 -->
<!-- GET /transfer?to=attacker&amount=1000 -->
<!-- 攻击者在任意页面插入 -->
<img src="https://bank.com/transfer?to=attacker&amount=1000" style="display:none">
浏览器加载图片时会自动发送 GET 请求,携带银行 Cookie。
链接诱导攻击
<!-- 伪装成正常链接 -->
<a href="https://bank.com/transfer?to=attacker&amount=1000">
点击领取优惠券
</a>
JSON 请求伪造
现代 Web 应用常用 JSON API:
<script>
fetch('https://bank.com/api/transfer', {
method: 'POST',
credentials: 'include', // 包含 Cookie
headers: {
'Content-Type': 'text/plain' // 避免预检请求
},
body: JSON.stringify({
to: 'attacker',
amount: 100000
})
});
</script>
注意:如果使用 Content-Type: application/json,浏览器会先发送 OPTIONS 预检请求,但某些配置不当的服务器可能仍会处理实际请求。
CSRF 的防御策略
策略一:SameSite Cookie 属性 —— 最简单有效
这是目前最简单且最有效的防御方式。通过设置 Cookie 的 SameSite 属性,控制跨站请求是否携带 Cookie。
三种值:
| 属性值 | 行为 | 安全性 |
|---|---|---|
Strict | 完全禁止跨站发送 Cookie | 最高,但可能影响用户体验 |
Lax | 允许顶级导航的 GET 请求携带 | 平衡,推荐使用 |
None | 允许跨站发送(需配合 Secure) | 无保护,仅用于必要场景 |
设置示例:
Set-Cookie: session=abc123; SameSite=Lax; Secure; HttpOnly
Strict 模式的影响:
用户在 email.com 点击 bank.com 的链接:
- Strict: 不发送 Cookie,用户需要重新登录
- Lax: 发送 Cookie,正常访问
推荐配置:
# 大多数 Cookie
Set-Cookie: session=xxx; SameSite=Lax; Secure; HttpOnly; Path=/
# 敏感操作 Cookie(如支付)
Set-Cookie: payment_session=xxx; SameSite=Strict; Secure; HttpOnly
# 需要跨站的 Cookie(如 OAuth 回调)
Set-Cookie: oauth_state=xxx; SameSite=None; Secure; HttpOnly
策略二:CSRF Token —— 经典防御方案
服务器生成一个随机、不可预测的 Token,嵌入到页面中。每次提交请求时必须携带正确的 Token。
工作原理:
1. 服务器生成 Token,存入 Session
2. 服务器将 Token 嵌入页面(表单隐藏字段或 meta 标签)
3. 客户端提交请求时携带 Token
4. 服务器验证 Token 是否匹配
服务端实现(Node.js):
const crypto = require('crypto');
// 生成 CSRF Token
function generateCSRFToken() {
return crypto.randomBytes(32).toString('hex');
}
// 中间件:为请求添加 CSRF Token
function csrfMiddleware(req, res, next) {
if (!req.session.csrfToken) {
req.session.csrfToken = generateCSRFToken();
}
// 将 Token 添加到 res.locals 供模板使用
res.locals.csrfToken = req.session.csrfToken;
next();
}
// 中间件:验证 CSRF Token
function verifyCSRF(req, res, next) {
const token = req.body._csrf ||
req.headers['x-csrf-token'] ||
req.query._csrf;
if (!token || token !== req.session.csrfToken) {
return res.status(403).json({ error: 'CSRF token 验证失败' });
}
next();
}
// 路由
app.get('/form', csrfMiddleware, (req, res) => {
res.render('form', { csrfToken: res.locals.csrfToken });
});
app.post('/submit', verifyCSRF, (req, res) => {
// 处理请求
res.json({ success: true });
});
前端实现(表单):
<form action="/submit" method="POST">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<!-- 其他字段 -->
<button type="submit">提交</button>
</form>
前端实现(AJAX):
<!-- 在 meta 标签中存储 Token -->
<meta name="csrf-token" content="<%= csrfToken %>">
<script>
// 从 meta 标签获取 Token
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
// 所有 AJAX 请求携带 Token
fetch('/api/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({ data: 'example' })
});
</script>
使用 csurf 中间件(Express):
const csrf = require('csurf');
// 创建 CSRF 保护中间件
const csrfProtection = csrf({ cookie: true });
// 需要保护的表单页面
app.get('/form', csrfProtection, (req, res) => {
res.render('form', { csrfToken: req.csrfToken() });
});
// 处理表单提交
app.post('/submit', csrfProtection, (req, res) => {
res.send('提交成功');
});
// 错误处理
app.use((err, req, res, next) => {
if (err.code === 'EBADCSRFTOKEN') {
return res.status(403).send('CSRF token 验证失败');
}
next(err);
});
策略三:双重 Cookie 验证 —— 无状态方案
适用于无状态架构(如 JWT),不需要服务器存储 Token。
工作原理:
1. 服务器设置一个 Cookie: csrf_token=xxx(不设置 HttpOnly)
2. 前端 JavaScript 读取 Cookie 值
3. 前端在请求头中发送相同的值
4. 服务器比对 Cookie 和 Header 是否一致
为什么有效:攻击者无法读取目标网站的 Cookie(同源策略),因此无法在请求头中设置正确的值。
实现示例:
// 服务端:设置 Cookie
app.use((req, res, next) => {
if (!req.cookies.csrf_token) {
const token = crypto.randomBytes(32).toString('hex');
res.cookie('csrf_token', token, {
httpOnly: false, // 允许 JavaScript 读取
secure: true,
sameSite: 'strict'
});
}
next();
});
// 服务端:验证
app.post('/api/action', (req, res) => {
const cookieToken = req.cookies.csrf_token;
const headerToken = req.headers['x-csrf-token'];
if (!cookieToken || cookieToken !== headerToken) {
return res.status(403).json({ error: 'CSRF 验证失败' });
}
// 处理请求
});
// 前端:读取 Cookie 并发送
function getCookie(name) {
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
return match ? match[2] : null;
}
fetch('/api/action', {
method: 'POST',
headers: {
'X-CSRF-Token': getCookie('csrf_token')
},
body: JSON.stringify(data)
});
策略四:验证 Origin 和 Referer 头
服务器检查请求的 Origin 或 Referer 头,确认请求来自同站。
function verifyOrigin(req, res, next) {
const allowedOrigins = ['https://example.com', 'https://www.example.com'];
const origin = req.headers.origin || req.headers.referer;
if (!origin) {
// 某些情况下可能没有 Origin(如旧浏览器)
// 对于敏感操作,建议拒绝
return res.status(403).json({ error: '缺少 Origin 头' });
}
try {
const originUrl = new URL(origin);
if (!allowedOrigins.includes(originUrl.origin)) {
return res.status(403).json({ error: '非法来源' });
}
} catch (e) {
return res.status(403).json({ error: '无效的 Origin' });
}
next();
}
// 对所有状态改变操作应用
app.post('/api/*', verifyOrigin);
app.put('/api/*', verifyOrigin);
app.delete('/api/*', verifyOrigin);
注意:
Referer可能被浏览器或隐私插件移除Origin在某些情况下不存在- 应作为辅助防御,不应作为唯一手段
策略五:JWT 与 CSRF
使用 JWT 进行身份验证时,将 Token 存储在 localStorage 或 sessionStorage 中,而不是 Cookie。请求时手动将 Token 添加到请求头。
为什么 JWT 天然防 CSRF:浏览器不会自动在请求中附加 Authorization 头,攻击者无法获取存储在 localStorage 中的 Token。
// 前端:手动添加 Token
const token = localStorage.getItem('jwt_token');
fetch('/api/action', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
注意:这种方式对 XSS 更敏感,如果存在 XSS 漏洞,Token 可能被窃取。因此仍需做好 XSS 防护。
框架集成
Spring Security
Spring Security 默认启用 CSRF 保护:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
return http.build();
}
}
前端获取 Token:
<!-- Thymeleaf 模板 -->
<input type="hidden" name="_csrf" th:value="${_csrf.token}"/>
<!-- 或从 Cookie 读取 -->
<meta name="_csrf" th:content="${_csrf.token}"/>
AJAX 请求:
const token = document.querySelector('meta[name="_csrf"]').content;
fetch('/api/action', {
method: 'POST',
headers: {
'X-XSRF-TOKEN': token
}
});
Django
Django 默认启用 CSRF 保护:
<!-- 模板中添加 -->
<form method="post">
{% csrf_token %}
<!-- 表单字段 -->
</form>
AJAX 请求:
// 获取 Cookie 中的 CSRF Token
function getCookie(name) {
let cookieValue = null;
if (document.cookie) {
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
cookie = cookie.trim();
if (cookie.startsWith(name + '=')) {
cookieValue = decodeURIComponent(cookie.slice(name.length + 1));
break;
}
}
}
return cookieValue;
}
fetch('/api/action', {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken')
}
});
Laravel
Laravel 自动为每个用户会话生成 CSRF Token:
<!-- 表单中 -->
<form method="POST" action="/profile">
@csrf
<!-- 表单字段 -->
</form>
<!-- 或手动添加 -->
<input type="hidden" name="_token" value="{{ csrf_token() }}">
AJAX 请求:
<!-- 在 head 中添加 -->
<meta name="csrf-token" content="{{ csrf_token() }}">
<script>
const token = document.querySelector('meta[name="csrf-token"]').content;
fetch('/api/action', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': token
}
});
</script>
Rails
Rails 使用 protect_from_forgery:
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
end
表单中自动添加:
<%= form_with model: @user do |form| %>
<!-- 自动包含 CSRF Token -->
<%= form.text_field :name %>
<%= form.submit %>
<% end %>
常见漏洞与陷阱
漏洞一:GET 请求执行敏感操作
// 危险:使用 GET 执行写操作
@GetMapping("/delete")
public String deleteUser(@RequestParam Long id) {
userService.delete(id);
return "redirect:/users";
}
攻击方式:
<img src="https://example.com/delete?id=1">
修复:所有状态改变操作必须使用 POST/PUT/DELETE。
漏洞二:弱 CSRF Token
// 危险:使用可预测的 Token
$token = md5($userId . time());
// 危险:Token 长度太短
$token = substr(md5(rand()), 0, 6);
修复:使用加密安全的随机数生成器。
// 正确:使用安全的随机 Token
$token = bin2hex(random_bytes(32));
漏洞三:Token 绑定错误
// 错误:Token 与 Session 没有关联
const token = generateToken();
res.cookie('csrf_token', token);
// 攻击者可能预测或重放 Token
修复:Token 必须与用户 Session 绑定并验证。
// 正确:Token 存储在 Session 中
req.session.csrfToken = generateToken();
// 验证时比对 Session 中的 Token
漏洞四:仅验证 Referer
// 不够安全:仅依赖 Referer
if (!req.headers.referer?.startsWith('https://example.com')) {
return res.status(403);
}
问题:Referer 可能被浏览器移除或被某些代理修改。
修复:结合多种防御手段。
防御检查清单
服务端检查
- 所有 Cookie 都设置了
SameSite属性(至少 Lax) - 敏感 Cookie 设置了
Secure和HttpOnly - 状态改变操作(POST/PUT/DELETE)都有 CSRF 保护
- CSRF Token 使用加密安全随机数生成
- Token 有适当的过期机制
- 验证失败返回明确的错误信息
前端检查
- 表单包含 CSRF Token 隐藏字段
- AJAX 请求在 Header 中携带 Token
- 不在 URL 参数中传递 Token(可能泄露到日志)
- Token 不暴露在 JavaScript 全局变量中
架构检查
- 敏感操作不使用 GET 请求
- API 有明确的 CORS 配置
- 使用框架内置的 CSRF 保护
- 登出后立即销毁 Session
总结
CSRF 是一种利用用户已认证身份进行恶意操作的攻击。防御 CSRF 的核心策略:
| 策略 | 效果 | 适用场景 |
|---|---|---|
| SameSite Cookie | 简单有效 | 所有现代浏览器 |
| CSRF Token | 经典可靠 | 传统 Web 应用 |
| 双重 Cookie | 无状态 | JWT / API 应用 |
| 验证 Origin | 辅助防御 | 配合其他方案 |
| JWT 手动传递 | 天然免疫 | SPA 应用 |
最佳实践是组合使用多种防御措施:
SameSite Cookie (基础防御)
+ CSRF Token (主要防御)
+ Origin 验证 (辅助防御)