给 Java 程序员的 CSRF 攻击详解:从原理到防御实战
想象一个场景:你正在公司内部系统里审批报销单,突然收到一封邮件,标题是"点击查看年会照片"。你顺手点开,照片没看到,但第二天却发现自己的账户莫名其妙地提交了好几笔报销申请。你确定自己没操作过,但系统日志清清楚楚地记着是你的账号干的。你是被黑了吗?这种"背锅"操作,往往就是一种典型的 CSRF 攻击。
作为 Java 程序员,我们每天都在和 Cookie、Session、Spring Security 打交道,如果不懂 CSRF,就像给大门装了最好的锁,却忘了关窗户。本文会用最接地气的方式,帮你彻底搞懂它。
一、CSRF 到底是什么?
CSRF ,全称 Cross-Site Request Forgery,中文叫"跨站请求伪造"。
简单说,就是攻击者盗用你的身份,以你的名义向某个网站发送恶意请求。这个请求对你来说是"被伪造"的------你什么都没做,但服务器却认为是你本人发起的,因为你的 Cookie 是有效的。
关键点在于:攻击者无法窃取你的 Cookie(因为有同源策略限制),但他可以"借用"你的浏览器在请求时自动把 Cookie 带上的特性。
二、攻击原理:为什么你的浏览器会"乖乖就范"?
要理解 CSRF,必须先理解两个浏览器机制:
- Cookie 的自动携带 :只要你登录了网站 A,浏览器就会把 A 的 Cookie 存储在本地。之后,无论从哪个网站、哪个页面发起的请求,只要是发往 A 的域名,浏览器都会自动把 A 的 Cookie 贴在请求头上。
- 同源策略并不阻止"发送请求" :同源策略阻止的是"读取另一个源的响应",但从不阻止"向另一个源发出请求" 。你可以在一个
evil.com的页面里写一个表单,让它提交到bank.com,浏览器会照样发,还会带上bank.com的 Cookie。
CSRF 就是利用这两个特性实施的"借刀杀人"。
三、一个生动的攻击流程
假设你正在使用的银行网站 bank.com 有一个转账接口,设计得非常简单粗暴:
ini
GET /transfer?toAccount=12345&amount=10000 HTTP/1.1
Host: bank.com
Cookie: JSESSIONID=your-session-id
你刚刚登录完银行,Session 有效。这时,攻击者给你发了一个"诱人"的网页,里面藏了这样一段代码:
html
<!-- 攻击者的网站 evil.com -->
<img src="http://bank.com/transfer?toAccount=attacker&amount=10000" style="display:none;" />
或者一个会自动提交的隐藏表单:
html
<form action="http://bank.com/transfer" method="POST" id="hackForm">
<input type="hidden" name="toAccount" value="attacker" />
<input type="hidden" name="amount" value="10000" />
</form>
<script>document.getElementById('hackForm').submit();</script>
攻击发生的时序是这样的:
- 你登录
bank.com,服务器给你一个JSESSIONID,浏览器存下。 - 你未登出,在同一浏览器中打开了
evil.com。 evil.com的页面加载,浏览器开始解析<img>标签,向bank.com/transfer...发起 GET 请求。- 浏览器检查目标域名是
bank.com,于是自动把bank.com的 Cookie 贴了上去。 bank.com服务器收到请求,验了 Cookie 发现是你的有效会话,认为就是你本人在转账,于是执行转账操作。
整个过程,你毫不知情,Cookie 也没有被窃取,但攻击已经完成。这就是"请求伪造"------攻击者伪造了一个从你浏览器发出的合法请求。
四、CSRF 的危害不止转账
任何只需依靠 Cookie/Session 就能确认身份的操作,都可能被 CSRF 攻击:
- 修改密码:伪造请求将密码改为攻击者知晓的值。
- 发布内容:以你的名义在论坛、微博发布垃圾广告。
- 修改邮箱/手机:劫持账户恢复途径。
- 管理员操作:添加管理员、修改配置,甚至关闭防火墙。
CSRF 的危险在于它"无声无息",而且能绕过登录认证,因为攻击请求本身就带着合法的登录凭证。
五、作为 Java 程序员,我们为什么容易踩坑?
在典型的 Java Web 应用中(如使用 Spring MVC、Servlet、Struts),我们习惯这样管理登录态:
- 用户登录后,服务器创建
HttpSession,把JSESSIONID通过Set-Cookie返回。 - 后续请求,浏览器自动带回
Cookie: JSESSIONID=xxx。 - 我们的
Filter或拦截器检查 Session 中有无登录标记,有则放行。
这种机制的隐含假设是:"能带上合法 Cookie 的请求,必然是用户本人发起的 "。而 CSRF 恰恰证明了这个假设不成立。只要一个恶意的 <img> 或 <form> 就能让浏览器发出带 Cookie 的请求,服务器完全无法区分。
六、防御方案大全(Java 视角)
CSRF 防御的核心思路是:让请求带上一个攻击者无法伪造的信息。攻击者能触发请求并带上 Cookie,但他无法读取 Cookie(因为同源策略),也无法知道我们页面上某个特定的动态值。
方案一:验证码/二次确认(最安全但体验差)
每次敏感操作都要求输入验证码或进行指纹验证。攻击者无法得知验证码,所以无法伪造请求。但这会打断用户体验,通常只用于支付、修改密码等核心操作。
方案二:检查 Referer / Origin 请求头(不太可靠)
浏览器在发起请求时会带上 Referer(当前页面地址)或 Origin(协议+域名+端口)。服务端可以检查这个头是不是来自自己的站点。
- 缺点 :早期浏览器可以禁用或篡改
Referer;HTTPS 跳转到 HTTP 时Referer会丢失;部分代理和浏览器插件会隐藏它。因此只能作为辅助手段,不能作为唯一防御。
方案三:同步令牌模式(CSRF Token)------ 主流方案
这是目前最成熟、最广泛使用的防御手段,也是 Spring Security 默认开启的策略。
原理:
- 服务器在用户首次访问时,生成一个随机、不可预测的字符串(CSRF Token)。
- 将这个 Token 与用户 Session 关联(通常就放在 Session 属性中)。
- 将这个 Token 下发给前端,方式可以是:
- 渲染在表单的隐藏域中(
<input type="hidden" ...>)。 - 写入一个前端可读的 Cookie 中(但要配合请求头使用)。
- 放在页面的
<meta>标签中。
- 渲染在表单的隐藏域中(
- 前端每次发起"写"请求(POST、PUT、DELETE 等)时,必须带上这个 Token:
- 表单提交:通过隐藏域。
- AJAX 请求:通过自定义请求头(如
X-CSRF-TOKEN)或请求体。
- 服务器接收到请求后,比较请求中的 Token 和 Session 中的 Token 是否一致。如果不一致或缺失,则拒绝请求。
为什么攻击者无法拿到 Token? 因为同源策略阻止了 evil.com 用 JS 读取 bank.com 的页面内容、Cookie(非 HttpOnly 时)或自定义响应头,所以攻击者无法获取到这个随机字符串,自然也就没办法在伪造请求中带上正确的 Token。
Java 原生实现示例(Filter 方式)
java
// 1. 生成 Token 并放入 Session 的 Filter(片段)
@WebFilter("/*")
public class CsrfTokenGeneratorFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpSession session = req.getSession();
// 如果 Session 中没有 Token,就生成一个
if (session.getAttribute("csrfToken") == null) {
String token = UUID.randomUUID().toString();
session.setAttribute("csrfToken", token);
}
// 把 Token 暴露给请求属性,方便 JSP 获取
req.setAttribute("csrfToken", session.getAttribute("csrfToken"));
chain.doFilter(request, response);
}
}
java
// 2. 验证 Token 的 Filter(拦截所有 POST/PUT/DELETE 等)
@WebFilter("/*")
public class CsrfValidationFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
String method = req.getMethod();
// 只验证可能改变状态的方法
if ("POST".equalsIgnoreCase(method) || "PUT".equalsIgnoreCase(method)
|| "DELETE".equalsIgnoreCase(method)) {
HttpSession session = req.getSession(false);
if (session == null) {
throw new ServletException("No session");
}
String sessionToken = (String) session.getAttribute("csrfToken");
// 从请求中获取 Token(假设通过 _csrf 参数或 X-CSRF-TOKEN 头)
String requestToken = req.getParameter("_csrf");
if (requestToken == null) {
requestToken = req.getHeader("X-CSRF-TOKEN");
}
if (sessionToken == null || !sessionToken.equals(requestToken)) {
HttpServletResponse resp = (HttpServletResponse) response;
resp.sendError(HttpServletResponse.SC_FORBIDDEN, "CSRF token missing or incorrect");
return;
}
}
chain.doFilter(request, response);
}
}
前端 JSP 页面在表单中放入隐藏域:
html
<form action="/transfer" method="post">
<input type="hidden" name="_csrf" value="${csrfToken}" />
<!-- 其他字段 -->
</form>
方案四:SameSite Cookie 属性(强力辅助)
这是一个相对较新的防御机制,由浏览器直接支持。设置 Cookie 时可以加上 SameSite 属性:
Strict:完全禁止第三方携带 Cookie。你从evil.com点击链接进入bank.com,连 GET 请求都不会带 Cookie。防御最强,但用户体验差(从邮件点回网站都要重新登录)。Lax:大部分情况禁止第三方 Cookie,但允许顶级导航的 GET 请求 携带。比如从evil.com点一个链接跳转到bank.com会带 Cookie,但evil.com里的<img>、<form POST>、iframe 和 AJAX 请求都不会带。这是 2020 年后 Chrome 等浏览器的默认行为,能有效防御绝大多数 CSRF 攻击。None:不限制,但必须同时设置Secure属性(仅 HTTPS 可用)。
在 Java 中可以通过修改 web.xml 的 Session-Config,或直接用 Cookie 对象设置:
java
Cookie cookie = new Cookie("JSESSIONID", sessionId);
cookie.setSecure(true);
cookie.setAttribute("SameSite", "Lax"); // 注意:Java EE 8 以下可能需要拼接字符串
response.addCookie(cookie);
或在 Spring Boot 中配置:
properties
server.servlet.session.cookie.same-site=lax
但是注意 ,老浏览器不支持 SameSite,所以它只能作为增强防御,不能替代 CSRF Token。
方案五:双重 Cookie 验证(无状态方案)
适用于无 Session 的场景(如 RESTful 令牌认证)。
- 服务器生成一个随机 Token,通过
Set-Cookie写入一个前端可读(非 HttpOnly)的 Cookie,比如csrf_token=xxxx。 - 前端 JS 读取该 Cookie 的值,并在每次 AJAX 请求时通过自定义请求头(如
X-CSRF-TOKEN)带回。 - 服务器检查请求头中的 Token 与 Cookie 中的 Token 是否一致。
原理:攻击者可以从他控制的子域名设置 Cookie(如果允许),但无法读取另一个域名的 Cookie,因此无法在自定义请求头里写入正确的值。不过这种方案要非常小心域和路径设置,防止 Cookie 覆盖攻击。
七、Spring Security 中的 CSRF 防护实战
如果你使用 Spring Security,恭喜你,从 4.0 版本开始,CSRF 保护是默认开启的。它会自动应用同步令牌模式。
1. 默认行为
- 为每个 Session 生成一个
CsrfToken。 - 任何 GET、HEAD、TRACE、OPTIONS 之外的请求,都必须携带合法的 Token,否则返回 403。
- 传统表单提交:通过名为
_csrf的请求参数传递。 - AJAX 请求:通过名为
X-CSRF-TOKEN的请求头传递。
2. 如何让前端拿到 Token?
如果你用 Thymeleaf,表单中什么都不用做,Thymeleaf 会自动添加隐藏域:
html
<form method="post" th:action="@{/transfer}">
<!-- Thymeleaf 会自动插入 <input type="hidden" name="_csrf" value="..."> -->
...
</form>
如果你用 JSP,需要在表单内显示地输出:
jsp
<form action="/transfer" method="post">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />
...
</form>
AJAX 请求(前后端分离): Spring Security 支持两种 Token 传递方式:
- 通过 Cookie 传递 :配置
CookieCsrfTokenRepository,这样 Token 会保存在一个名为XSRF-TOKEN的 Cookie 中(非 HttpOnly,前端 JS 可读)。前端 JS 读取该 Cookie,并在每个请求头中加上X-XSRF-TOKEN。
java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
return http.build();
}
前端(例如使用 Axios)自动携带:
javascript
// Axios 默认会读取 XSRF-TOKEN Cookie 并设置 X-XSRF-TOKEN 请求头
axios.defaults.withCredentials = true;
- 通过
<meta>标签传递:在 HTML 头部放入:
html
<meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
然后 AJAX 在全局拦截中读取 meta 并加到请求头。
3. 关闭 CSRF 保护(谨慎!)
有些纯 RESTful 后端的 API 如果使用无状态 JWT 等令牌认证,可以关闭 CSRF:
java
http.csrf(csrf -> csrf.disable());
但前提是你确认应用中没有任何基于 Cookie 的认证,否则就是裸奔。
八、常见误区与注意事项
-
"只保护 POST 请求就够了"
虽然规范上 GET 应该是幂等且不改变状态的,但现实中的代码可能不规范。如果一个 GET 请求执行了转账操作,它依然可以被 CSRF。务必让你的 GET 请求遵守"安全"方法约定,并仅对"不安全"方法实施 Token 校验。
-
"用了 HTTPS 就不会有 CSRF"
完全错误。HTTPS 加密传输数据,防止中间人篡改,但 CSRF 是让用户浏览器主动发送请求,HTTPS 照发不误。
-
"Token 放在 Cookie 里就安全了"
如果只是放在 Cookie 里,又让浏览器自动携带,那相当于没有防御。Token 必须通过攻击者无法自动携带的方式传回(如隐藏域或自定义请求头)。双重 Cookie 验证之所以有效,是因为攻击者无法从自己的域设置自定义请求头。
-
"Token 可以在多个用户间共享"
绝对不行。CSRF Token 必须与用户会话强绑定,最好一次性生成,用完即刷新(虽然很多实现 Session 内不变,但至少保证用户隔离)。否则一个合法用户拿到自己的 Token 后去攻击别人,就可能得逞。
-
"有了验证码,就不用 CSRF Token 了"
验证码适合最终的安全兜底,但如果在每个 POST 操作上都加验证码,产品会崩溃。Token 是无感防御,用在验证码之前。
九、总结
CSRF 是一种利用 Web 信任模型的攻击,它钻了"浏览器自动携带 Cookie"和"同源策略不阻止发送请求"的空子。作为 Java 开发者,你必须理解:
- 成因:基于 Cookie 的隐式认证,浏览器无条件携带。
- 核心防御 :加入一个攻击者无法获取和自动携带的动态令牌------CSRF Token。
- 具体落地 :在 Spring 生态中,保持
csrf()开启,并按照上述方式把 Token 传递给前端表单和 AJAX。 - 辅助增强 :为 Cookie 设置
SameSite=Lax,并在敏感操作叠加二次确认。
记住,安全不是一劳永逸的,理解原理才能应对万变。希望这篇文章能让你彻底搞懂 CSRF,并在日常编码中自信地填上这个坑。