跳转到帖子

文件上传功能看起来只是“接收文件并保存”,但它同时涉及输入校验、文件系统、Web 服务器解析规则、访问控制和异步处理等多个边界。实际审计中,很多问题并不来自某一行明显危险的代码,而是来自“校验逻辑”和“存储、访问逻辑”之间的不一致。

本文以一个本地测试环境中的文件上传功能为例,整理从定位风险、构造安全复现、分析根因到修复验证的完整方法。示例只上传普通文本文件,不涉及脚本执行、未授权访问或对真实系统的测试。

一、先明确需要回答的几个问题

审计上传功能时,不要一开始只盯着文件后缀。更有效的做法是沿着文件生命周期逐步确认:

  • 请求中的文件名、Content-Type 和文件内容分别由谁控制?
  • 服务端是否只依赖客户端提交的扩展名或 MIME 类型?
  • 文件最终保存在哪里,是否位于 Web 可直接访问的目录?
  • 保存后的文件名是否由用户输入决定?是否可能覆盖已有文件或形成路径穿越?
  • Web 服务器是否会解析该目录中的某些后缀?
  • 下载文件时是否重新校验了权限,还是只要知道 URL 就可以访问?
  • 缩略图、解压、转码等后处理组件是否会再次解析用户文件?

这几个问题基本覆盖了上传漏洞的主要风险面,也能避免审计只停留在“是否限制了 jpg 后缀”这一层。

二、典型的危险实现

下面是一段简化后的伪代码,问题不在于某个框架,而在于它把客户端输入直接用于安全决策和文件路径构造:

String name = multipartFile.getOriginalFilename();
String path = uploadDir + "/" + name;
if (name.endsWith(".jpg") || name.endsWith(".png")) {
    multipartFile.transferTo(new File(path));
}

这段代码至少存在四类隐患:

  • 大小写、尾随空格和多重后缀可能导致校验与实际解析结果不一致。
  • 原始文件名包含路径分隔符时,可能影响最终保存位置。
  • 文件内容没有经过格式验证,扩展名不能证明内容确实是图片。
  • 如果 uploadDir 位于 Web 根目录下,上传后的文件可能被直接访问,严重时还可能被服务器当作脚本解析。

即使应用本身不会执行上传文件,也不能忽略反向代理、应用服务器、静态文件组件和后续处理程序的组合行为。安全结论必须基于完整部署链路,而不是只看控制器代码。

三、审计时的定位路径

在代码审计中,可以从上传入口向下追踪数据流。常见入口包括 MultipartFile、ServletInputStream、Node.js 的 multer、PHP 的 $_FILES,以及经过网关转发的对象存储上传接口。

建议重点搜索以下类型的调用:

  • 获取原始文件名、请求头 MIME 类型或扩展名的代码。
  • File、Path、Files.copy、transferTo、rename、move 等文件写入操作。
  • 静态资源映射、下载接口、对象存储回调和 CDN URL 生成逻辑。
  • ImageIO、ImageMagick、libvips、解压缩库等对用户文件进行二次处理的代码。
  • 根据用户输入拼接目录、租户 ID、业务编号或资源 ID 的逻辑。

追踪时建议记录一张简单的数据流表:输入字段、第一次校验位置、规范化位置、保存位置、访问入口和权限判断位置。很多缺陷都能在这张表中暴露出来,例如保存时做了校验,但异步转码任务又根据原始文件名生成了新路径。

四、在本地环境进行安全复现

复现的目标是证明边界条件和影响范围,而不是追求破坏性结果。可以在本地启动测试服务,准备一个普通文本文件,并观察服务端最终保存的名称、目录和响应内容。

printf 'local upload test\n' > sample.txt
curl -i -X POST  \
  -F '[email protected];filename=sample.txt;type=text/plain'

随后依次测试无害的边界输入:

  • 文件名大小写变化,例如 SAMPLE.JPG。
  • 文件名包含尾随空格或多个点号。
  • 超出业务允许大小的普通文本文件。
  • 内容为文本但扩展名伪装成图片的文件。
  • 包含路径分隔符的文件名,确认服务端是否只使用 basename。
  • 同一文件重复上传,观察是否覆盖已有文件。

测试时应保留服务端日志、响应状态码、数据库记录和实际落盘路径。若应用使用对象存储,还要同时检查对象键名、Content-Type、访问策略和临时 URL 的权限范围。

五、根因分析:为什么只校验扩展名不够

扩展名、Content-Type 和文件内容属于三个不同层次的信息:

