跳到主要内容

CSRF 跨站请求伪造:身份的隐形窃贼

CSRF (Cross-Site Request Forgery) 是一种"借刀杀人"的攻击方式。它的核心是:攻击者诱导受害者在已登录的网站上执行非本意的操作,而用户对此毫不知情。

理解 CSRF 的本质

与 XSS 不同,CSRF 并不需要窃取用户的 Cookie 或 Session。它利用的是浏览器的自动身份验证机制:当你访问一个网站时,浏览器会自动带上该网站的所有 Cookie。

攻击原理

CSRF 成功的三要素

  1. 受害者已登录:目标网站存在有效的会话
  2. 自动认证:浏览器自动携带 Cookie
  3. 可预测的请求:攻击者知道请求的格式和参数

攻击示例

基础攻击:隐藏表单自动提交

攻击者网站 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 的防御策略

这是目前最简单且最有效的防御方式。通过设置 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);
});

适用于无状态架构(如 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 头

服务器检查请求的 OriginReferer 头,确认请求来自同站。

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 存储在 localStoragesessionStorage 中,而不是 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 设置了 SecureHttpOnly
  • 状态改变操作(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 验证 (辅助防御)

参考资料