背景Java Web 项目在企业内部系统、后台管理平台、业务中台里非常常见。相比直接跑扫描器,代码审计更适合发现一些“业务上下文相关”的问题,比如权限校验遗漏、对象越权、文件处理不当、危险反序列化入口等。这篇文章整理的是一套偏实战的审计流程,适合拿到一个 Spring MVC / Spring Boot 项目后做安全评估。内容侧重防御视角和漏洞定位方法,不涉及真实目标攻击,也不提供非法利用链操作。审计目标与场景假设我们拿到的是一个典型 Java Web 项目,包含如下结构:src/main/java
├── controller
├── service
├── mapper / dao
├── config
├── filter
└── util
src/main/resources
├── application.yml
├── mapper/*.xml
└── static / templates主要审计目标通常包括:未授权访问:接口缺少鉴权或路径被错误放行。越权访问:用户可访问或修改不属于自己的数据。SQL 注入:动态 SQL 拼接或 MyBatis 使用不当。文件上传与下载风险:任意文件上传、路径穿越、敏感文件读取。命令执行与表达式注入:Runtime、ProcessBuilder、SpEL、模板引擎危险用法。反序列化风险:不可信数据进入 ObjectInputStream、JSON 多态反序列化等入口。敏感信息泄露:配置文件、日志、异常回显、调试接口暴露。第一步:先看安全边界,而不是直接找危险函数很多审计容易一开始就全局搜索危险函数,但实际项目里更有效的做法是先建立安全边界:哪些接口对外开放、哪些接口需要登录、权限模型如何设计、数据归属如何校验。可以先查看这些位置:Spring Security 配置:SecurityConfig、WebSecurityConfigurerAdapter、SecurityFilterChain。拦截器:HandlerInterceptor、WebMvcConfigurer。过滤器:Filter、OncePerRequestFilter。网关注解:@PreAuthorize、@RequiresPermissions、@RequiresRoles。自定义登录态:TokenUtil、JwtUtil、SessionUtil。常见配置示例:http
.authorizeRequests()
.antMatchers(\"/login\", \"/captcha\", \"/static/**\").permitAll()
.anyRequest().authenticated();审计时需要关注两类问题:一类是放行范围过大,比如 /api/** 被 permitAll;另一类是接口层看似做了登录校验,但业务层没有做对象归属校验。第二步:从路由入口建立接口清单在 Spring 项目中,可以通过搜索以下注解建立接口清单:@RequestMapping
@GetMapping
@PostMapping
@PutMapping
@DeleteMapping建议将接口简单整理成表格,后续验证会高效很多。路径方法控制器是否鉴权关键参数风险点/user/detailGETUserController是id越权/file/downloadGETFileController是path路径穿越/admin/exportPOSTAdminController角色校验type数据导出这里不要只看接口是否需要登录,更要看接口是否存在“用户可控标识符”,例如 userId、id、orderId、tenantId、fileId、path 等。这些参数往往是越权和文件类问题的入口。第三步:重点看越权,不要只停留在鉴权业务系统中最常见的问题不是“完全没登录也能访问”,而是“登录后可以访问别人的数据”。典型代码如下:@GetMapping(\"/order/detail\")
public Order detail(Long orderId) {
return orderService.getById(orderId);
}这个接口虽然可能经过了登录拦截器,但如果 service 层只按 orderId 查询,而没有校验订单归属,就存在水平越权风险。更稳妥的写法应当把当前用户身份纳入查询条件:@GetMapping(\"/order/detail\")
public Order detail(Long orderId) {
Long currentUserId = SecurityContext.getCurrentUserId();
return orderService.getByIdAndUserId(orderId, currentUserId);
}对应 SQL 也应限制数据范围:SELECT *
FROM orders
WHERE id = #{orderId}
AND user_id = #{userId}
AND deleted = 0对于多租户系统,还要额外关注 tenantId。不要直接相信前端传入的 tenantId,应优先从登录态、网关上下文或服务端会话中获取。第四步:审计 MyBatis 动态 SQLMyBatis 项目中 SQL 注入常见于 ${}、字符串拼接、动态排序字段、动态表名等场景。高风险写法:SELECT * FROM user
WHERE name LIKE '%${keyword}%'
ORDER BY ${sortField} ${sortOrder}相对安全的参数绑定应使用 #{}:SELECT * FROM user
WHERE name LIKE CONCAT('%', #{keyword}, '%')但需要注意,排序字段、表名这类 SQL 结构不能简单用 #{} 绑定。比较稳妥的方式是白名单映射:private static final Map<String, String> SORT_FIELD_MAP = Map.of(
\"name\", \"name\",
\"createdTime\", \"created_time\",
\"status\", \"status\"
);
String sortColumn = SORT_FIELD_MAP.getOrDefault(req.getSortField(), \"created_time\");审计时可以全局搜索:${
statement.append(
createNativeQuery
jdbcTemplate.query
ORDER BY
LIKE '%如果发现 ${},不要立刻认定一定有漏洞,需要继续确认变量是否完全由用户控制,以及是否经过严格白名单限制。第五步:文件上传与下载的审计重点文件上传常见风险包括扩展名校验不严、MIME 类型信任客户端、文件名可控、上传目录可执行、解压 Zip 时路径穿越等。上传接口需要重点确认:是否限制文件大小。是否使用服务端生成文件名。是否限制扩展名白名单。是否校验真实文件类型。上传目录是否与 Web 可执行目录隔离。是否禁止脚本类后缀和双后缀绕过。安全性更好的处理方式:String originalName = file.getOriginalFilename();
String ext = FilenameUtils.getExtension(originalName).toLowerCase();
Set<String> allowExt = Set.of(\"jpg\", \"jpeg\", \"png\", \"pdf\");
if (!allowExt.contains(ext)) {
throw new IllegalArgumentException(\"unsupported file type\");
}
String newName = UUID.randomUUID() + \".\" + ext;
Path target = uploadBase.resolve(newName).normalize();
if (!target.startsWith(uploadBase)) {
throw new IllegalArgumentException(\"invalid path\");
}
file.transferTo(target.toFile());文件下载则重点看 path、fileName、url 等参数是否直接拼接成本地路径:@GetMapping(\"/download\")
public void download(String path, HttpServletResponse response) throws IOException {
File file = new File(baseDir + \"/\" + path);
// ...
}这类写法容易出现路径穿越。建议改为基于文件 ID 查询服务端记录,并用 normalize + startsWith 做路径边界校验。第六步:危险函数不是漏洞,数据流才是关键代码审计中经常会搜到 Runtime.exec、ProcessBuilder、ObjectInputStream 等危险调用,但是否构成漏洞,取决于不可信输入是否能够到达危险点。可以重点搜索:Runtime.getRuntime().exec
new ProcessBuilder
ObjectInputStream
readObject
ScriptEngineManager
SpelExpressionParser
Yaml.load
XMLDecoder
TemplatesImpl如果发现命令执行相关代码,要确认命令参数是否来自用户输入,以及是否存在命令拼接。内部固定命令不一定构成漏洞,但如果参数可控,就需要谨慎处理。相对安全的方式是避免 shell 拼接,使用参数数组,并对业务参数做白名单限制:List<String> command = List.of(
\"/usr/bin/convert\",
inputFile.toString(),
outputFile.toString()
);
ProcessBuilder pb = new ProcessBuilder(command);
pb.redirectErrorStream(true);
Process process = pb.start();需要注意的是,即便不用 shell,如果 inputFile 可控,也可能造成任意文件读取、覆盖或 SSRF 类问题,所以仍要校验路径边界和文件来源。第七步:反序列化与 JSON 多态配置Java 反序列化问题不只存在于 ObjectInputStream。很多项目使用 JSON、YAML、XML 库时,如果开启了危险的多态能力,也可能带来风险。审计时可以关注这些配置:enableDefaultTyping
activateDefaultTyping
@JsonTypeInfo
ObjectInputStream.readObject
Yaml.load
XStream.fromXML
XMLDecoder.readObject对于 Jackson,旧版本中 default typing 使用不当风险较高。建议避免对不可信输入启用全局多态反序列化。如果确实需要多态,尽量使用明确的类型白名单,并保持依赖版本更新。同时检查依赖版本也很重要:mvn dependency:tree | grep -E \"jackson|fastjson|xstream|snakeyaml\"依赖审计不要只看是否存在漏洞版本,还要确认危险功能是否被实际调用。版本风险和可达性结合判断,结果会更准确。第八步:配置文件与日志中的敏感信息很多项目漏洞不是代码逻辑问题,而是敏感信息被带进配置或日志。建议重点检查:application.yml、bootstrap.yml 中的数据库、Redis、MQ 密码。日志中是否打印 Authorization、Cookie、Token。异常处理是否把堆栈和 SQL 返回给前端。Actuator、Swagger、Druid Console 是否暴露。测试接口、调试开关是否在生产环境开启。Spring Boot Actuator 建议最小化暴露端点:management:
endpoints:
web:
exposure:
include: health,info
endpoint:
env:
enabled: false
beans:
enabled: false统一异常处理时,也不要把内部异常细节直接返回给客户端:@ExceptionHandler(Exception.class)
public ResponseEntity<?> handle(Exception e) {
log.error(\"internal error\", e);
return ResponseEntity.status(500).body(\"internal server error\");
}关键步骤总结先梳理鉴权、拦截器、过滤器,明确安全边界。建立接口清单,标记用户可控参数和敏感操作。重点审计越权,确认数据查询是否绑定当前用户或租户。检查 MyBatis ${}、动态排序、动态表名等 SQL 结构拼接。审计文件上传下载,关注路径边界、后缀白名单、存储位置。搜索危险函数,但以数据流可达性作为判断依据。检查反序列化入口和依赖版本,不只看 CVE,还要看实际调用。排查配置、日志、调试接口中的敏感信息泄露。注意点代码审计不是简单的关键字匹配。真正需要判断的是:输入从哪里来,经过了哪些处理,最终到达了哪里,是否缺少必要的安全约束。实际审计中建议保留审计记录,包括接口路径、风险描述、影响范围、复现条件、修复建议和代码位置。这样后续和开发沟通时会更清晰,也方便复测。对于高风险问题,验证时应尽量在本地环境、测试环境或授权范围内完成,不要对生产系统做破坏性测试。涉及文件、命令、数据修改类问题,验证到风险成立即可,不需要扩大影响。总结Java Web 代码审计的核心不是“搜到危险函数”,而是围绕业务入口建立数据流视角。先看鉴权边界,再看对象归属,然后跟踪用户输入是否进入 SQL、文件系统、命令执行、反序列化等敏感位置。长期来看,比较有效的安全建设方式是把这些审计规则固化到开发流程中:接口默认鉴权、数据默认按用户或租户隔离、动态 SQL 白名单、文件统一由平台层处理、敏感配置集中托管。这样比单次漏洞修补更有持续价值。
推荐意见