Java 文件上传漏洞通常是由于未对上传的文件进行足够的校验和限制导致的。攻击者通过构造恶意的上传请求,上传任意可执行文件或包含恶意脚本的文件来实现攻击。

常见文件上传方式

基于Servlet 3.0+ 的 Part API

无需额外依赖,直接使用 Servlet 规范提供的 API。

  • 核心API:javax.servlet.http.Part (Servlet 3.0+) 或 jakarta.servlet.http.Part (Servlet 5.0+, Jakarta EE)。

  • 实现方式:

    • 在 Servlet 或 Controller 方法上使用 @MultipartConfig 注解(Servlet)或框架自动处理(如 Spring)。

    • 通过 HttpServletRequest.getParts() 或 request.getPart("fileInputName") 获取 Part 对象。

    • 使用 Part.getInputStream() 读取文件流,Part.getSubmittedFileName() 获取原始文件名,Part.getSize() 获取文件大小,Part.write() 直接写入磁盘。

  • 优点: 标准化,无需第三方库,与 Servlet 容器集成好。

upload.html

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <title>文件上传</title>
</head>
<body>
<h2>上传文件</h2>
<!-- action指向处理上传的Servlet的URL -->
<form action="/upload" method="post" enctype="multipart/form-data">
    <label for="file">选择文件:</label>
    <input type="file" id="file" name="file" required><br><br>
    <input type="submit" value="上传">
</form>
</body>
</html>

FileUploadServlet.java

package xx.file.upload.testfileupload;

import javax.servlet.ServletException;
import javax.servlet.annotation.MultipartConfig;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.Part;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.UUID;

/**
 * 处理文件上传的 Servlet
 * 使用 @WebServlet 注解映射 URL 路径
 */
