最近做的工作有一部分需求,需要对读取到的文件做验证,只允许上传规定格式的文件(word),不仅仅是通过后缀,还需要验证文件的真实格式,因为某些有害脚本通过改写后缀同样能上传成功,这个时候需要做的就是通过字符流来验证文件的真实格式。从网上找了很多资料和方法,有些帮助,但也并不全面,于是站在前人的基础上,通过文件编码分析,来完成这样一篇博客。本文推荐的阅读逻辑结构如下:
- 读取文件并通过文件后缀首先排除部分非格式要求内容
- 读取文件头部信息并通过头部十六进制进行格式鉴别
- 仅通过文件头部信息鉴别不了的通过前8个字节位里的标示位来区分
- 部分无法区分格式的文件,这部分希望大家探讨补充
好了,那么接下来进行正文,文件格式分析,本文采用编程语言是C#,但也同样适用于Java。同时提前介绍一个文件分析利器,Ultra,它看,可以快速将文件以二进制或十六进制编码的方式展示。
1 读取文件并通过后缀判断
这部分代码存在的意义就是,可以过滤大部分非恶意的文件后缀,因为读取文件头并转换为十六进制位比较耗性能,所以首先通过后缀过滤可以防止这部分后缀不符合要求的文件入库。这部分为主执行逻辑,用于获取文件类型。
/// <summary> /// 判断当前文件的文件格式是否为word格式 /// </summary> /// <returns></returns> public FileTypeEnum GetFileType() { //首先通过文件名后缀进行判断,抛出当前类型 var fileFromClient = System.Web.HttpContext.Current.Request.Files[0]; //获取当前文件 var fileName = fileFromClient.FileName.ToUpper();//获取文件名并将文件名转成大大写的格式来判断 var fileSuffixName = fileName.Substring(fileName.LastIndexOf('.') + 1);//获取文件的后缀名 if (fileSuffixName.Equals("DOCX") || fileSuffixName.Equals("DOC")) { //如果通过了后缀名验证,为防止修改后缀骗过验证,则进行文件字符流验证 if (GetFileHeader(fileFromClient.InputStream) == OfficeFileClassEnum.DOCX || GetFileHeader(fileFromClient.InputStream) == OfficeFileClassEnum.DOC) { return FileTypeEnum.Word; } } return FileTypeEnum.Unknown; }
当然文件类型用枚举值来列举比较好,目前仅仅对Word文件进行校验,如果需要其他格式可以进行扩展。
/// <summary> /// 上传的文件格式 /// </summary> public enum FileTypeEnum { Word, // word格式的文件 Unknown, // 未知文件格式 }
2 通过头部十六进制进行格式鉴别
当然,对于部分恶意修改后缀的情况我们需要进行继续判定,通过获取文件头的十六进制格式其实就可以读取文件的真实类型,这些类型和规范都是各大厂商提前设定好的。这么做的依据来自如下实验:我创建了一个docx结尾的文档,然后拷贝一个副本,改了后缀,用工具UltraCompare打开,可以看到,二进制编码一模一样,所以仅仅修改后缀并不会更改文件的真实格式,所以通过文件头部信息读取确实能解决真正的区分文件格式的问题。
那么当然有人问了,我转个格式再传上去看你怎么判断,的确没法判断,但是大哥,你转了格式的文件还能用么,我的本质逻辑就是获取有用文件的正确格式,所以逻辑并不违背。言归正传,以下这部分代码用于依据十六进制文件头的信息来判断文件格式类型。
/// <summary> /// 依据十六进制文件头的信息来判断文件格式类型 /// </summary> /// <param name="fileStream"></param> /// <returns></returns> public OfficeFileClassEnum GetFileHeader(Stream fileStream) { //初始化文件头十六进制字符串和返回的文件格式类型 string fileCode; var byteArray = new byte[8]; //设置二进制数组 try { fileStream.Read(byteArray, 0, 8); //读取文件头前8位字节数组数组 fileCode = ByteToHexStr(byteArray).ToLower().Replace(" ", "");//将读取到的文件头二进制数组转为十六进制字符串并全部转换为小写并去掉全部空格 if (fileCode.IsNullOrEmpty()) return OfficeFileClassEnum.Unknown; } catch (Exception) { return OfficeFileClassEnum.Unknown; } //如果顺利取出前8个字符生成的十六进制字符串 var fileCodeHeaderType = fileCode.Substring(0, 8);//取字符串前8个十六进制位(4个字节位)进行判断 //对文件头进行简单判断 if (fileCodeHeaderType.Equals("504b0304")) //前4个字节体现为文件头部信息,可能存在DOCX和ZIP { var checkZipOrDocx = fileCode.Substring(12, 2); //获取第七个字节位的字符信息 if (checkZipOrDocx.Equals("06")) { return OfficeFileClassEnum.DOCX; } if (checkZipOrDocx.Equals("00")) { return OfficeFileClassEnum.ZIP; } } if (fileCodeHeaderType.Equals("d0cf11e0")) //前4个字节体现为文件头部信息,可能存在DOC和XLS { return OfficeFileClassEnum.DOC; } return OfficeFileClassEnum.Unknown; }
我们同样使用枚举类来标识真实的后缀文件:
/// <summary> /// 后缀文件格式判别 /// </summary> public enum OfficeFileClassEnum { DOCX, //DOCX是微软2007之后的格式,用于Word文件格式 DOC, //DOC是微软office 97-03的存储规范,用于Word文件格式 ZIP, //ZIP格式的文件 Unknown, // 未知格式 }
当然仅仅通过fileStream读取到的二进制字符较长,转为十六进制查看较为便捷。这部分代码如下:
/// <summary> /// 字节数组转16进制字符串 /// </summary> /// <param name="bytes"></param> /// <returns></returns> public string ByteToHexStr(byte[] bytes) { var hexString = string.Empty; if (bytes == null || bytes.Length <= 0) return hexString; foreach (var item in bytes) { hexString += item.ToString("X2"); } return hexString; }
那么问题来了,小伙子们一定会问,为啥读取了8个字节却先用4个字节判断呢,其实大多数情况下4个字节标识文件头部信息,8个字节对于某些格式的文件来说就已经涉及到内容了,如果完全按照8个字节去判别,会造成误判。那取8个字节的意义其实就体现在更加细微的区分上。这里奉上从网上搜集的文件头部格式标识(前4个字节):
- JPEG (jpg),文件头:FFD8FF
- PNG (png),文件头:89504E47
- GIF (gif),文件头:47494638
- TIFF (tif),文件头:49492A00
- Windows Bitmap (bmp),文件头:424D
- CAD (dwg),文件头:41433130
- Adobe Photoshop (psd),文件头:38425053
- Rich Text Format (rtf),文件头:7B5C727466
- XML (xml),文件头:3C3F786D6C
- HTML (html),文件头:68746D6C3E
- Email [thorough only] (eml),文件头:44656C69766572792D646174653A
- Outlook Express (dbx),文件头:CFAD12FEC5FD746F
- Outlook (pst),文件头:2142444E
- MS Word/Excel (xls.or.doc),文件头:D0CF11E0
- MS Word/Excel (xlsx.or.docx),文件头:504B0304
- MS Access (mdb),文件头:5374616E64617264204A
- WordPerfect (wpd),文件头:FF575043
- Postscript (eps.or.ps),文件头:252150532D41646F6265
- Adobe Acrobat (pdf),文件头:255044462D312E
- Quicken (qdf),文件头:AC9EBD8F
- Windows Password (pwl),文件头:E3828596
- ZIP Archive (zip),文件头:504B0304
- RAR Archive (rar),文件头:52617221
- Wave (wav),文件头:57415645
- AVI (avi),文件头:41564920
- Real Audio (ram),文件头:2E7261FD
- Real Media (rm),文件头:2E524D46
- MPEG (mpg),文件头:000001BA
- MPEG (mpg),文件头:000001B3
- Quicktime (mov),文件头:6D6F6F76
- Windows Media (asf),文件头:3026B2758E66CF11
- MIDI (mid),文件头:4D546864
看完这些文件头你就会发现,问题来了,zip格式的文件头和word的docx一样啊,这个怎么区分啊。这是为什么呢,因为DOCX和XLSX本质上就是一个压缩文件呀。修改docx后缀(并不改变该文件真实格式),然后用压缩工具查看,可以看到清晰的目录结构。
3 通过前8个字节位里的标示位
上面说到,没法通过文件头的四个十六进制位区分docx和zip,这个时候怎么办呢?没办法,只能查看ZIP文件的格式。于是从网上找到这篇文章:
ZIP文件格式分析 https://blog.csdn.net/a200710716/article/details/51644421
得出zip格式文件头应该有这样的意义标识:
然后终于发现了zip和docx的一点区别:第七个字节位,zip为00,而docx为06。
二者的含义也很清楚,比特位为00标识加密,为06表示强加密,所以这就是普通zip和word的区别。
4 无法区分的情况
如果真有别有用心的人,对zip进行强加密,前提是他得知晓我的判断逻辑,那确实没办法了,还有一个就是Excel和word也无法区分,这些在咨询了大佬之后,觉得似乎可以从内核层面去判断,但是基于当前业务,其实也已经够用了。不再做深究了吧。当然,交付使用前,千万别忘了单元测试:
[TestClass] public class WordFileHelperTest { [TestMethod] public void GetFileHeaderTest() { Stream docStream = new FileStream("D:\\doc.doc", FileMode.Open); //验证doc文件 Stream xlsStream = new FileStream("D:\\MsoIrmProtector.xls", FileMode.Open);//验证XLS文件 Stream docxStream = new FileStream("D:\\docx.docx", FileMode.Open);//验证docx文件 Stream xlsxStream = new FileStream("D:\\xlsx文件2.xlsx", FileMode.Open);//验证XLSX文件 Stream zipStream = new FileStream("D:\\xlsx文件2.zip", FileMode.Open);//验证zip文件 var docType = WordFileHelper.Instance.GetFileHeader(docStream); var xlsType = WordFileHelper.Instance.GetFileHeader(xlsStream); var docxType = WordFileHelper.Instance.GetFileHeader(docxStream); var xlsxType = WordFileHelper.Instance.GetFileHeader(xlsxStream); var zipType = WordFileHelper.Instance.GetFileHeader(zipStream); Assert.AreEqual(docType, OfficeFileClassEnum.DOC); Assert.AreEqual(xlsType, OfficeFileClassEnum.DOC); Assert.AreEqual(docxType, OfficeFileClassEnum.DOCX); Assert.AreEqual(xlsxType, OfficeFileClassEnum.DOCX); Assert.AreEqual(zipType, OfficeFileClassEnum.ZIP); } }
这段时间在新的团队里怎么说呢,虽然比较辛苦些,但能学到,研究到的东西也蛮多的,对成长还是很有帮助的吧,最起码搞明白了一些文件的格式和协议,如何进行安全设置。总结下,希望对大家有所帮助。事儿就这样成了。