文件上传是代码审计里很常见的入口,问题往往不在“是否允许上传”,而在上传后的处理链路:校验依据是否可靠、文件是否进入可解析目录、访问路径是否可控、后续异步处理是否信任了用户文件。本文整理一次后台上传功能审计中的经验,重点放在风险识别和加固方法,不涉及攻击利用细节。
背景
不少业务后台会提供头像、附件、合同、导入模板等上传能力。研发侧通常会做扩展名限制,例如只允许 jpg、png、pdf、xlsx。但从安全角度看,仅靠扩展名判断并不够,常见风险包括:
- 只校验文件名后缀,未校验文件真实类型。
- 上传目录位于 Web 可解析路径下,导致非预期解析。
- 文件名由用户控制,存在覆盖、路径穿越或特殊字符问题。
- 上传后由图片处理、文档转换、压缩解压等组件继续处理,引入二次风险。
- 权限控制只放在前端页面,接口层缺少鉴权和对象级授权。
这类问题单点看不一定严重,但多个薄弱环节叠加后,往往会变成高风险入口。
审计场景
审计对象是一个常见的 Java Web 后台,上传接口大致逻辑如下:用户登录后选择文件,后端读取 MultipartFile,检查后缀是否在白名单内,然后把文件保存到配置的 uploadPath,最后返回可访问 URL。
简化后的伪代码如下:
String name = file.getOriginalFilename();
String ext = name.substring(name.lastIndexOf('.') + 1);
if (!allowExt.contains(ext.toLowerCase())) {
throw new RuntimeException('invalid file type');
}
File dst = new File(uploadPath + '/' + name);
file.transferTo(dst);
return '/upload/' + name;这段代码的问题不在于“没有写校验”,而是校验边界不完整:文件名、文件内容、存储位置、访问方式都没有被系统性约束。
技术分析
1. 文件名不能作为可信输入
getOriginalFilename 来自客户端请求,不能直接用于服务端存储。它可能包含特殊字符、重复文件名、过长文件名,甚至在不同平台下触发路径处理差异。安全做法是服务端生成随机文件名,并保留原始文件名作为展示字段存入数据库。
String safeName = UUID.randomUUID().toString().replace('-', '') + '.' + normalizedExt;
Path target = uploadRoot.resolve(safeName).normalize();
if (!target.startsWith(uploadRoot)) {
throw new SecurityException('invalid path');
}这里有两个点:一是不要信任原始文件名;二是路径拼接后必须 normalize 并检查是否仍在预期目录内。
2. 后缀校验只能作为第一层
后缀白名单有必要,但不能作为唯一依据。更稳妥的做法是组合校验:扩展名、MIME、文件头特征、实际解析结果。以图片上传为例,建议用服务端图片库尝试读取并重新编码,而不是简单保存原始文件。
BufferedImage image = ImageIO.read(file.getInputStream());
if (image == null) {
throw new RuntimeException('not a valid image');
}
ImageIO.write(image, 'png', target.toFile());重新编码可以去掉一部分非图片数据和异常结构,但也要注意图片处理库自身的安全更新,尤其是历史上 ImageMagick、ExifTool、部分文档转换组件都出现过高危漏洞。
3. 上传目录不要具备脚本解析能力
上传文件应当存放在 Web 根目录之外,业务访问时通过受控下载接口读取。如果确实需要静态访问,也应在 Web 服务器层明确禁止脚本解析,并限制可访问类型。
Nginx 示例配置可以参考:
location /upload/ {
alias /data/app/upload/;
autoindex off;
default_type application/octet-stream;
add_header X-Content-Type-Options nosniff;
}
location ~* ^/upload/.*\.(php|jsp|jspx|asp|aspx|sh|py|pl)$ {
return 403;
}这段配置的核心不是“拦某几个危险后缀”就完事,而是上传目录不应该进入任何动态解释链路。对于 Java 应用,也要确认容器不会把上传目录映射为可执行资源路径。
4. 下载接口也要做授权
很多系统只关注上传接口鉴权,忽略了下载 URL。如果上传文件包含合同、报表、证件等敏感数据,下载接口必须检查当前用户是否有权限访问该对象。不要把“知道 URL”当作访问权限。
FileMeta meta = fileService.getById(fileId);
if (!permissionService.canRead(currentUser, meta.getBizId())) {
throw new AccessDeniedException('forbidden');
}
return streamFile(meta.getStorageKey());建议文件表中保存 storageKey、业务对象 ID、上传人、文件类型、大小、哈希值、创建时间等字段,避免只靠 URL 管理文件。
关键审计步骤
实际审计时,我一般按下面顺序看,效率会比较高:
- 定位所有上传入口:搜索 MultipartFile、CommonsMultipartResolver、transferTo、upload、import、attachment 等关键字。
- 确认鉴权位置:接口是否需要登录,是否校验业务对象权限。
- 检查文件名处理:是否使用原始文件名落盘,是否存在路径拼接、覆盖写入。
- 检查类型校验:是否只有后缀判断,是否存在大小写、空白字符、双后缀等处理不一致问题。
- 检查存储位置:是否位于 Web 根目录,是否可能被容器或网关解析。
- 检查返回路径:是否直接返回可猜测 URL,是否有有效期或访问控制。
- 检查后处理链路:图片压缩、文档预览、解压导入、OCR、杀毒扫描等组件是否安全配置。
- 检查日志与告警:失败原因、异常文件、超大文件、频繁上传是否有记录。
常见问题与修复建议
| 问题 | 风险 | 建议 |
| 直接使用原始文件名 | 覆盖、路径处理异常、信息泄露 | 服务端生成随机存储名,原名仅用于展示 |
| 只校验后缀 | 类型识别不可靠 | 后缀、MIME、文件头、解析结果组合判断 |
| 上传目录可被解析 | 扩大执行面 | 放到 Web 根目录外,或显式禁用动态解析 |
| 下载 URL 无鉴权 | 越权读取敏感文件 | 通过文件 ID 下载,并校验业务权限 |
| 无限制大小和数量 | 磁盘耗尽、服务阻塞 | 限制单文件大小、总量、频率和用户配额 |
| 解压导入未校验 | 目录穿越、压缩炸弹 | 校验解压路径、文件数量、总大小和压缩比 |
服务端加固参考
一个相对稳妥的上传流程可以拆成几层:
- 入口层:校验登录态、接口权限、文件大小、文件数量。
- 类型层:按业务白名单校验扩展名和内容特征。
- 存储层:生成随机 storageKey,保存到非 Web 目录或对象存储私有桶。
- 处理层:必要时重新编码或转换,处理组件运行在低权限环境。
- 访问层:通过下载接口鉴权,设置合理的 Content-Type 和 Content-Disposition。
- 审计层:记录上传人、IP、文件哈希、业务对象、处理结果。
Linux 侧可以检查上传目录权限,避免应用用户之外的进程随意写入:
ls -ld /data/app/upload
find /data/app/upload -type f -perm /111 -ls
find /data/app/upload -type f -size +100M -ls如果发现上传文件带可执行权限,一般应统一去掉:
find /data/app/upload -type f -exec chmod 0640 {} \;
find /data/app/upload -type d -exec chmod 0750 {} \;注意点
- 不要把黑名单当主要策略。黑名单容易漏,白名单加隔离更可靠。
- 不要只在前端限制文件类型。前端限制只能提升体验,不能作为安全边界。
- 不要把对象存储公开桶当默认方案。敏感文件建议私有桶加临时授权或后端代理下载。
- 文档预览服务要单独关注。Office、PDF、图片解析链路经常引入第三方组件风险。
- 日志中不要记录完整敏感文件内容,但应保留足够的审计字段。
总结
文件上传的安全核心是“不要让用户文件进入不受控的执行、解析和访问链路”。单独修一个后缀判断,通常只能解决表面问题。更长期有效的做法是:服务端命名、内容校验、存储隔离、下载鉴权、低权限处理、完整审计。
在代码审计中,如果看到上传接口直接保存原始文件名、上传目录可静态访问、返回 URL 无权限校验,基本就值得继续深挖。即使最终没有形成高危漏洞,这些点也应该作为工程加固项尽早处理。
推荐意见