跳转到帖子

背景

文件上传是 Web 系统里很常见的功能,头像、附件、工单截图、导入模板都会用到。它看起来只是一个业务入口,但如果校验、存储、访问控制处理不完整,往往会变成高危漏洞的起点。

这篇文章整理的是一次内部代码审计中的复盘。目标不是讲“如何打穿系统”,而是从安全审计视角拆解一个典型文件上传风险:后端只校验扩展名,上传目录又位于 Web 可访问路径下,最终导致非预期文件被直接访问。文章会重点放在风险识别、代码问题、修复方式和验证方法上。

问题场景

业务系统提供了一个附件上传接口,用于提交工单截图和文档。开发侧认为已经做了安全限制,因为前端限制了文件类型,后端也检查了文件后缀。但审计时发现几个明显问题:

  • 前端校验可被绕过,不能作为安全边界。
  • 后端只通过文件名后缀判断类型,没有校验实际内容。
  • 上传文件保存到了 Web 根目录下的静态资源目录。
  • 文件名使用用户原始文件名,存在覆盖、路径字符处理不严等风险。
  • 上传接口缺少权限边界和大小限制,容易被滥用。

这类问题在老系统和快速迭代项目里很常见。单点看似不严重,组合起来就会变成高风险入口。

风险代码示例

下面是一段经过简化的 Java/Spring 风格代码,保留了问题模式,便于说明审计思路:

String originalName = file.getOriginalFilename();
String suffix = originalName.substring(originalName.lastIndexOf(".") + 1);

List<String> allow = Arrays.asList("jpg", "png", "gif", "pdf", "docx");
if (!allow.contains(suffix.toLowerCase())) {
    throw new RuntimeException("file type not allowed");
}

String uploadDir = request.getServletContext().getRealPath("/upload/");
File dest = new File(uploadDir + originalName);
file.transferTo(dest);

return "/upload/" + originalName;

这段代码的问题比较典型:

  • 信任原始文件名:originalName 来自客户端,不能直接用于文件系统路径。
  • 扩展名校验过弱:攻击者可以构造特殊文件名、多后缀、大小写混淆,或者上传内容与后缀不一致的文件。
  • 存储路径不合理:上传目录在 Web 根路径下,任何上传成功的文件都可能被直接访问。
  • 缺少随机文件名:可能覆盖同名文件,也会暴露业务上传规律。
  • 缺少统一的下载鉴权:返回静态 URL 后,后续访问绕过业务权限控制。

审计时的关键检查点

实际代码审计中,不建议只盯着“能不能上传某种危险后缀”。更稳妥的方法是沿着上传链路完整检查。

1. 文件类型校验是否可信

扩展名只能作为辅助条件,不应该作为唯一判断依据。建议至少同时检查:

  • 扩展名白名单,而不是黑名单。
  • Content-Type 不能单独信任,只能参考。
  • 文件头 Magic Number。
  • 必要时使用成熟库解析文件内容,例如图片解码、PDF 结构解析。

示例:对图片类文件做基础文件头检查时,可以按业务类型建立映射,避免只靠字符串判断。

private static final Map<String, List<String>> MAGIC_MAP = Map.of(
    "jpg", List.of("FFD8FF"),
    "png", List.of("89504E47"),
    "gif", List.of("47494638"),
    "pdf", List.of("25504446")
);

private boolean matchMagic(byte[] header, String ext) {
    String hex = bytesToHex(header).toUpperCase();
    return MAGIC_MAP.getOrDefault(ext, List.of())
            .stream()
            .anyMatch(hex::startsWith);
}

注意,Magic Number 也不是万能的。比如某些格式允许附加数据,单纯文件头匹配仍可能不足。高风险业务应结合内容解析、重编码或异步扫描。

2. 上传目录是否可直接执行或访问

上传文件不应该保存在应用可执行目录,也不建议放在 Web 根目录下。更稳妥的做法是:

  • 保存到 Web 根目录之外。
  • 通过下载接口读取文件并做权限判断。
  • 对象存储场景使用私有桶,下载时生成短期授权 URL。
  • 静态服务层禁止对上传目录执行脚本或解释动态内容。

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|cgi|pl|sh)$ {
        return 403;
    }
}

这个配置不能替代后端校验,但可以作为防御纵深的一层。

3. 文件名是否安全生成

不要使用用户上传的原始文件名作为最终存储名。推荐做法是:原始文件名仅用于展示,真实存储名由服务端生成。

String ext = getSafeExt(originalName);
String safeName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
Path baseDir = Paths.get("/data/app/uploads").toAbsolutePath().normalize();
Path target = baseDir.resolve(safeName).normalize();

if (!target.startsWith(baseDir)) {
    throw new SecurityException("invalid file path");
}

Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);

