背景
文件上传是 Web 系统里很常见的功能,头像、附件、工单截图、导入文件都离不开它。也正因为入口多、业务差异大,上传点经常成为安全问题的高发位置。
在实际代码审计中,文件上传漏洞不一定表现为直接上传 WebShell。更多时候,它会和路径穿越、解析差异、对象存储权限、预览服务、异步处理队列等问题组合出现。单看一个上传接口可能没什么问题,但放到完整业务链路里就能发现风险。
这篇文章整理一次比较典型的审计思路,重点放在如何发现问题、如何判断风险、如何做工程化修复。内容只讨论防护和验证思路,不涉及恶意利用操作。
常见场景
一个常见的上传流程大致如下:
- 前端限制文件类型和大小;
- 后端接收 multipart 文件;
- 根据原始文件名生成存储路径;
- 保存到本地目录或对象存储;
- 写入数据库记录;
- 提供下载、预览或回显 URL。
这里面每一步都有可能出现问题。审计时不要只看“是否限制后缀”,而要关注完整生命周期:文件从哪里来、被怎么命名、保存到哪里、谁可以访问、后续会不会被解析或处理。
问题一:只依赖前端限制
很多系统会在前端写 accept 属性,或者用 JavaScript 判断文件后缀:
<input type="file" accept=".jpg,.png,.pdf">这类限制只能提升用户体验,不能作为安全边界。真正的校验必须放在服务端。审计时如果看到后端没有类型、大小、内容校验,仅依赖前端限制,基本可以直接标记为风险点。
后端至少应做这些检查:
- 限制文件大小,避免大文件压垮服务;
- 使用服务端白名单判断允许类型;
- 不要信任客户端传入的 Content-Type;
- 不要直接使用原始文件名作为最终存储名;
- 上传目录不应具备脚本执行能力。
问题二:后缀校验不严谨
常见的错误校验方式是使用 contains 或简单字符串匹配,例如:
if (filename.contains(".jpg") || filename.contains(".png")) {
save(file);
}这种写法容易被异常文件名干扰。正确方式应该是取最终扩展名,统一小写后与白名单做精确匹配,同时结合文件内容特征判断。
String ext = FilenameUtils.getExtension(originalName).toLowerCase(Locale.ROOT);
Set<String> allow = Set.of("jpg", "jpeg", "png", "pdf");
if (!allow.contains(ext)) {
throw new IllegalArgumentException("unsupported file type");
}但需要注意,后缀白名单也不是万能的。对于图片、PDF、Office 文档等复杂格式,还应考虑解析库漏洞、宏、嵌入脚本、压缩炸弹等问题。不同业务允许的文件类型越少,防护成本越低。
问题三:信任 MIME 类型
不少代码会直接读取上传请求中的 Content-Type:
String type = multipartFile.getContentType();
if (type.startsWith("image/")) {
save(file);
}这个值来自客户端,不可信。更稳妥的做法是结合魔数、文件解析库和业务白名单进行判断。以 Java 为例,可以引入 Apache Tika 做辅助识别,但不要把识别结果当作唯一依据。
Tika tika = new Tika();
String detected = tika.detect(multipartFile.getInputStream());
if (!Set.of("image/png", "image/jpeg", "application/pdf").contains(detected)) {
throw new IllegalArgumentException("invalid file content");
}实际工程中建议采用“扩展名白名单 + 内容识别 + 解析重编码”的组合。尤其是图片上传场景,可以用安全图像库重新解码再编码,剥离异常元数据和附加内容。
问题四:原始文件名带来的路径风险
很多上传漏洞不是类型校验出问题,而是文件路径拼接出问题。例如:
Path target = Paths.get(uploadDir + "/" + originalFilename);
file.transferTo(target.toFile());这里如果没有做路径规范化和文件名清洗,可能引入目录穿越、覆盖已有文件、特殊字符污染日志等问题。稳妥做法是完全丢弃用户提供的原始文件名,只将其作为展示字段保存,并对展示字段做长度和字符集限制。
String safeExt = getAllowedExtension(originalFilename);
String storedName = UUID.randomUUID() + "." + safeExt;
Path base = Paths.get(uploadDir).toAbsolutePath().normalize();
Path target = base.resolve(storedName).normalize();
if (!target.startsWith(base)) {
throw new SecurityException("invalid path");
}
Files.copy(inputStream, target, StandardCopyOption.REPLACE_EXISTING);这里有几个关键点:
- 存储名由服务端生成,不使用原始文件名;
- 路径使用 normalize 处理后再校验;
- 校验 target 是否仍位于预期目录内;
- 避免覆盖业务关键文件;
- 原始文件名只用于展示,并做 HTML 转义。
问题五:上传目录可执行
如果上传目录位于 Web 根目录下,并且服务器会解析其中的脚本文件,风险会明显放大。即便后缀限制看起来没问题,也可能因为中间件解析差异、错误配置、历史兼容规则等问题产生隐患。
更推荐的架构是:
- 上传文件存储在 Web 根目录之外;
- 下载通过受控接口读取文件流;
- 对象存储使用私有桶,访问通过临时签名 URL;
- 静态资源域名与主业务域名隔离;
- 上传目录禁止脚本执行和目录浏览。
Nginx 场景下,可以明确阻止上传目录中的脚本解析:
location /uploads/ {
autoindex off;
add_header X-Content-Type-Options nosniff;
}
location ~* ^/uploads/.*\.(php|jsp|jspx|asp|aspx)$ {
deny all;
}如果业务允许直接访问上传文件,还应确保响应头正确,避免浏览器错误嗅探内容类型:
X-Content-Type-Options: nosniff
Content-Disposition: attachment对于不需要在线预览的文件,优先使用 attachment 下载方式,减少浏览器解析带来的风险。
问题六:对象存储权限配置不当
现在很多系统会把上传文件放到 S3、OSS、COS 这类对象存储。代码本身可能没有明显漏洞,但权限策略配置错误也会造成数据暴露。
审计对象存储时,我通常会检查这些点:
- Bucket 是否公开读写;
- 是否允许匿名列目录;
- 上传后的对象 ACL 是否被客户端控制;
- 临时签名 URL 有效期是否过长;
- 不同租户文件是否通过权限隔离;
- 删除文件时数据库和对象存储是否一致。
服务端生成上传策略时,不应把过宽的权限下发给客户端。比如只允许上传到指定前缀、限制大小、限制 Content-Type,并设置较短有效期。
{
"conditions": [
["starts-with", "$key", "tenant-a/uploads/"],
["content-length-range", 1, 10485760],
["starts-with", "$Content-Type", "image/"]
],
"expiration": "2026-01-01T00:00:00Z"
}上面的策略只是示意。实际使用时,前缀必须和当前登录用户或租户绑定,不能让客户端自由传入。
技术分析:审计上传功能的检查清单
代码审计时可以按下面顺序排查,效率会高一些。
| 检查项 | 关注点 | 常见风险 |
|---|---|---|
| 入口定位 | Controller、路由、接口文档 | 遗漏隐藏上传接口 |
| 鉴权校验 | 是否登录、是否校验租户和角色 | 未授权上传、越权访问文件 |
| 大小限制 | 框架限制、业务限制、网关限制 | 拒绝服务、磁盘打满 |
| 类型校验 | 后缀、MIME、魔数、解析结果 | 伪造类型、危险文件混入 |
| 文件名处理 | 是否使用原始文件名 | 路径穿越、覆盖文件、XSS |
| 存储位置 | 本地目录、对象存储、共享盘 | 可执行、公开访问、权限过宽 |
| 访问方式 | 下载接口、预览接口、直链 | 越权下载、内容嗅探 |
| 后处理 | 图片压缩、文档转换、解压 | 解析库漏洞、压缩炸弹 |
| 日志审计 | 上传记录、异常告警 | 出事后无法溯源 |
关键步骤:如何做一次安全验证
在授权测试或内部自查时,建议验证重点放在防护是否生效,而不是追求破坏性结果。可以按以下步骤进行:
- 确认上传接口是否需要认证;
- 使用超出大小限制的测试文件,观察服务端是否拒绝;
- 上传不在白名单内的普通文本文件,确认是否被拦截;
- 修改 Content-Type 后再次上传,确认服务端是否仍按内容校验;
- 使用包含特殊字符的文件名,观察路径和展示是否安全;
- 检查上传后的 URL 是否可被未授权用户访问;
- 确认响应头是否包含 nosniff 和合理的 Content-Disposition;
- 检查上传目录是否禁止脚本解析和目录浏览;
- 检查对象存储桶策略是否最小权限。
这里不需要构造危险载荷。大多数情况下,用普通文本、空文件、超大文件、错误扩展名、异常文件名就能验证安全控制是否可靠。
修复建议
比较稳妥的修复方案可以概括为“入口收紧、存储隔离、访问受控、处理降权”。
- 服务端强制白名单,不依赖前端限制;
- 限制文件大小,网关、框架、业务层都要配置;
- 服务端生成随机文件名,原始文件名只做展示;
- 上传目录放在 Web 根目录之外;
- 下载和预览通过鉴权接口完成;
- 对象存储默认私有,使用短期签名访问;
- 图片类文件尽量重新编码;
- 文档预览服务独立部署,使用低权限账户和沙箱;
- 对上传、下载、删除行为记录审计日志;
- 异常上传频率要接入告警。
Spring Boot 中可以同时设置 multipart 限制,避免大文件直接进入业务逻辑:
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MBNginx 层也建议配置请求体大小:
server {
client_max_body_size 10m;
}注意,这些配置只能解决大小限制问题,不能替代业务层的类型校验和权限控制。
容易忽略的细节
- 删除文件时要校验文件归属,不能只根据文件 ID 删除;
- 下载接口不要直接接收文件路径参数;
- 预览转换后的文件也要做访问控制;
- 异步扫描失败时不要默认放行;
- 上传失败的临时文件要定期清理;
- 多租户系统要避免通过可预测路径枚举文件;
- 日志中记录原始文件名时要防止换行和控制字符污染;
- 如果接入杀毒或内容检测,要考虑超时、失败、重试策略。
上传安全不是一个 if 判断能解决的问题。真正可靠的方案,需要把文件类型、存储位置、访问控制、后处理链路一起纳入设计。
总结
文件上传漏洞之所以常见,是因为它横跨前端、后端、中间件、存储和运维配置。代码里看似只有几行保存文件的逻辑,背后却涉及完整的安全边界。
做代码审计时,不建议只盯着危险后缀。更有效的方法是沿着文件生命周期逐步追踪:入口是否受控,类型是否可信,路径是否安全,存储是否隔离,访问是否鉴权,后处理是否降权。
如果系统还处于设计阶段,尽量把上传能力做成统一组件。统一校验、统一命名、统一存储、统一审计,比每个业务模块自己实现一套要可靠得多,也更容易长期维护。
推荐意见