文件格式校验方案

一、背景

异常现象

很长一段时间以来,前后端都是根据扩展名判断文件类型,但近期发现用户上传的.jpg格式图片存在解析异常的问题。拿到原图后测试发现:

  • Windows 10 原生图片查看器提示文件损坏
  • 主流浏览器(Chrome/Firefox)可正常渲染
  • Windows 11 原生查看器正常显示

原因排查

这不禁让笔者感到好奇,于是打开二进制格式检查了下文件头,发现这些文件的 Magic Number 对应的并不是 JPEG 格式,而是 AVIF (文件头:6674797061766966),一种较新的图片格式。

用户的无心之过

从用户视角来看,用户上传.avif图片时发现系统不支持上传,于是手动修改图片后缀为.jpg(用户以为改了扩展名就相当于改了文件格式),绕过了前端校验,而且由于浏览器强大的兼容能力,用户上传后发现在浏览器上能正常预览图片,便认为自己的操作是合理的。而后,后端解码失败。这些用户并非恶意攻击者,而是因系统未兼容新型图片格式采取的无奈之举。

二、解决方案

除了判断文件扩展名之外,还可以进行文件头校验和内容特征解析

Magic Number判断

魔数指的是文件开头的一串特定的字节序列,相较于文件扩展名,魔数更能有效识别文件类型。魔数没有固定长度,大部分文件类型的魔数不同,但也有少量文件类型有相同魔数

文件类型 文件头 文件尾
jpeg(jpg) FF D8 FF D9
png 89 50 4E 47 0D 0A 1A 0A
bmp 42 4d
gif 47 49 46 38 39 61
tiff 4d 4d 或 49 49
zip/xlsx/pptx/docx 50 4B 03 04

少量文件类型的判断,可以直接校验文件头。比如若只允许用户上传jpg/png格式的图片,实现如下:

java 复制代码
@Getter
public enum MimeTypeEnum {

    IMAGE_JPEG("image/jpeg", "FFD8", "FFD9"),
    IMAGE_PNG("image/png", "89504E470D0A1A0A", null),
    IMAGE_BMP("image/bmp", "424D", null),
    ;

    private final String mimeType;
    private final byte[] header; // 文件头
    private final byte[] footer; // 文件尾

    MimeTypeEnum(String mimeType, String header, String footer) {
        this.mimeType = mimeType;
        this.header = header == null ? null : DatatypeConverter.parseHexBinary(header);
        this.footer = footer == null ? null : DatatypeConverter.parseHexBinary(footer);
    }

    public static final Set<MimeTypeEnum> whiteList = Sets.newHashSet(IMAGE_JPEG, IMAGE_PNG);
}
java 复制代码
public static void test(MultipartFile mFile) throws Exception {
    MimeTypeEnum mimeType = detectMimeType(mFile);

    Assert.isTrue(MimeTypeEnum.whiteList.contains(mimeType), "不支持文件类型:" + mimeType);
}

public static MimeTypeEnum detectMimeType(MultipartFile multipartFile) throws IOException {
    try (InputStream inputStream = multipartFile.getInputStream()) {
        byte[] header = new byte[8]; // 读取前 8 个字节
        byte[] footer = new byte[2];// 读取后 2 个字节
        inputStream.read(header);
        inputStream.skip(multipartFile.getSize() - 2 - 8);
        inputStream.read(footer);

        for (MimeTypeEnum mimeTypeEnum : MimeTypeEnum.values()) {
            if (matchMagicNumber(header, footer, mimeTypeEnum)) {
                return mimeTypeEnum;
            }
        }
    }
    return null;
}


private static boolean matchMagicNumber(byte[] header, byte[] footer, MimeTypeEnum mimeType) {
    // 检查文件头
    if (!Arrays.equals(mimeType.getHeader(), Arrays.copyOf(header, mimeType.getHeader().length))) {
        return false;
    }

    // 检查文件尾
    if (mimeType.getFooter() != null) {
        return Arrays.equals(mimeType.getFooter(), footer);
    }
    return true;
}

注意,zip/xlsx/pptx/docx的魔数都是相同的,无法用魔数精确分辨。具体方法后面说

主流检测库对比

常见的文件类型极多,手动维护魔数判断繁琐,目前已有许多文件类型校验库,没必要重复造轮子了

库名称 格式覆盖 文件类型明细
Tika >1k org/apache/tika/mime/tika-mimetypes.xml
JMimeMagic >100 src/main/resources/magic.xml

Tika的使用

Tika支持的文件类型最多,由Apache维护并跟进最新文件格式。在 tika-mimetypes.xml 中有笔者需要的.avif格式

java 复制代码
<mime-type type="image/avif">
    <!-- According to https://github.com/libvips/libvips/pull/1657
      older avif used to use the the heif 'ftypmif1' as well -->
    <_comment>AV1 Image File</_comment>
    <acronym>AVIF</acronym>
    <tika:link>https://en.wikipedia.org/wiki/AV1#AV1_Image_File_Format_(AVIF)</tika:link>
    <magic priority="60">
      <match value="ftypavif" type="string" offset="4"/>
    </magic>
    <glob pattern="*.avif"/>
