背景
文件上传是 Web 安全里很常见的风险点,但真正做代码审计时,问题往往不在“有没有上传接口”这么简单,而是出现在校验链路不一致、存储位置不合理、解析行为被忽略、权限边界缺失等细节上。
这篇文章整理一次典型文件上传缺陷的复盘思路,重点放在如何审计、如何验证风险、如何修复和加固。内容只讨论授权环境下的安全测试与代码审计,不涉及未授权攻击或恶意利用。
典型场景
某业务系统提供附件上传功能,用于上传头像、工单附件、富文本图片等。系统后端通常会做以下限制:
- 限制文件后缀,例如只允许 jpg、png、pdf、docx。
- 限制 Content-Type,例如 image/jpeg、image/png。
- 限制文件大小。
- 将文件保存到固定目录。
- 返回文件访问 URL,供前端展示或下载。
表面看这些措施已经比较完整,但在审计中经常能看到几个问题:只校验客户端传来的文件名、只校验 Content-Type、不校验真实文件内容、上传目录可执行、文件名可控、下载接口缺少权限判断。
问题拆解
文件上传风险可以按链路拆成四段来看:接收、校验、存储、访问。
| 阶段 | 常见问题 | 审计重点 |
|---|---|---|
| 接收 | 信任前端参数、未限制大小 | Multipart 解析、字段来源、异常处理 |
| 校验 | 只校验后缀或 Content-Type | 白名单、Magic Number、文件结构检测 |
| 存储 | 原文件名落盘、目录可执行 | 随机文件名、路径拼接、安全目录 |
| 访问 | 公开直链、越权下载 | 鉴权、引用关系、访问控制 |
代码审计切入点
实际审计时,不建议直接全局搜索“upload”就结束。可以结合路由、控制器、文件处理库、对象存储 SDK、下载接口一起看。
常用检索关键字包括:
upload
multipart
MultipartFile
getOriginalFilename
transferTo
move_uploaded_file
FileOutputStream
Files.copy
Content-Type
attachment
oss
cos
minio
download以 Java Web 项目为例,看到类似代码时需要重点关注:
String fileName = file.getOriginalFilename();
String ext = fileName.substring(fileName.lastIndexOf("."));
if (!allowExt.contains(ext)) {
throw new RuntimeException("invalid file type");
}
File dest = new File(uploadDir + "/" + fileName);
file.transferTo(dest);这段代码的问题比较典型:
- 使用原始文件名,可能存在重名覆盖、特殊字符、路径拼接风险。
- 只通过后缀判断类型,无法保证文件内容真实可信。
- 上传目录如果被 Web 容器直接解析,风险会进一步放大。
- 没有看到权限、大小、内容检测、异常清理等逻辑。
验证思路:重点看风险是否可达
在授权测试环境里,验证文件上传问题时,不建议只停留在“能上传一个异常后缀文件”。真正需要确认的是风险路径是否闭环。
- 异常文件是否能成功保存。
- 保存位置是否在 Web 可访问目录下。
- 访问时是下载还是被服务器解析。
- 文件 URL 是否可被未授权访问。
- 不同业务用户之间是否能访问彼此文件。
- 上传失败后是否留下临时文件。
可以使用无害的文本内容做验证,例如上传一个包含固定字符串的测试文件,确认系统是否按预期拒绝、重命名、隔离或下载。
SECURITY_UPLOAD_TEST_2026
This is a harmless test file for authorized validation.如果系统声明只允许图片,可以准备一张正常图片,并检查服务端是否真正解析图片结构,而不是只依赖文件名和请求头。对于图片类文件,建议后端读取并重新编码输出,避免把原始内容直接保存为可访问资源。
更可靠的校验方式
上传校验建议采用多层白名单,而不是单点判断。
- 后缀白名单:只允许明确业务需要的类型。
- MIME 类型检测:作为辅助判断,不作为唯一依据。
- Magic Number 检测:识别常见文件头。
- 文件结构解析:图片使用图片库读取,PDF、Office 文档使用成熟库做基础解析。
- 文件大小限制:按业务场景设置上限,并限制压缩包解压行为。
- 文件名重写:服务端生成随机文件名,不信任用户传入的名称。
示例:服务端生成安全文件名的思路。
String originalName = file.getOriginalFilename();
String ext = getSafeExtension(originalName);
if (!ALLOWED_EXTENSIONS.contains(ext)) {
throw new IllegalArgumentException("unsupported file type");
}
String safeName = UUID.randomUUID().toString().replace("-", "") + ext;
Path target = Paths.get(uploadBaseDir).resolve(safeName).normalize();
if (!target.startsWith(Paths.get(uploadBaseDir))) {
throw new SecurityException("invalid path");
}
Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);这里有两个关键点:一是文件名完全由服务端生成;二是路径 normalize 后要确认仍在预期目录内,避免路径拼接带来的边界问题。
存储与访问控制
很多上传问题最后变成安全事件,并不是因为上传本身,而是因为上传后的访问控制做得太粗糙。
推荐做法:
- 上传目录不要放在 Web 根目录下。
- 静态资源服务器禁用脚本解析,只按下载内容处理。
- 对象存储默认私有,访问时通过后端鉴权后生成短期有效 URL。
- 文件记录与业务对象绑定,例如工单 ID、用户 ID、租户 ID。
- 下载接口必须校验当前用户是否有权访问对应业务对象。
- 对敏感文件增加审计日志,包括上传者、下载者、时间、业务对象。
一个常见的下载接口反例是只根据 fileId 查询文件路径,然后直接返回:
GET /api/file/download?id=12345如果后端没有校验 fileId 与当前用户、当前租户或当前业务记录的关系,就容易出现越权下载。正确做法应该是把文件访问纳入业务权限模型,而不是单独把文件当作公共资源。
Nginx 层面的基础加固
如果业务需要通过 Nginx 提供附件下载,应确保上传目录不会执行脚本。以下配置仅作为思路示例,实际路径和策略需结合环境调整。
location /uploads/ {
alias /data/app/uploads/;
autoindex off;
default_type application/octet-stream;
add_header X-Content-Type-Options nosniff;
location ~* \.(php|jsp|jspx|asp|aspx|sh|pl|py)$ {
return 403;
}
}如果是图片展示场景,也建议加上 nosniff,减少浏览器根据内容嗅探带来的额外风险。
日志与告警
上传接口非常适合做安全审计日志,因为异常行为通常比较明显。
- 记录上传文件大小、后缀、检测结果、业务 ID、用户 ID。
- 对连续上传失败、异常后缀、超大文件、压缩包异常结构做告警。
- 记录文件访问日志,尤其是跨租户、批量下载、短时间高频下载。
- 上传失败时注意清理临时文件,避免磁盘被打满。
日志不要记录文件完整内容,也不要把敏感文件名、身份证号等直接打到普通日志里。可以记录哈希、文件 ID 和风险标签。
代码审计清单
做文件上传相关审计时,我通常会按下面清单过一遍:
- 是否所有上传入口都走统一组件。
- 是否存在绕过统一组件的历史接口。
- 是否只依赖前端限制。
- 是否只判断 Content-Type 或后缀。
- 是否使用用户原始文件名落盘。
- 是否存在路径拼接和目录穿越风险。
- 上传目录是否可被 Web 容器解析。
- 对象存储 Bucket 是否公开。
- 文件下载是否校验业务权限。
- 是否限制文件大小和上传频率。
- 压缩包是否有解压逻辑,是否限制解压层级、总大小和文件数量。
- 失败分支是否清理临时文件。
- 是否有上传与下载审计日志。
修复优先级建议
如果系统已经上线,建议按风险可达性排序,而不是一次性大改所有逻辑。
- 第一优先级:上传目录可执行、公开访问、权限缺失的问题。
- 第二优先级:文件类型校验薄弱、原文件名落盘、路径拼接问题。
- 第三优先级:日志、告警、频率限制、异常文件清理。
- 第四优先级:历史文件迁移、存储架构调整、统一文件服务改造。
修复时要注意兼容历史数据。例如原来公开访问的附件改为私有访问后,前端展示、富文本图片、移动端缓存都可能受影响,需要先梳理业务引用关系。
总结
文件上传安全不是单个 if 判断能解决的问题。比较稳妥的做法是把上传当成一条完整链路:入口统一、类型严格、文件名重写、目录隔离、访问鉴权、日志可追溯。
从代码审计角度看,最有价值的不是找到一个“能上传异常文件”的点,而是判断它能否被访问、能否被解析、是否越权、是否影响真实业务边界。只有把这些问题串起来,风险评级和修复建议才会比较准确,也更容易被研发团队接受和落地。
推荐意见