@WebServlet("/upload")
// 启用 multipart 配置,这是处理文件上传的关键注解
@MultipartConfig(
        // 限制单个文件大小,例如 10MB
        maxFileSize = 10485760,
        // 限制整个请求的大小(包含所有文件和字段),例如 50MB
        maxRequestSize = 52428800,
        // 文件大小超过此值时,将写入磁盘临时文件,否则保留在内存中,例如 1MB
        fileSizeThreshold = 1048576,
        // 指定存储临时文件的目录,可以是相对路径(相对于Servlet容器的工作目录)或绝对路径
        // 如果不指定,容器会选择默认临时目录
        location = "D:\\learn\\java\\testFileUpload\\src\\main\\webapp\\uploadTemp"
)
publicclass FileUploadServlet extends HttpServlet {
    // 定义文件上传的目标目录
    privatestaticfinal String UPLOAD_DIRECTORY = "uploads";

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {

        response.setContentType("text/html;charset=UTF-8");

        // 1. 获取名为 "file" 的文件Part (来自HTML表单的 input name="file")
        Part filePart = request.getPart("file");

        // 2. 验证文件 Part 是否存在且有内容
        if (filePart == null || filePart.getSize() <= 0) {
            response.getWriter().println("<h3 style='color:red'>错误: 未选择文件或文件为空。</h3>");
            return;
        }

        // 3. 获取文件信息
        String fileName = filePart.getSubmittedFileName(); // 获取客户端提交的原始文件名
        String contentType = filePart.getContentType(); // 获取MIME类型 (如 image/jpeg)

        // 4. 安全性检查
        // a. 检查文件大小是否超出Servlet配置的限制 (maxFileSize 已经做了)
        // b. 检查文件类型 (MIME类型) - 防止上传恶意脚本
        // 注意:getContentType() 可能不可靠(客户端可伪造),最好结合文件扩展名和内容检测
        if (contentType != null) {
            // 只允许图片
            if (!contentType.startsWith("image/")) {
                response.getWriter().println("<h3 style='color:red'>错误: 不支持的文件类型: " + contentType + "</h3>");
                return;
            }
        } else {
            // 如果 contentType 为 null,可以根据文件扩展名判断
            String fileExtension = getFileExtension(fileName);
            if (!isAllowedExtension(fileExtension)) {
                response.getWriter().println("<h3 style='color:red'>错误: 文件扩展名不被允许: ." + fileExtension + "</h3>");
                return;
            }
        }

        // c. 对文件名进行安全处理,防止路径遍历攻击 (如 ../../etc/passwd)
        //    移除或替换特殊字符,或者使用唯一ID作为文件名
        //    这里采用生成唯一ID的方式,更安全
        String uniqueFileName = generateUniqueFileName(fileName); // 推荐:使用UUID或时间戳

        // 5. 确定目标存储路径
        // 获取ServletContext,用于获取服务器上真实路径
        String applicationPath = getServletContext().getRealPath("");
        // 构建上传目录的完整路径
        String uploadPath = applicationPath + File.separator + UPLOAD_DIRECTORY;
        Path uploadDirPath = Paths.get(uploadPath);

        // 6. 创建上传目录 (如果不存在)
        try {
            if (!Files.exists(uploadDirPath)) {
                Files.createDirectories(uploadDirPath);
            }
        } catch (IOException e) {
            response.getWriter().println("<h3 style='color:red'>服务器内部错误: 无法创建上传目录。</h3>");
            return;
        }

        // 7. 构建目标文件的完整路径
        Path filePath = uploadDirPath.resolve(uniqueFileName);

        // 8. 保存文件 (使用 InputStream - 更灵活,可进行流处理)
        try (InputStream fileInputStream = filePart.getInputStream()) {
            // 使用 Files.copy() 将输入流复制到目标文件
            // StandardCopyOption.REPLACE_EXISTING: 如果目标文件已存在则替换
            Files.copy(fileInputStream, filePath, StandardCopyOption.REPLACE_EXISTING);

            // 响应客户端
            response.getWriter().println("<h3 style='color:green'>文件上传成功 (通过流)!</h3>" +
                    "<p>文件路径: " + filePath + "</p>");
        } catch (IOException e) {
            response.getWriter().println("<h3 style='color:red'>保存文件失败 (流): " + e.getMessage() + "</h3>");
        }
    }

    // --- 辅助方法 ---

    /**
     * 从文件名中提取扩展名
     * @param fileName 原始文件名
     * @return 扩展名 (小写,不含点),如果无扩展名则返回空字符串
     */
    private String getFileExtension(String fileName) {
        if (fileName == null || fileName.isEmpty()) {
            return"";
        }
        int lastDotIndex = fileName.lastIndexOf('.');
        if (lastDotIndex > 0 && lastDotIndex < fileName.length() - 1) {
            return fileName.substring(lastDotIndex + 1).toLowerCase();
        }
        return"";
    }

    /**
     * 检查文件扩展名是否在允许列表中
     * @param extension 扩展名
     * @return true if allowed
     */
    private boolean isAllowedExtension(String extension) {
        String[] allowedExtensions = {"jpg", "jpeg", "png", "gif"};
        for (String allowed : allowedExtensions) {
            if (allowed.equalsIgnoreCase(extension)) {
                returntrue;
            }
        }
        returnfalse;
    }

    /**
     * 生成唯一的文件名,避免冲突和覆盖
     * @param originalFileName 原始文件名
     * @return 唯一的文件名
     */
    private String generateUniqueFileName(String originalFileName) {
        String extension = getFileExtension(originalFileName);
        String uniqueId = UUID.randomUUID().toString();
        return extension.isEmpty() ? uniqueId : uniqueId + "." + extension;
    }

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        // 可以转发到上传页面,或者直接输出HTML
        request.getRequestDispatcher("/upload.html").forward(request, response);
    }
}

Spring MVC/Spring Boot 的 MultipartFile

在 Spring 生态中最主流的文件上传方式。

  • 核心API: org.springframework.web.multipart.MultipartFile。

  • 实现方式:

    • 在 Controller 方法中,将文件参数直接声明为 MultipartFile 类型。

    • Spring 框架会自动将 HTTP multipart 请求中的文件数据绑定到 MultipartFile 对象。

    • 使用 MultipartFile.getBytes(), getInputStream(), getOriginalFilename(), getSize(), transferTo() 等方法处理文件。

  • 优点: 极其简单、方便,与 Spring 框架无缝集成,支持验证,transferTo() 方法可以直接保存文件。

  • 配置: 需要在配置文件(如 application.properties)中配置 Multipart 相关属性(如 spring.servlet.multipart.max-file-size,spring.servlet.multipart.max-request-size)。

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>test.file.upload</groupId>
    <artifactId>MultipartFileUpload</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>MultipartFileUpload</name>
    <description>MultipartFileUpload</description>
    <properties>
        <java.version>17</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <spring-boot.version>3.0.2</spring-boot.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>17</source>
                    <target>17</target>
                    <encoding>UTF-8</encoding>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>${spring-boot.version}</version>
                <configuration>
                    <mainClass>test.file.upload.multipartfileupload.MultipartFileUploadApplication</mainClass>
                    <skip>true</skip>
                </configuration>
                <executions>
                    <execution>
                        <id>repackage</id>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

