背景
代码审计最容易走偏的地方,是一上来就全局搜索危险函数,然后在大量误报里消耗时间。真实项目里,漏洞通常不是单点 API 使用错误,而是“入口、权限、数据流、业务约束、框架特性”共同作用的结果。
这篇文章整理一套我在审计 Java Web 项目时常用的流程,偏实战复盘,不针对某个具体漏洞利用场景,也不提供攻击性操作。重点放在如何更高效地识别风险点、确认影响范围,并给出可落地的修复建议。
适用场景
本文更适合以下几类场景:
- 接手一个 Spring Boot / Spring MVC 项目,需要做上线前安全评审。
- 对历史系统做安全巡检,希望快速定位高风险接口。
- 漏洞响应阶段,需要判断某个输入点是否能影响敏感操作。
- 团队希望建立一套可重复执行的代码审计 checklist。
不适合单纯依赖自动化扫描的场景。工具可以帮助发现一部分问题,但业务鉴权缺失、越权、状态机绕过这类问题,仍然需要结合代码和业务流程判断。
审计前的准备
开始前建议先把项目跑起来,至少要能完成编译和单元测试。无法运行的项目也能审,但效率和准确性会明显下降。我的习惯是先做三件事:
- 确认技术栈:Spring MVC、MyBatis、JPA、Shiro、Spring Security、JWT、网关等。
- 梳理模块边界:用户中心、权限中心、文件服务、订单、支付、后台管理等。
- 确认配置来源:application.yml、环境变量、配置中心、Nacos、Apollo、K8s Secret 等。
常用的初始命令可以比较简单:
grep -R "@RequestMapping\|@GetMapping\|@PostMapping" -n src/main/java
grep -R "SecurityConfig\|WebSecurityConfigurerAdapter\|SecurityFilterChain" -n src/main/java
grep -R "ShiroFilterFactoryBean\|@RequiresPermissions\|@RequiresRoles" -n src/main/java
grep -R "upload\|download\|export\|import\|callback\|webhook" -ni src/main/java这些搜索不是为了直接下结论,而是先建立项目的接口分布和安全控制方式。
第一步:先看入口,不急着看危险函数
Java Web 项目里,入口大多集中在 Controller、Filter、Interceptor、MessageListener、定时任务和第三方回调处理器中。审计时建议先按入口类型分组,而不是按文件目录机械浏览。
| 入口类型 | 重点关注 | 常见风险 |
|---|---|---|
| Controller | 参数绑定、鉴权注解、业务对象 ID | 越权、注入、任意文件操作 |
| Filter / Interceptor | 认证逻辑、白名单、路径匹配 | 鉴权绕过、路径规范化问题 |
| 回调接口 | 签名校验、幂等、来源可信度 | 伪造回调、状态异常变更 |
| 异步消息 | 消息来源、反序列化、重放 | 数据污染、重复消费 |
| 定时任务 | 外部配置、批处理 SQL、文件路径 | 批量误操作、敏感数据泄露 |
一个比较有效的办法是把所有对外接口导出成清单,标记是否需要登录、是否需要角色、是否涉及资源 ID、是否涉及文件或外部请求。后续审计基本围绕这张表推进。
第二步:确认鉴权模型,而不是只看有没有注解
很多项目表面上有权限注解,但风险往往出在资源级授权缺失。例如用户只能访问自己的订单,但代码只判断了是否登录,没有判断订单归属。
建议重点看三类代码:
- 全局安全配置:哪些路径被放行,哪些路径需要认证。
- 方法级权限:是否使用 @PreAuthorize、@RequiresPermissions 等。
- 业务级校验:是否校验 tenantId、userId、orgId、ownerId。
典型的风险代码形态如下:
@GetMapping("/order/detail")
public OrderVO detail(@RequestParam Long orderId) {
return orderService.getDetail(orderId);
}这类代码不能只看接口是否登录。更关键的是 service 层是否有类似下面的限制:
public OrderVO getDetail(Long orderId, Long currentUserId) {
Order order = orderMapper.selectById(orderId);
if (order == null) {
throw new BizException("订单不存在");
}
if (!Objects.equals(order.getUserId(), currentUserId)) {
throw new AccessDeniedException("无权访问该订单");
}
return convert(order);
}如果项目是多租户系统,还要额外关注 tenantId 是否来自服务端上下文,而不是信任客户端传参。很多越权问题就出在 tenantId、orgId 可控。
第三步:沿数据流追踪高风险参数
确认入口后,再看用户输入如何流向敏感操作。这里的“敏感操作”不只包括传统危险函数,也包括业务敏感动作。
- 数据库查询:拼接 SQL、动态排序字段、动态表名。
- 文件操作:上传、下载、解压、删除、模板渲染。
- 外部请求:URL 参数、回调地址、图片抓取、Webhook。
- 表达式解析:SpEL、OGNL、MVEL、模板引擎。
- 对象反序列化:Java 原生序列化、JSON 多态、XML 解析。
- 状态变更:审核、退款、发货、授权、重置密码。
例如 MyBatis 中动态 SQL 并不一定都有问题,但需要区分 #{} 和 ${}。前者通常走预编译参数,后者是字符串替换,适用于字段名、排序方向等少数场景,但必须做白名单。
<select id="list" resultType="User">
select * from user
where status = #{status}
order by ${sortField} ${sortOrder}
</select>更稳妥的做法是把排序字段限制在固定集合内:
private static final Set<String> ALLOWED_SORT_FIELDS = Set.of("id", "create_time", "username");
public String normalizeSortField(String field) {
if (!ALLOWED_SORT_FIELDS.contains(field)) {
return "id";
}
return field;
}审计时不要只搜索“${”。还要看 Wrapper、Criteria、Specification 等动态查询封装,部分项目会把原始 SQL 片段藏在工具类里。
第四步:文件功能要单独审
上传、下载、导入、导出是企业系统里最常见的高风险功能。这里建议单独拉一条线审计,不要混在普通接口里看。
上传重点关注:
- 文件类型判断是否只依赖后缀或 Content-Type。
- 文件名是否直接使用原始文件名。
- 存储路径是否可被用户影响。
- 是否允许覆盖已有文件。
- 上传后是否会被 Web 容器直接解析或访问。
一个相对稳妥的处理方式:
String originalName = file.getOriginalFilename();
String ext = FilenameUtils.getExtension(originalName).toLowerCase(Locale.ROOT);
if (!Set.of("jpg", "png", "pdf", "xlsx").contains(ext)) {
throw new BizException("不支持的文件类型");
}
String safeName = UUID.randomUUID() + "." + ext;
Path baseDir = Paths.get(uploadRoot).toAbsolutePath().normalize();
Path target = baseDir.resolve(safeName).normalize();
if (!target.startsWith(baseDir)) {
throw new BizException("非法文件路径");
}
Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);下载重点关注路径穿越和权限校验。不要让接口直接接收完整路径,也不要仅通过文件名拼接读取文件。更推荐通过文件 ID 查询服务端保存的元数据,再校验当前用户是否有访问权限。
第五步:外部请求类功能要防 SSRF 和数据外带
现代业务里经常有“通过 URL 抓取头像”“配置 Webhook”“填写回调地址”“导入远程图片”等功能。这些功能的核心风险不一定是命令执行,而是服务端替用户访问了不该访问的地址。
审计时关注以下点:
- 是否允许访问内网地址、回环地址、云元数据地址。
- 是否处理重定向后的最终地址。
- 是否限制协议,仅允许 http/https。
- 是否设置连接超时、响应大小限制。
- 是否有 DNS 解析结果校验,避免校验前后不一致。
修复上不要只做字符串 contains 判断,例如判断 URL 中是否包含 127.0.0.1 远远不够。至少应基于 URI 解析、DNS 解析、IP 范围判断,并在发起请求前后都进行约束。
第六步:配置和密钥经常比代码更敏感
代码审计时很容易忽略配置文件。实际上很多事故来自测试配置被带到生产,或者历史密钥长期未轮换。
建议检查:
- application.yml 中是否有明文数据库密码、AK/SK、JWT Secret。
- 是否启用了 actuator,敏感端点是否暴露。
- 日志级别是否在生产环境保留 debug。
- 跨域配置是否过宽,例如允许任意 Origin 携带凭据。
- 默认账号、初始化脚本、测试接口是否残留。
可以用简单命令先做一次粗筛:
grep -R "password\|secret\|accessKey\|token\|privateKey\|jdbc" -ni . \
--exclude-dir=target --exclude-dir=.git发现密钥泄露时,不要只删除代码里的值。正确处理应包括确认是否已进入版本历史、是否已部署到环境、是否需要立即轮换。
常见误区
- 只看 Controller,不看 Service:很多权限和数据处理实际在 Service 层。
- 只扫危险函数,不看业务状态:退款、审核、授权这类接口更依赖业务约束。
- 只验证单个接口,不看调用链:一个低权限接口可能触发后台异步任务。
- 只修输入过滤,不修权限模型:越权问题靠过滤参数通常解决不了根因。
- 只依赖黑盒结果:黑盒能发现现象,白盒才能更准确判断影响范围。
输出审计结论的建议格式
安全审计最终要能推动修复,所以结论不要写得太泛。建议每个问题至少包含以下内容:
- 问题位置:类名、方法名、接口路径、关键代码行。
- 影响条件:是否需要登录、需要什么角色、受影响的数据范围。
- 风险说明:可能造成什么业务影响,避免夸大。
- 修复建议:给出具体改法,而不是只写“加强校验”。
- 验证方式:说明修复后如何回归测试。
一个好的审计结论,应该让开发同事能在较短时间内定位、理解并修复,而不是只得到一个模糊的“存在安全风险”。
总结
Java 代码审计的核心不是背危险函数清单,而是建立稳定的方法论:先梳理入口,再确认鉴权模型,随后追踪数据流到敏感操作,最后结合业务状态判断影响。
长期看,最有价值的不是一次性找出几个问题,而是把审计中反复出现的风险沉淀成团队规范,比如统一的资源权限校验、文件服务封装、外部请求安全组件、动态 SQL 白名单工具。这样后续项目的安全质量会明显稳定下来。
推荐意见