信息来源可靠性常见问题
文件扩展名低通常由客户端提供,可随意修改
请求 Content-Type低请求头可以被客户端任意设置
文件签名或格式解析相对较高仍需考虑解析器漏洞和格式边界

因此,安全校验应当是分层的:先限制大小和数量,再对文件名做规范化处理,然后使用服务端生成的随机名称,最后根据业务需要对文件内容进行格式检测。对于图片,还应使用受信任的图片库重新解码并重新编码,避免直接保留复杂的原始元数据。

需要注意的是,文件签名校验也不是万能的。它只能说明文件开头符合某种格式,不能自动保证后续内容安全,也不能解决路径穿越、权限缺失和静态目录解析等问题。

六、推荐的修复方案

一个相对稳妥的上传流程可以拆成以下步骤:

  • 限制单文件大小、请求总大小和并发上传数量。
  • 拒绝空文件,并根据业务定义允许的格式集合。
  • 不使用原始文件名作为物理存储名,改用随机 ID 或不可预测的对象键。
  • 对原始文件名进行规范化,仅将其作为展示信息保存,不参与路径拼接。
  • 将文件保存到 Web 根目录之外,或通过专门的下载接口输出。
  • 下载时重新执行对象级授权检查,不能只依赖不可猜测的 URL。
  • 图片、压缩包等高风险格式应在隔离环境中处理,并设置资源消耗上限。
  • 记录上传者、文件哈希、大小、检测结果和访问审计日志。

下面是一个简化的 Java 示例,重点展示“生成服务端文件名”和“限制存储目录”的思路。实际项目还需要补充文件内容检测、权限校验和异常处理:

Path baseDir = Paths.get('/srv/app-data/uploads').toAbsolutePath().normalize();
String originalName = Optional.ofNullable(file.getOriginalFilename()).orElse('unknown');
String safeDisplayName = Paths.get(originalName).getFileName().toString();

if (file.isEmpty() || file.getSize() > MAX_SIZE) {
    throw new IllegalArgumentException('invalid file size');
}

String storedName = UUID.randomUUID().toString() + '.bin';
Path target = baseDir.resolve(storedName).normalize();
if (!target.startsWith(baseDir)) {
    throw new SecurityException('invalid target path');
}

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

这里使用统一的 .bin 仅是为了说明物理存储名不应直接反映用户输入。展示给用户的文件名应单独保存在数据库中,并在输出 HTML 时进行上下文相关的编码。

七、修复验证不能只看代码

修复后应重新执行完整验证,而不是只确认某个 if 判断已经增加。至少应检查以下结果:

  • 上传一个普通允许格式文件,能够正常保存和下载。
  • 上传错误格式或超大文件时,服务端拒绝请求且不会产生残留临时文件。
  • 修改文件名大小写、空格和多重后缀后,校验结果仍然一致。
  • 上传相同文件两次不会覆盖其他用户或其他业务对象的文件。
  • 构造包含路径分隔符的展示名称,不会改变实际落盘路径。
  • 直接访问物理存储路径时无法绕过业务授权。
  • 静态服务器不会对上传目录中的内容进行脚本解析。
  • 异常、超时和客户端中断情况下,不会留下可被后续访问的半成品文件。

如果系统使用对象存储,还要检查存储桶是否被错误设置为公开读写、上传接口是否允许客户端自定义对象键、临时下载地址是否包含合理的过期时间,以及删除和回收机制是否真正生效。

八、经常被忽略的风险点

  • 压缩包解压时的目录穿越。解压前应对每个条目的规范化路径进行检查,并限制文件数量、总大小和递归深度。
  • 图片解析导致的资源消耗。大尺寸图片、异常元数据和压缩炸弹可能造成内存或 CPU 被耗尽。
  • 文件名在不同组件中的编码差异。应用、操作系统、对象存储和日志系统对 Unicode 规范化的处理可能不一致。
  • 异步任务信任了原始请求参数。上传完成后由消息队列触发的转码服务也必须重新读取可信的数据库记录。
  • 只保护了上传接口,没有保护下载接口。文件对象的访问权限应与业务对象权限保持一致。

九、总结

文件上传漏洞的核心不是“有没有限制某个后缀”,而是用户可控数据是否一路影响了文件类型判断、物理路径、解析行为和访问授权。审计时应从完整生命周期出发,区分展示名称与物理存储名,把文件内容校验、路径安全、服务器配置和业务权限放在同一条分析链路中。

一个可落地的判断标准是:即使攻击者可以完全控制文件名、请求头和文件内容,系统也不应让这些输入决定文件的执行方式、存储位置或他人的访问权限。做到这一点,才能把一次局部修补变成真正有效的安全闭环。

代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

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