一、背景
异常现象
很长一段时间以来,前后端都是根据扩展名判断文件类型,但近期发现用户上传的.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 |
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。所以目前三者之间并没有精确识别的办法。
三、总结
文件扩展名校验虽然不够准确,但实现起来简单,能满足大部分情况(毕竟修改扩展名的用户只是极少数),适合作为短期方案。但长期来看还是推荐组合校验(扩展名+魔数+内容),能更精确识别文件类型。