给 Java 程序员的 CSRF 攻击详解:从原理到防御实战

给 Java 程序员的 CSRF 攻击详解:从原理到防御实战

想象一个场景:你正在公司内部系统里审批报销单,突然收到一封邮件,标题是"点击查看年会照片"。你顺手点开,照片没看到,但第二天却发现自己的账户莫名其妙地提交了好几笔报销申请。你确定自己没操作过,但系统日志清清楚楚地记着是你的账号干的。你是被黑了吗?这种"背锅"操作,往往就是一种典型的 CSRF 攻击

作为 Java 程序员,我们每天都在和 Cookie、Session、Spring Security 打交道,如果不懂 CSRF,就像给大门装了最好的锁,却忘了关窗户。本文会用最接地气的方式,帮你彻底搞懂它。


一、CSRF 到底是什么?

CSRF ,全称 Cross-Site Request Forgery,中文叫"跨站请求伪造"。

简单说,就是攻击者盗用你的身份,以你的名义向某个网站发送恶意请求。这个请求对你来说是"被伪造"的------你什么都没做,但服务器却认为是你本人发起的,因为你的 Cookie 是有效的。

关键点在于:攻击者无法窃取你的 Cookie(因为有同源策略限制),但他可以"借用"你的浏览器在请求时自动把 Cookie 带上的特性。

二、攻击原理:为什么你的浏览器会"乖乖就范"?

要理解 CSRF,必须先理解两个浏览器机制:

  1. Cookie 的自动携带 :只要你登录了网站 A,浏览器就会把 A 的 Cookie 存储在本地。之后,无论从哪个网站、哪个页面发起的请求,只要是发往 A 的域名,浏览器都会自动把 A 的 Cookie 贴在请求头上。
  2. 同源策略并不阻止"发送请求" :同源策略阻止的是"读取另一个源的响应",但从不阻止"向另一个源发出请求" 。你可以在一个 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>

攻击发生的时序是这样的:

  1. 你登录 bank.com,服务器给你一个 JSESSIONID,浏览器存下。
  2. 你未登出,在同一浏览器中打开了 evil.com
  3. evil.com 的页面加载,浏览器开始解析 <img> 标签,向 bank.com/transfer... 发起 GET 请求。
  4. 浏览器检查目标域名是 bank.com,于是自动把 bank.com 的 Cookie 贴了上去
  5. 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 默认开启的策略。

原理

  1. 服务器在用户首次访问时,生成一个随机、不可预测的字符串(CSRF Token)。
  2. 将这个 Token 与用户 Session 关联(通常就放在 Session 属性中)。
  3. 将这个 Token 下发给前端,方式可以是:
    • 渲染在表单的隐藏域中(<input type="hidden" ...>)。
    • 写入一个前端可读的 Cookie 中(但要配合请求头使用)。
    • 放在页面的 <meta> 标签中。
  4. 前端每次发起"写"请求(POST、PUT、DELETE 等)时,必须带上这个 Token:
    • 表单提交:通过隐藏域。
    • AJAX 请求:通过自定义请求头(如 X-CSRF-TOKEN)或请求体。
  5. 服务器接收到请求后,比较请求中的 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>

这是一个相对较新的防御机制,由浏览器直接支持。设置 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

适用于无 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 的认证,否则就是裸奔。

八、常见误区与注意事项

  1. "只保护 POST 请求就够了"

    虽然规范上 GET 应该是幂等且不改变状态的,但现实中的代码可能不规范。如果一个 GET 请求执行了转账操作,它依然可以被 CSRF。务必让你的 GET 请求遵守"安全"方法约定,并仅对"不安全"方法实施 Token 校验。

  2. "用了 HTTPS 就不会有 CSRF"

    完全错误。HTTPS 加密传输数据,防止中间人篡改,但 CSRF 是让用户浏览器主动发送请求,HTTPS 照发不误。

  3. "Token 放在 Cookie 里就安全了"

    如果只是放在 Cookie 里,又让浏览器自动携带,那相当于没有防御。Token 必须通过攻击者无法自动携带的方式传回(如隐藏域或自定义请求头)。双重 Cookie 验证之所以有效,是因为攻击者无法从自己的域设置自定义请求头。

  4. "Token 可以在多个用户间共享"

    绝对不行。CSRF Token 必须与用户会话强绑定,最好一次性生成,用完即刷新(虽然很多实现 Session 内不变,但至少保证用户隔离)。否则一个合法用户拿到自己的 Token 后去攻击别人,就可能得逞。

  5. "有了验证码,就不用 CSRF Token 了"

    验证码适合最终的安全兜底,但如果在每个 POST 操作上都加验证码,产品会崩溃。Token 是无感防御,用在验证码之前。

九、总结

CSRF 是一种利用 Web 信任模型的攻击,它钻了"浏览器自动携带 Cookie"和"同源策略不阻止发送请求"的空子。作为 Java 开发者,你必须理解:

  • 成因:基于 Cookie 的隐式认证,浏览器无条件携带。
  • 核心防御 :加入一个攻击者无法获取和自动携带的动态令牌------CSRF Token。
  • 具体落地 :在 Spring 生态中,保持 csrf() 开启,并按照上述方式把 Token 传递给前端表单和 AJAX。
  • 辅助增强 :为 Cookie 设置 SameSite=Lax,并在敏感操作叠加二次确认。

记住,安全不是一劳永逸的,理解原理才能应对万变。希望这篇文章能让你彻底搞懂 CSRF,并在日常编码中自信地填上这个坑。

相关推荐
江南十四行1 小时前
Spring框架核心(上)——IoC控制反转与DI依赖注入详解
java·后端·spring
fb_123452 小时前
Linux磁盘分区从入门到实操:MBR_GPT全解析+分区工具实战指南
java·linux·gpt
码智社3 小时前
JSON 零基础到资深全体系教程
java·json
wgego4 小时前
基础的反序列化一些总结(php和java)
java·开发语言·笔记
机建狂魔5 小时前
Codex 接入第三方模型 API 实战:以 Mimo 为例
java·服务器·数据库·ai·ai编程·codex
山峰哥5 小时前
数据库工程与SQL调优:从慢查询到秒级响应的实战之路
java·开发语言·数据库·sql·深度优先·启发式算法
乐观的Terry5 小时前
11、发布系统-用户认证与权限体系
java·spring boot·spring·spring cloud·mybatis
前端开发张小七5 小时前
Java 学习笔记 · 第二课:面向对象核心(封装、继承、多态)及接口与异常
java·后端·程序员
wangjialelele5 小时前
Selenium4 + Java Web自动化测试入门指南:从环境搭建到常用操作详解
java·开发语言·前端·测试工具·自动化