背景
做 Java Web 代码审计时,很多问题并不是一眼看到危险函数就能确认的。真实项目里通常有多层 Controller、Service、DAO、Filter、Interceptor,还有各种参数绑定和工具类封装。单点搜索容易漏报,也容易误报。
这篇文章整理一个偏通用的审计思路:围绕“入口参数如何流向敏感操作”做数据流追踪。内容以本地靶场或授权测试环境为前提,重点放在漏洞确认、修复建议和审计方法,不涉及未授权利用和攻击扩展。
问题场景
假设某 Java Web 项目提供了一个文件预览接口,业务上允许用户查看自己上传目录下的文档。代码经过多层封装后,大概流程如下:
- Controller 接收参数 fileName;
- Service 拼接上传目录路径;
- 工具类读取文件并返回内容;
- 前端根据响应展示预览结果。
这类功能的常见风险包括路径穿越、任意文件读取、越权访问以及日志泄露。审计时不能只看是否调用了 FileInputStream,还要确认用户输入是否经过规范化、边界校验和权限校验。
核心代码示例
下面是一段简化后的示例代码,便于说明问题。实际项目中可能散落在不同类里。
@GetMapping("/preview")
public ResponseEntity<String> preview(@RequestParam String fileName,
HttpServletRequest request) throws IOException {
Long userId = getCurrentUserId(request);
String content = filePreviewService.readUserFile(userId, fileName);
return ResponseEntity.ok(content);
}
public String readUserFile(Long userId, String fileName) throws IOException {
String baseDir = uploadRoot + "/" + userId + "/";
File file = new File(baseDir + fileName);
return FileUtils.readFileToString(file, StandardCharsets.UTF_8);
}这段代码的问题在于,fileName 直接参与路径拼接。即使 baseDir 按用户 ID 隔离,攻击者仍可能通过特殊路径片段尝试跳出目录边界。审计时应关注“最终访问的真实路径是否仍在允许目录内”。
技术分析:从入口到敏感点
我一般按以下顺序追踪:
- 入口点:Controller 参数、JSON Body、Header、Cookie、上传文件名、批量导入内容。
- 传播路径:DTO 绑定、BeanUtils 拷贝、Map 传参、Service 层拼接、工具类封装。
- 安全处理:白名单校验、路径规范化、权限判断、编码处理、异常处理。
- 敏感点:文件读写、命令执行、SQL 拼接、模板渲染、反序列化、反射调用、URL 请求。
以文件读取为例,单纯判断是否包含 “..” 并不稳妥。不同操作系统路径分隔符、URL 编码、重复编码、符号链接、相对路径都会影响结果。更可靠的方式是基于 canonical path 或 normalized path 做目录边界判断。
关键复现步骤
在授权环境中,可以用最小化方式验证问题是否存在。建议不要直接读取敏感系统文件,而是先在测试目录中放置标记文件,验证是否能越过业务目录边界。
# 测试目录示例
/tmp/app/uploads/1001/a.txt
/tmp/app/marker.txt如果业务预期只能读取 /tmp/app/uploads/1001/ 下的文件,而通过构造参数能够读取到 /tmp/app/marker.txt,就说明目录边界控制存在缺陷。测试时重点记录:
- 当前用户 ID 对应的业务目录;
- 目标文件是否属于该用户目录;
- 服务端最终解析到的真实路径;
- 是否经过权限判断和审计日志记录。
为了避免误判,可以在服务端临时增加调试日志,仅输出 canonical path,不输出文件内容:
File base = new File(uploadRoot, String.valueOf(userId));
File target = new File(base, fileName);
log.info("basePath={}", base.getCanonicalPath());
log.info("targetPath={}", target.getCanonicalPath());注意:调试日志不要在生产环境长期保留完整路径信息,路径、用户名、业务 ID 等都可能成为后续攻击的辅助信息。
修复思路
推荐修复方式是“规范化路径 + 目录边界判断 + 业务权限校验”组合使用,而不是依赖单一字符串过滤。
public String readUserFileSafe(Long userId, String fileName) throws IOException {
Path basePath = Paths.get(uploadRoot, String.valueOf(userId))
.toRealPath(LinkOption.NOFOLLOW_LINKS);
Path targetPath = basePath.resolve(fileName)
.normalize();
if (!targetPath.startsWith(basePath)) {
throw new SecurityException("invalid file path");
}
if (!Files.isRegularFile(targetPath, LinkOption.NOFOLLOW_LINKS)) {
throw new FileNotFoundException("file not found");
}
return Files.readString(targetPath, StandardCharsets.UTF_8);
}这段代码仍需要结合业务实际继续加固:
- 如果允许预览的文件类型有限,应增加扩展名或 MIME 白名单;
- 如果文件记录在数据库中,优先使用 fileId 查询真实存储路径,而不是直接信任文件名;
- 对下载、预览、删除等操作都要校验文件归属;
- 避免把服务端物理路径返回给前端;
- 对异常响应做统一处理,避免泄露堆栈和绝对路径。
代码审计中的几个注意点
1. 不要只搜危险函数
很多项目会封装文件操作工具类,例如 StorageService、FileManager、OssHelper。直接搜索 FileInputStream 可能搜不到真实入口。建议同时搜索业务关键字:
preview
readFile
download
export
upload
fileName
path
storage
attachment2. 注意框架自动绑定
Spring MVC 中 @RequestParam、@PathVariable、@RequestBody 都可能引入外部输入。尤其是复杂对象绑定时,危险字段可能藏在 DTO 内部,例如 path、template、callbackUrl、sort、filter 等。
3. 关注二次传递
有些参数不是当前接口直接使用,而是写入数据库、缓存或消息队列,随后被异步任务读取。审计这类问题时,要把“写入点”和“消费点”连起来看,否则容易漏掉存储型风险。
4. 区分漏洞和不可利用缺陷
如果某个危险函数的输入完全来自后端固定配置,且没有被外部参数影响,通常不能直接定性为漏洞。审计报告中要说明可控性、触发条件、影响范围和修复建议,避免只给结论。
辅助工具实践
静态分析工具能提高覆盖率,但不应替代人工判断。日常可以组合使用 grep、IDE 调用链、Semgrep 或 CodeQL。下面是一个简单的 Semgrep 规则示例,用于辅助发现 Java 中用户参数进入文件读取的可疑模式。
rules:
- id: java-file-read-from-request-param
languages: [java]
message: Request parameter may flow into file read operation
severity: WARNING
patterns:
- pattern-either:
- pattern: |
$T $V = $REQ.getParameter($P);
...
new FileInputStream($V);
- pattern: |
@RequestParam $TYPE $V
...
Files.readString(... $V ...);这类规则只能发现一部分浅层问题。真实项目中变量可能经过多次赋值、对象封装、方法返回,仍需要人工沿调用链确认。
风险评级建议
| 判断维度 | 关注点 |
|---|---|
| 可控性 | 参数是否由外部用户直接或间接控制 |
| 边界突破 | 是否能访问授权目录外的资源 |
| 权限影响 | 是否能读取其他用户或系统级文件 |
| 数据敏感性 | 是否涉及配置、密钥、用户数据、业务凭据 |
| 触发门槛 | 是否需要登录、特定角色或复杂前置条件 |
如果只能读取当前用户自己上传的普通文件,风险相对有限;如果能越权读取其他用户文件或服务端配置,风险应明显上调。评级最好结合实际影响,不要只按漏洞类型机械套级别。
总结
代码审计的关键不是记住多少危险函数,而是建立稳定的数据流追踪习惯:入口是否可控,过程中是否被正确处理,最终是否进入敏感操作。对于文件读取这类常见功能,建议重点检查路径规范化、目录边界、文件归属和异常回显。
从长期维护角度看,修复不应停留在某个接口的临时过滤。更好的方式是把文件访问能力收敛到统一服务层,统一做路径解析、权限校验、日志记录和异常处理。这样既减少重复代码,也能降低后续功能迭代带来的回归风险。
推荐意见