跳转到帖子

背景

文件上传一直是 Web 安全里比较容易被低估的点。很多业务在设计时只关注“能不能上传成功”,但在安全层面,文件类型校验、存储路径、访问控制、解析行为、清理策略都会影响最终风险。尤其是 Spring Boot 项目里,上传接口通常写起来很快,代码量不大,但如果边界处理不清晰,后续很容易演变成任意文件写入、敏感文件覆盖、越权访问或存储型 XSS 等问题。

这篇文章整理一次偏通用的审计和验证思路,不针对特定厂商或真实线上目标。内容重点放在如何识别风险点、如何在本地环境做安全验证,以及如何给出可落地的修复建议。

常见场景

在代码审计中,文件上传功能通常出现在头像上传、附件上传、工单材料、富文本图片、导入模板、日志打包上传等场景。风险并不一定来自“上传 WebShell”这种极端情况,更多时候是业务和框架组合后的细节问题。

  • 只校验文件后缀,没有校验 MIME、魔数和实际解析结果。
  • 使用用户传入的文件名直接拼接保存路径。
  • 上传目录位于静态资源目录下,文件上传后可被直接访问。
  • 未做鉴权或只做前端限制,接口可被未授权调用。
  • 同名文件覆盖导致业务文件、配置文件或其他用户文件被覆盖。
  • 错误配置 Nginx、Tomcat 或对象存储,导致脚本、HTML、SVG 等被以危险方式解析。

问题代码示例

下面是一段常见的上传实现,逻辑上看起来没什么复杂点,但里面已经包含几个典型风险。

@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
    String fileName = file.getOriginalFilename();
    String uploadDir = "./static/upload/";

    File dest = new File(uploadDir + fileName);
    file.transferTo(dest);

    return "/upload/" + fileName;
}

这类代码的主要问题包括:直接使用原始文件名、未限制文件大小、未验证文件类型、未规范化路径、上传目录可能被直接访问。如果应用还把 static 暴露为静态资源目录,上传后的文件就具备了访问入口。

技术分析

1. 文件名与路径拼接风险

getOriginalFilename() 来自客户端请求,不应直接信任。攻击者可以构造特殊文件名,例如包含路径分隔符、控制字符或非常规 Unicode 字符。即使新版框架或运行环境对部分路径穿越有防护,也不应把安全性建立在框架偶然行为上。

审计时可以重点搜索:

getOriginalFilename()
transferTo(
new File(
Paths.get(
FileOutputStream

如果看到用户输入参与路径拼接,需要继续确认是否做了 normalize、目录边界校验、文件名重命名等处理。

2. 后缀校验不是充分条件

只判断 .jpg、.png 这类后缀并不可靠。文件后缀可以伪造,MIME 也可以由客户端伪造。更稳妥的做法是综合校验:后缀白名单、服务端 MIME 判断、文件头魔数检查,必要时对图片进行重新编码。

例如图片上传可以用 ImageIO 尝试读取,并重新生成一份安全图片,而不是直接保存原始字节。

BufferedImage image = ImageIO.read(file.getInputStream());
if (image == null) {
    throw new IllegalArgumentException("invalid image file");
}

String safeName = UUID.randomUUID() + ".jpg";
File output = new File(baseDir, safeName);
ImageIO.write(image, "jpg", output);

重新编码的意义在于减少多格式混淆、嵌入脚本内容、畸形文件触发解析问题的概率。对于普通头像、封面图场景,这个方式比直接保存原文件更稳。

3. 上传目录不要直接暴露执行能力

上传目录最好和应用代码、模板目录、可执行脚本目录隔离。对于传统部署方式,尽量不要把上传目录放到 Web 根目录下。对于 Spring Boot,也不建议放到 resources/static、public 等静态资源目录里。

推荐方案是:文件保存到应用外部的数据目录,访问时通过受控下载接口读取,并做权限判断、Content-Type 固定、Content-Disposition 控制。

@GetMapping("/files/{id}")
public ResponseEntity<Resource> download(@PathVariable String id, Principal principal) {
    FileMeta meta = fileService.findByIdAndUser(id, principal.getName());
    Resource resource = fileService.loadAsResource(meta.getStorageName());

    return ResponseEntity.ok()
        .header("Content-Disposition", "attachment; filename=\"" + meta.getOriginalName() + "\"")
        .header("X-Content-Type-Options", "nosniff")
        .contentType(MediaType.APPLICATION_OCTET_STREAM)
        .body(resource);
}

如果确实需要图片在线预览,也应只对确定安全的图片类型返回对应 Content-Type,并保留 X-Content-Type-Options: nosniff。

4. 鉴权与对象级权限容易被忽略

上传接口通常会有登录态校验,但下载或预览接口经常被做成公开 URL。如果业务文件包含身份证件、合同、工单附件等敏感内容,仅依赖“文件名随机”并不算严格访问控制。

审计时可以检查:

  • 上传接口是否需要登录。
  • 下载接口是否校验文件归属关系。
  • 文件 ID 是否可枚举。
  • 临时访问链接是否有过期时间和签名。
  • 管理员、普通用户、游客之间是否存在越权访问。

本地验证步骤

为了避免对真实系统造成影响,建议在本地或测试环境验证。验证目的不是提供攻击路径,而是确认安全边界是否可靠。

1. 构造测试项目

可以用一个最小 Spring Boot 项目复现上传逻辑,配置本地上传目录:

file:
  upload-dir: /tmp/demo-upload

确保目录不和代码目录、静态资源目录混用。

2. 验证文件名处理

准备一些边界文件名,观察服务端保存结果和日志表现。

normal.png
..%2Ftest.png
..\test.png
空格 文件.png
unicode-测试.png
very-long-name-aaaaaaaaaaaaaaaaaaaaaaaa.png

重点不是看某个特殊名称是否一定成功,而是确认服务端是否统一重命名、是否做了规范化、是否能保证文件不会写出预期目录。

3. 验证类型识别

准备正常图片、改后缀文本文件、畸形图片文件,观察接口是否只依赖后缀判断。服务端日志应能区分“用户输入不合法”和“系统异常”,不要把完整堆栈直接返回给前端。

curl -i -F "[email protected]" 
curl -i -F "[email protected]"  fake.jpg 可以是普通文本改名而来,用于验证服务端是否读取实际内容。测试只需在本地环境进行。

4. 验证访问控制

创建两个测试账号 A 和 B,分别上传文件,再尝试用 B 的身份访问 A 的文件下载接口。预期结果应是拒绝访问,而不是只要知道文件 ID 就能下载。

GET /files/{fileId}

如果使用对象存储,也要检查桶策略是否公开、临时 URL 是否过期、签名参数是否可复用。

修复建议

文件上传的修复不建议只补一个后缀判断,而应从整体链路处理。

  • 服务端生成存储文件名,避免使用用户原始文件名作为真实路径。
  • 保存原始文件名时仅作为展示字段,并进行长度限制和字符过滤。
  • 使用白名单策略限制类型,不使用黑名单。
  • 图片类文件尽量重新编码后保存。
  • 限制文件大小、单用户上传频率和总容量。
  • 上传目录放在应用目录之外,不授予执行权限。
  • 下载接口统一走后端鉴权,不直接暴露敏感附件。
  • 响应头增加 X-Content-Type-Options: nosniff,按场景设置 Content-Disposition。
  • 记录上传人、IP、文件哈希、文件大小、存储位置和访问日志,便于后续排查。

相对安全的实现片段

下面是一段更适合生产环境改造的核心思路,省略了业务鉴权和异常封装部分。

Path baseDir = Paths.get("/data/app-upload").toAbsolutePath().normalize();
Files.createDirectories(baseDir);

String originalName = file.getOriginalFilename();
String ext = getSafeExtension(originalName);

Set<String> allowExt = Set.of("jpg", "jpeg", "png");
if (!allowExt.contains(ext)) {
    throw new IllegalArgumentException("unsupported file type");
}

if (file.getSize() > 5 * 1024 * 1024) {
    throw new IllegalArgumentException("file too large");
}

BufferedImage image = ImageIO.read(file.getInputStream());
if (image == null) {
    throw new IllegalArgumentException("invalid image");
}

String storageName = UUID.randomUUID().toString().replace("-", "") + ".jpg";
Path target = baseDir.resolve(storageName).normalize();

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

ImageIO.write(image, "jpg", target.toFile());

这里有几个关键点:基础目录先转为绝对路径并规范化;真实存储名由服务端生成;最终路径再次校验必须位于基础目录内;图片通过解码和重新编码写入。这样即便客户端提交异常文件名,也不会影响真实保存路径。

Nginx 侧的辅助限制

如果上传文件由 Nginx 提供静态访问,应至少禁止脚本执行和类型嗅探。不同部署方式配置会有差异,下面只给一个思路示例。

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

location ~* ^/uploads/.*\.(php|jsp|jspx|asp|aspx|sh|cgi)$ {
    return 403;
}

更推荐的方式仍然是通过后端接口做鉴权访问,而不是把上传目录整体暴露出去。

审计清单

检查项风险表现建议处理
文件名使用原始文件名拼接路径服务端生成随机文件名
路径未做 normalize 和目录边界校验校验 target.startsWith(baseDir)
类型仅依赖后缀或前端校验后缀、魔数、解码结果综合判断
存储上传到静态资源或代码目录保存到应用外部数据目录
访问知道 URL 即可下载后端鉴权并校验归属
响应浏览器可能嗅探类型设置 nosniff 和下载头
日志无法追踪上传来源记录用户、哈希、大小、IP

注意点

  • 不要把安全判断放在前端,前端限制只能改善体验。
  • 不要只依赖 Content-Type,请求头可以被客户端自行指定。
  • 对象存储的公开读策略要谨慎,尤其是包含敏感附件的业务。
  • 文件清理任务也要做权限和路径边界校验,避免误删非上传目录文件。
  • 异常信息不要直接返回堆栈,避免泄露路径、类名和部署结构。

总结

文件上传安全的核心不是某一个校验函数,而是完整链路的边界控制:输入不可信、路径不越界、类型可确认、存储不可执行、访问需授权、日志可追踪。实际审计时,可以先从 getOriginalFilename、transferTo、静态资源目录和下载接口入手,通常很快能定位主要风险点。

对研发团队来说,最有效的改造方式是沉淀统一上传组件,而不是让每个业务接口各写一套上传逻辑。这样既能减少重复代码,也能避免同类问题在不同模块反复出现。

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

0篇意见

推荐意见

没有意见。

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

SiteMap HACKHAT © 2026 All rights reserved.