FileUploadController.java

package test.file.upload.multipartfileupload.controller;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.UUID;

@RestController
publicclass FileUploadController {

    // 从application.yml读取上传目录
    @Value("${file.upload-dir}")
    private String uploadDir;

    // 处理文件上传的POST请求
    @PostMapping("/upload")
    public String handleFileUpload(@RequestParam("file") MultipartFile file) {
        // 1. 检查文件是否为空
        if (file.isEmpty()) {
            return"文件为空,请选择一个文件上传。";
        }

        try {
            // 1. 获取原始文件名和扩展名
            String originalFilename = file.getOriginalFilename();
            String fileExtension = "";
            if (originalFilename != null && originalFilename.contains(".")) {
                fileExtension = originalFilename.substring(originalFilename.lastIndexOf("."));
            }

            // 2. 生成唯一的新文件名
            String uniqueFileName = UUID.randomUUID() + fileExtension;

            // 3. 构建目标文件路径
            Path filePath = Paths.get(uploadDir).resolve(uniqueFileName);

            // 4. 将上传的文件保存到目标路径
            file.transferTo(filePath.toFile());

            // 5. 返回成功信息
            return"文件上传成功!保存为: " + uniqueFileName;

        } catch (IOException e) {
            // 如果保存失败,返回错误信息
            return"文件上传失败: " + e.getMessage();
        }
    }
}

upload.html

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <title>Spring Boot 文件上传</title>
</head>
<body>
<h2>文件上传</h2>
<form action="/upload" method="post" enctype="multipart/form-data">
    <input type="file" name="file">
    <input type="submit" value="上传">
</form>
</body>
</html>

application.yml

# application.yml
server:
port:8080

# 文件上传相关配置
spring:
servlet:
    multipart:
      # 是否启用文件上传支持 (默认true)
      enabled:true
      # 单个文件的最大大小 (例如 10MB)
      max-file-size:10MB
      # 多部分请求(总)的最大大小 (例如 50MB)
      max-request-size:50MB
      # 文件大小超过此阈值时,将写入磁盘临时文件 (例如 1MB)
      file-size-threshold:1MB
      # 临时文件存储位置 (可选)
      # location: /tmp
      # location: C:/temp

# 自定义属性:文件上传目录
file:
upload-dir:"D:/uploads"# 相对于项目根目录或绝对路径

Apache Commons FileUpload

在 Servlet 3.0 之前最流行的第三方库,现在主要用于维护旧项目或特定需求。

  • 核心组件: ServletFileUpload, FileItem, DiskFileItemFactory。

  • 实现方式:

    • 创建 DiskFileItemFactory 实例来管理内存和临时文件。

    • 创建 ServletFileUpload 实例,设置文件大小限制等。

    • 调用 parseRequest() 方法解析请求,得到 FileItem 集合。

    • 遍历 FileItem,判断是否是表单字段 (isFormField()) 还是文件,然后处理文件项。

  • 缺点: 需要额外引入依赖,代码相对繁琐,现代框架(如 Spring)已内置更好支持。

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
  <modelVersion>4.0.0</modelVersion>
<groupId>test.file.upload</groupId>
<artifactId>CommonsFileUpload</artifactId>
<packaging>war</packaging>
<version>1.0-SNAPSHOT</version>
<name>CommonsFileUpload Maven Webapp</name>
<url>http://maven.apache.org</url>
<dependencies>
      <!-- Servlet API -->
      <dependency>
          <groupId>javax.servlet</groupId>
          <artifactId>javax.servlet-api</artifactId>
          <version>4.0.1</version>
          <scope>provided</scope>
      </dependency>

      <!-- Apache Commons FileUpload -->
      <dependency>
          <groupId>commons-fileupload</groupId>
          <artifactId>commons-fileupload</artifactId>
          <version>1.5</version>
      </dependency>

      <!-- Apache Commons IO (FileUpload 依赖它) -->
      <dependency>
          <groupId>commons-io</groupId>
          <artifactId>commons-io</artifactId>
          <version>2.11.0</version>
      </dependency>
</dependencies>
<build>
    <finalName>CommonsFileUpload</finalName>
</build>
</project>

src/main/webapp/index.jsp

