跳转到帖子

背景

文件上传一直是 Web 安全里比较“老”的问题,但在真实环境中它仍然反复出现。原因并不复杂:业务上确实需要上传头像、附件、合同、图片、压缩包;开发侧又容易把重点放在功能可用性上,而忽略文件类型识别、存储隔离、访问控制和后续处理链路。

这篇文章不讨论攻击利用细节,也不提供任何恶意控制手段,重点放在一次常见上传风险的复盘方法:如何在授权测试或内部自查中确认风险、定位问题代码、给出可落地的修复方案,并避免修复只停留在“限制后缀名”这种表层。

典型场景

一个业务系统提供附件上传功能,后端使用 Java Web 框架接收 multipart/form-data 请求,上传后的文件会被保存到服务器本地目录,并通过静态资源路径直接访问。

表面上看,系统已经做了限制:

  • 前端限制只能选择 jpg、png、pdf、docx 等格式;
  • 后端检查文件名后缀;
  • 上传目录不允许目录遍历;
  • 文件名会被重命名为 UUID。

但复盘时发现,风险并不一定来自“文件名能不能控制”,而是来自几个组合问题:后缀判断不严谨、内容类型信任客户端、上传目录位于 Web 可访问路径、应用服务器或中间件对特定扩展名存在解析行为。

问题分析

文件上传风险通常不是单点问题,而是上传、存储、访问、解析、二次处理这几段链路共同决定的。

1. 只检查后缀名是不够的

很多代码会写成下面这种形式:

String filename = file.getOriginalFilename();
String ext = filename.substring(filename.lastIndexOf(".") + 1);
if (!allowExt.contains(ext)) {
    throw new RuntimeException("invalid file type");
}

这类检查至少有几个问题:

  • 没有统一大小写,例如 JPG、Png 是否处理一致;
  • 没有处理空扩展名、隐藏文件、多点文件名等边界;
  • 没有校验真实文件内容;
  • allowExt 如果来自配置,可能被误配为过宽;
  • 后续存储或访问环节仍可能改变文件语义。

后缀名适合作为第一层过滤,但不应该作为唯一判断依据。实际加固时,建议同时校验扩展名、MIME 类型、文件魔数,并根据业务场景做更严格的白名单。

2. Content-Type 不能直接信任

上传请求里的 Content-Type 由客户端提交,不能作为安全判断依据。比如某些实现会这样处理:

String contentType = file.getContentType();
if (!contentType.startsWith("image/")) {
    throw new RuntimeException("only image allowed");
}

这只能作为辅助信息。服务端应自行读取文件头部特征,必要时使用成熟库做识别,例如 Apache Tika、ImageIO、libmagic 等。对于图片类文件,还可以进一步尝试解码并重新编码,降低夹带异常内容的概率。

3. 上传目录不应位于 Web 根目录

很多历史系统会把上传文件直接放在类似路径下:

/var/www/html/upload/
/opt/tomcat/webapps/ROOT/upload/

这样做的结果是,上传成功后文件可以被 Web 服务器直接访问。即使当前只允许图片和文档,也会扩大风险面:一旦过滤逻辑存在缺陷,文件可能被错误解析;或者将来中间件配置变化后引入新风险。

更稳妥的做法是:

  • 上传文件保存到 Web 根目录之外;
  • 通过后端下载接口读取并返回文件;
  • 下载接口做权限校验和文件归属校验;
  • 响应头设置为下载或安全的展示类型;
  • 避免让应用服务器直接解析上传目录内容。

关键排查步骤

在授权范围内做上传风险自查时,我一般按下面顺序检查,效率比较高,也方便形成整改闭环。

1. 梳理上传入口

先不要急着测试某一个按钮,建议从路由、接口文档、网关日志、代码搜索几个角度梳理全部上传点。常见关键词包括:

MultipartFile
CommonsMultipartFile
multipart/form-data
upload
attachment
file
avatar
import
FileOutputStream
transferTo

很多风险点不在明显的“上传附件”功能,而是在头像、富文本编辑器、Excel 导入、工单附件、消息图片、批量导入模板这些边缘入口。

2. 看服务端校验逻辑

重点看四类判断:

  • 扩展名白名单是否严格;
  • 是否校验文件大小;
  • 是否识别真实文件类型;
  • 是否对不同业务类型采用不同策略。

一个相对合理的后端校验示例:

private static final Set<String> ALLOWED_EXT = Set.of("jpg", "jpeg", "png", "pdf");
private static final Set<String> ALLOWED_MIME = Set.of("image/jpeg", "image/png", "application/pdf");

String originalName = file.getOriginalFilename();
String ext = getSafeExtension(originalName).toLowerCase(Locale.ROOT);
if (!ALLOWED_EXT.contains(ext)) {
    throw new IllegalArgumentException("unsupported extension");
}

String detectedMime = tika.detect(file.getInputStream());
if (!ALLOWED_MIME.contains(detectedMime)) {
    throw new IllegalArgumentException("unsupported content type");
}

if (file.getSize() <= 0 || file.getSize() > 10 * 1024 * 1024) {
    throw new IllegalArgumentException("invalid file size");
}

注意:检测流读取后要考虑 InputStream 是否还能再次读取,实际工程里可以先保存到临时文件,再做检测和搬运。

3. 检查文件存储路径

需要确认上传文件最终保存在哪里,而不是只看代码里写的相对路径。配置中心、环境变量、容器挂载目录都可能改变实际路径。