这里有两个点值得注意:

  • 使用 UUID 或雪花 ID 这类不可预测文件名,避免覆盖和枚举。
  • 路径拼接后必须 normalize,并检查最终路径是否仍在预期目录内,避免路径穿越类问题。

4. 下载链路是否有权限控制

很多系统上传时做了登录校验,但下载使用静态地址,导致“知道 URL 就能访问”。这在工单附件、合同、身份证明材料等场景风险很高。

更合理的方式是建立附件表,下载时根据附件 ID 查询元数据,再结合当前用户身份做权限判断。

GET /api/files/{fileId}/download

校验逻辑:
1. 用户是否登录
2. fileId 是否存在
3. 当前用户是否属于该业务单据的可见范围
4. 文件是否处于可下载状态
5. 通过后读取磁盘文件并返回流

这样即使文件真实路径泄露,也不会直接暴露给外部访问。

5. 大小、数量与频率限制

上传接口还需要考虑资源消耗风险。常见限制包括:

  • 单文件大小限制,例如图片 5MB、文档 20MB。
  • 单个业务对象附件数量限制。
  • 单用户、单 IP 上传频率限制。
  • 服务端临时目录容量监控。
  • 异常上传失败时及时清理临时文件。

Spring Boot 中可以先设置基础限制:

spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=20MB

但配置层限制不等于业务层限制,接口中仍应结合实际业务做二次校验。

推荐修复方案

结合这次审计情况,最终采用了以下加固方案:

  • 后端统一封装上传组件,业务侧不再自行处理 MultipartFile。
  • 使用扩展名白名单,并结合文件头检查。
  • 图片类文件进行重新编码,去除异常附加内容。
  • 文件保存到 Web 根目录之外。
  • 真实文件名使用随机 ID,原始文件名仅记录在数据库中。
  • 下载必须经过业务接口鉴权。
  • 上传和下载操作写入审计日志。
  • 对异常类型、超大文件、高频上传做告警。

一个相对完整的上传处理流程如下:

1. 校验用户登录态和业务权限
2. 校验文件是否为空
3. 校验文件大小
4. 提取并规范化扩展名
5. 校验扩展名白名单
6. 读取文件头并校验 Magic Number
7. 根据类型做内容解析或重编码
8. 生成服务端存储文件名
9. 保存到非 Web 根目录
10. 写入附件元数据表
11. 返回附件 ID,而不是直接返回磁盘路径

验证方法

修复后建议不要只测正常上传,还要覆盖异常场景。可以准备一组测试用例:

测试项预期结果
正常 jpg/png/pdf 上传上传成功,返回附件 ID
空文件拒绝上传
超出大小限制文件拒绝上传并记录日志
后缀不在白名单拒绝上传
后缀与文件头不一致拒绝上传
包含路径字符的文件名不会影响最终存储路径
未授权用户下载附件返回 403 或业务拒绝
猜测附件 URL无法直接访问真实文件

如果团队有自动化安全测试,可以把这些用例加入接口测试或集成测试,避免后续迭代重新引入问题。

日志与监控建议

文件上传类漏洞往往不是一次性出现,很多时候会伴随异常行为。建议记录以下字段:

  • 用户 ID、IP、User-Agent。
  • 业务对象 ID。
  • 原始文件名、最终文件 ID。
  • 文件大小、扩展名、识别出的 MIME 类型。
  • 上传结果和失败原因。
  • 下载访问结果。

需要注意,日志中不要记录完整本地路径,也不要记录敏感业务内容。日志的价值在于追踪行为,而不是复制一份敏感数据。

常见误区

  • 只做前端限制:前端限制主要改善体验,不能承担安全校验。
  • 黑名单过滤危险后缀:黑名单容易遗漏,应该使用白名单。
  • 信任 Content-Type:该字段由客户端提交,不能单独作为依据。
  • 上传后直接返回静态 URL:容易绕过业务权限。
  • 忽略临时文件:上传失败、解析异常时也要清理资源。
  • 把对象存储设为公共读:方便了访问,也扩大了数据泄露面。

总结

文件上传安全的核心不是某一个校验点,而是完整链路的边界控制:入口校验、内容识别、安全存储、鉴权下载、日志审计和资源限制都要覆盖。

从代码审计角度看,遇到上传功能时建议优先问几个问题:文件能不能被直接访问?文件类型如何判定?最终路径是否由用户影响?下载是否经过鉴权?是否有大小和频率限制?这些问题基本能覆盖大部分真实风险。

经验上,上传功能最好做成统一基础组件,而不是每个业务模块各写一套。安全问题往往不是某个开发写错一行代码,而是缺少统一边界和可复用的安全实现。
代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

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