接手过一个老项目,Spring Boot 2.0 跑在 JDK 8 上,安全扫描报告打出来 200 多个高危漏洞。运维丢过来一句话:“这周必须修完,不然不让上线。” 面对密密麻麻的 CVE 编号,很多开发第一反应是升级 Spring Boot 版本,结果升完发现启动报错、接口不通、第三方依赖冲突,项目直接瘫痪。安全加固不是简单改版本号,而是一套组合拳:依赖治理、配置收敛、代码防御、运行时保护,缺一不可。

依赖治理:从源头切断供应链攻击

绝大多数 Java 后端项目的安全漏洞来自第三方依赖。最快的止血方式是引入 OWASP Dependency-Check Maven 插件,在构建阶段自动扫描已知 CVE。在 pom.xml 中加入:

<plugin>
    <groupId>org.owasp</groupId>
    <artifactId>dependency-check-maven</artifactId>
    <version>8.4.0</version>
    <configuration>
        <failBuildOnCVSS>7</failBuildOnCVSS>
        <formats>
            <format>HTML</format>
        </formats>
    </configuration>
    <executions>
        <execution>
            <goals>
                <goal>check</goal>
            </goals>
        </execution>
    </executions>
</plugin>

执行 mvn verify 就会生成详细的漏洞报告。但光扫出来不够,修复才是重头戏。遇到 Fastjson 反序列化漏洞,不要只升级 Fastjson,要检查项目中是否真的需要这个库。很多项目引入 Fastjson 仅仅为了序列化一个返回对象,完全可以用 Jackson 替代,从依赖树里直接排除。对于无法剔除的核心依赖,比如 Spring Framework,需要精确升级到安全版本,同时关注 Spring 官方发布的安全公告,确认修复版本是否引入了破坏性变更。一个容易忽略的点是传递性依赖——你引入的 A 依赖内部又依赖了有漏洞的 B 版本,这时需要在 pom.xml 中显式声明 B 的安全版本,利用 Maven 的 nearest-wins 原则强制覆盖。处理完依赖后,用 mvn dependency:tree 反复核对,确保没有遗漏的旧版本残留。

反序列化漏洞:不止是 Fastjson 的问题

Java 反序列化漏洞的根源在于 ObjectInputStream 在读取字节流时会无条件实例化其中的类,攻击者构造恶意字节序列就能触发任意代码执行。即使你不用 Fastjson,项目中的 RMI、JMX、Spring Session 都可能成为攻击入口。修复的核心思路是“白名单校验”。如果你必须使用 Java 原生序列化,可以重写 resolveClass 方法:

public class SafeObjectInputStream extends ObjectInputStream {
    private static final Set<String> ALLOWED_CLASSES = Set.of(
        "java.util.ArrayList",
        "com.yourcompany.dto.UserDTO"
    );

    @Override
    protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
        if (!ALLOWED_CLASSES.contains(desc.getName())) {
            throw new InvalidClassException("Unauthorized deserialization attempt", desc.getName());
        }
        return super.resolveClass(desc);
    }
}

更彻底的方案是放弃 Java 原生序列化,改用 JSON、Protobuf 这类纯数据格式。如果项目间调用必须用序列化,可以考虑 Kryo 并配置白名单。对于 Redis 存储 Session 的场景,检查是否使用了 JdkSerializationRedisSerializer,换成 Jackson2JsonRedisSerializer 就能从根源消除反序列化风险。另外,检查项目中所有接受外部输入并反序列化的端点,包括 Controller 中 @RequestBody 标注的对象,如果对象图里有 readObject 自定义逻辑,要逐行审计。

SQL 注入:参数化查询是底线,但还不够

用了 MyBatis 或 JPA 不代表就安全了。MyBatis 的 ${} 拼接和 JPA 的原生查询拼接是重灾区。全项目搜索 ${},逐个确认是否真的需要动态表名或排序字段。对于确实需要动态排序的场景,必须用白名单校验:

private static final Set<String> ALLOWED_COLUMNS = Set.of("id", "username", "create_time");
private static final Set<String> ALLOWED_DIRECTIONS = Set.of("ASC", "DESC");

public List<User> getUsersByOrder(String orderBy, String sortDirection) {
    if (orderBy == null || !ALLOWED_COLUMNS.contains(orderBy)) {
        throw new IllegalArgumentException("Invalid column: " + orderBy);
    }
    if (sortDirection == null || !ALLOWED_DIRECTIONS.contains(sortDirection.toUpperCase())) {
        throw new IllegalArgumentException("Invalid direction: " + sortDirection);
    }
    return mapper.getUsersByOrder(orderBy, sortDirection.toUpperCase());
}

JPA 的 @Query 注解中如果用了原生查询,同样要检查拼接点。更隐蔽的是 JPA Criteria API 的误用,比如直接用 cb.literal 拼接用户输入,这跟字符串拼接没有本质区别。还有一个容易被忽略的点是数据库权限配置:应用连接的数据库账号应该只拥有 CRUD 权限,严禁 DDL 和 DROP 权限。即使 SQL 注入被利用,最小权限原则能极大限制攻击的破坏范围。

