跳转到帖子

背景

文件上传是 Web 安全里非常常见的一类风险点,但很多项目里的问题并不只是“没校验后缀”这么简单。实际审计中更常见的是:业务层做了一部分限制,框架层又做了一部分处理,最后在存储、访问、解析链路上出现偏差,导致风险被放大。

这篇文章整理一个偏通用的分析思路:如何从代码审计角度定位上传功能风险,如何在本地环境做安全复现,以及如何给出可落地的修复建议。内容偏防御和验证,不涉及对第三方系统的攻击操作。

典型场景

假设一个业务系统提供头像、附件、富文本图片等上传能力。后端大致流程如下:

  • 前端限制文件类型,例如只允许 jpg、png、pdf。
  • 后端接收 multipart/form-data 请求。
  • 后端根据文件后缀或 Content-Type 做校验。
  • 文件保存到本地目录或对象存储。
  • 返回文件访问 URL,供前端展示或下载。

看起来流程完整,但真实风险往往出现在以下几个点:

  • 只依赖前端校验,后端未做强校验。
  • 只检查文件名后缀,未检查文件真实类型。
  • 上传目录位于 Web 可执行路径下。
  • 文件名由用户可控,存在路径穿越或覆盖风险。
  • 下载接口缺少鉴权,导致越权访问附件。
  • 图片处理库、解压缩逻辑、文档解析组件引入二次风险。

代码审计时的关注点

审计上传功能时,不建议只搜索 upload 关键字。实际项目里接口命名可能是 attachment、file、media、resource、import、avatar、material 等。可以从路由、Controller、Service、存储工具类三个层面去看。

1. 接口入口

先确认哪些接口接收文件对象,例如 Java Spring 项目中常见的 MultipartFile:

@PostMapping("/upload")
public Result upload(@RequestParam("file") MultipartFile file) {
    return fileService.save(file);
}

这里要继续跟进 fileService.save 的实现,而不是停留在 Controller。重点看以下问题:

  • 是否判断空文件和文件大小。
  • 是否限制业务允许的类型。
  • 校验逻辑是白名单还是黑名单。
  • 是否直接使用原始文件名。
  • 是否将文件写入 Web 根目录。

2. 文件名处理

很多漏洞都和原始文件名有关。常见危险写法如下:

String filename = file.getOriginalFilename();
File dest = new File(uploadDir + "/" + filename);
file.transferTo(dest);

这类代码至少有几个问题:路径拼接不安全、文件名未规范化、可能覆盖已有文件、可能被特殊字符影响日志或后续处理。更稳妥的方式是服务端生成文件名,并保留原始文件名仅用于展示。

String ext = getSafeExt(file);
String storedName = UUID.randomUUID().toString().replace("-", "") + ext;
Path target = Paths.get(uploadDir).resolve(storedName).normalize();
if (!target.startsWith(Paths.get(uploadDir).normalize())) {
    throw new IllegalArgumentException("invalid path");
}
Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);

3. 类型校验

上传类型校验尽量采用“多层校验”,不要只信任 Content-Type 或文件后缀。一个相对稳妥的策略是:

  • 按业务定义允许的扩展名白名单。
  • 检查 MIME 类型,但不单独依赖。
  • 检查文件头魔数。
  • 对图片类文件进行解码重写,去除异常内容。
  • 对压缩包、Office、PDF 等复杂格式做更严格限制。

例如图片上传场景,可以使用图片库实际读取并重新编码,而不是原样保存:

BufferedImage image = ImageIO.read(file.getInputStream());
if (image == null) {
    throw new IllegalArgumentException("not an image");
}
ImageIO.write(image, "png", targetFile);

注意:图片重编码不是万能方案,但它能有效降低“伪装图片”或异常元数据带来的风险。对于 SVG 这类文本型图片,建议默认禁用,确有需求时必须做标签和事件属性清洗。

4. 存储位置与访问方式

上传文件不应放在应用可执行目录下,也不应让脚本文件具备执行机会。推荐策略:

  • 上传目录与应用部署目录分离。
  • 静态资源服务只做下载,不做脚本解析。
  • 对象存储使用私有桶,通过后端签名或鉴权代理访问。
  • 敏感附件访问必须绑定业务权限。

Nginx 场景下,如果必须提供本地文件访问,可以单独配置静态目录,并禁止可疑类型被执行:

location /uploads/ {
    alias /data/app/uploads/;
    autoindex off;
    default_type application/octet-stream;
    add_header X-Content-Type-Options nosniff;
}

如果历史包袱较重,上传目录曾经混放过各种类型文件,建议先做一次存量扫描和迁移,再调整访问策略,避免修复后影响业务。

安全复现的基本方法

