背景
文件上传功能在业务系统里很常见,头像、附件、工单截图、合同材料都可能涉及上传。也正因为入口普遍、实现差异大,它一直是 Web 安全审计里的高频风险点。
这篇文章整理的是一次偏防守视角的复盘:从代码审计中发现上传接口存在缺陷,到复现风险、分析成因,再到给出可落地的修复方案。文中不会涉及真实目标攻击,也不提供恶意利用引导,重点放在审计方法和加固思路。
问题场景
某业务系统提供了一个附件上传接口,前端限制只能上传图片或 PDF。开发同学最初的判断是:前端已经限制了文件类型,后端也检查了扩展名,因此风险不大。
但在代码审计中可以看到几个典型问题:
- 文件类型主要依赖原始文件名后缀判断。
- 上传目录位于 Web 可访问路径下。
- 保存文件名使用用户提交的文件名拼接。
- 未对文件内容做可信校验。
- 缺少统一的大小限制、权限控制和日志记录。
单个问题未必一定导致严重后果,但几个点叠加后,上传功能就可能从普通业务入口变成高风险入口。
代码片段与风险点
审计时看到的代码大致类似下面这种写法。这里做了脱敏和简化,只保留关键逻辑。
String fileName = multipartFile.getOriginalFilename();
String suffix = fileName.substring(fileName.lastIndexOf(".") + 1);
if (!Arrays.asList("jpg", "png", "pdf").contains(suffix.toLowerCase())) {
throw new RuntimeException("file type not allowed");
}
String uploadDir = request.getServletContext().getRealPath("/upload/");
File dest = new File(uploadDir + fileName);
multipartFile.transferTo(dest);
return "/upload/" + fileName;这类实现的问题比较集中:
- 信任原始文件名:原始文件名来自客户端,不能直接作为服务端保存路径的一部分。
- 后缀判断不足:后缀只能作为辅助信号,不能代表真实内容类型。
- Web 目录直出:上传目录如果能被 Web 容器直接访问,风险面会明显扩大。
- 路径拼接不安全:直接拼接路径容易引入路径穿越、覆盖文件等问题。
- 缺少二次处理:图片类文件没有重新编码,元数据和异常结构没有被清理。
技术分析
文件上传审计通常不要只看“有没有限制后缀”,而是要从数据流角度看完整链路:
- 请求入口:接口是否需要认证,是否有权限校验。
- 文件解析:是否使用可信库解析内容,异常是否可控。
- 文件校验:是否校验大小、扩展名、MIME、魔数、内容结构。
- 文件存储:是否使用随机文件名,是否存到非 Web 目录。
- 文件访问:是否通过受控下载接口返回,而不是静态目录直出。
- 后续处理:是否进入图片处理、PDF 解析、OCR、压缩解压等链路。
实际项目里,风险往往不止在上传当下。比如上传后的文件会被异步任务处理,或被其他服务拉取解析,这时还要关注解析组件版本、沙箱隔离、临时目录权限等问题。
复现验证思路
为了避免测试动作影响业务环境,建议在本地或测试环境复现。验证目标不是“打穿系统”,而是确认后端实际安全边界是否符合预期。
可以用以下几个方向做安全验证:
- 上传超大文件,确认大小限制是否在服务端生效。
- 上传后缀大小写混合的文件,确认判断是否一致。
- 上传文件名包含特殊字符的样本,确认服务端是否安全重命名。
- 上传内容与后缀不匹配的样本,确认是否做内容校验。
- 上传同名文件,确认是否会覆盖已有文件。
- 未登录或低权限账号访问上传接口,确认鉴权是否完整。
例如在测试环境中,可以用普通文本内容构造一个伪装成图片后缀的样本,验证后端是否仅依赖后缀:
printf 'this is not a real image' > sample.jpg
curl -i -X POST \
-H "Cookie: SESSION=your_test_session" \
-F "[email protected];type=image/jpeg" \
使用白名单并统一规范化后缀可以保留,但只能作为第一层过滤。需要先对文件名做规范化处理,并且不使用用户原始文件名作为落盘文件名。
private static final Set<String> ALLOWED_EXT = Set.of("jpg", "jpeg", "png", "pdf");
private String getSafeExt(String originalName) {
if (originalName == null || !originalName.contains(".")) {
throw new IllegalArgumentException("invalid file name");
}
String ext = originalName.substring(originalName.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
if (!ALLOWED_EXT.contains(ext)) {
throw new IllegalArgumentException("file type not allowed");
}
return ext;
}
2. 校验真实文件类型
对于图片,可以读取文件头并尝试使用图片库解码。对于 PDF,可以校验基础结构并限制解析行为。MIME 头来自客户端,不应直接信任。
private boolean isPng(byte[] header) {
byte[] png = new byte[] {(byte)0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
if (header.length < png.length) return false;
for (int i = 0; i < png.length; i++) {
if (header[i] != png[i]) return false;
}
return true;
}
如果业务只允许图片,建议上传后重新编码生成新图片,而不是原样保存用户文件。重新编码能清理一部分异常结构和元数据。
3. 存储到非 Web 目录
上传文件不建议放在 Web 容器可直接访问的静态目录下。更稳妥的方式是存储到对象存储、独立文件服务,或应用进程不可执行的本地目录,并通过受控接口下载。
Path baseDir = Paths.get("/data/app/uploads").toAbsolutePath().normalize();
String ext = getSafeExt(originalName);
String safeName = UUID.randomUUID() + "." + ext;
Path target = baseDir.resolve(safeName).normalize();
if (!target.startsWith(baseDir)) {
throw new SecurityException("invalid path");
}
Files.copy(inputStream, target, StandardCopyOption.REPLACE_EXISTING);
4. 限制大小与数量
大小限制需要在多层配置中同时关注,包括网关、应用框架和业务逻辑。只在前端限制没有意义。
# Nginx 示例
client_max_body_size 10m;
# Spring Boot 示例
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=20MB
如果是多文件上传,还要限制单次上传数量、用户每日上传总量,并对异常频率做告警。
5. 下载接口做鉴权和安全响应头
如果文件需要被访问,建议通过下载接口进行鉴权,而不是直接暴露路径。响应时尽量使用附件下载方式,避免浏览器直接解释内容。
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="download.bin"
X-Content-Type-Options: nosniff
对图片预览场景,可以单独走缩略图服务,避免把原始上传文件直接暴露给浏览器。
6. 日志与审计
上传接口需要记录关键字段,便于后续排查:
- 用户 ID、角色、来源 IP。
- 原始文件名、服务端保存名。
- 文件大小、识别出的文件类型。
- 校验失败原因。
- 下载访问记录。
日志里不要记录完整敏感内容,也不要把用户上传文件内容直接打到日志中。
代码审计检查清单
检查项 关注点 鉴权 上传接口是否必须登录,是否校验业务权限 类型校验 是否只看后缀,是否校验文件头和内容结构 文件名 是否使用随机名,是否避免路径拼接风险 存储位置 是否位于 Web 可访问目录,是否具备执行权限 大小限制 网关、框架、业务层是否都有明确限制 后续处理 图片、PDF、压缩包解析是否存在组件风险 访问控制 下载或预览是否校验权限 日志审计 是否能追踪上传者、文件、时间和结果
容易忽略的细节
- 反向代理限制不等于业务限制:Nginx 限制了大小,不代表应用内部就不用校验。
- 对象存储也要控权限:不要为了方便把 Bucket 设置成公开读写。
- 异步处理要隔离:缩略图、OCR、PDF 解析建议放在低权限环境中执行。
- 异常信息不要外显:上传失败时不要返回服务端绝对路径、组件栈信息。
- 临时文件也要清理:上传失败、中断、解析异常时都要处理临时文件残留。
上传安全的核心不是“禁止某些危险后缀”,而是把文件当作不可信输入处理:入口要控、内容要验、存储要隔离、访问要鉴权、行为要留痕。
总结
文件上传漏洞在审计中并不难发现,但要修得稳,需要从业务链路整体考虑。单纯加一个后缀黑名单,或者依赖前端限制,基本不能算有效修复。
比较可靠的做法是:服务端白名单校验、内容识别、随机重命名、非 Web 目录存储、受控下载、大小和频率限制、日志审计,再结合后续解析链路的隔离。这样即使某一层判断出现偏差,也不至于让风险直接扩大。
这类问题适合作为代码审计里的固定检查项。尤其是老系统、后台系统、内部工具,往往因为“只有内部人用”而放松限制,但实际风险并不会因此消失。

代码审计、调用链与关键函数定位示意
推荐意见