认证与授权:JWT 不是银弹

大量项目用 JWT 做认证,但实现细节上漏洞百出。最常见的错误是把用户角色、权限信息直接放在 JWT Payload 里,而服务端不做二次校验。JWT 一旦签发,在过期前无法撤销,如果用户权限被管理员修改,旧的 JWT 仍然能访问敏感接口。正确的做法是 JWT 只存用户标识,每次请求到后端时,从数据库或缓存中实时查询当前权限。另一个高频漏洞是 JWT 签名密钥硬编码在代码里,甚至用 "secret" 这种弱密钥。密钥必须从配置中心或环境变量注入,长度至少 256 位,并且定期轮换。对于登出场景,必须维护一个 Token 黑名单,用 Redis 存储已注销的 JWT ID,过滤器每次校验时先查黑名单。Spring Security 的配置也要逐行检查,确认 /actuator、/swagger-ui 这些端点是否暴露了不必要的权限,生产环境必须关闭或加 IP 白名单。

敏感信息泄露:日志和异常处理是重灾区

线上出问题时,开发第一反应是看日志,但日志里往往躺着大量敏感信息。用户密码、身份证号、手机号、Token 被完整打印,甚至整个请求体被 log.info 输出。修复方案是统一封装日志脱敏工具类,对手机号中间四位、身份证号中间八位进行掩码处理。更关键的是全局异常处理:很多项目用 @ControllerAdvice 捕获异常后,直接把 e.getMessage() 返回给前端,这会把数据库表结构、SQL 语句、堆栈信息暴露出去。生产环境的异常响应只应包含一个模糊的错误提示和日志追踪 ID,详细信息记录到日志文件并限制访问权限。另外,检查 application.yml 中的数据库密码、Redis 密码、第三方 API Key 是否硬编码,全部迁移到环境变量或 Vault 这类密钥管理服务。Git 提交历史中如果曾经提交过密码,要用 git filter-branch 彻底清除,单纯删掉再提交是不够的。

CSRF 与 CORS:跨域配置不是 * 就完事

前后端分离项目配置 CORS 时,图省事直接 allowOrigins("*") 的大有人在。这等于告诉浏览器:任何域名的页面都可以带着你的 Cookie 调用接口。正确做法是明确列出允许的前端域名,并且 allowCredentials 设为 true 时,allowedOrigins 不能用通配符。Spring Security 中关闭 CSRF 保护也是常见操作,但如果项目还保留了 Session 机制的接口,关闭 CSRF 就意味着攻击者可以诱导用户点击恶意链接完成转账等操作。对于纯 API 项目,如果确定不用 Cookie 做认证,关闭 CSRF 是合理的,但要确保所有接口都用 Authorization Header 传递 Token,而不是依赖 Cookie。

运行时保护:SecurityManager 与容器安全

即使代码层面做到位,运行时环境也需要加固。Java 的 SecurityManager 虽然从 JDK 17 开始被标记为废弃,但在 JDK 8/11 的项目中仍然有效。通过配置 .policy 文件,可以限制应用只能读写指定目录、只能连接特定端口的网络、禁止反射调用私有方法。Docker 容器中运行 Java 应用时,不要用 root 用户启动,在 Dockerfile 中显式创建非 root 用户并切换。JVM 参数也要检查,关闭 JMX 远程端口,禁用 Attach API,防止攻击者通过 JConsole 或 JMap 导出堆内存中的敏感数据。对于 Spring Boot Actuator,生产环境只保留 health 端点,其余全部关闭,health 端点也要配置为只展示状态不展示详情。

安全测试自动化:把检查嵌入 CI/CD 流水线

手动修复漏洞永远追不上新漏洞的披露速度。在 CI/CD 中集成自动化安全检查是最后的防线。除了前面提到的 Dependency-Check,还应该加入 SpotBugs 配合 Find Security Bugs 插件做静态代码分析,检测硬编码密码、路径遍历、命令注入等模式。SonarQube 的安全规则集也能发现不少问题。每次提交代码自动触发扫描,发现高危问题直接阻断构建,强制开发修复。定期执行渗透测试,用 ZAP 或 Burp Suite 对测试环境做主动扫描,模拟真实攻击路径。安全加固不是一次性项目,而是一个持续迭代的过程,每次新功能上线都可能引入新的攻击面。

修完那 200 多个漏洞后,最大的感触是:安全加固真正的难点不是技术,而是对业务系统的深入理解。你得知道每个接口的调用链路、每个依赖的实际用途、每行配置的影响范围,才能在不破坏功能的前提下完成加固。把安全能力嵌入到日常开发流程中,让每一个提交都经过自动化的安全检查,才是从根本上降低风险的策略。