背景
文件上传是 Java Web 项目里很常见的功能,但也是代码审计中高频出问题的点。很多项目看起来做了扩展名限制、大小限制,甚至还把文件名改成了 UUID,但实际风险往往不只在“能不能上传 jsp”这一层。
我在审计业务系统时,通常会把文件上传当成一个独立攻击面来看:上传入口、文件存储位置、文件访问方式、后续解析链路、异步处理逻辑、对象存储策略,都可能影响最终风险。本文整理一套相对通用的分析方法,重点放在安全边界和修复建议上,不涉及利用型攻击操作。
典型场景
常见业务包括头像上传、附件上传、Excel 导入、富文本图片、工单附件、合同扫描件等。不同场景的风险不完全一样:
- 头像、富文本图片:通常会被前端直接访问,关注内容类型校验、图片重编码、存储桶权限。
- 附件、合同文件:关注敏感信息泄露、越权下载、路径穿越、文件名注入。
- Excel/CSV 导入:关注解析库漏洞、公式注入、压缩包炸弹、异步任务异常处理。
- 压缩包上传:关注解压路径穿越、软链接、超大文件膨胀比。
很多问题不是单点漏洞,而是“上传 + 可访问 + 可解析 + 权限缺失”组合出来的风险。
技术分析
1. 只校验后缀是不够的
下面这种代码在老项目里很常见:
String filename = file.getOriginalFilename();
String suffix = filename.substring(filename.lastIndexOf(".") + 1);
if (!Arrays.asList("jpg", "png", "pdf").contains(suffix)) {
throw new RuntimeException("file type not allowed");
}
file.transferTo(new File(uploadDir, filename));这里至少有几个问题:
- 未处理大小写,例如 JPG、Png。
- 未处理空字节、特殊 Unicode 字符、双扩展名带来的展示误判。
- 直接使用原始文件名,可能造成覆盖、路径穿越或日志污染。
- 只看扩展名,不验证真实内容。
- 上传目录如果在 Web 根目录下,风险会明显扩大。
更稳妥的做法是同时校验扩展名、MIME、文件头,并按业务进行内容处理。例如图片类文件,建议读取后重新编码输出,而不是原样保存用户上传内容。
2. 存储位置决定风险上限
上传文件如果直接落在 Web 可执行目录下,是非常危险的设计。即使当前只允许图片,也不建议依赖容器配置来兜底。比较稳妥的原则是:
- 上传目录不要放在应用 WebRoot 下。
- 业务访问文件时通过下载接口或对象存储临时授权访问。
- 静态资源域名与主站隔离,避免 Cookie、同源策略风险扩大。
- 对象存储桶默认私有,按需生成短时有效访问链接。
如果历史原因必须使用本地目录,也建议至少确保该目录不可执行,只做静态文件读取,并在反向代理层限制可访问扩展类型。
3. 文件名不能直接信任
文件名经常被用在保存路径、下载响应头、页面展示、日志记录中。原始文件名如果不处理,可能带来多个问题:
- 包含 ../ 造成路径穿越。
- 包含换行符导致日志伪造。
- 包含特殊字符导致响应头注入。
- 同名文件覆盖。
- 超长文件名触发异常或绕过部分校验逻辑。
建议保存时使用服务端生成的随机文件名,原始文件名只作为元数据保存,展示前做 HTML 转义,下载时对 Content-Disposition 做安全编码。
String originalName = file.getOriginalFilename();
String safeExt = detectAllowedExtension(file);
String storedName = UUID.randomUUID().toString().replace("-", "") + "." + safeExt;
Path target = Paths.get(uploadBaseDir).resolve(storedName).normalize();
if (!target.startsWith(Paths.get(uploadBaseDir).normalize())) {
throw new SecurityException("invalid upload path");
}
file.transferTo(target.toFile());4. 内容识别要结合业务
文件类型检测不要只依赖浏览器传来的 Content-Type,因为这个字段可以由客户端控制。后端至少需要自己判断文件内容。Java 项目里常见选择包括 Apache Tika、文件头魔数识别、图片解码库等。
Tika tika = new Tika();
String mime = tika.detect(file.getInputStream());
Set<String> allowed = Set.of("image/jpeg", "image/png", "application/pdf");
if (!allowed.contains(mime)) {
throw new IllegalArgumentException("unsupported file type: " + mime);
}不过也要注意,Tika 这类库本身会解析复杂文件格式,历史上也出现过漏洞。因此对于不需要深度解析的场景,不建议对所有文件做重型解析。图片类可以优先使用 ImageIO 尝试解码并重新编码;文档类可以限制大小和页数,必要时放入隔离环境处理。
关键步骤
步骤一:梳理上传入口
代码审计时,先全局搜索上传相关关键字:
MultipartFile
CommonsMultipartFile
@RequestParam("file")
transferTo
getOriginalFilename
FileOutputStream
Files.copy
putObject同时不要漏掉富文本编辑器、第三方组件、后台管理接口、导入任务接口。这些入口经常不在统一文件服务里。
步骤二:确认校验逻辑是否集中
如果项目里每个 Controller 都自己写一段上传校验,后续很容易出现策略不一致。建议抽象成统一的 FileUploadService,所有入口调用同一套规则,然后通过业务类型传入策略。
| 检查项 | 建议 |
|---|---|
| 扩展名 | 白名单,小写归一化,不使用黑名单 |
| 文件大小 | 按业务类型设置上限,避免全局过大 |
| MIME | 服务端检测,不信任客户端传值 |
| 文件名 | 服务端生成保存名,原始名仅作为展示元数据 |
| 存储路径 | 不可执行、不可直接拼接用户输入 |
| 访问控制 | 下载接口校验归属关系和权限 |
步骤三:验证下载链路
很多上传问题最终体现在下载接口上。例如上传时做了权限校验,但下载时只要知道文件 ID 就能访问。审计下载接口时重点看:
- 是否校验当前用户对文件的访问权限。
- 文件 ID 是否可枚举。
- 是否存在根据路径参数直接读取文件的逻辑。
- 是否允许读取任意目录下文件。
- 响应头中的文件名是否经过安全处理。
Path base = Paths.get(fileBaseDir).normalize();
Path target = base.resolve(storedName).normalize();
if (!target.startsWith(base)) {
throw new SecurityException("invalid file path");
}这个 normalize + startsWith 的校验很基础,但能避免一类常见路径穿越问题。注意不要用字符串 contains 或简单 replace 来处理路径。
步骤四:关注异步处理与解压逻辑
Excel 导入、压缩包解压、图片压缩通常会交给异步任务处理。这里容易被忽略,因为上传接口本身返回正常,但后台任务可能存在更高风险。
压缩包处理建议加入以下限制:
- 限制压缩包大小和解压后总大小。
- 限制文件数量和目录层级。
- 拒绝绝对路径和包含 ../ 的条目。
- 处理符号链接时要非常谨慎,默认建议拒绝。
- 解压目标目录使用临时隔离目录,处理完再迁移安全产物。
Path destDir = Paths.get(tempDir).normalize();
for (ZipEntry entry : Collections.list(zip.entries())) {
Path out = destDir.resolve(entry.getName()).normalize();
if (!out.startsWith(destDir)) {
throw new SecurityException("zip entry path traversal detected");
}
}注意点
1. 不要依赖前端校验
前端限制只能提升体验,不能作为安全边界。包括 accept 属性、JS 判断文件大小、前端压缩图片等,都必须在后端重新校验。
2. 不要使用黑名单
黑名单很难覆盖所有情况,例如不同容器的解析规则、大小写、历史遗留扩展、复合文件格式等。上传策略应当以白名单为主,并且每种业务类型单独定义允许类型。
3. 图片也不是绝对安全
图片可能包含异常元数据、超大尺寸、畸形结构。对图片业务,比较推荐“解码成功 + 限制宽高 + 重新编码 + 去除元数据”的处理方式。这样可以降低原始内容被直接分发的风险。
4. 日志里不要记录敏感文件内容
排查问题时,有些代码会把解析失败的内容片段写入日志。对于合同、身份证、财务附件这类文件,这会造成二次泄露。日志建议只记录文件 ID、业务类型、大小、检测结果和异常摘要。
5. 安全扫描要配合代码审计
自动化扫描通常能发现明显的路径穿越、越权下载、弱校验问题,但对于业务归属关系、异步处理链路、对象存储策略,很难完全覆盖。比较有效的方式是先梳理数据流,再对关键入口做针对性验证。
修复参考方案
一个相对稳妥的上传处理流程可以是:
- 根据业务类型获取上传策略。
- 检查文件大小、扩展名、服务端 MIME。
- 对图片类文件进行解码和重编码。
- 生成随机保存名,不使用用户原始文件名作为路径。
- 保存到不可执行目录或私有对象存储。
- 写入文件元数据,包括上传人、业务 ID、哈希、大小、类型。
- 下载时通过文件 ID 查询元数据,并校验当前用户权限。
- 定期清理未绑定业务的临时文件。
可以把策略设计成类似下面的结构,避免散落在各个接口里:
public class UploadPolicy {
private String bizType;
private long maxSize;
private Set<String> allowedExtensions;
private Set<String> allowedMimeTypes;
private boolean requireImageReencode;
private boolean privateAccess;
}总结
文件上传漏洞的核心不是某个单独判断写错,而是边界不清:哪些文件能上传、谁能访问、存在哪里、是否会被解析、是否会进入异步任务。审计时建议按“入口、校验、存储、访问、处理链路”五个维度拆开看。
实践里最有效的改造通常不是加几个 if,而是把上传能力收敛到统一服务,配合白名单策略、私有存储、权限校验和安全的文件处理流程。这样即使后续新增业务,也不容易因为复制旧代码引入同类问题。
经验上,上传功能一旦分散在多个模块里,后期很难靠人工约定维持安全一致性。能统一入口就尽量统一,不能统一也至少复用同一个校验和存储组件。
推荐意见