# 示例:排查运行目录和上传目录
ps -ef | grep java
ls -lah /data/app/uploads
find /data/app/uploads -maxdepth 2 -type f | head

如果上传目录在 Nginx root、Tomcat webapps、Spring Boot static、resources/public 等可直接访问路径内,需要优先整改。

4. 检查静态资源映射

Spring MVC 或网关层可能配置过静态资源映射:

@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
    registry.addResourceHandler("/files/**")
            .addResourceLocations("file:/data/app/uploads/");
}

这类配置不一定有问题,但要确认:

  • 是否所有上传文件都能匿名访问;
  • 是否做了鉴权;
  • 是否允许目录列表;
  • 响应头是否合理;
  • 是否存在可被浏览器直接执行或渲染的类型。

更推荐通过受控下载接口返回文件,而不是直接映射整个目录。

5. 检查二次处理链路

上传后的文件可能会被异步任务处理,例如图片压缩、Office 转 PDF、压缩包解压、病毒扫描、OCR 识别等。这些处理链路也可能引入风险。

重点关注:

  • 解压是否限制总大小、文件数量、递归层级;
  • 是否处理 Zip Slip 这类路径穿越问题;
  • 第三方转换工具是否定期更新;
  • 处理进程是否有最小权限;
  • 异常文件是否被隔离,而不是继续进入业务流程。

压缩包解压时,应对条目路径做规范化校验:

Path targetDir = Paths.get("/data/app/extract").toRealPath();
Path targetFile = targetDir.resolve(entry.getName()).normalize();

if (!targetFile.startsWith(targetDir)) {
    throw new SecurityException("invalid zip entry path");
}

加固建议

比较稳妥的文件上传防护,不是依赖某一个检查点,而是多层约束。

1. 使用严格白名单

  • 按业务场景分别配置允许类型,不要全局放宽;
  • 扩展名统一转小写后判断;
  • 不允许未知类型、双重用途类型;
  • 文档、图片、压缩包分开处理。

2. 服务端重新生成文件名

不要信任用户上传的原始文件名作为存储名。可以保留原文件名用于展示,但存储文件名应由服务端生成。

String safeName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
Path savePath = uploadDir.resolve(safeName).normalize();
if (!savePath.startsWith(uploadDir)) {
    throw new SecurityException("invalid path");
}
file.transferTo(savePath.toFile());

3. 上传目录与执行环境隔离

  • 上传目录放到 Web 根目录之外;
  • 目录权限只允许应用写入必要范围;
  • 禁止脚本执行和服务端解析;
  • 对象存储建议使用私有桶,通过签名或后端代理访问;
  • 不同业务、不同租户尽量做路径隔离。

4. 下载接口做权限校验

不要只靠文件 URL 的复杂度来保护资源。下载接口至少应校验当前用户是否有权访问该文件,并避免任意文件读取。

GET /api/files/{fileId}/download

后端根据 fileId 查询数据库中的存储路径、所属用户、业务单据,再决定是否返回文件,而不是让用户直接传入路径。

5. 设置安全响应头

对于下载类响应,可以根据业务设置:

Content-Disposition: attachment; filename="example.pdf"
X-Content-Type-Options: nosniff
Content-Security-Policy: sandbox

如果文件必须在线预览,也要限制可预览类型,并确认浏览器不会把未知内容当作脚本或 HTML 执行。

代码审计关注点

审计点常见问题建议
后缀判断黑名单、大小写绕过、空扩展名使用严格白名单
MIME 判断直接信任 Content-Type服务端识别真实类型
存储路径位于 Web 根目录移出可访问目录
文件名处理使用原始文件名服务端生成随机名
访问控制知道 URL 即可访问通过下载接口鉴权
二次处理解压、转换无资源限制限制大小、数量、层级和权限

日志与监控

上传功能建议记录必要的安全日志,方便后续排查:

  • 上传用户 ID、业务 ID;
  • 原始文件名和服务端存储名;
  • 检测到的 MIME 类型;
  • 文件大小、哈希;
  • 校验失败原因;
  • 下载访问记录。

这里要注意隐私和合规,不建议把文件内容写入日志。记录哈希、元信息和处理结果通常就够了。

常见误区

  • 只做前端限制:前端限制只能改善体验,不能作为安全边界;
  • 只做黑名单:黑名单很容易漏掉边界类型和环境差异;
  • 上传成功后直接返回真实路径:容易暴露目录结构;
  • 对象存储桶设为公开读:后期权限收敛成本很高;
  • 忽略异步处理组件:转换工具和解压逻辑经常成为薄弱点。

总结

文件上传的核心思路可以概括为三点:入口严格校验、存储隔离、访问受控。不要把安全性押在文件后缀或前端限制上,也不要让上传目录直接暴露在 Web 解析路径下。

实际整改时,建议先完成上传入口盘点,再按“类型校验、路径隔离、权限控制、二次处理、日志监控”逐项收敛。这样做虽然比简单加一个后缀判断麻烦一些,但长期看维护成本更低,也更能抵抗环境变化带来的风险。

安全验证应在授权范围内进行。对生产系统做测试时,建议优先使用测试环境和无害样本,并提前约定窗口期、回滚方案和日志留存方式。
代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

游客
抱歉,你的帖子内容包括我们不允许的字词。请编辑你的帖子,删除下面高亮的屏蔽字。
添加意见…