</mime-type>

引入pom依赖后,通过detect方法判断出mimeType,示例代码如下:

java 复制代码
public void test(MultipartFile file) {                                                                                                           
    String mimeType = new Tika().detect(file.getInputStream());

    log.info(mimeType) // image/avif
}

tika返回的mimeType(Multipurpose Internet Mail Extensions),用于标识互联网上传输的文件类型和格式,常见的mimeType如下:

扩展名 MIME 类型
.jpeg, .jpg image/jpeg
.png image/png
.avif image/avif
.gif image/gif
.mp4 video/mp4
.pdf application/pdf
.ppt application/vnd.ms-powerpoint
.pptx application/vnd.openxmlformats-officedocument.presentationml.presentation
.doc application/msword
.docx application/vnd.openxmlformats-officedocument.wordprocessingml.document
.xls application/vnd.ms-excel
.xlsx application/vnd.openxmlformats-officedocument.spreadsheetml.sheet

区分zip/xlsx/pptx/docx

由于xlsx/pptx/docx魔数相同,都是ooxml(Office Open XML File Formats),Tika只能识别为application/x-tika-ooxml,因此需要额外读取实际内容判断其类型。如果将文件修改扩展名为zip,就可以发现Excel的实际文件目录如下,我们可以通过workbook.xml识别其为excel。其他格式同理。

java 复制代码
│   [Content_Types].xml
│
│───_rels
│      .rels
│
├───docProps
│       app.xml
│       core.xml
│
└───xl
   │   sharedStrings.xml
   │   styles.xml
   │   workbook.xml
   │
   ├───_rels
   │      workbook.xml.rels
   │
   └───worksheets
          sheet1.xml

检测代码如下:

java 复制代码
/* 文件类型白名单 */
public static List<String> mimeTypeWhiteList = Arrays.asList(
    "image/jpeg", 
    "image/png");

public  void test(MultipartFile multipartFile) throws Exception {
    String mimeType = new Tika().detect(file.getInputStream());
    
    if ("application/x-tika-ooxml".equals(mimeType)) {
        mimeType = detectOOXML(file);
    }
    log.info(mimeType);

    Assert.isTrue(mimeTypeWhiteList.contains(mimeType), "不支持文件类型:" + mimeType);
}

/**
 * 解析ooxml(Office Open XML File Formats)
 */
private String detectOOXML(File file) throws IOException {
    try (ZipFile zipFile = new ZipFile(file)) {
        if (zipFile.getEntry("word/document.xml") != null) {
            return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
        }
        if (zipFile.getEntry("xl/workbook.xml") != null) {
            return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
        }
        if (zipFile.getEntry("ppt/presentation.xml") != null) {
            return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
        }
    }
    return "application/zip";
}

区分xls/ppt/doc

xls/ppt/doc是Microsoft Office的早期版本,使用二进制文件格式,读取文件内容可以进行大致识别。

java 复制代码
private static String detectMsOffice(InputStream inputStream) throws Exception {
    byte[] buffer = new byte[1024 * 10];

    while (inputStream.read(buffer) != -1) { // todo 滑动窗口优化
        if (containsSubArray(buffer, "Excel".getBytes())) {
            return "application/vnd.ms-excel";
        }
        if (containsSubArray(buffer, "PowerPoint".getBytes())) {
            return "application/vnd.ms-powerpoint";
        }
        if (containsSubArray(buffer, "Office Word".getBytes())) {
            return "application/msword";
        }
    }
    return "unknown";
}

然而读取文件内容进行识别并不一定准确,如下图,假如在excel中输入"PowerPoint"就可能被识别为ppt。所以目前三者之间并没有精确识别的办法。

三、总结

文件扩展名校验虽然不够准确,但实现起来简单,能满足大部分情况(毕竟修改扩展名的用户只是极少数),适合作为短期方案。但长期来看还是推荐组合校验(扩展名+魔数+内容),能更精确识别文件类型。

相关推荐
Wukong.Sun1 分钟前
操作系统的概念,功能和目标
java·linux·开发语言·windows
白露与泡影19 分钟前
Java面试避坑指南:牛客网最新高频考点+答案详解
java·开发语言·面试
qq_124987075319 分钟前
基于Node.js的线上教学系统的设计与实现(源码+论文+调试+安装+售后)
java·spring boot·后端·node.js·毕业设计
CodeCraft Studio22 分钟前
图像处理控件Aspose.Imaging教程:用Java将 CMX 转换为 PNG
java·图像处理·python·aspose
葡萄城技术团队29 分钟前
Java 实现 Excel 转化为 PDF
java
@zcc@32 分钟前
Java日期格式化
java·开发语言
coding随想34 分钟前
你的电脑在开“外卖平台”?——作业管理全解析
后端
DS小龙哥36 分钟前
基于单片机+毫米波雷达技术设计的车内生命体征监测系统
后端
颜颜颜yan_36 分钟前
【HarmonyOS5】掌握UIAbility启动模式:Singleton、Specified、Multiton
后端·架构·harmonyos
葡萄城技术团队38 分钟前
Java 实现 Excel 转化为图片
java