复现建议只在本地靶场、测试环境或授权环境进行。复现目标不是“拿权限”,而是确认校验链路是否存在缺陷,并为修复提供证据。

1. 准备本地环境

可以搭一个最小上传服务,观察不同校验方式的行为差异。例如使用 curl 上传测试文件:

curl -i -X POST  \
  -F "[email protected];type=image/png"

然后分别观察后端记录:

  • 服务端获取到的原始文件名。
  • Content-Type 是否来自客户端。
  • 最终保存路径。
  • 返回 URL 是否可直接访问。
  • 访问该文件时响应头是否符合预期。

2. 构造边界用例

建议准备一些合法但容易触发边界问题的测试样本,例如:

  • 超大文件,验证大小限制和内存占用。
  • 无扩展名文件,验证默认处理逻辑。
  • 双扩展名文件,验证后缀解析方式。
  • 包含空格、中文、特殊字符的文件名,验证路径和日志处理。
  • 损坏图片,验证图片解码异常处理。
  • 压缩包内多层目录,验证解压路径控制。

对于压缩包导入功能,要特别注意 Zip Slip 类问题。解压时不能直接信任条目名称:

Path destDir = Paths.get("/data/import").normalize();
for (ZipEntry entry : entries) {
    Path out = destDir.resolve(entry.getName()).normalize();
    if (!out.startsWith(destDir)) {
        throw new SecurityException("invalid zip entry");
    }
    // 继续写入文件
}

3. 验证访问控制

文件上传后的访问控制经常被忽略。很多系统上传时需要登录,但下载时只要知道 URL 就能访问。审计时要确认:

  • 附件 URL 是否包含可枚举 ID。
  • 下载接口是否校验当前用户是否有权限访问该业务对象。
  • 临时文件是否会长期保留。
  • 删除业务数据后附件是否仍可访问。

一个常见的安全设计是:文件表只保存存储 key,不直接暴露真实路径;下载时通过业务 ID 查询权限,再由后端读取文件流返回。

@GetMapping("/file/{id}")
public void download(@PathVariable Long id, HttpServletResponse response) {
    FileMeta meta = fileService.findById(id);
    permissionService.checkReadable(meta.getBizId(), currentUser());
    fileStorage.writeToResponse(meta.getStorageKey(), response);
}

常见误区

  • 只做黑名单:例如禁止 jsp、php、exe,但遗漏大小写、变体后缀或服务端解析差异。
  • 信任 Content-Type:该字段可由客户端控制,只能作为辅助信息。
  • 认为对象存储一定安全:如果桶权限、回源策略、签名 URL 过期时间配置不当,仍然会造成泄露。
  • 忽略下载鉴权:上传接口安全不代表文件访问安全。
  • 忽略二次处理:图片压缩、文档预览、压缩包解压、Excel 导入都可能引入新攻击面。

修复建议清单

风险点建议
文件类型校验不足使用扩展名白名单、MIME 辅助校验、魔数校验,必要时重编码
文件名可控服务端生成随机文件名,原始文件名仅展示使用
路径拼接不安全使用 Path resolve 和 normalize,并校验目标路径前缀
上传目录可执行上传目录与应用目录隔离,静态服务禁止脚本解析
附件越权访问下载接口绑定业务权限,不直接暴露真实路径
大文件导致资源消耗限制单文件大小、总请求大小、上传频率和存储配额
复杂格式处理风险限制解析组件能力,及时升级依赖,隔离预览/转换服务

日志与监控

上传功能还需要配合日志和监控。建议记录但不要泄露敏感内容:

  • 上传用户、业务对象、文件大小、校验结果。
  • 存储 key、文件哈希、来源 IP。
  • 失败原因分类,如类型不允许、大小超限、解析失败。
  • 异常频率,如短时间大量失败上传。

文件哈希在排查重复上传、异常样本和事件响应时比较有用。示例:

MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream in = file.getInputStream()) {
    byte[] buf = new byte[8192];
    int len;
    while ((len = in.read(buf)) != -1) {
        digest.update(buf, 0, len);
    }
}
String sha256 = HexFormat.of().formatHex(digest.digest());

总结

文件上传漏洞的核心不只是“能不能上传某个后缀”,而是整个链路是否可信:入口校验、文件名处理、存储隔离、访问控制、二次解析、日志监控都要一起看。

实际审计时,我通常按三个问题推进:第一,用户能控制什么;第二,文件会被放到哪里、以什么方式访问;第三,后续有没有解析、预览、解压、转码等处理。只要把这三条链路理清,大多数上传相关风险都能定位到具体代码和配置上。

建议在项目基线里把上传安全做成统一组件,不要让各业务模块各写一套。上传功能越分散,越容易出现某个接口绕过统一策略的问题。
代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

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