<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<!DOCTYPE html>
<html>
<head>
    <title>ServletFileUpload 示例</title>
</head>
<body>
<h1>使用 ServletFileUpload 上传文件</h1>
<form action="/upload" method="post" enctype="multipart/form-data">
    <label for="file">选择文件:</label>
    <input type="file" name="file" required />
    <br><br>
    <button type="submit">上传</button>
</form>
</body>
</html>

src/main/java/file/upload/FileUploadServlet.java

package file.upload;

import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.FileUploadException;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.apache.commons.fileupload.servlet.ServletFileUpload;

import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.List;
import java.util.UUID;

@WebServlet("/upload") // URL 映射
publicclass FileUploadServlet extends HttpServlet {
    // 上传目录
    private String uploadDir = "uploads";

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws IOException {

        // // 设置响应内容类型和字符编码
        response.setContentType("text/html;charset=UTF-8");
        // 获取 PrintWriter
        PrintWriter out = response.getWriter();

        // 配置 DiskFileItemFactory
        DiskFileItemFactory factory = new DiskFileItemFactory();
        // 创建 ServletFileUpload
        ServletFileUpload upload = new ServletFileUpload(factory);

        // 构建上传目录路径
        String uploadPath = getServletContext().getRealPath("") + File.separator + uploadDir;
        File uploadDirFile = new File(uploadPath);
        if (!uploadDirFile.exists()) {
            uploadDirFile.mkdirs();
        }

        try {
            // 解析请求,获取所有表单项 (FileItem)
            List<FileItem> formItems = upload.parseRequest(request);

            if (formItems == null || formItems.isEmpty()) {
                out.println("未找到上传的文件。");
                return;
            }

            // 遍历表单项
            for (FileItem item : formItems) {
                // 跳过非文件字段 (比如普通的文本输入框)
                if (item.isFormField()) {
                    // String fieldName = item.getFieldName();
                    // String fieldValue = item.getString("UTF-8");
                    // 处理普通表单字段...
                    continue;
                }

                // 处理文件字段
                String fileName = item.getName();
                if (fileName == null || fileName.trim().isEmpty()) {
                    continue; // 跳过空文件
                }

                // 获取文件扩展名
                String fileExtension = "";
                int lastDotIndex = fileName.lastIndexOf('.');
                if (lastDotIndex > 0) {
                    fileExtension = fileName.substring(lastDotIndex);
                }

                // 生成唯一文件名
                String uniqueFileName = UUID.randomUUID() + fileExtension;
                String filePath = uploadPath + File.separator + uniqueFileName;

                // 创建文件对象
                File storeFile = new File(filePath);

                // 将上传的文件保存到指定位置
                item.write(storeFile); // ✅ 核心方法:写入文件

                // 删除临时文件 (write 方法内部可能已处理,但显式调用更安全)
                item.delete();

                // 返回成功信息
                out.println("文件上传成功!保存为: " + uniqueFileName + "<br>");
                out.println("原始文件名: " + fileName);
                return; // 假设只处理一个文件

            }

            out.println("未找到有效的文件字段。");

        } catch (FileUploadException ex) {
            out.println("文件上传错误: " + ex.getMessage());
            ex.printStackTrace();
        } catch (Exception ex) {
            out.println("错误: " + ex.getMessage());
            ex.printStackTrace();
        } finally {
            out.close();
        }
    }
}

文件上传审计

文件上传漏洞的本质还是未对文件名做严格校验,常见的主要有如下几种情况:未对文件做任何过滤、仅在前端通过 js 检验、只判断了 Content-Type、后缀过滤不全、读取后缀方式错误等。

一些经典的关键字:

  • org.apache.commons.fileupload

  • java.io.File

  • upload

  • MultipartFile

  • RequestMethod

  • MultipartHttpServletRequest

  • CommonsMutipartResolver

  • DiskFileItemFactory

  • ServletFileUpload

RuoYi-Ai 文件上传审计

漏洞背景简述
  • 漏洞名称:RuoYi-Ai v2.0.0 文件上传漏洞(CVE-2025-6466)

  • 漏洞类型:未对上传文件类型进行任何校验便写入磁盘导致的任意文件上传

  • 影响版本:v2.0.0(已修复)

  • 利用点:语音转文本(/chat/audio)

  • 搜索关键字:MultipartFile

  • 审计链接:https://github.com/ageerle/ruoyi-ai/issues/9

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